ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

高并发余额扣减方案剖析:从数据库原子更新到Redis预扣减

高并发余额扣减方案剖析:从数据库原子更新到Redis预扣减 先问一个问题在订单支付、会员充值、优惠券核销这类业务里你有没有遇到过用户疯狂点击“提交订单”结果账户余额被扣成负数或者同一笔订单被扣了两次钱的情况余额扣减看起来只是“查余额、减金额、写回库”三步操作但一旦并发量上来这条简单的链路就会变成事故现场超扣、负余额、重复扣款、死锁、库存对不上……问题一个接一个。本文将从最基础的数据库操作讲起逐步拆解高并发场景下余额扣减的常用方案包括数据库原子更新、乐观锁、悲观锁、Redis 预扣减、异步对账最终一致性等。每个方案都会给出可运行的示例代码、适用场景和坑点说明希望能帮你建立一套完整的余额扣减技术方案选型认知。1. 为什么余额扣减在高并发下这么难1.1 余额扣减的本质是“资源竞争”余额在业务系统里本质是一种可消耗资源。用户发起一笔扣减请求本质上是请求系统从某个账户中划走一部分余额。这个操作并不复杂复杂的是多个请求同时发生时系统如何保证不超扣账户余额不能扣成负数除非业务允许透支。不重复扣同一笔支付请求不能被处理两次。不丢更新两个并发请求不能互相覆盖对方的扣减结果。这三个问题本质上都是并发控制问题。1.2 先看最容易出错的“读改写”写法很多新手第一次实现余额扣减会天然地按顺序写代码// 文件路径src/main/java/com/example/balance/service/WalletService.java // 注意这段代码存在并发安全问题仅用于演示错误写法 public boolean deduct(Long userId, BigDecimal amount) { // 1. 查出当前余额 Account account accountMapper.selectByUserId(userId); BigDecimal balance account.getBalance(); // 2. 判断余额是否充足 if (balance.compareTo(amount) 0) { throw new BusinessException(余额不足); } // 3. 扣减余额 BigDecimal newBalance balance.subtract(amount); account.setBalance(newBalance); accountMapper.updateById(account); return true; }这段代码在单线程下没有任何问题。但在高并发场景下两个线程同时读到余额为 100 元同时判断余额充足同时扣减 50 元最后写回的还是 50 元而正确的余额应该是 0 元。这就是典型的“读改写”并发问题本质上是丢失更新。1.3 高并发余额扣减的评判标准判断一个余额扣减方案是否合格可以从以下四个维度来看维度说明正确性不超扣、不重复扣、不丢更新性能单接口在并发量下是否还能保持较低延迟一致性数据库与缓存、异步流水是否最终一致可排查性出现问题后能否通过日志和流水快速定位不同的业务阶段对这四点的侧重点不同。早期项目用户量小数据库方案完全够用到了大促、秒杀这类场景可能就要引入缓存和异步队列。2. 环境准备与示例项目结构在开始写代码之前先说明一下本文示例的运行环境。本文不绑定某一个固定版本重点演示设计思路你在实际项目中可以根据团队技术栈调整。组件说明JDK8Spring Boot2.x 或 3.x 均可数据库MySQL 5.7Redis5.0方案二和三使用ORMMyBatis-Plus 或 MyBatis构建工具Maven示例项目的目录结构如下balance-demo ├── pom.xml ├── sql │ └── init.sql └── src/main/java/com/example/balance ├── BalanceApplication.java ├── controller │ └── WalletController.java ├── entity │ └── Account.java ├── mapper │ └── AccountMapper.java ├── service │ ├── WalletService.java │ └── WalletServiceImpl.java └── common └── BizException.java先来看数据库中账户表的结构。以下 SQL 会在后面的所有示例中使用。-- 文件路径sql/init.sql CREATE TABLE t_account ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_id bigint(20) NOT NULL COMMENT 用户ID, balance decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 账户余额, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户表; CREATE TABLE t_balance_log ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_id bigint(20) NOT NULL COMMENT 用户ID, biz_type varchar(32) NOT NULL COMMENT 业务类型ORDER_PAY-订单支付, RECHARGE-充值, biz_id varchar(64) NOT NULL COMMENT 业务单号如订单号, amount decimal(12,2) NOT NULL COMMENT 变动金额正数为加负数为减, balance_after decimal(12,2) NOT NULL COMMENT 变动后余额, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_biz_id_type (biz_id, biz_type), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT余额流水表;这里特意给流水表加了uk_biz_id_type唯一索引它的作用后面讲到“幂等”时会提到。3. 方案一数据库原子更新最基础也是最重要的方案3.1 核心思路把“读改写”变成一次原子 SQL在第一节的错误示例中问题出在“先查后写”。如果能把判断余额和扣减金额合成一条 SQL就能避免并发覆盖。-- 核心SQL UPDATE t_account SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount};这条 SQL 做了三件事直接在数据库层面扣减余额。通过balance #{amount}条件检查余额是否充足。返回的影响行数可以用来判断是否扣减成功。为什么这条 SQL 是原子的因为在 InnoDB 存储引擎下UPDATE语句会对匹配的行加行锁直到事务提交或回滚。两个并发请求同时执行这条 SQL 时后一个请求会等待前一个请求释放锁然后再执行。3.2 对应的 Java 实现先定义 mapper 方法// 文件路径src/main/java/com/example/balance/mapper/AccountMapper.java public interface AccountMapper { /** * 原子扣减余额 * * param userId 用户ID * param amount 扣减金额 * return 影响行数1表示扣减成功0表示扣减失败余额不足 */ int deductBalance(Param(userId) Long userId, Param(amount) BigDecimal amount); }对应的 XML!-- 文件路径src/main/resources/mapper/AccountMapper.xml -- update iddeductBalance UPDATE t_account SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount} /updateService 层实现// 文件路径src/main/java/com/example/balance/service/WalletServiceImpl.java Service public class WalletServiceImpl implements WalletService { Resource private AccountMapper accountMapper; Resource private BalanceLogMapper balanceLogMapper; Override Transactional(rollbackFor Exception.class) public boolean deduct(Long userId, BigDecimal amount, String bizId) { // 1. 幂等校验如果该业务单号已经处理过直接返回成功 int logCount balanceLogMapper.countByBizIdAndType(bizId, ORDER_PAY); if (logCount 0) { return true; } // 2. 原子扣减余额 int rows accountMapper.deductBalance(userId, amount); if (rows 0) { throw new BusinessException(余额不足或账户不存在); } // 3. 查询扣减后的余额 Account account accountMapper.selectByUserId(userId); // 4. 记录流水 BalanceLog log new BalanceLog(); log.setUserId(userId); log.setBizType(ORDER_PAY); log.setBizId(bizId); log.setAmount(amount.negate()); log.setBalanceAfter(account.getBalance()); balanceLogMapper.insert(log); return true; } }3.3 关键点解释为什么扣减和写流水要在同一个事务里如果扣减成功了但流水没写成功事务回滚扣减也会回滚这样两边是一致的。如果拆成两个事务中途发生异常就会出现余额已扣但流水缺失的问题对账时很难排查。为什么流水表要建唯一索引为了幂等。网络超时、接口重试、MQ 重复消费都可能导致同一个业务单号被处理多次。有了uk_biz_id_type唯一索引第二次插入流水时就会报唯一键冲突我们可以在代码里捕获这个异常并直接返回成功。这个设计在分布式系统里非常重要。3.4 这种方案的优缺点优点实现简单不依赖额外中间件。数据库本身保证强一致。性能在大多数业务场景下够用。缺点数据库行锁会增加竞争并发量极高时性能会成为瓶颈。UPDATE操作会占用数据库连接扣减操作耗时较长时连接池可能被打满。3.5 适用场景大多数常规业务场景例如普通商城下单、会员余额支付、话费充值只要单账户的扣减 QPS 不是极高数据库原子更新是最值得优先考虑的方案。之前我维护过一个电商项目最初用的就是这种方案单账户日扣减峰值在每分钟几百次数据库毫无压力。后来上线秒杀活动才开始引入下面的 Redis 方案。4. 方案二乐观锁方案适合需要友好提示的读多写少场景4.1 乐观锁的原理乐观锁的思路是不在 SQL 层面做余额检查而是通过版本号来判断数据是否被修改过。每次更新时带上版本号条件如果版本号不匹配说明数据已经被其他事务修改更新失败。4.2 核心 SQL-- 核心SQL UPDATE t_account SET balance balance - #{amount}, version version 1 WHERE user_id #{userId} AND version #{version};4.3 Java 实现// 文件路径src/main/java/com/example/balance/service/WalletServiceImpl.java Override Transactional(rollbackFor Exception.class) public boolean deductWithOptimisticLock(Long userId, BigDecimal amount, String bizId) { // 1. 查询账户获取当前版本号 Account account accountMapper.selectByUserId(userId); if (account null) { throw new BusinessException(账户不存在); } // 2. 判断余额 if (account.getBalance().compareTo(amount) 0) { throw new BusinessException(余额不足); } // 3. 按版本号更新 int rows accountMapper.deductBalanceByVersion(userId, amount, account.getVersion()); if (rows 0) { // 更新失败说明版本号已变化可以重试或提示用户 throw new BusinessException(系统繁忙请重试); } // 4. 写流水省略 return true; }对应 mapper XMLupdate iddeductBalanceByVersion UPDATE t_account SET balance balance - #{amount}, version version 1 WHERE user_id #{userId} AND version #{version} /update4.4 乐观锁的局限乐观锁在余额扣减场景中的问题在于高并发下失败率会明显上升。如果 100 个线程同时抢着扣同一个账户真正的成功次数很少大量请求需要重试或直接失败。用户感知差。相比数据库原子更新在一条 SQL 内完成判断乐观锁需要先查一次再更新多了一次查询也多了版本冲突的可能。所以乐观锁更适合“并发竞争不激烈但偶尔有冲突”的场景比如编辑资料、修改配置。余额扣减这种高频强竞争操作并不适合把它作为首选方案。5. 方案三Redis 预扣减 异步对账秒杀场景常用5.1 为什么要引入 Redis当扣减 QPS 上升到每秒几千甚至上万数据库原子更新就会暴露出两个问题行锁竞争导致大量请求排队数据库连接池被打满。数据库压力过大影响其他核心业务。Redis 是单线程模型DECR、EVAL这类操作天然是原子的处理扣减请求的能力远高于数据库。在秒杀场景中可以先在 Redis 中完成余额预扣减再由异步任务把扣减结果同步到数据库。5.2 流程设计简单的流程如下系统启动时或用户注册时把账户余额同步到 Redis。用户发起扣减请求时先执行 Redis 的 Lua 脚本完成余额检查和扣减。扣减成功后把一条“待入账流水”写入消息队列。异步任务消费消息在数据库中执行真实的扣减和流水记录。这个方案的核心是Redis 高并发挡流量数据库异步落账保证最终一致。5.3 Lua 脚本扣减示例Redis 的 Lua 脚本可以保证多个操作的原子性下面这段脚本实现“余额充足则扣减并返回扣减后余额余额不足则返回 -1”。-- 文件路径src/main/resources/deduct_balance.lua -- KEYS[1] 余额key例如 balance:user:1001 -- ARGV[1] 扣减金额整数单位分为避免小数问题 if tonumber(redis.call(GET, KEYS[1]) or 0) tonumber(ARGV[1]) then return redis.call(DECRBY, KEYS[1], ARGV[1]) end return -1由于余额通常用小数表示在 Redis 中建议以“分”为单位存储整数避免浮点数精度问题。5.4 Java 侧调用// 文件路径src/main/java/com/example/balance/service/WalletServiceImpl.java Resource private RedisTemplateString, String redisTemplate; private static final String BALANCE_KEY_PREFIX balance:user:; private static final String DEDUCT_LUA if tonumber(redis.call(GET, KEYS[1]) or 0) tonumber(ARGV[1]) then return redis.call(DECRBY, KEYS[1], ARGV[1]) else return -1 end; public boolean deductWithRedis(Long userId, BigDecimal amount, String bizId) { String balanceKey BALANCE_KEY_PREFIX userId; // 金额转换为分 long amountInCents amount.multiply(BigDecimal.valueOf(100)).longValue(); // 执行 Lua 脚本 DefaultRedisScriptLong script new DefaultRedisScript(DEDUCT_LUA, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(balanceKey), String.valueOf(amountInCents)); if (result null || result -1L) { throw new BusinessException(余额不足); } // 扣减成功发送 MQ 消息异步落库 // mqProducer.send(buildMessage(userId, amount, bizId)); return true; }5.5 这种方案的核心问题Redis 和数据库不一致Redis 预扣减方案并非银弹它最大的难点在于保证 Redis 与数据库的最终一致。常见的不一致场景Redis 扣减成功MQ 消息丢失数据库没扣。Redis 扣减成功异步任务执行失败数据库没扣。用户重复请求Redis 扣了两次数据库也扣了两次。解决思路分为两块第一保证 MQ 消息不丢。使用 RocketMQ / Kafka 等带有持久化机制的消息队列并开启生产者确认和消费者手动 ACK。第二落库消费要幂等。也就是前面提到过的唯一索引。消费者在写余额流水时如果发现biz_id biz_type已经存在直接忽略这条重复消息。另外还需要一个定时对账任务定期比对 Redis 中的余额和数据库中的余额发现不一致时以数据库流水为准进行修正。图片里常说的“余额扣减架构图”本质上就是围绕“Redis 挡流量、MQ 做缓冲、数据库落账、定时任务兜底”这四个环节来设计的。6. 异步落库的最终一致性实现6.1 为什么必须落库Redis 中的余额是缓存状态不能作为最终数据源。原因在于Redis 宕机可能导致数据丢失即使开启 AOF 持久化也可能丢失最近一秒的数据。Redis 中的数据缺少完整的审计流水无法支撑对账、客服查询、财务结算。所以无论 Redis 侧扣减多快最终都必须落库并且每笔扣减都要有对应的流水记录。6.2 简化版异步落库代码下面用一个简单的线程池模拟异步落库过程实际项目中建议替换为消息队列。// 文件路径src/main/java/com/example/balance/service/async/AsyncDeductService.java Service public class AsyncDeductService { Resource private AccountMapper accountMapper; Resource private BalanceLogMapper balanceLogMapper; Resource private ThreadPoolTaskExecutor taskExecutor; public void asyncDeduct(Long userId, BigDecimal amount, String bizId) { taskExecutor.execute(() - { // 这里同样要做幂等判断并在事务内执行 doDeduct(userId, amount, bizId); }); } Transactional(rollbackFor Exception.class) public void doDeduct(Long userId, BigDecimal amount, String bizId) { // 幂等判断流水表已存在则跳过 int count balanceLogMapper.countByBizIdAndType(bizId, ORDER_PAY); if (count 0) { return; } // 数据库原子扣减 int rows accountMapper.deductBalance(userId, amount); if (rows 0) { // 理论情况下不会出现因为 Redis 已扣减成功 // 但为了保险这里要记录告警日志触发人工处理 log.error([异步落库] 余额扣减失败userId{}, amount{}, bizId{}, userId, amount, bizId); return; } // 查余额并写流水 Account account accountMapper.selectByUserId(userId); BalanceLog log new BalanceLog(); log.setUserId(userId); log.setBizType(ORDER_PAY); log.setBizId(bizId); log.setAmount(amount.negate()); log.setBalanceAfter(account.getBalance()); balanceLogMapper.insert(log); } }6.3 扣减后还需要关注回滚引入 Redis 预扣减之后还要考虑“扣了但订单没支付成功”的场景。通常做法是订单超时未支付时需要给用户退款。退款同样走流水表记录并且要保证不能重复退款。退款前要检查原支付单的状态只有支付成功的单才能发起退款。退款在实现上就是“加余额”操作但同样要防止超退、重复退。7. 常见问题与排查思路7.1 高并发扣减常见问题汇总下面这张表汇总了我在实践中常见的几类问题问题现象常见原因解决思路余额被扣成负数代码使用了“查余额→判断→改余额”的读改写模式替换为数据库原子更新加上balance amount条件同一笔订单被扣两次接口重试、MQ 重复消费流水表加biz_id biz_type唯一索引做幂等处理数据库更新死锁多个更新语句的加锁顺序不一致统一按固定顺序更新例如按 user_id 排序Redis 扣减成功但数据库没扣异步任务失败或消息丢失使用持久化 MQ开启 ACK定时任务对账补偿并发极高时数据库连接池被打满大量请求同时执行 UPDATE 触发行锁等待引入 Redis 预扣减数据库异步落库退款重复到账退款接口被重复调用或回调重复通知退款流水也建唯一索引退款前检查原单状态7.2 死锁问题排查思路如果系统报警Deadlock found when trying to get lock; try restarting transaction优先排查同一事务里对多张表的加锁顺序。比如事务 A 先更新t_account再更新t_balance_log事务 B 先更新t_balance_log再更新t_account两个事务互相等待对方的锁就产生了死锁。解决办法是约定全局统一的更新顺序比如所有事务都先更新主表再更新流水表。另外单事务内更新的行数越少持锁时间越短死锁概率也越低。7.3 如何排查超扣问题排查超扣问题时不要只盯着余额字段要结合流水表逐笔还原计算。操作步骤找到异常账户。按时间范围查出该用户的所有余额流水。从某个基准时间点的余额开始把所有流水金额按时间顺序累加。对比计算出的余额与当前余额是否一致。如果不一致说明存在丢失更新、漏记流水或重复扣减。这类问题如果手动做一遍非常痛苦所以生产环境一定要把流水表设计好并且定期跑对账任务。8. 最佳实践与工程建议8.1 金额存储一律用最小单位Java 中double和float都不能精确表示十进制小数BigDecimal在计算上是安全的但 Redis 脚本里处理时仍然建议统一转成“分”进行整数运算。数据库字段建议使用DECIMAL(12,2)或者直接使用BIGINT存储“分”。使用整数存储的好处是不会出现浮点精度问题而且后续如果要上 Redis 预扣减数据结构也更容易对齐。8.2 唯一索引是幂等的基石不管是数据库原子更新方案还是 Redis 预扣减 异步落库方案只要有写流水表的动作biz_id biz_type唯一索引几乎是必备的。常见的方式是捕获唯一键冲突异常然后按照“已处理过”逻辑处理而不是直接抛 500 错误。// 文件路径src/main/java/com/example/balance/service/WalletServiceImpl.java try { balanceLogMapper.insert(log); } catch (DuplicateKeyException e) { // 已处理过直接返回成功 log.info(重复流水忽略处理bizId{}, bizId); }8.3 事务时间要短余额扣减的事务里尽量只做与扣减强相关的操作不要把发短信、调第三方接口、生成大量报表数据放在同一个事务里。事务时间越长数据库连接占用越久行锁持有时间也越长系统吞吐量会明显下降。8.4 对账任务必须有只要系统里有两条数据链路比如 Redis 和数据库就一定要有对账任务。对账不一定要实时每天凌晨跑一次即可。发现差异生成告警转人工或自动补偿。具体补偿逻辑要根据业务确定不能无脑加钱或扣钱。8.5 生产环境变更有红线余额扣减属于资金链路核心操作任何变更都必须在测试环境完成充分验证后再上生产。上线前需要做好数据库备份。变更脚本评审。灰度发布。监控大盘关注接口 QPS、成功率、平均耗时、数据库死锁次数、Redis 扣减失败数。回滚方案确认新代码可以快速回退到旧版本。8.6 日志记录足够详细每条扣减请求都应该有完整的日志链路至少包含用户 ID。扣减金额。业务单号。扣减前余额。扣减后余额。请求来源。耗时。这样出现问题后才能根据日志快速还原现场。8.7 选择合适的方案不要一开始就上 Redis我在项目里见过一种情况业务量每天只有几百单却把所有余额扣减都改成了 Redis 预扣减 MQ 异步落库结果引入了一堆对账、补偿、消息丢失的问题维护成本很高。方案选型应该跟着业务阶段走初期用户量小数据库原子更新 唯一索引 流水表就够了。用户量增长单账户扣减频率变高优先优化数据库连接池参数、SQL 索引或者做账户级分区。真正进入秒杀、大促场景再考虑 Redis 预扣减 异步落库。不要为了“高并发”这三个字过早引入不必要的复杂度。9. 总结高并发下的余额扣减没有一种放之四海而皆准的方案而是一套组合拳。最核心的一点是永远不要在代码里先查余额再改余额要用数据库原子操作或者在 Redis 侧用原子脚本完成“检查并扣减”。在这个前提下再根据业务并发量和一致性要求选择不同的落库策略。如果你正在设计或维护一个涉及余额扣减的系统可以从数据库原子更新 幂等流水表开始先把正确性做扎实等流量真正上来了再逐步演进到 Redis 预扣减和异步对账。每一步演进都应该由真实的业务指标驱动而不是拍脑袋决定。总而言之把扣减动作原子化、把流水记录完整化、把重复请求幂等化、把最终一致性兜底化这四件事做好了余额扣减这个“老大难”问题就能被稳稳拿捏。
返回列表