第19章:库存扣减与秒杀系统:Redis 扛峰值流量

1. 项目背景

1.1 秒杀系统的本质挑战

秒杀是电商系统中最具挑战性的场景。平时商品详情接口 QPS 可能只有 2000,活动开始瞬间飙到 50000。这种爆发式流量如果直接打到数据库,会在第一秒就将数据库连接池打满。

更棘手的是------这不仅仅是"流量大"的问题。秒杀还有三个特殊要求:

  1. 不能超卖:100 件库存绝不能卖出 101 件。这是资损红线。
  2. 尽量少卖少:如果因为过度保护导致 100 件库存只卖出 70 件------运营也会投诉。
  3. 公平性:同一个用户不能重复抢到,不能因为网络快慢产生明显的不公平。

传统数据库方案在这些要求面前非常狼狈------行锁竞争导致大量请求阻塞等待,数据库 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 思考题

  1. 设计题:秒杀系统如果改为"先到先得"的模式(不需要预先预热库存,而是在活动时间内持续开放),Redis 的数据结构方案需要做哪些调整?

  2. 故障题: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 的投入产出比不合理。

相关推荐
leo在掘金1 小时前
2.8万亿MoE跑昇腾:0 Day适配改了啥
后端·架构
爱写Bug的浮游1 小时前
RTA高频请求下的Redis短缓存工程闭环
redis
没有气的汽水1 小时前
redis加了个分片,把线上写挂了一个多小时
redis
xixiaoyunya2 小时前
Redis缓存三大问题实战:穿透、击穿、雪崩,我用一个电商秒杀场景讲清楚
数据库·redis·缓存
广凌科技2 小时前
广凌校园物联网中台整体解决方案:三层架构驱动校园数字化智治
物联网·架构
这个DBA有点耶3 小时前
Merge还是Materialize?一文讲透MySQL 8.0 CTE底层的执行计划博弈与性能陷阱
数据库·mysql·架构
鶴哥只手遮天4 小时前
《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
redis·后端·开源
一条Go4 小时前
四个进程,一把锁:Alioth 的告警评测为什么不需要消息队列
后端·设计模式·架构
70后写java的老码农4 小时前
docker-compose 部署 Redis Stack Server 6.2.6-v19
redis·docker