1. 项目背景
1.1 秒杀系统的本质挑战
秒杀是电商系统中最具挑战性的场景。平时商品详情接口 QPS 可能只有 2000,活动开始瞬间飙到 50000。这种爆发式流量如果直接打到数据库,会在第一秒就将数据库连接池打满。
更棘手的是------这不仅仅是"流量大"的问题。秒杀还有三个特殊要求:
- 不能超卖:100 件库存绝不能卖出 101 件。这是资损红线。
- 尽量少卖少:如果因为过度保护导致 100 件库存只卖出 70 件------运营也会投诉。
- 公平性:同一个用户不能重复抢到,不能因为网络快慢产生明显的不公平。
传统数据库方案在这些要求面前非常狼狈------行锁竞争导致大量请求阻塞等待,数据库 CPU 飙升,但又不敢放开并发(怕超卖)。Redis 的秒杀方案正是把最核心的"库存判断与扣减"前置到内存层,让数据库只处理"成功抢到的订单"------从"数据库扛 5 万 QPS"变成"Redis 扛 5 万 QPS + 数据库扛 500 QPS"。
1.2 整体架构
arduino
秒杀链路全景:
用户请求
│
▼
┌─────────────┐
│ 防刷层 │ ← IP 限流、用户频控、验证码、黑名单
│ (Redis计数器) │
└──────┬──────┘
│ 通过
▼
┌─────────────┐
│ 资格校验层 │ ← 活动时间校验、库存预检、限购判断
│ (Lua 脚本) │
└──────┬──────┘
│ 通过
▼
┌─────────────┐
│ 库存扣减层 │ ← 原子扣减库存 + 记录购买资格
│ (Lua 脚本) │
└──────┬──────┘
│ 成功
▼
┌─────────────┐
│ 异步下单层 │ ← 写入 Stream/List,后台消费者落库
│ (Stream) │
└──────┬──────┘
│
▼
┌─────────────┐
│ 数据兜底层 │ ← 数据库唯一约束 + 定时对账 + 库存补偿
│ (MySQL) │
└─────────────┘
1.3 本章目标
- 实现完整的秒杀链路:防刷 → Lua 原子扣减 → Stream 异步下单 → 数据库兜底。
- 处理超卖/少卖/重复下单的边界。
- 设计热点库存拆分方案。
- 压测验证:1 万并发无超卖、链路可追踪。
2. 项目设计
2.1 第一幕:为什么 GET 后再 DECR 会超卖?
小胖 :"秒杀不就是 DECR stock 吗?库存从 100 减到 0,谁抢到算谁的。"
小白:"那如果用户在 10 秒内刷了 100 次怎么办?如果库存扣成功了但订单没创建怎么办?如果活动还没开始用户就发请求怎么办?"
大师 :"更根本的问题------你说的 DECR stock 是一条命令,没错它是原子的。但业务逻辑是:先检查库存是否大于 0,大于 0 才能扣。你把这个判断放在客户端------那在 GET 和 DECR 之间,别人也可能看到库存 > 0。这就是超卖的根源。"
lua
-- Lua 解决: 把判断和扣减放进同一个原子块
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1 -- 库存不足
end
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
-- 整个脚本在 Redis 主线程中一次执行完,中间不会被其他请求插入
小胖又问:"那返回给用户的是'抢购成功'还是'订单创建成功'?"
大师:"这是秒杀系统的一个关键认知------Redis 扣库存成功 ≠ 订单创建成功。Redis 只是给用户发了张'排队号',真正的订单创建是异步的。所以前端应该显示'抢购成功,订单处理中',而不是'订单已创建'。这个文案差异能避免大量客服投诉。"
2.2 第二幕:防刷放在哪一层?
大师:"防刷越靠前越好。在流量进入核心扣减逻辑之前就拦截掉,这样才能保护后面的每一层。"
javascript
防刷漏斗(从宽到严):
┌───────────────────────────────────┐
│ 第1层: 网关限流 (Nginx/API Gateway) │ ← 单 IP/单用户 QPS 限制
├───────────────────────────────────┤
│ 第2层: 活动时间校验 │ ← 没开始/已结束直接拒绝
├───────────────────────────────────┤
│ 第3层: 用户冷却 (Redis INCR) │ ← 同一用户 1 秒内只允许 1 次
├───────────────────────────────────┤
│ 第4层: 验证码/滑块 │ ← 拦截自动化脚本
├───────────────────────────────────┤
│ 第5层: 黑名单 (Redis Set) │ ← 已知黄牛/恶意用户
└───────────────────────────────────┘
2.3 第三幕:库存热点怎么拆分?
小胖 :"库存 key stock:sku1001 就一个,100 件库存。有 10 万人在抢------这个 key 会不会成为瓶颈?"
大师 :"会。单个库存 key 在 10 万并发下,Redis 单线程处理这个 key 的命令会成为瓶颈。解决方案是库存拆分------把 100 件库存分到 10 个 key 里:"
ini
方案 A: 水平分片
stock:sku1001:0 = 10
stock:sku1001:1 = 10
...
stock:sku1001:9 = 10
请求到来 → hash(userId) % 10 → 扣对应分片的库存
优点: 并发压力分散到 10 个 key
缺点: 某个分片卖完但其他分片有余量 → 需要"借库存"逻辑
方案 B: 令牌桶(List)
预热: RPUSH stock:sku1001:tokens 1 1 1 ... (100 个 1)
秒杀: LPOP stock:sku1001:tokens
拿到 → 有库存;nil → 没库存
优点: 原子性天然保证,不需要 Lua
缺点: 不能灵活处理"一次买多件"的场景
3. 项目实战
3.1 环境准备
bash
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:活动预热
bash
# 1. 设置活动状态和有效时间
127.0.0.1:6379> HSET mall:seckill:activity:618 status "running" start 1714470000 end 1714473600
(integer) 3
# 2. 预热库存
127.0.0.1:6379> SET mall:seckill:stock:618:sku:1001 100
OK
# 3. 清空已购买用户集合(新活动)
127.0.0.1:6379> DEL mall:seckill:buyers:618:sku:1001
# 4. 创建下单 Stream
127.0.0.1:6379> XGROUP CREATE mall:stream:seckill:orders order-workers 0 MKSTREAM
3.3 实战二:核心 Lua 脚本------防刷+限购+扣减+通知
lua
-- seckill_full.lua
-- KEYS[1]: 活动状态 Hash
-- KEYS[2]: 库存 key
-- KEYS[3]: 已购买用户 Set
-- KEYS[4]: 用户冷却 key
-- KEYS[5]: 订单 Stream key
-- ARGV[1]: 用户 ID
-- ARGV[2]: SKU ID
-- ARGV[3]: 当前时间戳
-- ARGV[4]: 请求 ID (幂等键)
-- 返回: 1=成功, -1=库存不足, -2=已购买, -3=活动未开始/结束, -4=请求过快
local activityKey = KEYS[1]
local stockKey = KEYS[2]
local buyerKey = KEYS[3]
local cooldownKey = KEYS[4]
local streamKey = KEYS[5]
local userId = ARGV[1]
local skuId = ARGV[2]
local now = tonumber(ARGV[3])
local requestId = ARGV[4]
-- 1. 活动时间校验
local startTime = tonumber(redis.call('HGET', activityKey, 'start') or '0')
local endTime = tonumber(redis.call('HGET', activityKey, 'end') or '0')
local status = redis.call('HGET', activityKey, 'status')
if status ~= 'running' or now < startTime or now > endTime then
return -3
end
-- 2. 用户冷却(1 秒内只允许 1 次请求)
if redis.call('EXISTS', cooldownKey) == 1 then
return -4
end
redis.call('SET', cooldownKey, '1', 'EX', 1)
-- 3. 限购检查
if redis.call('SISMEMBER', buyerKey, userId) == 1 then
return -2
end
-- 4. 库存扣减
local stock = tonumber(redis.call('GET', stockKey) or '0')
if stock <= 0 then
return -1
end
-- 5. 原子操作
redis.call('DECR', stockKey)
redis.call('SADD', buyerKey, userId)
redis.call('XADD', streamKey, '*',
'userId', userId, 'skuId', skuId, 'requestId', requestId)
return 1
执行测试:
bash
127.0.0.1:6379> EVAL "..." 5 mall:seckill:activity:618 mall:seckill:stock:618:sku:1001 mall:seckill:buyers:618:sku:1001 mall:seckill:cooldown:618:user:7 mall:stream:seckill:orders user-7 sku:1001 1714470100 req-001
(integer) 1 # 成功
127.0.0.1:6379> GET mall:seckill:stock:618:sku:1001
"99" # 库存减 1
3.4 实战三:异步下单消费者
java
public class SeckillOrderConsumer {
private static final String STREAM_KEY = "mall:stream:seckill:orders";
private static final String GROUP = "order-workers";
private static final String CONSUMER = "worker-1";
@Scheduled(fixedDelay = 100) // 每 100ms 检查一次
public void consume() {
// 1. 读取新消息
List<MapRecord<String, String, String>> messages = redis.opsForStream()
.read(Consumer.from(GROUP, CONSUMER),
StreamReadOptions.empty().count(10).block(Duration.ofSeconds(1)),
StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()));
for (MapRecord<String, String, String> msg : messages) {
String requestId = msg.getValue().get("requestId");
String userId = msg.getValue().get("userId");
String skuId = msg.getValue().get("skuId");
// 2. 幂等检查
if (orderRepository.existsByRequestId(requestId)) {
redis.opsForStream().acknowledge(STREAM_KEY, GROUP, msg.getId());
continue;
}
// 3. 创建订单
try {
orderRepository.createSeckillOrder(userId, skuId, requestId);
redis.opsForStream().acknowledge(STREAM_KEY, GROUP, msg.getId());
log.info("秒杀订单创建成功: {}", requestId);
} catch (DuplicateKeyException e) {
redis.opsForStream().acknowledge(STREAM_KEY, GROUP, msg.getId());
log.info("重复订单, 已忽略: {}", requestId);
} catch (Exception e) {
log.error("订单创建失败, 待重试: {}", requestId, e);
// 不 ACK, 消息留在 Pending List 等待重试
}
}
// 4. 认领超时未确认的 Pending 消息
redis.opsForStream().claim(STREAM_KEY, GROUP, CONSUMER,
Duration.ofSeconds(60),
StreamOffset.create(STREAM_KEY, ReadOffset.from("0-0")));
}
}
3.5 实战四:库存补偿与对账
java
@Component
public class SeckillReconciliation {
// 每小时对账一次
@Scheduled(cron = "0 0 * * * *")
public void reconcile() {
// 1. Redis 中的剩余库存
String redisStock = redis.get("mall:seckill:stock:618:sku:1001");
int redisRemaining = redisStock != null ? Integer.parseInt(redisStock) : 0;
// 2. 数据库中的实际卖出数量
int dbSold = orderRepository.countSeckillOrders("618", "sku:1001");
// 3. 库存 = 初始 - 卖出
int totalStock = 100; // 配置或从 Redis 读取初始值
int expectedRemaining = totalStock - dbSold;
if (redisRemaining != expectedRemaining) {
log.warn("库存不一致: Redis={}, DB推算={}, 卖出={}",
redisRemaining, expectedRemaining, dbSold);
if (dbSold < totalStock && redisRemaining > expectedRemaining) {
// 情景: 消费者失败导致 Redis 扣了但数据库没创建订单
// 处理: 人工审核死信队列,确认是否需要补回库存
log.warn("可能存在 Redis 扣减但未落库的订单,请检查死信队列");
}
}
}
}
3.6 实战五:压测验证
bash
# 准备压测脚本 test_seckill.lua (redis-benchmark 不支持直接测 Lua, 用 shell 并发模拟)
# 重置库存
redis-cli -a redis123 SET mall:seckill:stock:test 100
redis-cli -a redis123 DEL mall:seckill:buyers:test
# 并发执行 100 个请求(100 个后台进程)
for i in $(seq 1 100); do
redis-cli -a redis123 EVAL "
local sk=KEYS[1]
local bk=KEYS[2]
local stock=tonumber(redis.call('GET',sk) or '0')
if stock<=0 then return -1 end
if redis.call('SISMEMBER',bk,ARGV[1])==1 then return -2 end
redis.call('DECR',sk)
redis.call('SADD',bk,ARGV[1])
return 1
" 2 mall:seckill:stock:test mall:seckill:buyers:test user-$i &
done
wait
# 验证
redis-cli -a redis123 GET mall:seckill:stock:test
# 结果应为 0 (100-100=0, 无超卖)
redis-cli -a redis123 SCARD mall:seckill:buyers:test
# 结果应为 100 (每个用户只买了一单)
3.7 常见坑
| 坑 | 表现 | 解法 |
|---|---|---|
| Lua 中操作大 Set | SMEMBERS 在 Lua 中同样阻塞 | 永远不在 Lua 中遍历集合 |
| 扣库存成功但 Stream 写入失败 | 库存减了但消息没入队 | Lua 中检查 XADD 的返回值 |
| Set 记录购买用户过大 | 百万用户的 Set 占用几百 MB | 活动结束归档后删除;或用 Bloom Filter 替代 |
| 补偿库存时重复补 | 退了两次库存 | 补偿操作也需幂等------带唯一补偿事件 ID |
3.8 实战六:库存拆分------水平分片方案
java
@Component
public class ShardedStockService {
private static final int SHARD_COUNT = 10;
// 预热库存:将总量分配到各个分片
public void warmUpStock(String skuId, int totalStock) {
int perShard = totalStock / SHARD_COUNT;
int remainder = totalStock % SHARD_COUNT;
for (int i = 0; i < SHARD_COUNT; i++) {
int shardStock = perShard + (i < remainder ? 1 : 0);
redis.set("mall:stock:" + skuId + ":shard:" + i, String.valueOf(shardStock));
}
}
// 秒杀扣减:随机选分片
private static final String SHARD_DEDUCT_LUA =
"local sk = KEYS[1]; local bk = KEYS[2]; " +
"local uid = ARGV[1]; " +
"if redis.call('SISMEMBER', bk, uid) == 1 then return -1 end; " +
"local stock = tonumber(redis.call('GET', sk) or '0'); " +
"if stock <= 0 then return 0 end; " +
"redis.call('DECR', sk); " +
"redis.call('SADD', bk, uid); " +
"return 1";
public int tryDeduct(String userId, String skuId) {
int shardId = Math.abs(userId.hashCode()) % SHARD_COUNT;
String stockKey = "mall:stock:" + skuId + ":shard:" + shardId;
String buyerKey = "mall:buyers:" + skuId;
Long result = redis.eval(SHARD_DEDUCT_LUA,
Arrays.asList(stockKey, buyerKey),
Collections.singletonList(userId));
return result != null ? result.intValue() : -1;
}
}
3.9 实战七:全链路监控与数据大盘
java
@Component
public class SeckillMonitor {
// 记录每个环节的通过率
public void recordSeckillFlow(String stage, boolean passed) {
metricsCollector.increment("seckill." + stage + "." + (passed ? "pass" : "reject"));
}
// 活动结束后输出摘要
@Scheduled(fixedDelay = 10000)
public void printSummary() {
long totalRequests = metricsCollector.getCounter("seckill.total.requests");
long passedRateLimit = metricsCollector.getCounter("seckill.ratelimit.pass");
long stockPassed = metricsCollector.getCounter("seckill.stock.pass");
long stockRejected = metricsCollector.getCounter("seckill.stock.reject");
long orderCreated = metricsCollector.getCounter("seckill.order.created");
log.info("=== 秒杀流量摘要 ===");
log.info("总请求: {}, 过限流: {}, 过库存: {}, 库存拒绝: {}, 订单创建: {}",
totalRequests, passedRateLimit, stockPassed, stockRejected, orderCreated);
log.info("漏斗转化: {:.1f}% → {:.1f}% → {:.1f}%",
100.0 * passedRateLimit / Math.max(totalRequests, 1),
100.0 * stockPassed / Math.max(passedRateLimit, 1),
100.0 * orderCreated / Math.max(stockPassed, 1));
}
}
4.3 秒杀常见翻车现场
| 事故 | 表现 | 根因 | 教训 |
|---|---|---|---|
| 超卖 300 件 | 100 件库存卖出 400 件 | 用 GET stock > 0 再 DECR,两条命令之间有并发窗口 |
必须用 Lua |
| 少卖 500 件 | 10 万件库存只卖出 2 万件 | 防刷策略过于激进,正常用户也被拦截 | 分层防刷,逐层放松 |
| 下单后库存不回补 | 用户取消订单后库存没恢复 | 取消订单的回补逻辑有 bug,补偿脚本也未执行 | 补偿逻辑必须有幂等+监控+告警 |
| 活动开始瞬间 Redis CPU 100% | 前 3 秒 Redis 完全卡死 | 库存 key 成为热点,10 万 QPS 集中在一个 key 上 | 库存拆分或多级缓存 |
| 用户看到"已抢光"但实际有库存 | 体验极差 | Redis 库存为 0 但数据库还有未消费的订单占着库存 | 增加超时未支付自动释放库存的逻辑 |
4.4 完整压测脚本(wrk + Lua)
lua
-- wrk_seckill.lua
-- wrk -t10 -c1000 -d30s -s wrk_seckill.lua http://localhost:8080/api/seckill
request = function()
local userId = "user-" .. math.random(1, 50000)
local body = string.format('{"userId":"%s","skuId":"sku:1001","quantity":1}', userId)
return wrk.format("POST", "/api/seckill/submit",
{["Content-Type"] = "application/json"}, body)
end
response = function(status, headers, body)
if status == 200 then
-- 统计成功和失败
if string.find(body, '"success"') then
wrk.thread:set("success", (wrk.thread:get("success") or 0) + 1)
end
end
end
5. 秒杀压测完整脚本
bash
#!/bin/bash
# seckill_bench.sh - 秒杀完整压测
REDIS="redis-cli -a redis123"
SKU="sku:1001"
STOCK=100
echo "=== 1. 重置库存 ==="
$REDIS SET "mall:seckill:stock:$SKU" $STOCK
$REDIS DEL "mall:seckill:buyers:$SKU"
echo "库存: $STOCK"
echo "=== 2. 并发秒杀 ($STOCK 个用户) ==="
LUA='
local sk=KEYS[1]; local bk=KEYS[2]; local uid=ARGV[1]
if redis.call("SISMEMBER",bk,uid)==1 then return -2 end
local s=tonumber(redis.call("GET",sk) or "0")
if s<=0 then return -1 end
redis.call("DECR",sk)
redis.call("SADD",bk,uid)
return 1
'
success=0
for i in $(seq 1 $STOCK); do
result=$($REDIS EVAL "$LUA" 2 "mall:seckill:stock:$SKU" "mall:seckill:buyers:$SKU" "user-$i")
if [ "$result" = "1" ]; then
((success++))
fi
if [ $((i % 10)) -eq 0 ]; then
echo "已处理: $i, 成功: $success"
fi
done
echo "=== 3. 验证结果 ==="
remaining=$($REDIS GET "mall:seckill:stock:$SKU")
buyers=$($REDIS SCARD "mall:seckill:buyers:$SKU")
echo "剩余库存: $remaining (应为 0)"
echo "购买人数: $buyers (应为 $STOCK)"
echo "脚本成功数: $success (应为 $STOCK)"
if [ "$remaining" = "0" ] && [ "$buyers" = "$STOCK" ]; then
echo "✅ 秒杀验证通过:无超卖、无少卖"
else
echo "❌ 秒杀验证失败!"
fi
5.1 秒杀系统架构速查卡
sql
秒杀链路速查:
网关限流 (Nginx limit_req)
→ 活动时间校验 (Redis HSET)
→ 用户冷却 (Redis INCR + EXPIRE 1s)
→ 黑名单检查 (Redis SISMEMBER)
→ 库存扣减 (Lua: DECR + SADD + XADD)
→ 异步下单 (Stream XREADGROUP)
→ 幂等落库 (MySQL INSERT IGNORE + unique requestId)
→ 对账补偿 (定时任务: Redis GET vs MySQL COUNT)
关键时刻:
- 活动开始前: 预热库存到 Redis, 清空购买记录
- 活动进行中: 监控 Redis CPU+Stream积压+DB QPS
- 活动结束后: 归档购买记录, 清理活动 key
5.2 库存扣减三种方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| Lua 原子扣减 | GET stock → if >0 → DECR 在 Lua 中 |
精确、易理解 | 单 key 瓶颈 | 中小规模秒杀 |
| 库存分片 | 库存分 N 个 key, 请求哈希选 shard | 并发分散 | 分片间库存不平衡 | 大规模秒杀 |
| 令牌桶 (List) | RPUSH 100 个令牌, LPOP 原子弹出 |
天然原子 | 不能买多件 | 简单抢购 |
5.3 秒杀 Lua 脚本检查清单
ini
□ 脚本中有库存检查吗?(stock <= 0 → 返回失败)
□ 脚本中有限购检查吗?(SISMEMBER buyer → 返回重复)
□ 脚本中记录购买了吗?(SADD buyer 在 DECR 之后)
□ 脚本执行时间 < 1ms 吗?(SLOWLOG 验证)
□ 脚本有明确的返回值约定吗?(1=成功, -1=库存不足, -2=重复)
□ 数据库端有唯一约束兜底吗?(requestId UNIQUE)
5.4 数据库与 Redis 的双写一致性保障
秒杀场景中最棘手的问题之一:Redis 扣了库存,但数据库没创建订单。
markdown
一致性保障方案:
时间线:
T1: Lua 脚本 DECR stock, SADD buyer, XADD stream ← 全部原子完成
T2: 消费者 XREADGROUP 拿到消息
T3: 消费者 INSERT INTO orders (requestId, ...) ← 数据库唯一约束
T4: 消费者 XACK 确认
如果 T3 失败:
- 消息留在 Pending List → XAUTOCLAIM 重试
- 多次重试失败 → 死信队列 → 人工补偿
如果 T4 失败(T3 成功但 XACK 前宕机):
- 消息仍在 Pending List → 其他消费者认领
- 消费者处理时发现 requestId 已入库 → XACK + 跳过 (幂等)
定时对账 (每小时):
- Redis remaining = GET stock
- DB sold = COUNT(*) WHERE sku_id = ?
- 总库存 = remaining + sold
- 如果总库存 != 初始库存 → 告警,人工核查死信队列
5.5 从单体到微服务的秒杀演进
随着业务增长,秒杀系统可以从单体演进到微服务架构:
yaml
阶段 1: 单服务 + Redis 原子扣减(本章实现)
特点: 简单、发布快、适合中小规模
Redis: Lua 库存扣减 + Stream 异步下单
阶段 2: 多实例 + 库存分片
特点: 库存拆成 N 个分片,每个实例负责自己分片
Redis: 分片库存 key + Lua 扣减
阶段 3: 微服务化
特点: 限流服务、库存服务、订单服务独立部署
Redis: Cluster 集群 + Sentinel 高可用
数据库: 分库分表 + 最终一致性
每个阶段的演进触发条件: 当前阶段 QPS 达到瓶颈的 70%
工程忠告 :秒杀系统的核心不是 Redis 技巧,而是分层治理的思想。防刷在网关、库存判断在 Redis、订单创建在数据库、对账在定时任务------每一层做自己擅长的事。别让 Redis 承担数据库的责任,也别让数据库承受秒杀的并发。好的架构就是让合适的工具做合适的事。总结一句话:Redis 在秒杀系统中的角色是"前置过滤器",不是"最终裁判"------过滤掉 99% 不可能成功的请求,让数据库只处理那 1% 真正需要落库的订单。这个分工一旦模糊,要么 Redis 承担了不该由它承担的复杂度,要么数据库被不该由它承受的流量打垮。好的架构就是各司其职------Redis负责速度,数据库负责正确性,架构师负责让二者各司其职。
4. 项目总结
4.1 秒杀系统设计原则
| 原则 | 说明 |
|---|---|
| 前置削峰 | 把库存判断和扣减放在 Redis,数据库只处理成功的订单 |
| 原子脚本 | 校验 + 扣减 + 通知必须在同一个 Lua 脚本中 |
| 异步落库 | Redis 扣减成功 ≠ 订单创建成功,前端文案表达"处理中" |
| 数据库兜底 | 唯一约束(requestId、userId+skuId)防止重复落库 |
| 定时对账 | Redis 库存和数据库卖出数的差异需要补偿 |
| 热点拆分 | 单库存 key 无法支撑 10 万并发,需要分片或令牌桶 |
4.2 思考题
-
设计题:秒杀系统如果改为"先到先得"的模式(不需要预先预热库存,而是在活动时间内持续开放),Redis 的数据结构方案需要做哪些调整?
-
故障题:Redis 执行 Lua 脚本到一半突然宕机(比如 DECR 成功了但 SADD 还没执行)。Redis 重启后,AOF 中会记录这部分操作吗?如果没有,库存和购买记录会出现什么不一致?
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第18章):
第1题 (定时任务锁):key =
lock:cron:taskName(不同任务不同 key),TTL = 任务最大执行时间 × 2(留余量),续期每 TTL/3 续一次。如果机器宕机,锁会在 TTL 后自动过期,下一轮 cron 触发时其他机器能抢到。第2题(Redlock 迁移):成本------需要部署 5 个独立 Redis 节点,运维复杂度翻倍;客户端需要改造。收益------降低了单 Redis 故障导致锁安全的概率(但并非消除)。风险------Redlock 的时钟和 GC 问题仍然存在;当前订单系统的数据库唯一约束已经提供了最终兜底。结论:当前场景下 Redlock 的投入产出比不合理。