在拼团场景中,参团请求需要同时操作 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 分钟内修复数据,实现最终一致性。
五、总结
归拢本文提到的三种方案:
本文是我开发拼团模块的实践记录,欢迎交流讨论
评论交流
欢迎留下你的想法