尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

高并发下余额扣减的完整方案:乐观锁、悲观锁与Redis预扣实战

高并发下余额扣减的完整方案:乐观锁、悲观锁与Redis预扣实战 余额扣减这个话题我做了好几年的交易系统几乎每年都会被拉出来讨论一次。网上讲乐观锁、讲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。并发情况下两个请求都查到了version1先执行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) ) ENGINEInnoDB;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) ) ENGINEInnoDB;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维度做防护。具体配置规则落在控制台规则形如“对参数索引0userId的访问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预扣的那一天你已经有足够的数据和经验来判断什么该缓存、什么必须落库、对账怎么做。技术方案不是越新越复杂越好能在正确性、性能和维护成本之间找到平衡点的才是最适合你的方案。
返回列表