在拼团场景中,参团请求需要同时操作 Redis 缓存和 MySQL 数据库。本文记录我在项目中处理“先扣 Redis,后写数据库”时遇到的一致性问题,以及如何通过补偿机制实现最终一致性。

一、问题场景

拼团参团的核心逻辑是:用户点击参团 → 扣减库存和名额 → 创建订单。

为了保障并发我将所有的校验都放到redis数据库只做最终的持久化。具体流程如下:

用户参团请求 → Redis Lua 脚本原子校验并扣减 → 同步创建数据库订单

接下来思考这个顺序会出现一个问题:就是我Redis扣减成功了,但数据库订单创建失败。

比如:

  • 订单插入时数据库连接断开

  • 唯一键冲突(如订单号重复)

  • 业务校验失败触发事务回滚

一旦发生这种情况,Redis 里的库存和名额已经被扣掉了,但用户并没有真正下单成功。

二、更改顺序

既然会出现以上问题,那么为什么不先用数据库行锁扣库存,等事务提交成功再更新 Redis?

答案:高并发下数据库扛不住。

设想一下,当一个商品拼团人数就差一人时,价格优惠一半,即使你目前用不到,也会想这么便宜......万一我以后要用呢,买!

当参团请求瞬间涌入。如果全都压在数据库的 update 行锁上,连接池很快会被占满,进而影响整个系统的正常查询。

Redis + Lua 的方案可以把写压力前置到缓存层,数据库只做最终落地。代价就是需要额外处理“跨存储介质”的回滚问题。

那么这样看来还是乖乖的解决这个问题吧。。。

三、具体实现方案

3.1 参团 Lua 脚本(原子校验 + 扣减)

-- KEYS[1]: 活动库存Key
-- KEYS[2]: 团名额Key
-- KEYS[3]: 用户参团记录Key (Set)
-- ARGV[1]: userId
-- ARGV[2]: 扣减数量
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[2]) then
    return 0  -- 库存不足
end

local remain = redis.call('get', KEYS[2])
if not remain or tonumber(remain) <= 0 then
    return -1 -- 名额不足
end
-- 重复参团检查
local exists = redis.call('sismember', KEYS[3], ARGV[1])
if exists == 1 then
    return -2 -- 已参团
end
-- 原子扣减
redis.call('decrby', KEYS[1], ARGV[2])
redis.call('decr', KEYS[2])
redis.call('sadd', KEYS[3], ARGV[1])
return 1

一次 Lua 调用完成四重校验,保证并发安全。

3.2 业务层调用与补偿

public Result joinGroup(JoinRequest request) {
    // 1. 执行 Lua 脚本,先扣 Redis
    Long result = redisTemplate.execute(luaScript, keys, args);
    if (result != 1) {
        return Result.fail("参团失败");
    }
    try {
        // 2. 创建数据库订单
        orderService.createOrder(request);  // @Transactional
    } catch (Exception e) {
        // 3. 数据库失败,立即执行补偿 Lua 脚本回滚 Redis
        redisTemplate.execute(rollbackLuaScript, keys, args);
        log.error("订单创建失败,已回滚 Redis 数据", e);
        return Result.fail("系统繁忙,请重试");
    }
    return Result.success();
}

3.3 补偿 Lua 脚本(逆向回滚)

-- 与扣减脚本参数完全对称
redis.call('incrby', KEYS[1], ARGV[2])
redis.call('incr', KEYS[2])
redis.call('srem', KEYS[3], ARGV[1])
return 1

关键点: 补偿脚本必须在 catch 块中同步调用,尽可能快地修复数据。

四、补偿失败怎么办?——兜底机制

上面的补偿方案有一个前提:补偿脚本能成功执行。

如果补偿时 Redis 网络超时,或者服务直接宕机,Redis 里的数据就“脏”了。

我的兜底方案是:超时关团任务二次释放。

拼团订单都有有效期(如 30 分钟未支付自动取消)。我利用 RabbitMQ 延迟队列实现了超时关团功能。在关团逻辑中,除了更新订单状态,还会检查该团是否真的满员,如果存在“已扣名额但订单无效”的情况,会再次释放名额。

// 延迟队列消费者
public void handleTimeoutGroup(Long groupId) {
    Group group = groupMapper.selectById(groupId);
    if (group.getStatus() == GroupStatus.WAITING) {
        // 关团,释放名额
        group.setStatus(GroupStatus.FAILED);
        groupMapper.updateById(group);
        // 兜底:找出所有“参团记录存在但订单无效”的用户,回滚 Redis
        List<Long> invalidUsers = findInvalidUsers(groupId);
        redisTemplate.execute(rollbackLuaScript, buildKeys(groupId, invalidUsers));
    }
}

这样,即使主流程的补偿失败了,延迟队列的兜底逻辑也会在 30 分钟内修复数据,实现最终一致性。

五、总结

归拢本文提到的三种方案:

方案

优点

缺点

纯数据库事务

强一致,简单

高并发下性能差,影响整个系统

先 Redis 后 DB + 补偿

性能高,Redis 扛流量

需要处理补偿失败场景

先 Redis 后 DB + 补偿 + 兜底

高可用,数据最终一致

代码复杂度稍高

本文是我开发拼团模块的实践记录,欢迎交流讨论