2024我给公司亏钱了,数据一致性问题真的马虎不得
最近五阳遇到了线上资损问题,我开始重视分布式事务的数据一致性问题,拿我擅长的场景分析下。
举个🌰例子
在付费会员场景,用户购买会员后享受会员权益。在会员售后场景,用户提交售后,系统需要冻结权益并且原路赔付退款。
系统如何保证冻结权益和订单退款的数据一致性呢?当无法保证数据一致时,会导致什么问题呢?
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
通过这个例子可以看到电商场景中,数据不一致可能会导致资金损失。这是电商场景对数据一致性要求高的原因,很多资损(资金损失)问题都是源于数据不一致。
如何理解数据一致性,一致性体现在哪里?
狭义上的数据一致是指:数据完全相同,在数据库主从延迟场景,主从数据一致是指:主数据副本和从数据副本,数据完全相同,客户端查询主库和查询从库得到的结果是相同的,也就是一致的。
除数据多副本场景使用数据一致性的概念之外,扩展后其他场景也使用这个概念。例如分布式事务中,多个事务参与者各自维护一种数据,当多种数据均处于合法状态且符合业务逻辑的情况下,那就可以说整体处于数据一致了。(并不像副本场景要求数据完全相同)
例如会员订单有支付状态和退款状态,会员优惠券有未使用状态和冻结状态。 在一次分布式事务执行前后,订单和优惠券的状态是一致的,即会员订单退款、会员券冻结;会员订单未退款,会员券状态为可使用;
此外还有异构数据一致性,超时一致。异构数据一致性是指同一种数据被异构到多种存储中间件。例如本地缓存、Redis缓存和数据库,即三级缓存的数据一致性。还有搜索场景,需要保证数据库数据和 ElasticSearch数据一致性,这也是分布式事务问题。
一致性和原子性的区别
原子性 指的是事务是一个不可分割的最小工作单元,事务中的操作要么全部成功,要么全部失败。
一致性 指的是事务执行前后,所有数据均处于一致性状态,一致性需要原子性的支持。如果没有实现原子性,一致性也无法实现。一致性在原子性的基础上,还要求实现数据的正确性。例如在同一个事务中实现多商品库存扣减,多个SQL除了保证同时成功同时失败外,还需要保证操作的正确性。如果所有SQL都返回成功了,但是数据是错误的,这无法接受。这就是一致性的要求。
由此可见,数据一致性本身就要求了数据是正确的。
隔离性是指:其他事务并发访问同一份数据时,多个事务之间应该保持隔离性。隔离性级别:如读未提交、读已提交、可重复读和串行化。
隔离性强调的是多个事务之间互不影响的程度,一致性强调的是一个事务前后,数据均处于一致状态。
什么是强一致性
在分布式事务场景,强一致性是指:任何一个时刻,看到各个事务参与者的数据都是一致的。系统不存在不一致的情况。
值得一提的是,CAP理论指出,数据存在多副本情况下,要保证强一致性(在一个绝对时刻,两份数据是完全一致的)需要牺牲可用性。
也就是说系统发现自身处于不一致状态时,将向用户返回失败状态。直至数据一致后,才能返回最新数据,这将牺牲可用性。
为了保证系统是可用的,可以返回旧的数据,但是无法保证强一致性。
会员售后能保证强一致性吗?
会员售后关键的两个动作:权益冻结和订单退款。 两者能保证强一致性吗?答案是不能。假设权益冻结是一个本地事务性操作,但是订单退款包括订单状态流程、支付系统资金流转等等。
如此复杂的流程难以保证任意一个绝对时刻,用户看到权益冻结后,资金一定到账了;这是售后场景 无法达到强一致性的根本原因。
最终一致性和强一致性
最终一致性不要求系统在任意一个时刻,各参与方数据都是一致的,它要求各参与方数据在一定时间后处于一致状态。
最终一致性没有明确这个时间是多长,所以有人说最终一致性就是没有一致性,谁知道多久一定能保证一致呢。
保证最终一致性的手段有哪些?
TCC
TCC 包含 Try、Confirm 和 Cancel 三个操作。
- 确保每个参与者(服务)都实现了 Try、Confirm 和 Cancel 操作。
- 确保在业务逻辑中,如果 Try 操作成功,后续必须执行 Confirm 操作以完成事务;如果 Try 失败或者 Cancel 被调用,则执行 Cancel 操作撤销之前的操作。
需要说明,如果Confirm执行失败,需要不停不重试Confirm,不得执行Cancel。 按照TCC的语义 Try操作已经锁定了、预占了资源。 Confirm在业务上一定是可以成功的。
TCC的问题在于 分布式事务的任意一个操作都应该提供三个接口,每个参与者都需要提供三个接口,整体交互协议复杂,开发成本高。当发生嵌套的分布式事务时,很难保证所有参与者都实现TCC规范。
为什么TCC 方案包含Try
如果没有Try阶段,只有Confirm和 Cancel阶段,如果Confirm失败了,则调用Cancel回滚。为什么Tcc不这样设计呢?
引入Try阶段,为保证不发生状态回跳的情况。
Try阶段是预占资源阶段,还未实际修改资源。设想资金转账场景, A账户向B账户转账100元。 在Try阶段 A账户记录了预扣100元,B 账户记录了预收100元。 如果A账户不足100元,那么Try阶段失败,调用Cancel回滚,这种情况,在任意时刻,A、B用户视角转账是失败的。
Try阶段成功,则调用Confirm接口,最终A账户扣100元,B收到100元。虽然这无法保证在某个时刻,A、B账户资金绝对一致。但是如果没有Try阶段,那么将发生 状态回跳的情况;
状态回跳:即A账户操作成功了,但是B账户操作失败,A账户资金又被回滚了。那么用户A 看到自己的账户状态就是 钱被扣了,但是过一会钱又回来了。
同理B账户也可能遇到收到钱了,但是过一会钱又没了。在转账场景,这种回跳情况几乎是不能接受的。
引入了Try阶段,就能保证不发生状态回跳的情况。
最大努力通知
最大努力通知是指通知方通过一定的机制最大努力将业务处理结果通知到接收方。一般用于最终一致性时间敏感度低的场景,并且接收方的结果不会影响到发起方的结果。即接收方处理失败时,发送方不会跟随回滚。
在电商场景,很多场景使用最大努力通知型作为数据一致性方案。
会员售后如何保证最终一致性?
回到开头的问题,以下是数据不一致的两种情况。
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
平台资损难以追回,当发生容易复现的平台资损时,会引来更多的用户“薅羊毛”,资损问题将进一步放大,所以平台资损一定要避免。
当发生用户资损时,用户可以通过客服向平台追诉,通过人工兜底的方式,可以保证“最终”数据是一致的。
所以基于这个大的原则,大部分系统都是优先冻结会员权益,待用户权益明确冻结后,才根据实际冻结情况,向用户退款。这最大程度上保证了售后系统的资金安全。
会员售后整体上是最大努力通知一致性方案。当权益冻结后,系统通过可重试的消息触发订单退款,且务必保证退款成功(即便是人工介入情况下)。虽说是最大努力通知型,但不代表一致性弱,事实上支付系统的稳定性要求是最高级别的,订单退款成功的可靠性是能得到保证的。
如果权益冻结是分布式事务,如何保证一致性
一开始我们假设权益冻结是一个本地事务,能保证强一致性,这通常与实际不符。权益包含很多玩法,例如优惠券、优惠资格、会员日等等,权益冻结并非是本地事务,而是分布式事务,如何保证一致性呢?
方案1 TCC方案
假设 权益系统包含三个下游系统,身份系统(记录某个时间段是会员)、优惠券系统、优惠资格系统。 TCC方案将要求三方系统均实现 Try、Confirm、Cancel接口。开发成本和上下游交付协议比较复杂。
方案2 无Try 的 TCC方案
假设无Try阶段,直接Confirm修改资源,修改失败则调用Cancel,那么就会出现状态跳回情况,即优惠券被冻结了,但是后面又解冻了。
这种情况下,系统只需要实现扣减资源和回滚资源 两种接口。 系统设计大大简化
方案3 Prepare + Confirm
Prepare: 检查接口,即检查资源是否可以被修改,但是不会锁定资源。
Confirm: 修改资源接口,实际修改资源状态。
如果Prepare失败,则返回执行失败,由于未预占用资源,所以无需回滚资源。在Prepare成功后,则立即调用Confirm。如果Confirm执行失败,则人工介入。
一定需要回滚吗?
在一致性要求高的场景,需要资源回滚能力,保证系统在一定时间后处于一致状态。如果没有回滚,势必导致在某些异常情况下,系统处于不一致状态,且无法自动恢复。
会员售后场景虽然对资金较为敏感,但不需要资源回滚。理由如下
- 将订单退款置为 权益成功冻结之后,可以保证系统不出现平台资损。即权益未完全冻结,订单是不是退款的。
- 用户资损情况可以通过人工客服兜底解决。
在上述方案3中,先通过Prepare阶段验证了参与方都是可以冻结的,在实际Confirm阶段这个状态很难发生改变。所以大概率Confirm不会失败的。
只有极低的概率发生Confirm失败的情况,即用户在权益冻结的一瞬间,使用优惠券,这将导致资源状态发生改变。
解决此类问题,可以在权益冻结后,评估冻结结果,根据实际的冻结结果,决定如何赔付用户,赔付用户多少钱。所以用户并发用券,也不会影响资金安全。
人工兜底与数据一致性
程序员应该在业务收益、开发成本、数据不一致风险等多个角度评估系统设计的合理性。
会员售后场景,看似数据一致性要求高,仿佛数据不一致,就会产生严重的资损问题。但实际分析后,系统并非需要严格的一致性。
越是复杂的系统设计,系统稳定性越差。越是简洁的系统设计,系统稳定性越高。当选择了复杂的系统设计提高数据一致性,必然需要付出更高的开发成本和维护成本。往往适得其反。
当遇到数据一致性挑战时,不妨跳出技术视角,尝试站在产品视角,思考能否适当调整一下产品逻辑,容忍系统在极端情况下,有短暂时间数据不一致。人工兜底处理极端情况。
大多数情况下,产品经理会同意。
来源:juejin.cn/post/7397013935105769523