先问一个问题:在订单支付、会员充值、优惠券核销这类业务里,你有没有遇到过用户疯狂点击“提交订单”,结果账户余额被扣成负数,或者同一笔订单被扣了两次钱的情况?
余额扣减看起来只是“查余额、减金额、写回库”三步操作,但一旦并发量上来,这条简单的链路就会变成事故现场:超扣、负余额、重复扣款、死锁、库存对不上……问题一个接一个。
本文将从最基础的数据库操作讲起,逐步拆解高并发场景下余额扣减的常用方案,包括数据库原子更新、乐观锁、悲观锁、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. 环境准备与示例项目结构
在开始写代码之前,先说明一下本文示例的运行环境。本文不绑定某一个固定版本,重点演示设计思路,你在实际项目中可以根据团队技术栈调整。
| 组件 | 说明 |
|---|---|
| JDK | 8+ |
| Spring Boot | 2.x 或 3.x 均可 |
| 数据库 | MySQL 5.7+ |
| Redis | 5.0+(方案二和三使用) |
| ORM | MyBatis-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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 id="deductBalance"> UPDATE t_account SET balance = balance - #{amount} WHERE user_id = #{userId} AND balance >= #{amount} </update>Service 层实现:
// 文件路径: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 XML:
<update id="deductBalanceByVersion"> UPDATE t_account SET balance = balance - #{amount}, version = version + 1 WHERE user_id = #{userId} AND version = #{version} </update>4.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 RedisTemplate<String, 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 脚本 DefaultRedisScript<Long> 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 预扣减和异步对账。每一步演进都应该由真实的业务指标驱动,而不是拍脑袋决定。
总而言之,把扣减动作原子化、把流水记录完整化、把重复请求幂等化、把最终一致性兜底化,这四件事做好了,余额扣减这个“老大难”问题就能被稳稳拿捏。