不上悲观锁也不上 Redis:一个 AI 平台的钱包并发是怎么搞的
摘要:本文分享了一个AI平台积分钱包系统应对并发扣减问题的实战方案。面对LLM调用场景下长事务锁定的挑战,我们放弃了会阻塞其他操作的悲观锁和引入成本过高的Redis分布式锁,转而采用基于数据库版本号的乐观锁,并结合应用层自动重试机制。文章详细介绍了MyBatis-Plus中乐观锁的配置、重试策略的关键实现(必须每次重试都重新读取最新数据),以及配合幂等键防止重复扣费的完整思路。该方案上线后运行稳定,在保证数据一致性的同时,避免了长时间锁表和引入额外中间件的复杂度。
事情是这样的。
我们这个 AI 平台有套"星源值"积分钱包,用户充值换星源值,调模型的时候按 token 扣。本来挺简单一事------查余额、扣钱、完事。直到有一天测试妹子跑过来跟我说:"我开了三个浏览器标签页同时发消息,结果扣费扣出负数了。"
我一看日志,好家伙,三个请求几乎同时读到同一个余额(比如还剩 50),各自算出"够扣 30",然后都去扣。UPDATE wallet SET star_credits = star_credits - 30 这条 SQL 跑了三遍,最后余额变成了 -40。
经典的并发扣减问题。教科书级的那种。
先说说我一开始是怎么想的(错的)
第一反应:上锁呗。MySQL 行锁,SELECT ... FOR UPDATE 把钱包那行锁住,扣完再放。多简单。
sql
BEGIN;
SELECT star_credits FROM t_wallet WHERE user_id = 1 FOR UPDATE; -- 锁住
-- 应用层算好新余额
UPDATE t_wallet SET star_credits = 20 WHERE user_id = 1;
COMMIT;
写完自己跑了一下,没问题,并发解决了。正准备提测,突然反应过来一件事------我们这是 LLM 调用啊。
LLM 调用的特点是啥?慢。一个流式对话请求,从 reserve(预扣)到 commit(结算),中间隔着整个模型推理过程,动辄十几秒,长的能到几分钟。要是在这段时间里用 FOR UPDATE 把钱包行锁住......
那这个用户的其他所有请求全得排队等。改个头像?等。查个余额?等。而且万一某个请求卡住了(比如上游超时),锁就一直不释放,后面全堵死。数据库连接池那点连接也不够这么造的。
更要命的是,如果同时有几百个用户在调模型,每个都持有行锁几分钟,MySQL 的连接数和锁等待会直接爆掉。前面那篇 502 的事故里,连接池泄漏就是这么把服务搞挂的------我可不想再来一次。
所以悲观锁这条路,对于"锁住时间很长"的场景,基本是条死路。
那分布式锁呢?
我也考虑过 Redis 分布式锁(Redisson 那套)。理论上也能解决并发,而且不占数据库连接。
但是------我们整个项目没有 Redis。对,一个 Spring Boot 项目,没引 Redis,连缓存都是直接查库的。为了一个扣费场景单独把 Redis 拉进来,感觉有点重了。还得维护 Redis 的高可用、搞主从、担心锁超时续期的问题(Redisson 的 watchdog 那一套),运维成本一下子上来了。
而且说实话,扣费这个场景的并发冲突没那么高频。一个用户同时发三个请求是偶尔的事,大多数时候同一个钱包的更新都是串行的。为了这种"偶发冲突"上分布式锁,杀鸡用牛刀。
乐观锁:适合"冲突不多但要兜住"的场景
最后落到了乐观锁上。这玩意儿在并发教程里都讲过,但很多人觉得它"low",不如悲观锁"专业"。其实不是这么回事------它俩是针对不同场景的,没有谁高级。
乐观锁的核心思想是:我不锁你,但你改的时候得证明"你读的时候到现在,没人动过这行" 。怎么证明?靠一个 version 版本号。
具体到我们这个钱包:
sql
CREATE TABLE t_wallet (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
balance DECIMAL(15,2) DEFAULT 0 COMMENT '人民币余额',
star_credits DECIMAL(15,2) DEFAULT 0 COMMENT '星源值(实际消费货币)',
frozen DECIMAL(15,2) DEFAULT 0 COMMENT '冻结金额(预扣占位)',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', -- 就是它
UNIQUE KEY uk_user_id (user_id)
);
每次更新带上 version 条件:
sql
-- 读的时候记下 version
SELECT star_credits, version FROM t_wallet WHERE user_id = 1;
-- 假设读到 star_credits=50, version=3
-- 更新时:version 必须还是 3,并且 +1
UPDATE t_wallet
SET star_credits = 20, version = version + 1
WHERE user_id = 1 AND version = 3;
如果在这期间别人改过这行(version 变成 4 了),这个 UPDATE 的 WHERE version = 3 就匹配不到,rowcount = 0。这时候应用层就知道"冲突了,得重试"。
为什么这适合我们:
- 不持有锁:扣费那几分钟里,数据库行是完全自由的,不阻塞其他请求。改头像、查余额、别的用户操作,全都不受影响。
- 冲突成本低:绝大多数时候不会冲突(同一个用户的请求没那么频繁同时到达),一次就成功。偶尔冲突了,重试一下就行。
- 不引入新依赖:纯数据库机制,不用 Redis。
MyBatis-Plus 怎么搞乐观锁
MyBatis-Plus 对乐观锁有原生支持,不用手写 SQL 拼 version。配置很简单。
第一步:实体类标 @Version
java
@Data
@TableName("t_wallet")
public class Wallet extends BaseEntity {
private Long userId;
private BigDecimal balance;
private BigDecimal starCredits;
private BigDecimal frozen;
@Version
private Integer version; // 就这一个注解
}
第二步:注册乐观锁插件
java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件(顺手配的)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
配完之后,调用 walletMapper.updateById(wallet) 时,MyBatis-Plus 会自动:
- 把实体的
version值填到WHERE条件里 SET version = version + 1
实际执行的 SQL 就是上面写的那样。你完全不用自己拼。
关键部分:冲突了怎么办(重试)
乐观锁的核心不在"怎么加 version",而在冲突后的重试策略。这才是容易写错的地方。
错误的写法是:冲突了直接抛异常让用户重试。用户体验极差------"系统繁忙请重试"这种提示谁看了都想骂人。
正确的是:应用层自动重试。读最新数据、重新计算、再更新,循环几次。对用户完全透明。
看我们 reserve(预扣)的代码:
java
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> reserve(Long userId, String reservationId, BigDecimal amount, ...) {
// ... 前面的幂等检查省略 ...
// 余额校验 + 乐观锁扣减,最多重试 3 次
int maxRetries = 3;
Wallet wallet = null;
for (int attempt = 0; attempt < maxRetries; attempt++) {
// 1. 读最新数据(含最新 version)
wallet = walletMapper.selectOne(
new LambdaQueryWrapper<Wallet>().eq(Wallet::getUserId, userId));
if (wallet == null) throw new BusinessException("钱包不存在");
// 2. 业务校验
if (safeCredits(wallet.getStarCredits()).compareTo(amount) < 0) {
throw new BusinessException(402, "星源值余额不足,无法进行预扣");
}
// 3. 算新值
wallet.setStarCredits(safeCredits(wallet.getStarCredits()).subtract(amount));
wallet.setFrozen(nullSafe(wallet.getFrozen()).add(amount));
// 4. 乐观锁更新(自动带 WHERE version=?)
int rows = walletMapper.updateById(wallet);
if (rows > 0) {
break; // 成功,跳出循环
}
// rows == 0:version 不匹配,说明有人抢先改了
if (attempt == maxRetries - 1) {
throw new BusinessException("预扣失败,请重试(并发冲突)");
}
// 否则:for 循环下一轮,重新 select 读最新 version,再算一次
}
// ... 写预扣记录、返回 ...
}
重点看这个 for 循环的逻辑:
- 每轮都重新
selectOne------拿到的是别人改完后的最新余额和最新 version。 - 重新基于最新余额算新值------这保证即使中间有人扣过,你的扣减也是基于最新状态算的,不会多扣。
updateById失败(rows=0)说明又被抢了,继续下一轮。- 三次都失败才抛异常。
为什么是 3 次?经验值。实际上同一个钱包的并发更新很少,一轮就成功的概率在 95% 以上。高峰期偶尔冲突,两三轮基本都能成。3 次还冲突那基本是真·极端高并发了,这时候抛"并发冲突"让用户稍等是合理的。
commit(结算)也是同样的套路:
java
// commitIncremental 里的钱包更新
for (int attempt = 0; attempt < 3; attempt++) {
Wallet wallet = walletMapper.selectOne(...); // 重读
wallet.setFrozen(wallet.getFrozen().subtract(deductFromFrozen));
wallet.setStarCredits(wallet.getStarCredits().subtract(deductFromCredits));
wallet.setTotalCreditsOut(wallet.getTotalCreditsOut().add(discounted));
if (walletMapper.updateById(wallet) > 0) break;
if (attempt == 2) throw new BusinessException("提交失败,请重试(并发冲突)");
}
一个容易忽略的点:重试时要重读,不能用旧值
我见过有人这么写(错的):
java
Wallet wallet = walletMapper.selectOne(...); // 只读一次
for (int attempt = 0; attempt < 3; attempt++) {
wallet.setStarCredits(wallet.getStarCredits().subtract(amount));
int rows = walletMapper.updateById(wallet);
if (rows > 0) break;
// 失败了直接重试,但 wallet 还是上面那个旧对象!
}
这写法的问题在于:失败重试时,用的还是最开始读到的余额。假设一开始读到 50,第一次扣 30 失败了(说明别人改过),第二次还是基于 50 去扣 30------可这时候真实余额可能已经被别人扣到 10 了,你基于 50 算出来扣完剩 20,一更新就把别人的扣减覆盖了,钱就错了。
正确做法是每轮都重新 select ,拿到最新余额再算。这点特别重要,我上面代码里 selectOne 是放在 for 循环里面的,就是这个原因。
配合幂等键,防重复扣
乐观锁解决了"并发扣减不一致",但还有个问题:网络抖动导致 Python 网关重试,同一个请求可能调过来两遍。这时候就算没有并发,也会被扣两次。
所以乐观锁之外,还得加一层业务幂等。每个扣费请求带一个唯一的 idempotency_key,扣之前先查有没有处理过:
java
// 幂等检查
if (idempotencyKey != null) {
ReservationLedger existing = ledgerMapper.selectOne(
new LambdaQueryWrapper<ReservationLedger>()
.eq(ReservationLedger::getIdempotencyKey, idempotencyKey));
if (existing != null) {
// 处理过了,直接返回上次的结果
return Map.of("committed_total", existing.getAmount());
}
}
这块我专门写了篇博客讲两阶段计费(reserve-commit-release),这里就不展开了。核心是:乐观锁管并发,幂等键管重试,两者配合才能保证扣费不多不少。
这套方案上线后的情况
上线跑了一段时间,偶尔日志里能看到"并发冲突"的重试,但都是一两轮就成功了,用户完全无感。
真出过一次问题是有个大客户开了 20 个标签页同时调模型,冲突率一下子上来了,有几次 3 轮重试都没成功,用户看到"并发冲突请重试"。后来把 maxRetries 调到 5,再没出现过。极端高并发场景下,乐观锁确实有它的天花板,这时候可能得上分段锁或者把扣费异步化。不过对我们目前的量级,5 轮重试足够了。
顺嘴吐槽一句
用乐观锁还有个隐性好处:调试方便。因为 version 字段会变,你盯着一个钱包看,version 从 3 跳到 5,就知道中间被改过两次。排查"余额怎么对不上"的时候,这个信息很有用。悲观锁就啥痕迹都不留,出了问题只能翻 binlog。
还有个坑是 @Version 字段必须是 Integer 不能是 int------基本类型默认值是 0,新插入的行 version 永远是 0 没问题,但 MyBatis-Plus 在判断"要不要带 version 条件"时,是看这个字段是不是 null。用 int 的话它以为没设 version,乐观锁就不生效了。这个坑我踩过,排查了一下午。
最后
总结一下整个思路,其实就几句话:
- 扣费场景锁的时间长(跨整个 LLM 推理),悲观锁会把钱包行锁死,不行。
- 上 Redis 分布式锁成本太高,为了一个场景引一套中间件不划算。
- 乐观锁 + 应用层重试,刚好契合"冲突不多、但发生时要正确处理"的特点。冲突了自动重试,对用户透明,绝大多数时候一轮就过。
- 别忘了配幂等键防重复扣------这是另一条防线。
说白了,技术选型没有谁高级谁低级,看场景。悲观锁适合"冲突频繁、每次锁的时间短"(比如秒杀库存);乐观锁适合"冲突偶尔、锁住时间长"。我们的钱包正好是后者,就这么选了。