余额扣减这个话题,我做了好几年的交易系统,几乎每年都会被拉出来讨论一次。网上讲乐观锁、讲Redis预扣的文章挺多,但真正能把来龙去脉拆清楚、把实战中的坑讲明白的,确实不多。今天这篇我不想只给一个“标准答案”,因为高并发下的余额扣减根本没有银弹。我会从最朴素的做法开始,一步步推演到高并发场景下的成熟架构,把每条技术路径的边界条件、适用场景、容易踩的坑都摊开讲。适合正在做电商、支付、积分、会员钱包等涉及账户资金操作的开发同学,也适合准备面试系统设计题、想把这个点讲出深度的朋友。
1. 先把病根儿找出来:高并发下余额扣减到底难在哪
1.1 一次典型的并发超扣现场还原
先说一个我早年真实遇到过的线上事故。业务场景是用户下单购买,支付成功后同步扣减账户余额。当时的代码逻辑很简单:查询余额,判断是否充足,然后执行扣减。看起来天经地义,但线上压测一开,超扣就来了。
假设用户账户余额只有100元,他同时发起了两个金额都是80元的扣款请求。正常情况下,第一笔扣完余额剩20元,第二笔应该因为余额不足被拒绝。但并发场景下,两个请求同时进入了代码判断逻辑:都查到了余额为100元,都判断“余额充足”,然后各自执行了扣减。结果就变成了余额负60元,一个典型的超扣事故。
这个问题的根源在于“先查后扣”的逻辑不是原子的。查询和更新之间存在时间窗口,并发请求在这个窗口内互相看不到对方的操作,于是多个请求都拿着旧数据做判断,最后一起写库,把正确性完全破坏了。很多团队第一次遇到这种问题,第一反应都是“加锁吧”,但锁怎么加、加在哪里、粒度多大,才是真正要命的细节。
1.2 正确性、性能、一致性,三个矛盾怎么权衡
深入看,余额扣减在技术上有三股互相拉扯的力量。
第一是正确性。余额是钱,一分一毫都不能错。不能多扣,不能少扣,更不能把余额扣成负数。这意味着扣减操作必须满足原子性和严格的余额校验,任何并发冲突都不能破坏账户金额的准确性。
第二是性能。高并发场景下,扣减操作需要扛住大量请求。如果每个扣减都退化成数据库的串行操作,响应时间会飙升,数据库连接池也会被打满,整个服务跟着瘫痪。
第三是一致性。很多方案会引入缓存层或异步流程来提升性能,但一旦引入了异步,就得面对“缓存里的余额和数据库里的余额不一致”的难题,还需要额外的对账和补偿机制。
这三者很难同时做到极致。真实系统里,正确性永远是底线,性能和一致性则需要在具体业务场景中做取舍。比如账户数量很大的C端产品,通常选乐观锁或Redis预扣;账户数量少但单笔金额敏感的金融类场景,反而更愿意用数据库悲观锁换强一致。方案没有绝对优劣,只有适不适合当前场景。
2. 方案怎么选:从行锁到Redis预扣的完整路线图
2.1 方案一:数据库悲观锁(行锁)
悲观锁的思路很直接:在事务里对账户所在的行加锁,让并发请求排队执行,让查询和扣减变成不可分割的操作。
-- 开启事务 BEGIN; -- 对目标账户行加锁 SELECT balance FROM account WHERE user_id = 1 FOR UPDATE; -- 在应用层判断余额是否充足 -- 充足则执行扣减 UPDATE account SET balance = balance - 80 WHERE user_id = 1; COMMIT;这段SQL的关键在于FOR UPDATE。这行语句会让数据库锁定满足条件的行,其他事务再执行同样的FOR UPDATE时会被阻塞,直到当前事务提交或回滚。这样一来,并发请求就变成了串行执行,正确性有了保障。
悲观锁的优点非常明显:实现简单,强一致,应用层不需要处理重试逻辑。但缺点同样硬伤,就是性能。锁意味着串行,在高并发下数据库连接会被大量占用,连接池一旦满了,应用就会报“获取连接超时”,整个服务跟着雪崩。另外,如果多个事务加锁的顺序不一样,还可能产生死锁。比如A账户转B账户、B账户转A账户这两个操作同时发生,一个先锁A再锁B,另一个先锁B再锁A,两边互相等对方释放锁,僵持不下,最终一个事务被数据库判定为死锁牺牲品回滚掉。
所以悲观锁更适合账户数量少、单账户并发极高、宁可慢一点也必须保证强一致的核心金融场景,并不适合所有业务一刀切。我一般建议团队优先想清楚:当前业务的并发量到底有多高、能不能接受行锁带来的延迟,再决定要不要上悲观锁。
2.2 方案二:版本号乐观锁
乐观锁的思路是“我假设大多数情况下不会冲突,所以不加锁,只在更新时校验版本号”。需要在余额表增加一个version字段,每次扣减时对版本号做校验。
-- 1. 查询余额和版本号 SELECT balance, version FROM account WHERE user_id = 1; -- 程序里判断 balance >= 80 -- 2. 执行扣减,条件带上版本号 UPDATE account SET balance = balance - 80, version = version + 1 WHERE user_id = 1 AND version = 1; -- 3. 检查影响行数 -- 影响行数为1,扣减成功 -- 影响行数为0,说明版本号已过期,需要重查并重试这段SQL的精髓在于WHERE version = 1。并发情况下,两个请求都查到了version=1,先执行UPDATE的那个请求会把version改成2,后到的UPDATE会因为version不匹配而影响行数为0,从而避免了超扣。
乐观锁的好处是并发能力比悲观锁强很多,因为大部分时间请求可以并行读、并行扣,只有在更新瞬间才需要数据库行锁,锁持有时间非常短。代价则是需要在应用层实现“失败重试”逻辑,而且如果冲突率很高,大量请求会白忙活一轮再重试,数据库压力反而变大。从我的实践来看,同一账户的扣减并发在几十到一两百这个量级时,乐观锁的性能非常稳定,实现成本也低,是大多数业务的第一选择。
2.3 方案三:Redis预扣减 + 异步落库
当单个账户的扣减并发进一步升高,比如秒杀场景下几万人同时抢购同一商品,数据库每秒只能扛住几百上千次UPDATE操作,再乐观的锁也顶不住。这时候就必须把扣减操作前置到性能更强的缓存层。
核心思路是先把账户余额加载到Redis,扣减操作全部在Redis内存里完成,然后通过异步任务把扣减结果同步到数据库。Redis本身是单线程模型,执行Lua脚本是原子操作,所以用它扣减天然不会超扣。
-- KEYS[1] = user account balance key -- ARGV[1] = deduct amount local balance = tonumber(redis.call('GET', KEYS[1]) or '0') local amount = tonumber(ARGV[1]) if balance < amount then return -1 end redis.call('DECRBY', KEYS[1], amount) return balance - amount这段Lua脚本先读取余额,判断足够,再执行扣减。整个过程在Redis内部完成,不会被其他命令打断,所以不存在多个请求同时读到旧值的问题。扣减成功后,应用把这笔扣款事件写入消息队列,由下游消费者异步更新数据库余额。
这个方案把每秒几千上万次的扣减压力扛在了Redis身上,数据库只处理异步落库,性能提升非常明显。但代价也大:需要处理Redis宕机导致的数据丢失、缓存与数据库的最终一致性、冷热账户的缓存加载策略等一堆问题。它适合那种单账户扣减量极大、对短时数据一致性容忍度较高的场景。
2.4 三种方案的横向对比
为了更直观地体现差异,我把三种方案的核心维度列成了一张表,方便团队做技术选型时快速对比。
| 方案 | 一致性保证 | 性能表现 | 实现复杂度 | 典型适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 强一致 | 低,会串行阻塞 | 低 | 账户数少、单笔金额敏感,需要极强一致性 |
| 乐观锁版本号 | 强一致 | 中,冲突多时性能下降 | 中 | 常规电商、积分系统,单账户并发几十到上百 |
| Redis预扣异步落库 | 最终一致 | 高,可支撑秒杀级别 | 高 | 秒杀、抢购等极端高并发,且能接受短暂不一致 |
选择方案时我建议先做一道判断题:你的业务对一致性的要求,真的需要强到秒级一致吗?很多业务其实能接受“用户看到的余额在10秒后变回真实值”,如果答案是能,那Redis预扣方案就值得考虑。如果答案是“必须实时”,那还是老老实实回到乐观锁或者悲观锁,别为了追求并发量硬上缓存方案,账不平会让人想离职。
3. 动手落地:一个可直接抄的乐观锁扣费实现
3.1 表结构设计
我的建议是不要把扣减逻辑想得太简单,光有一张account表远远不够。实际生产环境至少需要两张表:账户余额表和扣款流水表。
CREATE TABLE `account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` varchar(64) NOT NULL COMMENT '用户ID', `balance` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额', `version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB;CREATE TABLE `deduct_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `deduct_no` varchar(64) NOT NULL COMMENT '扣款流水号,唯一', `user_id` varchar(64) NOT NULL COMMENT '用户ID', `amount` decimal(12,2) NOT NULL COMMENT '扣款金额', `status` tinyint(4) NOT NULL COMMENT '状态: 0-处理中,1-成功,2-失败', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_deduct_no` (`deduct_no`) ) ENGINE=InnoDB;account表里的version字段是乐观锁的核心,扣款流水表里的deduct_no是幂等的关键。关于幂等,后面我会展开讲,这里先记住:任何资金类操作都必须留流水,而且流水号必须有唯一约束。
3.2 核心代码逻辑
以Java + MyBatis为例,扣减逻辑建议按这个顺序写:先插入流水(状态为处理中),再执行条件更新扣减余额,然后根据更新结果更新流水状态。
先写Mapper层的方法:
@Mapper public interface AccountMapper { // 条件扣减,返回影响行数 @Update("UPDATE account SET balance = balance - #{amount}, version = version + 1 " + "WHERE user_id = #{userId} AND version = #{version} AND balance >= #{amount}") int deductBalance(@Param("userId") String userId, @Param("amount") BigDecimal amount, @Param("version") Integer version); }这里有两个细节值得注意。第一,扣减用的是balance = balance - #{amount},而不是先在应用层算出新余额再赋值给SQL,这样可以避免应用层读到过期数据导致丢失更新。第二,balance >= #{amount}这个条件一定要写在SQL里,这是防御超扣的最后一道闸门,即使前面查到的余额已经很旧,数据库层也会拦住。
Service层的逻辑大致是这样:
@Service public class AccountService { public void deduct(String userId, BigDecimal amount, String deductNo) { // 1. 插入扣款流水,状态为处理中 deductLogMapper.insert(deductNo, userId, amount, 0); // 2. 乐观锁循环重试 int maxRetry = 3; for (int i = 0; i < maxRetry; i++) { Account account = accountMapper.selectByUserId(userId); if (account.getBalance().compareTo(amount) < 0) { // 余额不足,更新流水为失败 deductLogMapper.updateStatus(deductNo, 2); throw new BusinessException("余额不足"); } int rows = accountMapper.deductBalance(userId, amount, account.getVersion()); if (rows > 0) { // 扣减成功,更新流水为成功 deductLogMapper.updateStatus(deductNo, 1); return; } // 影响行数为0,说明version被其他请求改掉了,重试 } // 重试次数用尽,按失败处理,并做告警 deductLogMapper.updateStatus(deductNo, 2); throw new BusinessException("系统繁忙,请稍后重试"); } }这个实现看起来不复杂,但每个环节都有知识点。核心是循环里的“查-判-扣”三步:每次重试都会重新查询最新余额和版本号,保证拿到的不是过期数据。然后通过条件更新判断是否成功,失败就继续循环。
3.3 重试策略怎么定
重试次数不是拍脑袋定的。我在线上设置的是3次,为什么?因为经过压测,同样的账户并发冲突概率在3次重试内能覆盖绝大多数场景。如果重试10次才能成功,说明系统已经处于极端饱和状态,再重试下去也只是浪费数据库资源。
重试之间还要加退避策略。最简单的做法是每次重试前Thread.sleep(20 * i)毫秒,i是当前重试次数。这样第一次冲突后等20ms,第二次等40ms,给数据库行锁一个释放的缓冲时间。如果一点退避都没有,重试请求会像洪水一样涌向同一个行,容易把数据库连接池打满。
另外一个容易被忽略的点:重试和业务幂等要配合。假设扣减真的成功了,但数据库返回时网络超时,客户端发起重试,新的请求又插入了一条流水、执行了一次扣减,用户就会被重复扣款。所以流水表一定要有deduct_no的唯一约束,并且写入的时候要做异常捕获,遇到唯一键冲突直接返回“该扣款请求已在处理中”,而不是再扣一次。
3.4 流水与幂等:扣款必须留痕
我见过不少团队为了省事,只做一行UPDATE,不写流水。短期看没问题,一旦出了账不平的线上事故,排查起来简直生不如死,因为你根本不知道钱是怎么少的。所以我强烈建议:只要涉及余额变化,必须写流水,而且流水的状态要和余额的变更在同一个业务周期里处理清楚。
关于幂等,简单说就是同一笔扣款请求,无论客户端重试多少次,服务端至多执行一次。实现方法也很明确:用扣款流水号作为幂等键,流水表给deduct_no建唯一索引,插入时冲突就说明请求重复了。这一步虽然不起眼,但能避免大量资损问题。
听我一句劝,扣款逻辑里“先插流水,再扣余额”的顺序不要反。如果先扣余额再写流水,扣款成功但流水插入失败,一旦出错,你连追溯的依据都没有。先插流水的好处是,即使后续余额扣减失败,也能在流水里留下一条失败记录,方便对账。
4. 再进一步:Redis预扣减的完整方案与一致性保障
4.1 Lua脚本扣减流程设计
前面给了基础的Lua脚本,实际生产里还需要考虑更多细节。首先是数据预热问题:Redis里没有这个账户余额数据怎么办?一般做法是在扣减前先从数据库加载余额到Redis,用SET balance:{userId} 100这类命令初始化,并设置一个合理的过期时间。
其次,Lua脚本需要配合SETNX之类的方式防止缓存被并发加载时重复覆盖。比如两个请求同时发现缓存不存在,同时去数据库加载余额,后加载的那个可能覆盖先加载的扣减结果。为了避免这个问题,我常用的一种方式是:先用SETNX抢初始化锁,抢到的线程负责加载余额,加载成功后再用SET写入缓存,其他线程等一会儿再读。
这里直接给一个更贴近生产场景的Lua脚本,包含余额初始化的幂等处理逻辑:
-- KEYS[1] = balance key,例如 balance:1001 -- ARGV[1] = 本次扣减金额 -- ARGV[2] = 账户初始余额(当缓存不存在时使用) local balance = redis.call('GET', KEYS[1]) if balance == false then -- 缓存不存在,说明账户是冷数据,使用传入的数据库余额初始化 balance = tonumber(ARGV[2]) end local amount = tonumber(ARGV[1]) if balance < amount then return -1 end redis.call('SET', KEYS[1], balance - amount) return balance - amount这种写法把初始化逻辑直接放进Lua脚本,避免多线程并发初始化带来的覆盖问题。扣减成功后,Redis里已经是最新的余额,应用层再把“用户、金额、流水号、扣减前余额、扣减后余额”打包成消息发送到MQ,由异步任务落库。
4.2 异步落库与对账补偿
异步落库的核心难点是“消息不丢”和“分布式事务”。业界用的比较多的方案是本地消息表,也就是把扣款流水和预扣减消息放在同一个本地事务里写入。流水表插入一条状态为“待同步”的记录,同时给MQ发一条消息,消费者从MQ拉取后再去更新数据库余额,更新成功就把流水状态改为“已同步”。
但这里有个很实际的问题:MQ消息可能丢,消费者可能挂了,可能处理到一半重启了。所以必须有一个定时对账任务兜底。比如每分钟扫描一次流水表,找出状态还是“待同步”且超过一定时间的记录,重新发送消息,或者直接同步执行一次更新。
对账任务还要处理“Redis余额和数据库余额不一致”的问题。比较常见的做法是每天凌晨做一次全量对账:把数据库的余额捞出来,和Redis里的余额做比对,差异过大的账户要告警。还有一个更轻量的做法是按用户维度做增量对账,就是每次异步落库成功之后,顺手把该账户Redis里的余额和数据库里的余额差值记下来,如果差值始终为0,说明流程正常;如果差值一直不为0,就要触发补偿更新。
4.3 缓存一致性隐患与应对
Redis预扣减最容易翻车的地方就是一致性。缓存扣了而数据库没扣,用户看到余额少了,但实际余额没变,账不平;反向的,缓存没扣而数据库扣了,用户余额虚高,继续下单会失败甚至资损。
两个典型的坑我必须提醒一下。第一个是缓存过期时间设置不当。假设账户的Redis key设置了30分钟过期,但异步落库队列积压了1小时,key过期后余额重新从数据库加载,此时数据库余额可能还是旧值,导致缓存的余额“变多”了。解决思路是把对账周期压缩到远小于缓存过期时间,或者干脆对发生扣减的账户key设置“每次扣减都刷新过期时间”的续期策略。
第二个是Redis宕机。Redis一旦重启,所有余额数据全没了,这时候如果请求直接落库,可能瞬间打爆数据库;如果不落库,业务又停摆。比较稳妥的做法是:Redis不可用时做降级,扣减请求直接走数据库乐观锁,等待Redis恢复后再切回缓存模式。降级开关要提前设计好,别等事故发生了再临时改代码。
5. 高并发流量治理:别让扣费服务成为雪崩起点
5.1 为什么扣费服务需要限流降级
很多人以为把余额扣减做成乐观锁或者Redis预扣,并发问题就彻底解决了。其实这是错觉。扣费服务通常是交易链路的核心节点,上游是订单服务、支付回调、活动服务,下游是数据库和消息队列,任何一个环节被瞬时流量打懵,扣费服务都会跟着遭殃。
举个例子:某次大促活动,订单服务突然涌入10倍流量,所有请求都来调用扣费接口。如果扣费服务没有自我保护机制,数据库连接池会先被打满,然后应用线程阻塞,最后整个服务不可用。更可怕的是,上游服务发现扣费超时,还会自动重试,重试流量叠加,直接引发雪崩。
所以高并发余额扣减不只是数据库层面的技术问题,它还需要在服务入口做流量治理。热点词的组合“高并发im、实战alibaba sentinel”里的Sentinel,就是专门解决这一类问题的开源组件。作为一个有经验的开发者,我强烈建议把限流、熔断、降级这些能力纳入扣费服务的标配。
5.2 限流与熔断的基本思路
限流的本质是在入口控制并发请求数量,超出阈值的请求快速失败,保护下游不被冲垮。扣费服务常用的限流维度有两个:一个是整体QPS限流,比如扣费接口的QPS上限是5000,超过的直接返回“系统繁忙”;另一个是线程数限流,比如扣费线程池最大100个线程,队列满了就拒绝新请求。
熔断则是服务调用下游时,当下游错误率达到阈值,比如5秒内错误率超50%,就自动把调用方断开一段时间,直接返回降级结果,避免继续给下游输送压力。这个机制非常像家里的保险丝——电流过大时先烧断自己,保护整个电路。
降级则是提前准备好的备选方案,比如Redis挂了扣费直接切到数据库乐观锁,或者活动期间的扣费失败可以先返回“稍后自动重试”,而不是让用户看到报错。降级方案要和业务方提前对齐,哪些操作可以延后、哪些必须实时完成,都要写清楚。
5.3 基于Sentinel的落地要点
Sentinel贵在一个“实时”和“自动”。它可以在控制台上动态配置规则,不用重启应用就能生效。在扣费服务落地Sentinel,我建议重点做三件事:
第一,给扣费接口定义资源。最简单的方式是用@SentinelResource("deductBalance")注解标注扣费方法,这样Sentinel就能监控这个方法的QPS、RT、异常率。
@SentinelResource(value = "deductBalance", blockHandler = "deductBlockHandler") public void deduct(String userId, BigDecimal amount, String deductNo) { // 扣费核心逻辑 }第二,配置热点参数限流。余额扣减天然是热点场景,同一个SKU或者同一个商家分分钟可能涌入海量请求,可以用Sentinel的热点参数限流,直接针对userId维度做防护。具体配置规则落在控制台,规则形如“对参数索引0(userId)的访问,QPS超过100就限流”。
第三,配置熔断降级。当扣费服务调用数据库的慢调用比例升高,或者依赖的MQ投递失败率上升,Sentinel会自动熔断,此时可以先快速返回“处理中,请稍后查询结果”,避免请求全部挂在数据库上。
我再提醒一句:Sentinel的阈值不能拍脑袋。先做一轮压测,确定当前服务的真实承载能力,比如压测发现数据库是瓶颈,5000QPS时数据库CPU已经90%,那就把限流阈值设为4000,留出缓冲。配置完成后还要持续观察监控,动态调整,没有一劳永逸的阈值。
6. 踩坑实录与压测经验
6.1 常见问题速查表
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 余额被扣成负数 | 并发下先查后扣,SQL无余额条件 | UPDATE语句必须加balance >= amount |
| 同一请求重复扣款 | 缺少幂等机制,客户端重试导致重复执行 | 流水表加唯一键,插入前捕获冲突 |
| 数据库连接池耗尽 | 悲观锁或慢SQL长时间占用连接 | 改用乐观锁,给慢SQL加索引,设置连接池超时 |
| 缓存和数据库账不平 | 异步落库失败或缓存Key过期时间设置不当 | 加对账任务,定时补偿,合理设置过期时间 |
| 上游重试放大流量 | 服务无限流,重试叠加引发雪崩 | 扣费接口加限流,重试策略做退避 |
| 系统重启后Redis余额丢失 | Redis未持久化或未做降级 | 开启AOF持久化,Redis不可用时降级到数据库 |
6.2 三个印象最深的线上事故
第一个事故就是文章开头提到的超扣。复盘时发现是因为SQL查询和更新分离,中间有个同事在代码里加了一次远程配置中心的调用,导致查询和扣减之间的时间窗口被放大,并发下更容易出问题。教训就是:扣减临界区代码不要做任何可能阻塞的操作,远程调用一律抽出事务。
第二个事故是重复扣款。某次活动用户连续点了几次支付按钮,网关层做了重试,但重试时没有带同一个幂等号,而是每次生成了新流水号。结果用户付了一笔钱,账户被扣了三笔。后来我们把幂等键统一为“支付单号+操作类型”,在流水表上建唯一索引,这个问题再没出现过。
第三个事故是Redis预扣模式下缓存突然失效。当时负责的同事把缓存过期时间设成了10分钟,但异步落库的MQ发生积压,积压了超过10分钟,缓存Key过期后从数据库重新加载了旧余额,用户乍一看余额好像“变多了”,于是连续下单,结果数据库侧余额不足导致大量下单失败。这件事让我深刻认识到,缓存过期时间必须和对账周期耦合设计,不能只看缓存本身的访问情况。
6.3 压测怎么做才不白压
很多人压测就是拿JMeter建一个线程组,设置100个线程,点启动,看TPS。这种做法往往得出错误结论。我建议按这个顺序来做扣费接口的压测:
第一步,先做单接口基准压测。在数据库无压力的前提下,用100并发跑5分钟,观察TPS、平均RT、P99 RT。这个数据是系统的基准能力。
第二步,做梯度压测。并发从50、100、200、500、1000逐步递增,每档跑5分钟,记录TPS和错误率。当TPS不再随并发增长而增长,错误率开始抬头时,那个点就是系统的极限拐点。
第三步,做混合链路压测。只压扣费接口没用,要把订单、支付回调、账户服务串起来跑一遍,才能真正代表线上流量。压测过程中要同时盯数据库的连接数、CPU、慢SQL、Redis的命中率等指标。
压测之后一定要做容量评估。比如压测得出扣费服务单机极限是2000 QPS,但你的线上集群有20台机器,理论总容量是40000 QPS,再考虑到单机故障要留30%冗余,那Sentinel的限流阈值就可以设定在25000 QPS左右。这些数据就是配置规则的依据,没有压测数据支撑就把阈值调到天上,是非常危险的做法。
我在实际踩过几次坑之后,对余额扣减方案有了一个更务实的看法:先把最简单的正确方案落到线上,用压测数据推动演进,而不是一开始就设计一个复杂度极高的缓存方案。大多数业务的并发量其实用乐观锁就能撑住,真到了必须上Redis预扣的那一天,你已经有足够的数据和经验来判断什么该缓存、什么必须落库、对账怎么做。技术方案不是越新越复杂越好,能在正确性、性能和维护成本之间找到平衡点的,才是最适合你的方案。