在趣玩搭社交平台的活动抢票场景中,我使用 Redis Lua 脚本实现了库存的原子性扣减。本文从 Lua 脚本的设计思路讲起,深入讨论为什么必须用 Lua 而不是普通 Redis 命令,以及 Redis 主节点宕机时如何保证库存数据不丢失。
一、为什么必须用 Lua 脚本
1.1 业务场景
趣玩搭的热门活动(如限量 50 人的露营)开抢时,瞬间可能有几千个请求同时争抢有限名额。扣减库存的逻辑看起来很简单:
1. 读取当前剩余库存
2. 判断库存 > 0
3. 库存减 1
4. 记录该用户已抢到但这四步如果不是原子执行的,在高并发下就会出现经典的超卖问题。
1.2 普通 Redis 命令为什么不行
用 Redis 的 GET + DECR 组合:
// 危险!非原子操作
Long stock = redisTemplate.opsForValue().get("activity:1001:stock"); // step1
if (stock != null && stock > 0) { // step2
redisTemplate.opsForValue().decrement("activity:1001:stock"); // step3
}问题在于 step1 和 step3 之间存在时间窗口。假设库存剩 1,线程 A 和线程 B 同时执行到 step2,都读到库存为 1,都判断 > 0 成立,然后都执行 step3 扣减,最终库存变成 -1——超卖了。
有人会说用 WATCH + MULTI + EXEC(乐观锁事务),但在高并发场景下,WATCH 的冲突重试率极高(几千个请求同时 WATCH 同一个 Key,只有一个能成功,其余全部重试),吞吐量急剧下降。
1.3 Lua 脚本的原子性保障
Redis 执行 Lua 脚本时,整个脚本作为一个不可分割的操作在 Redis 主线程中执行——不会被其他命令打断。这是 Redis 单线程模型的天然优势:一个 Lua 脚本执行期间,没有任何其他客户端的命令可以插入执行。
这意味着"读库存 → 判断 → 扣减 → 记录"这四步在 Lua 脚本中天然是原子的,不需要额外的锁机制。
二、Lua 脚本设计
2.1 核心扣减脚本
-- KEYS[1]: 库存Key activity:{activityId}:stock
-- KEYS[2]: 已抢用户集合 activity:{activityId}:users
-- ARGV[1]: 用户ID
-- ARGV[2]: 当前时间戳
-- 1. 检查用户是否已抢过(防重复)
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1 -- 返回 -1 表示重复抢票
end
-- 2. 获取当前库存
local stock = tonumber(redis.call('GET', KEYS[1]))
-- 3. 判断库存是否充足
if stock == nil or stock <= 0 then
return 0 -- 返回 0 表示库存不足
end
-- 4. 扣减库存
redis.call('DECR', KEYS[1])
-- 5. 将用户加入已抢集合
redis.call('SADD', KEYS[2], ARGV[1])
-- 6. 返回扣减后的剩余库存
return stock - 1脚本返回值的含义:-1 表示重复抢票,0 表示库存不足,正数或零表示扣减成功及剩余库存。
2.2 Java 端调用
@Service
public class RedisTicketService {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> deductScript;
@PostConstruct
public void init() {
// 预加载 Lua 脚本(只加载一次,后续通过 SHA1 调用,减少网络传输)
deductScript = new DefaultRedisScript<>();
deductScript.setLocation(new ClassPathResource("scripts/deduct_stock.lua"));
deductScript.setResultType(Long.class);
}
public TicketResult deductStock(Long activityId, Long userId) {
String stockKey = "activity:" + activityId + ":stock";
String usersKey = "activity:" + activityId + ":users";
Long result = redisTemplate.execute(
deductScript,
Arrays.asList(stockKey, usersKey),
userId.toString(),
String.valueOf(System.currentTimeMillis())
);
if (result == null) {
throw new SystemException("Redis 脚本执行异常");
}
if (result == -1L) {
return TicketResult.duplicated("您已抢到该活动名额,请勿重复操作");
}
if (result == 0L) {
return TicketResult.soldOut("手慢了,名额已被抢光");
}
// 扣减成功,发送 MQ 异步创建订单
orderMQProducer.sendCreateOrder(activityId, userId);
return TicketResult.success("抢票成功!剩余名额: " + result);
}
}2.3 脚本设计中的几个细节
防重复放在最前面。 SISMEMBER 检查放在第一步,如果用户已经抢过,直接返回 -1,不会执行后续的库存读取和扣减操作,减少无效计算。
KEYS 参数的 Cluster 兼容性。 Redis Cluster 模式下,Lua 脚本中操作的所有 Key 必须落在同一个 hash slot 上。我通过 {activityId} 的 hash tag 机制保证了这一点:
String stockKey = "activity:{" + activityId + "}:stock";
String usersKey = "activity:{" + activityId + "}:users";
// {activityId} 是 hash tag,Redis 只对花括号内的部分计算 slot
// 同一个 activityId 的两个 Key 一定在同一个 slot脚本预加载(EVALSHA)。 首次执行时 Redis 会缓存脚本的 SHA1 摘要,后续调用只传 SHA1 而不是完整脚本文本,减少了网络传输开销。Spring Data Redis 的 DefaultRedisScript 自动处理了这个优化。
三、Redis 主节点宕机:库存数据安全保障
3.1 问题分析
Lua 脚本保证了操作的原子性,但没有解决持久化问题。Redis 是内存数据库,如果主节点宕机,内存中的数据可能丢失。在抢票场景中,这意味着:已经扣减的库存可能"回滚",导致超卖。
Redis 的两种持久化机制各有局限:
RDB(快照): 定时生成内存快照到磁盘,但两次快照之间的数据变更会丢失。如果快照间隔是 5 分钟,最多可能丢失 5 分钟的库存扣减记录。
AOF(追加日志): 将每条写命令追加到日志文件。根据 appendfsync 配置的不同:always 每条命令都刷盘(零丢失但性能最差),everysec 每秒刷盘(最多丢 1 秒数据),no 由操作系统决定(性能最好但丢失不可控)。
3.2 我的方案:AOF + Sentinel + 数据库兜底的三层保障
第一层:AOF everysec + RDB 结合。
# Redis 配置
appendonly yes
appendfsync everysec # 每秒刷盘,最多丢1秒数据
save 900 1 # 15分钟内有1次修改则触发RDB
save 300 10 # 5分钟内有10次修改则触发RDB
aof-use-rdb-preamble yes # 混合持久化:RDB头+AOF尾,重启恢复更快everysec 是性能和安全性的最佳平衡点。always 理论上更安全,但在高并发抢票场景下,每次 Lua 脚本执行都强制刷盘,会让 Redis 的吞吐量从 10W+ QPS 下降到 1W 左右,代价太大。
第二层:Redis Sentinel 自动故障转移。
部署了 3 个 Sentinel 节点监控 Redis 主从集群。主节点宕机后,Sentinel 在秒级完成故障检测和主从切换,从节点提升为新主节点。由于主从复制几乎是实时的(延迟通常在毫秒级),切换后丢失的数据极少。
# sentinel.conf
sentinel monitor ticket-master 192.168.1.10 6379 2
sentinel down-after-milliseconds ticket-master 5000 # 5秒无响应判定下线
sentinel failover-timeout ticket-master 15000 # 故障转移超时15秒第三层:数据库作为最终兜底(最核心的一层)。
前面两层保护的是 Redis 的数据不丢,但在极端情况下(主从同时宕机、AOF 文件损坏),Redis 数据仍然可能丢失。所以我设计了一个关键机制:MySQL 中的库存记录是最终权威数据源,Redis 中的库存只是一个"快速通道"。
整体流程如下:
抢票请求
│
▼
Redis Lua 脚本扣减库存(快速通道,毫秒级响应)
│ 扣减成功
▼
发送 MQ 消息(异步)
│
▼
订单消费者:
1. 在 MySQL 中创建订单(INSERT)
2. 在 MySQL 中扣减库存(UPDATE stock SET remain = remain - 1 WHERE remain > 0)
3. 如果 MySQL 扣减失败(remain <= 0),说明真实库存已耗尽
→ 回滚订单
→ 回补 Redis 库存(INCR)
→ 通知用户抢票失败@Transactional
public void consumeOrderMessage(OrderMessage msg) {
// 1. MySQL 中扣减库存(带乐观锁条件)
int affected = activityMapper.deductStock(msg.getActivityId());
if (affected == 0) {
// MySQL 库存已耗尽,Redis 多扣了(极端情况)
// 回补 Redis 库存
redisTemplate.opsForValue().increment(
"activity:{" + msg.getActivityId() + "}:stock");
// 将用户从已抢集合移除
redisTemplate.opsForSet().remove(
"activity:{" + msg.getActivityId() + "}:users",
msg.getUserId().toString());
// 通知用户
notifyService.sendTicketFailed(msg.getUserId(), "库存异常,抢票未成功,已全额退款");
return;
}
// 2. 创建订单
orderService.createOrder(msg.getActivityId(), msg.getUserId());
}这样的设计使得:Redis 负责高并发场景下的快速扣减(性能),MySQL 负责最终的数据一致性(正确性)。即使 Redis 数据完全丢失,MySQL 中的库存记录是准确的,系统可以从 MySQL 重新加载库存到 Redis 恢复服务。
3.3 Redis 宕机后的恢复流程
如果 Redis 主节点宕机且 Sentinel 完成了故障转移,新主节点的数据可能比宕机前少了最多 1 秒的写入。针对这个情况,我设计了一个恢复校验机制:
@Component
public class StockReconciliationTask {
/**
* 每5分钟执行一次,校验 Redis 库存与 MySQL 库存的一致性
*/
@Scheduled(fixedRate = 300000)
public void reconcileStock() {
List<Activity> activeActivities = activityMapper.selectActiveActivities();
for (Activity activity : activeActivities) {
// MySQL 中的真实剩余库存
int dbStock = activity.getRemainStock();
// Redis 中的库存
String redisKey = "activity:{" + activity.getId() + "}:stock";
String redisStock = redisTemplate.opsForValue().get(redisKey);
if (redisStock == null) {
// Redis 中没有库存数据(可能是宕机后丢失),从 MySQL 恢复
redisTemplate.opsForValue().set(redisKey, String.valueOf(dbStock));
log.warn("活动 {} Redis 库存缺失,已从 MySQL 恢复: {}", activity.getId(), dbStock);
continue;
}
int rStock = Integer.parseInt(redisStock);
if (Math.abs(rStock - dbStock) > 2) {
// 差异超过阈值,告警 + 以 MySQL 为准修正
log.error("活动 {} 库存不一致!Redis={}, MySQL={}", activity.getId(), rStock, dbStock);
redisTemplate.opsForValue().set(redisKey, String.valueOf(dbStock));
alertService.send("库存不一致告警", "活动" + activity.getId());
}
}
}
}允许 2 的容差是因为 MQ 消息有处理延迟,Redis 已经扣减但 MySQL 还没来得及扣减的"在途"订单会导致短暂的不一致。
四、压测验证
4.1 功能验证:超卖测试
模拟一个 50 名额的活动,5000 个用户同时抢票:
零超卖,零重复。
4.2 性能验证
4.3 宕机模拟
手动 kill -9 Redis 主节点,观察系统表现:
8 秒的服务中断对抢票场景来说是可接受的——用户看到"系统繁忙请重试",刷新后即可继续。
五、经验总结
Lua 脚本是 Redis 场景下保证原子性的最佳工具,但它解决的是"操作原子性"而非"数据持久性"。 不要把 Redis 当成数据库用,内存数据永远需要一个持久化的兜底方案。
"Redis 做性能,MySQL 做正确性"是高并发库存扣减的黄金模式。 Redis 的 Lua 脚本保证了毫秒级的并发扣减能力,MySQL 保证了最终一致性。两者互补而非替代。
设计时就要考虑"如果 Redis 完全丢失了怎么恢复"。 库存校验定时任务和从 MySQL 恢复的能力,不是锦上添花而是必要的生命线。
如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。