高并发余额扣减的架构演进与避坑实践

发布时间:2026/9/16 8:58:32
高并发余额扣减的架构演进与避坑实践 高并发下的余额扣减恐怕是每个做交易、支付、钱包系统的后端工程师都绕不开的一道坎。你说它难吧核心逻辑就是一条 UPDATE 语句的事你说它简单吧一旦流量真的打上来各种超扣、死锁、数据不一致的问题会排着队来找你。我前前后后做过几个百万用户级的交易系统在余额扣减这个看似不起眼的小功能上踩过的坑比写业务代码踩过的都多。今天就把这些年沉淀下来的一些经验和思考整理出来从最简单的方案到相对完善的架构演进一次性讲透。先说一个核心结论余额扣减的本质不是“扣钱”而是“并发控制”。如果把这个问题想清楚了后面所有的技术选型和架构设计就都是顺理成章的事。1. 余额扣减的核心难点并发场景下的资源竞争余额扣减业务上要求的就三件事不多扣、不少扣、扣完可追溯。翻译成技术语言就是原子性、一致性、可审计性。这三件事在单机单线程下毫无难度但在高并发场景下每一件都变得棘手。1.1 为什么一条 UPDATE 语句会出问题很多新手写余额扣减第一版往往长这样UPDATE account SET balance balance - 100 WHERE user_id 123;看起来很简单对吧SELECT 余额减掉扣款金额再 UPDATE 回去或者直接在 SQL 里做减法。这两种写法在低并发下都没问题但一旦 QPS 上来问题就暴露了。问题出在“先查后改”这种非原子操作上。两个请求同时读到余额是 100 元都认为可以扣 100结果最后余额变成 0 而不是 -100这就是超扣。就算你用了上面那条原子 UPDATE也只能保证“扣减”这个动作不超扣却没法保证“被扣的钱”是同一个请求扣的——因为在高并发下两个请求可能分别扣了两次 100而账户余额只有 150。更麻烦的是行锁竞争。InnoDB 引擎下这条 UPDATE 会对 user_id123 这一行加上排他锁所有对该行的更新操作都必须串行排队。当并发量达到几千甚至上万 QPS 时这一行的锁等待会把数据库连接吃满最终表现为接口耗时急剧上升、连接池爆掉、整个服务雪崩。1.2 并发扣减的三个典型困境我把高并发余额扣减的难点归纳成三个方便后续对照方案超扣这是最致命的。用户余额 10 元两个请求同时买 10 元的商品结果都成功了。你的钱包系统在用户不知情的情况下“凭空生钱”这是绝对不允许的。锁等待与死锁多个请求同时操作同一账户时请求 A 先扣余额再插入流水请求 B 先插入流水再扣余额就可能出现相互持有对方需要的锁最终触发死锁。MySQL 检测到死锁会回滚其中一个事务如果业务层没有做好重试用户的请求就直接失败了。余额一致性即便扣款成功了流水记录和余额对不上。比如扣款成功但流水写入失败或者流水写了但余额没扣成功都会导致账实不符对账系统半夜报警运维同学欲哭无泪。注意余额系统对数据准确性的要求是百分百不是 99.99%。这也意味着任何分布式事务方案、缓存方案最终都必须能够收敛到“数据库中的数据完全正确”这个终极目标上。2. 数据库层方案从乐观锁到原子更新先讲数据库层怎么做因为这是所有架构方案的基石。缓存、MQ、分布式锁再花哨最后落库的那一下才是真正的胜负手。2.1 乐观锁方案版本号机制这是最直觉的方案在余额表上加一个 version 字段UPDATE account SET balance balance - 100, version version 1 WHERE user_id 123 AND version 5;执行 UPDATE 后通过影响行数affected rows来判断是否更新成功。如果影响行数为 0说明 version 已经变了有并发操作抢先完成了扣减此时业务层需要重试或者返回失败。乐观锁的优点是无锁、无阻塞读多写少场景下性能很好。缺点也同样明显并发冲突率高时大量请求会拿到“更新失败”的结果用户体验极差。一个用户同时下多笔订单每笔都要扣余额结果只有一笔成功剩下全部要用户手动重试这在电商大促场景下基本没法接受。适用场景并发度不高的业务或者用户维度操作频次很低的场景比如企业账户打款。2.2 悲观锁方案SELECT FOR UPDATE悲观锁的思路是既然并发操作会冲突那就先锁住这一行让其他人等着。-- 开启事务 BEGIN; SELECT balance FROM account WHERE user_id 123 FOR UPDATE; -- 业务判断余额是否充足 -- 执行扣减 UPDATE account SET balance balance - 100 WHERE user_id 123; COMMIT;SELECT FOR UPDATE 会对命中的行加排他锁直到事务提交或回滚。这个方案在逻辑上最稳余额判断和扣减在同一个锁保护的临界区里不会出现超扣。缺点就是前面说的行锁会串行化所有对该账户的操作高并发下吞吐量上不去。很多人会问那把数据库连接池调大不就行了这里的瓶颈只是锁等待引入的并发反而会导致大量线程阻塞进一步放大数据库的连接压力。我见过一个事故某个账户被秒杀活动“打爆”高峰期 2000 个数据库连接全卡在这一个账户的行锁上整个库的其他业务全部被拖垮。适用场景单体应用、低并发内部系统或者对一致性要求极高但并发极低的场景。2.3 原子条件更新兼顾性能与安全的务实之选这是我最推荐的一个数据库层方案一句话讲就是把余额判断和余额扣减合并成一条带条件的 UPDATE。UPDATE account SET balance balance - 100 WHERE user_id 123 AND balance 100;这条 SQL 的巧妙之处在于MySQL 在执行 UPDATE 时会先对符合条件的行加锁再对 balance 字段做减法同时通过balance 100这个条件天然防止了负余额。如果影响行数为 0说明要么账户不存在要么余额不足业务层直接返回“余额不足”即可。它本质上是把“查余额”和“扣余额”这两个动作合并成了一个原子操作消除掉了先查后改之间的时间窗口。和悲观锁相比它不需要显式开启事务也不需要在应用层持有锁资源和乐观锁相比它不会因为 version 冲突而白白失败重试吞吐量明显更高。我实际压测过单条原子 UPDATE 在相同硬件条件下QPS 大约是“SELECT FOR UPDATE”方案的 3 到 4 倍。原因很简单悲观锁方案中事务执行期间锁是长时间持有的而原子 UPDATE 的锁持有时间非常短锁冲突的概率大幅下降。适用场景绝大多数互联网业务系统的余额扣减这也是我目前在项目中默认采用的方案。3. 缓存层方案性能与一致性的博弈有人会说即使原子 UPDATE 很快数据库毕竟是磁盘操作撑不住百万并发。于是很多人想到引入 Redis 缓存余额先扣缓存再异步同步数据库。这个思路本身没问题但做起来坑非常多。3.1 Redis 扣减方案先扣缓存再异步落库用 Redis 做余额扣减核心思路是把余额放在 Redis 里用DECR或 Lua 脚本原子扣减再通过 MQ 异步同步到数据库。-- 扣减余额的 Lua 脚本 local balance redis.call(GET, KEYS[1]) if not balance then return -1 -- 账户不存在 end if tonumber(balance) tonumber(ARGV[1]) then return -2 -- 余额不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 扣减成功Lua 脚本在 Redis 中执行是原子的这个方案在性能上能顶住极高的 QPS。但问题也随之而来第一Redis 中的数据并不是可靠存储。主从切换、宕机、AOF 重写异常都可能导致缓存里的余额和数据库不一致。第二异步落库的最终一致性需要保证MQ 消息丢失、重复消费、消费失败重试等问题一个比一个棘手。第三缓存与数据库的同步窗口期用户看到的余额可能是旧的。针对这些问题我见过几种加固措施但每种都有代价定时全量对账每天凌晨跑一次全量对账任务以数据库为准修正 Redis 中的余额。对账窗口内的一致性依然无法保证。扣减流水同步Redis 每笔扣减都生成一条流水MQ 消费后逐笔应用到数据库。流水量大时MQ 积压、重复消费问题会被放大。双写一致性先写 Redis再同步写数据库用分布式事务保证两边都成功。分布式事务本身就是一个大工程复杂度直接翻倍。3.2 缓存与数据库的一致性处理如果你还是想用 Redis 缓存余额这里分享一个比较可靠的加固方案。首先缓存只作为读加速不作为扣减决策依据。也就是余额的“权威数据”永远是数据库Redis 只存储热点账户的余额副本供查询。扣减操作直接走数据库扣完后主动失效缓存下次读取时回源数据库并重建缓存。其次写操作全部走数据库读操作走缓存。这个方案在一致性上放弃了 Redis 扣减的性能优势退回到纯数据库方案只是用缓存缓解了余额查询的压力。这样性能提升有限但一致性风险小得多。最后如果一定要用 Redis 做扣减需要建立以流水为核心的补偿机制。每一笔扣减在 Redis 和 MySQL 里都记录一条流水编号通过比对流水号来发现漏扣和多扣定期校正。这个方案工程量大但确实能保证最终一致。心得缓存的最终一致性方案核心是“确定谁才是最终真相”。我的做法是数据库是唯一事实源Redis 只是加速索引。所有绕开这一原则的方案到最后都会在某个凌晨的对账任务里还债。3.3 热点账户缓存更新策略实践针对热点账户比如秒杀活动中的单一商家账户缓存更新的策略很重要。我踩过的一个坑是“缓存穿透”当热点账户的余额被大量线程同时读取且缓存刚好失效时这些线程会把请求全部打到数据库数据库瞬间压力暴增。规避办法是加分布式锁重建缓存。同一时刻只允许一个线程回源数据库重建缓存其他线程短暂等待后重试读取缓存。再配合热点 key 多级缓存把单 key 的热点请求分散到多个副本 key 上能明显缓解 Redis 单 key 的压力。这个做法适用于极端热点场景比如明星直播带货时商家的账户余额被高频访问。关于缓存过期时间我用的是一个简单策略热点账户的缓存不设置过期时间而是通过数据库变更事件主动删除。因为热点账户的余额更新非常频繁固定 TTL 容易导致缓存刚被删就又被大量打库加了 TTL 反而是多余的负担。4. 架构层方案把并发流量挡在系统前面数据库和缓存的方案讲完了接下来聊聊架构层的思路。高并发余额扣减的核心矛盾是所有流量最终都要落在数据库同一个账户的同一行数据上这本身就是单点瓶颈。因此架构层的核心思路是“分流”和“削峰”。4.1 流量削峰与排队MQ 异步化方案把同步扣减改成异步扣减是应对瞬时高并发流量的经典方案。用户发起扣款请求后网关层立即返回“受理成功”同时把扣款消息发送到 MQ。后端服务消费消息按用户 ID 维度串行执行扣减操作。因为 MQ 天然有削峰填谷的作用数据库承受的压力从“瞬时的万级 QPS”变成了“平稳的千级 QPS”。这个方案有一个关键设计点必须保证用户 ID 只被同一个消费者线程处理。否则同一账户的多个扣款请求可能被不同消费者并发消费又回到数据库行锁竞争的泥潭。具体实现上可以用 RocketMQ 的 MessageQueue 选择器根据用户 ID hash 到固定的队列或者使用 RabbitMQ 的 Consistent Hash Exchange。// RocketMQ 按用户ID选择队列保证同一用户的扣款消息串行消费 MessageQueueSelector selector (mqs, msg, arg) - { Long userId (Long) arg; int index (int) (userId % mqs.size()); return mqs.get(index); }; SendResult result producer.send(message, selector, userId);异步化还有一个好处是天然支持重试。数据库死锁、网络抖动导致的扣款失败都可以通过 MQ 的重试机制自动补偿不用让用户重试。同时配合死信队列做兜底人工或者自动化任务处理最终失败的消息。代价用户感知的“扣款成功”不等于“真扣款成功”只是“扣款请求已被受理”。如果一秒后查询余额发现没扣用户可能产生疑惑所以业务上要有明确的扣款中状态提示。另外异步化给业务流程带来了状态管理的复杂度扣款结果需要通过回调、轮询或者消息通知告知用户。4.2 分布式锁按用户维度的并行控制如果不想引入 MQ 的复杂度还想保留同步调用的体验可以用分布式锁来保证同一用户的扣款请求串行执行。方案很简单以用户 ID 为锁的 key在扣款业务逻辑执行前加锁执行完释放锁。这样即使用户同时发起多个请求同一时刻只有一个请求能进入扣款逻辑其余请求要么等待锁释放要么直接快速失败。String lockKey balance:lock: userId; RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { // 拿锁失败返回“系统繁忙请稍后再试” throw new BizException(系统繁忙请稍后再试); } try { // 执行余额扣减逻辑 int result accountMapper.deductBalance(userId, amount); if (result 0) { throw new BizException(余额不足); } } finally { lock.unlock(); }分布式锁方案比 MQ 轻量且业务方是同步等待结果开发体验更接近普通的接口调用。但需要注意几点锁的粒度要细锁 key 必须包含用户 ID绝对不能全局一把锁否则所有用户的扣款全部串行化性能直接归零。锁的超时时间要合理执行业务时间超过锁的超时时间锁会被自动释放后续请求可能并发进入。解决办法是启动一个 WatchDog 自动续期Redisson 默认就带这个能力。锁的公平性如果不希望线程排队太久可以用公平锁。但公平锁的性能略低一般场景下非公平锁就够了。4.3 灰度与兜底架构方案的降级预案任何高并发系统都要有降级方案。余额扣减服务如果被流量打垮损失的可不是一两个接口的可用性而是整个交易链条的崩溃。所以务必在架构设计中预留降级开关同步转异步开关当数据库 RT 升高、线程池排队严重时将同步扣减降级为 MQ 异步扣减牺牲一部分体验保命。限流开关对单个用户的扣款频率做限流比如同一用户在 100ms 内只允许一个扣款请求进入核心逻辑其余直接拒绝。短路开关监控数据库连接池和锁等待时间超过阈值时对非核心账户的扣款请求直接返回兜底结果等系统恢复后再开放。这些开关的状态可以放在配置中心紧急情况下人工一键切换不允许因为链路故障导致用户既没扣款成功也不知道失败。5. 兜底方案分布式事务与账务一致性讲完了贷面的并发控制最后必须说兜底。因为再完善的并发控制也防不住跨服务调用的分布式事务问题。扣余额和加积分、扣库存、生成订单如果跨了多个服务必须考虑分布式事务的最终一致性。5.1 最终一致性与对账体系我的经验是交易核心链路绝不依赖分布式事务框架而是依赖最终一致性和对账。因为分布式事务在高并发下往往会成为新的瓶颈要么性能骤降要么隔离级别导致脏读。你真正需要的是三样东西本地消息表扣余额和写流水在同一条数据库事务里完成保证两者要么都成功要么都失败。这是最终一致性的地基。可靠消息投递业务表的事务提交成功后将消息写入消息表由独立任务轮询发送到 MQ。消息发送成功后才删除本地消息记录否则重试。对账任务定时比对流水表、余额表和外部账单发现差异后走人工或自动补偿流程。这套东西虽然听起来不如“TCC 事务”高级但胜在简单可靠、吞吐高而且是互联网大厂验证过无数次的方案。我之前负责的一个钱包项目日交易流水千万级就是靠“本地消息表 MQ 对账任务”支撑起来的从来没有因为分布式事务出现过资金差错。5.2 流水记录每笔扣款都必须留下痕迹很多新手做余额扣减时忽略了一个关键环节——流水表。实际上余额扣减和数据流写入必须放在同一个数据库事务里这是账务系统最底层的定律。Transactional(rollbackFor Exception.class) public void deductBalance(Long userId, BigDecimal amount, String bizOrderNo) { // 1. 原子扣减余额 int result accountMapper.deductBalanceWithCheck(userId, amount); if (result 0) { throw new BizException(余额不足); } // 2. 写扣减流水与扣余额同事务 AccountFlow flow new AccountFlow(); flow.setUserId(userId); flow.setChangeAmount(amount.negate()); flow.setBizOrderNo(bizOrderNo); flowMapper.insert(flow); }流水的核心字段至少要包含用户 ID、变动金额正负号表示入账和扣减、业务订单号、交易时间、账户操作前余额、操作后余额。有了前余额和后余额对账和审计才有了凭据。还有一个细节流水表必须以业务订单号做唯一索引。这样可以防止“同一个订单重复扣款”——这是幂等性的最后一道防线。如果订单号相同直接返回“该订单已处理”不再执行扣减。5.3 资金安全与幂等设计幂等性在余额扣减里怎么强调都不过分。网络超时、M Q 重试、前端重复点击任何一个环节都可能导致同一笔扣款被重复执行。我常用的幂等方案是基于业务订单号做防重扣款前先查询流水表如果该业务订单号已有扣减流水直接返回成功。如果没有执行扣款并插入流水同事务。流水表上业务订单号加唯一索引数据库层兜底防重。这套方案是“先查再插”“唯一索引”的组合拳虽然在高并发下两个请求同时查到不存在的订单号可能都会尝试插流水但唯一索引会保证只有一个成功另一个插入报错后事务回滚不会产生超扣。注意幂等设计最重要的原则是“先防重、后扣款”顺序不能反。反过来的话先扣款再检查幂等就可能在扣款前就存在重复消费的隐患。6. 全链路实践高可用余额扣减系统的完整落地前面几节分别讲了数据库、缓存、MQ、分布式锁、流水和幂等这一节我把它们组合起来给出一套我自己在项目中验证过的完整落地参考方案。这个方案适用于千万级用户、日订单量百万级、峰值 QPS 上千的系统。6.1 整体架构设计整个余额扣减链路分为三层接入层负责限流、风控、鉴权把非法流量和超频请求挡在最前面。这一层可以用 Nginx 网关实现针对用户维度的限流规则在网关层做。服务层包含扣款服务、余额查询服务、流水查询服务。扣款服务是这个链路的核心同步走数据库事务异步场景走 MQ 消费。用户维度的并发控制在服务层通过红锁或队列选择器实现。存储层包括 MySQL账户表和流水表、Redis余额缓存和分布式锁、RocketMQ异步扣款消息。核心流程如下用户发起扣款请求网关层做限流。服务层校验幂等查询流水表确认该业务订单是否已处理过。加分布式锁按用户 ID防止同一用户的并发请求。在数据库事务内执行“原子 UPDATE 判断余额”和“插入流水”。扣款成功后删除 Redis 余额缓存保证下次读到的是最新值。如果有跨服务衍生操作如积分变更、优惠券核销通过本地消息表 MQ 异步处理。这套流程最核心的特点是并发控制下放在服务层一致性兜底放在数据库层性能提升依赖缓存和异步。每一层各司其职不会出现“什么都想做什么都做不好”的局面。6.2 关键配置与参数参考我把自己常用的一些核心参数整理成一个表方便大家直接抄作业。注意这些参数需要根据实际机器规格和压测结果调整不要盲目照搬。配置项推荐参数说明MySQL 连接池最大连接数50不要盲目调大连接数过多反而增加锁竞争和上下文切换开销数据库事务超时时间5s事务内只做扣款插流水两个操作超过 5s 说明有锁等待需要排查Redis 分布式锁等待时间500ms超过直接返回失败让用户重试避免线程长时间阻塞MQ 消费线程数与用户量匹配建议 16~32保证同一用户的消息串行消费即可不要盲目增加线程数MySQL 单表流水量阈值2000 万超过后建议分表推荐按用户 ID 哈希分表账户表是否分库分表是商城项目建议至少按用户 ID 分 16 库降低单库热点余额缓存 TTL热点账户不设 TTL靠数据库变更事件主动失效避免缓存穿透关于分库分表多说一句账户表的分拆一定要按用户 ID 哈希不能按余额或者其他业务维度。因为余额扣减永远以用户 ID 为操作维度分库分表后同一用户的账户记录和流水必须落在同一个库的同一张表上否则事务就无法跨库保证了。6.3 性能压测方案与预期指标落地之后压测是必须的一步。我通常用 JMeter 或者自研压测脚本分三个场景进行场景一单用户高频扣款。模拟同一用户连续发起 1000 次小额扣款观察是否出现超扣和死锁。该场景最考验并发控制正常结果应该是全部成功或部分提示余额不足绝不能出现负余额。场景二多用户均匀流量。模拟 10 万用户、峰值 QPS 5000 的随机扣款流量观察整体吞吐、平均耗时和数据库连接池、锁等待指标。场景三热点账户突刺。模拟 10000 个请求同时扣同一个商家账户的余额观察行锁等待和接口耗时验证限流和排队机制是否生效。在上面的架构下我压测出来的参考指标大约是多用户均匀流量场景下峰值 QPS 6000 时接口平均 RT 稳定在 50ms 以内P99 在 150ms 以内热点账户突刺场景下经过限流和排队后数据库行锁等待率能控制在 5% 以下。这个量级对于绝大多数电商和内容付费业务来说已经很够用了。6.4 上线监控与告警指标系统上线后光靠“代码正确”还不够必须有完善的监控告警。针对余额扣减系统我重点关注这几个指标数据库行锁等待时间超过 100ms 需要关注超过 500ms 必须告警。扣款失败率失败率超过 1% 就需要排查是余额不足还是系统异常要区分开。余额负数率出现负数说明有 bug立即告警。Redis 缓存命中率低于 90% 说明缓存策略有问题。MQ 消费积压量积压超过阈值说明消费能力不足需要扩容。监控数据可以用 Prometheus Grafana 搭一套数据库指标用 MySQL Exporter应用指标自己埋点上报。7. 常见问题与排查技巧实录这部分整理几个我实际碰到过的高频问题每个都附上排查思路和解决办法全是干货。7.1 明明加了余额判断怎么还是扣成负数一种情况是事务隔离级别设置不当。如果业务代码里先查了余额再做扣减两个操作之间的时间窗口里另一个事务可能已经扣过这一笔了。解决办法就是放弃“先查后改”全部改成原子 UPDATE 带余额条件。另一种情况是精度问题。余额字段如果用 Double 或 Float 类型存储浮点数运算误差会累积。比如 0.1 0.2 在计算机里并不等于 0.3扣减多次后余额就可能变成负数或者不平。解决办法是使用 Decimal 类型同时金额单位统一用“分”存储Java 里用 BigDecimal 运算。7.2 数据库死锁怎么快速定位和解决死锁发生时先在数据库里执行SHOW ENGINE INNODB STATUS;查看 LATEST DETECTED DEADLOCK 部分里面会明确告诉你死锁涉及哪些事务、持有哪些锁、等待哪些锁。我遇到最多的场景是两个事务都以不同的顺序操作账户表和流水表导致相互等待。解法是统一锁的获取顺序。比如约定先操作账户表再操作流水表所有业务代码都必须遵守这个约定死锁概率会大大降低。即便偶尔死锁应用层也要做重试机制捕获死锁异常后延迟 100ms 重试一次一般就能成功。7.3 MQ 重复消费导致同一订单被扣两次这是个经典问题。解决思路是幂等表机制在接收 MQ 消息后先查流水表判断该业务订单号是否已存在存在则直接返回成功不执行扣款不存在则执行扣款并插入流水。这里的关键点我已经强调过多次流水表的业务订单号一定要加唯一索引数据库层面兜底防重。7.4 缓存和数据库余额不一致出现不一致优先排查缓存失效逻辑。比如扣款成功后是否主动删了缓存如果删了下一次读会不会因为重建缓存用到旧数据。还有一个隐蔽问题分布式环境下多实例部署时每个实例的本地缓存不一致。我的建议是余额这类强一致数据就不要用本地缓存只用 Redis 做分布式缓存并且所有扣款操作完成后统一发送缓存删除消息。7.5 热点账户拖垮整个数据库这是高并发余额系统最大的隐患。我经历过一次事故某个大主播直播带货其商家账户被瞬时几十万粉丝下单扣款指向MySQL 的行锁竞争达到了崩溃级别整个库的响应时间从 5ms 飙升到超过 1s导致所有商户的订单业务全部受到影响。解决思路分两步。第一步是服务层加分布式锁让同一账户的扣款请求串行排队。第二步是 MQ 削峰把瞬时峰值流量分散到时间轴上让数据库只收到平滑的扣款请求。如果热点账户再极端还可以单独把该账户的数据隔离到独立的高规格数据库实例中避免影响其他账户。8. 扩展思考敏感操作的安全与审计余额扣减直接关系资金安全所以除了技术问题还要考虑安全与合规问题。这里简单提三个容易被忽视的点。第一操作权限管控。余额调整操作人工退款、后台调账必须走独立的审批流程不能和普通用户自助扣款混在一起。系统要能区分“用户主动扣款”和“管理员调账”并分别记录审计日志。第二敏感操作通知。余额发生变动后及时推送通知给用户告知余额变动明细。一旦出现异常扣款用户能立刻发现并反馈这比系统自己发现要快得多。第三白名单与黑名单机制。针对大额扣款、频繁扣款、异地登录等异常行为在接入层做风控拦截。宁可误伤一些正常用户也要保证资金不被盗刷。经验做余额系统的安全感永远来自于“出事能及时发现发现后能准确追责”。流水表的完善程度决定了你事故处理能力的上限。一定要把流水的字段设计想清楚宁可多留一些冗余字段也别事后加字段迁移数据。最后分享一点个人体会抛开花里胡哨的框架和架构余额扣减最本質的东西就是把“并发下不会出错”这个底线守住。原子 UPDATE 是底线流水和幂等是底线对账兜底也是底线。缓存、MQ、分布式锁都只是为了让底线在更高并发下依然有效。这个方向我也还在持续摸索。如果你正在做钱包、支付、订单这类系统希望这篇文章能帮你少踩几个坑。也欢迎大家一起交流实际业务中的余额扣减方案互相补补课。