最近赶项目,好久没写文章啦~ 这篇想写好久了,终于写完啦~
推荐以下俺的公众号,欢迎大家关注呀~
场景是什么样的?
很多情况下都会涉及到一致性的问题,这里我们以常见的支付场景为例。
在常见的支付场景下,我们会有 余额变动 的场景存在。比如说:在购买一个商品的时候,我们要去进行 支付 ;或者在某个 APP 里看了很多短视频后,我们要对奖励进行提现,这些都涉及到余额的变动。
如果说对于数据的一致性未能做好保障,那就可能会有 用户充值了 100,同时又花掉了 100,但是用户的余额还多了 100 的情况出现。
正常情况下数据会是什么样子?
在实际的场景中,通常我们在执行数据更新前都会有一些业务逻辑。
在实际业务逻辑处理前,我们可能就会从数据库中 读取 对应的数据,然后执行业务逻辑处理,等到处理完成后再将最终结果 更新 回数据库。如下图。
假设,用户的余额有 100 元,现在要支付 100 元,那么我们按照流程,最终写回 0 元是没有问题的。但是这个没有问题的前提是:数据在整个处理逻辑中,未被更改。 也就是只适用于低并发的场景。
会有哪些异常情况?
那什么情况下会有异常呢?数据同时被多个线程操作
无论是高并发又或者说什么分布式,其实都是因为数据被多个线程操作引起了不一致的情况。
我们同样以充值、支付两个场景为例子:
- 当两个业务在查询的时候,都从数据库读到了 100 块钱。(因为两个事务可能本身就在两个应用上部署,所以在读的时候互不干扰)
- 之后各自基于读取的数据执行不同的业务处理
- 充值业务:余额要 + 100,所以最终准备更新为 200
- 支付业务:余额要 - 100,所以最终更新为 0
- 但是因为两个业务下,业务处理逻辑的复杂度不一样/机器性能不一样等,导致耗时不同,最终一个先提交一个后提交。
此时异常出现了!原有金额 100 元,在充值业务执行慢、提交晚的情况下,数据库余额变成了 200。我们丢失了中间支付操作的一次修改,不一致性情况出现了。
如果我是顾客我很开心,如果我是员工,我估计就要拎包走人了。😂
有哪些解决方案?
针对上述场景,我们可以采用哪些方式解决呢?考虑现在多是分布式部署,所以肯定优先考虑各个机器都能读取到的共同数据来对数据一致性保证。大树这里列几个可能方案,供大家参考:
分布式锁
通过引入 Redis 或者 zookeeper 这样的支持分布式的中间件来实现分布式锁,在发生可能更改数据的操作时,直接针对记录维度进行上锁,阻塞其他线程进入。
这种悲观锁方案需要引入额外的组件(redis/zk),并且会一定程度降低吞吐量。那有没有轻一点的方式呢?
这时候我们可能就想到 CAS 的思想了。
CAS 方式乐观锁
对更新字段增加 CAS
对于上述充值、支付的场景,我们发现主要关心的其实是 余额 这个核心数据的变更,那我们能不能在更新记录的时候,对余额进行校验呢?
update 用户余额信息
set
余额 = #{更新金额}
WHERE
用户 ID = #{用户 ID} AND 余额 = #{期望余额}
其中 AND 余额 = #{期望余额}
就是我们新增的校验逻辑。
此时两个业务场景下一起更新,只有一个会成功,而执行晚的那个因为余额信息发生了改变,则会失败。
诶,此时可能有人想了,我直接更新的时候,进行计算不就好了吗? 就是像下边一样
update 用户余额信息
set
余额 = 余额 - #{本次操作金额}
WHERE
用户 ID = #{用户 ID}
看起来是 ok 的,但是这个 sql 在同一场景同一参数多次执行的情况下(比如接口超时,调用方二次发起请求),会出现不幂等的情况。也就是执行 n 次,余额就会发生 n 次变化,所以 肯定是不 OK 的。多充钱会丢饭碗,多扣钱也会丢饭碗。。。😒(关于幂等性咱们可以回头再聊聊)
聊到了 CAS 肯定就会有 ABA 问题的出现。比如说,在上述场景下用户的余额被线程 1 从 100 变成 200,而后又被线程 2 变成了 100,此时数据实际发生了改变的,但是在线程 3 更新 DB 的时候,并不能感知到。
此时对于线程 3 来说,这时候的 100 其实并不是他读取时的那个 100。(有点忒修斯之船的感觉了)
在我们这个场景下可能不会有什么影响,但是在其他的业务场景下,就不一定了。为了解决这个问题,我们可以引入 版本号 来做 CAS。
引入版本号做 CAS
和 对更新字段做 CAS 不一样的是,我们不关心字段本身,而是关心这个记录的版本。
update 用户余额信息
set
version = version + 1,
余额 = #{更新金额}
WHERE
用户 ID = #{用户 ID} AND version = #{期望 version}
在每次更新的时候,我们对版本号进行增加,用于区分数据版本。此时如果有并发操作就会失败,也实现了我们对于数据更新时一致性的保护。
总结
涉及到数据并发修改的场景,要考虑数据的并发一致性。可以根据实际应用场景来选择具体的方案,比如:
- 分布式锁
- 基于更新字段 或者 version 的乐观锁。需要注意的是 基于更新字段的方式可能存在 ABA 问题,需要充分考虑;建议使用 version 方式。