不上悲观锁也不上 Redis:一个 AI 平台的钱包并发是怎么搞的

不上悲观锁也不上 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。这时候应用层就知道"冲突了,得重试"。

为什么这适合我们

  1. 不持有锁:扣费那几分钟里,数据库行是完全自由的,不阻塞其他请求。改头像、查余额、别的用户操作,全都不受影响。
  2. 冲突成本低:绝大多数时候不会冲突(同一个用户的请求没那么频繁同时到达),一次就成功。偶尔冲突了,重试一下就行。
  3. 不引入新依赖:纯数据库机制,不用 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 会自动:

  1. 把实体的 version 值填到 WHERE 条件里
  2. 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 分布式锁成本太高,为了一个场景引一套中间件不划算。
  • 乐观锁 + 应用层重试,刚好契合"冲突不多、但发生时要正确处理"的特点。冲突了自动重试,对用户透明,绝大多数时候一轮就过。
  • 别忘了配幂等键防重复扣------这是另一条防线。

说白了,技术选型没有谁高级谁低级,看场景。悲观锁适合"冲突频繁、每次锁的时间短"(比如秒杀库存);乐观锁适合"冲突偶尔、锁住时间长"。我们的钱包正好是后者,就这么选了。

相关推荐
神奇小汤圆3 小时前
记一次线上翻车:加了Redisson分布式锁,数据还是被并发打穿了
后端
南雨北斗3 小时前
ThinkPHP6 设置响应 `Content-Type: application/json`
后端
用户3945071778243 小时前
Git 安装与 SSH 公钥配置教程
后端
政采云技术4 小时前
工单处理的智能革命:钉钉AI助理辅助系统探索
人工智能·后端·ai编程
Zane19944 小时前
钻石继承调用哪个方法?一文讲透 MRO 与 C3 线性化算法
后端·python
码事漫谈4 小时前
国产替代的硬核样本:金仓数据库如何撑起固井软件的数据底座
后端
老孙讲技术5 小时前
【4G IPC 上云】临时点位怎么免布线上云?listDeviceDetailsByPage + 辅码流预览|智慧工地实战
后端·物联网