面试知识点梳理及相关面试题(十六)-- 分布式设计

1. 库存秒杀架构设计

秒杀扣库存在不使用 Canal 情况下的线上最佳实践,整体思路是"Redis 做实时扣减扛并发,MQ 异步落库保证持久化,数据库乐观锁兜底防超卖,定时对账修复不一致"。

1.1 整体架构

text 复制代码
用户请求 → 前端限流 → 网关限流 → 资格校验 → Redis Lua 原子扣减
                                                      │
                                          扣减成功 ────┤─── 扣减失败 → 直接返回"已售罄"
                                                      │
                                                 发送 MQ 消息
                                                      │
                                                 消费者异步落库
                                                      │
                                            UPDATE stock WHERE stock >= qty
                                                      │
                                          成功 → 创建订单    失败 → 告警 + 补偿

核心原则:数据流是单向的 ------ Redis → MQ → 数据库,不做反向同步。Redis 是"可售库存"负责实时扣减,数据库是"账面库存"负责持久化记录和对账。

1.2 秒杀前:库存预热

活动开始前,通过定时任务将库存加载到 Redis:

java 复制代码
// 活动开始前 1~5 分钟执行
public void warmUpStock(Long skuId, Integer stock) {
    String key = "seckill:stock:" + skuId;
    redisTemplate.opsForValue().set(key, stock);
    // 可选:同时初始化去重集合和限购信息
    redisTemplate.delete("seckill:dedup:" + skuId);
}

1.3 核心链路:Redis Lua 原子扣减

这是整个方案的核心,利用 Redis 单线程 + Lua 脚本原子性,将"查库存 → 判断 → 扣减 → 去重"打包为一个不可分割的操作:

lua脚本:

lua 复制代码
-- KEYS[1]: 库存key  seckill:stock:{skuId}
-- KEYS[2]: 去重key  seckill:dedup:{skuId}
-- ARGV[1]: 扣减数量
-- ARGV[2]: 用户ID(用于一人一单校验)
-- ARGV[3]: 限购数量

-- 1. 一人一单校验
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
    return -2  -- 已购买过
end

-- 2. 库存判断 + 扣减
local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
local qty = tonumber(ARGV[1])
if stock >= qty then
    redis.call('DECRBY', KEYS[1], qty)
    redis.call('SADD', KEYS[2], ARGV[2])
    return 1   -- 扣减成功
else
    return -1  -- 库存不足
end


-- 如果是可以购买大于1的库存,使用hash来判断用户购买数量
-- 用 Hash,field=userId,value=已购数量
local bought = tonumber(redis.call('HGET', KEYS[2], ARGV[2]) or 0)
local limit = tonumber(ARGV[3])  -- 限购数量
local qty = tonumber(ARGV[1])    -- 本次购买数量

if bought + qty > limit then
    return -2  -- 超出限购
end
redis.call('HINCRBY', KEYS[2], ARGV[2], qty)
java 复制代码
public SeckillResult deductStock(Long skuId, Long userId) {
    String stockKey = "seckill:stock:" + skuId;
    String dedupKey = "seckill:dedup:" + skuId;
    
    Long result = redisTemplate.execute(
        seckillScript,
        Arrays.asList(stockKey, dedupKey),
        "1",                    // 扣减数量
        userId.toString(),      // 用户ID
        "1"                     // 限购数量
    );
    
    if (result == 1) {
        return SeckillResult.SUCCESS;
    } else if (result == -1) {
        return SeckillResult.SOLD_OUT;
    } else {
        return SeckillResult.DUPLICATE;
    }
}
  • 为什么用 Lua 而不是 DECR? 因为 DECR 只做扣减,无法同时做"库存够不够"的判断和"是否重复购买"的校验。Lua 脚本把这些逻辑打包成原子操作,中间不会被其他请求打断。
  • 此处秒杀用的一人一个库存,所以使用的是set来进行去重的,如果是一个人可以购买多件商品的话,这里可以用hash来限制购买数量,如skuId:userId:nums

1.4 异步落库:MQ 削峰

Redis 扣减成功后,发送消息到 MQ,消费者异步完成数据库操作:

1.4.1 消息体设计

java 复制代码
public class SeckillMessage {
    private String msgId;       // 消息唯一ID(用于幂等)
    private Long orderId;       // 预生成的订单ID
    private Long skuId;         // 商品SKU
    private Long userId;        // 用户ID
    private Integer quantity;   // 购买数量
    private Long timestamp;     // 消息时间戳
}

1.4.2 发送消息(带本地消息表兜底)

java 复制代码
@Transactional
public void afterDeductSuccess(Long skuId, Long userId) {
    // 1. 插入订单
    Order order = new Order();
    order.setId(snowflakeId);
    order.setUserId(userId);
    order.setSkuId(skuId);
    order.setStatus(OrderStatus.PENDING);
    orderMapper.insert(order);
    
    // 2. 构建业务消息体
    SeckillMessage seckillMsg = new SeckillMessage();
    seckillMsg.setMsgId(UUID.randomUUID().toString());
    seckillMsg.setOrderId(order.getId());
    seckillMsg.setSkuId(skuId);
    seckillMsg.setUserId(userId);
    seckillMsg.setQuantity(1);
    seckillMsg.setTimestamp(System.currentTimeMillis());
    
    // 3. 构建本地消息记录(把 SeckillMessage 序列化后塞进去)
    LocalMessage localMsg = new LocalMessage();
    localMsg.setMsgId(seckillMsg.getMsgId());
    localMsg.setTopic("seckill-order-topic");
    localMsg.setBody(JSON.toJSONString(seckillMsg));  // ← 序列化
    localMsg.setStatus("PENDING");
    localMessageMapper.insert(localMsg);  // ← 插入的是 LocalMessage
    
    // 4. 事务提交后发送 MQ
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                try {
                    mqProducer.send(seckillMsg.getTopic(), seckillMsg);
                    localMessageMapper.updateStatus(seckillMsg.getMsgId(), "SENT");
                } catch (Exception e) {
                    log.error("MQ发送失败, msgId={}", seckillMsg.getMsgId(), e);
                }
            }
        }
    );
}

1.4.3 消费者逻辑(幂等 + 乐观锁)

java 复制代码
@RocketMQMessageListener(topic = "seckill-order-topic", consumerGroup = "seckill-consumer")
public class SeckillConsumer implements RocketMQListener<SeckillMessage> {
    
    @Override
    public void onMessage(SeckillMessage msg) {
        // 1. 幂等校验:检查订单状态
        Order order = orderMapper.selectById(msg.getOrderId());
        if (order == null || order.getStatus() != OrderStatus.PENDING) {
            return; // 已处理过,跳过
        }
        
        // 2. 数据库扣减库存(乐观锁兜底)
        int affected = inventoryMapper.deductStock(msg.getSkuId(), msg.getQuantity());
        // SQL: UPDATE inventory SET stock = stock - #{qty} 
        //      WHERE sku_id = #{skuId} AND stock >= #{qty}
        
        if (affected == 1) {
            // 3. 扣减成功 → 更新订单状态
            orderMapper.updateStatus(msg.getOrderId(), OrderStatus.CONFIRMED);
            // 4. 更新本地消息表状态
            localMessageMapper.updateStatus(msg.getMsgId(), "CONSUMED");
            // 4. ★ 发送延迟消息:5分钟后检查是否已支付
	        SeckillTimeoutMessage timeoutMsg = new SeckillTimeoutMessage();
	        timeoutMsg.setOrderId(msg.getOrderId());
	        timeoutMsg.setSkuId(msg.getSkuId());
	        timeoutMsg.setUserId(msg.getUserId());
	        timeoutMsg.setQuantity(msg.getQuantity());
	        
	        rocketMQTemplate.syncSend(
	            "seckill-timeout-topic",
	            MessageBuilder.withPayload(timeoutMsg).build(),
	            3000,           // 发送超时
	            14              // 延迟级别 14 = 5分钟
	        );
        } else {
            // 6. 扣减失败(库存不足)→ 回滚 Redis 库存 + 告警
            log.error("DB库存扣减失败, skuId={}, orderId={}", msg.getSkuId(), msg.getOrderId());
            redisTemplate.opsForValue().increment("seckill:stock:" + msg.getSkuId(), msg.getQuantity());
            redisTemplate.opsForValue().getOperations().delete(
                Collections.singletonList("seckill:dedup:" + msg.getSkuId()), 
                msg.getUserId().toString()
            );
            orderMapper.updateStatus(msg.getOrderId(), OrderStatus.FAILED);
        }
    }
}

1.5 数据库层:乐观锁兜底

sql 复制代码
-- 库存表
CREATE TABLE inventory (
    id BIGINT PRIMARY KEY,
    sku_id BIGINT NOT NULL,
    stock INT NOT NULL DEFAULT 0,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_sku (sku_id)
);

-- 扣减 SQL(核心:stock >= qty 是防超卖的最后一道防线)
UPDATE inventory 
SET stock = stock - #{quantity}
WHERE sku_id = #{skuId} 
  AND stock >= #{quantity};

为什么必须加 stock >= #{quantity}?

  • MQ 消息可能乱序(回滚消息先到、扣减消息后到)
  • 极端情况下幂等失效导致重复扣减
  • 数据库是最终防线,即使上游全错,这里也不能超卖

1.6 超时未支付:库存回滚

用户抢到但没付款,需要把库存还回去:

java 复制代码
// 消费者:检查订单是否已支付
@RocketMQMessageListener(topic = "seckill-timeout-topic", consumerGroup = "timeout-consumer")
public class SeckillTimeoutConsumer implements RocketMQListener<SeckillTimeoutMessage> {
    
    @Override
    public void onMessage(SeckillTimeoutMessage msg) {
        // 1. 查询订单当前状态
        Order order = orderMapper.selectById(msg.getOrderId());
        
        // 2. 判断是否已支付
        if (order == null) {
            return; // 订单不存在,忽略
        }
        
        if (order.getPayStatus() == PayStatus.PAID) {
            return; // 已支付,不需要处理
        }
        
        if (order.getStatus() == OrderStatus.CANCELLED) {
            return; // 已取消(用户主动取消或其他原因),不重复处理
        }
        
        // 3. 未支付 → 取消订单 + 回滚库存
        // 3.1 用乐观锁更新订单状态,防止并发(比如用户刚好在这一秒支付了)
        int affected = orderMapper.cancelOrder(
            msg.getOrderId(), 
            OrderStatus.WAIT_PAY,  // 只有当前状态是 WAIT_PAY 才更新
            OrderStatus.CANCELLED
        );
        // SQL: UPDATE orders SET status = 'CANCELLED' 
        //      WHERE id = #{orderId} AND status = 'WAIT_PAY'
        
        if (affected == 0) {
            // 状态已变更(可能刚好支付了),不再回滚
            return;
        }
        
        // 3.2 回滚 DB 库存
        inventoryMapper.returnStock(msg.getSkuId(), msg.getQuantity());
        // SQL: UPDATE inventory SET stock = stock + #{qty} WHERE sku_id = #{skuId}
        
        // 3.3 回滚 Redis 库存(Lua 脚本保证幂等)
        returnRedisStock(msg.getSkuId(), msg.getUserId(), msg.getQuantity(), msg.getOrderId());
    }
}

回滚 Redis 的 Lua 脚本(带幂等):

bash 复制代码
-- KEYS[1]: 库存key
-- KEYS[2]: 回滚标记key  seckill:rollback:{orderId}
-- ARGV[1]: 回滚数量
-- ARGV[2]: 用户ID

-- 幂等检查:是否已回滚过
if redis.call('EXISTS', KEYS[2]) == 1 then
    return 0
end

-- 回滚库存
redis.call('INCRBY', KEYS[1], ARGV[1])
-- 移除去重标记
redis.call('SREM', KEYS[1] .. ':dedup', ARGV[2])
-- 写入回滚标记(7天过期)
redis.call('SET', KEYS[2], 1, 'EX', 604800)
return 1

1.7 定时对账:修复不一致

这是不用 Canal 的情况下保证最终一致性的关键兜底:

java 复制代码
@Scheduled(fixedRate = 300000) // 每 5 分钟执行一次
public void reconcile() {
    // 1. 获取所有秒杀中的 SKU
    List<Long> activeSkus = seckillActivityMapper.getActiveSkuIds();
    
    for (Long skuId : activeSkus) {
        // 2. 读取 Redis 库存
        String redisStock = redisTemplate.opsForValue()
            .get("seckill:stock:" + skuId);
        
        // 3. 读取 DB 库存
        Integer dbStock = inventoryMapper.getStock(skuId);
        
        // 4. 计算已确认订单的总扣减量
        Integer orderDeducted = orderMapper.sumConfirmedQuantity(skuId);
        
        // 5. 校验:DB初始库存 - orderDeducted 应该等于 DB当前库存
        Integer initialStock = seckillActivityMapper.getInitialStock(skuId);
        Integer expectedDbStock = initialStock - orderDeducted;
        
        if (!expectedDbStock.equals(dbStock)) {
            // DB 库存不一致,以订单明细为准修正
            log.warn("DB库存不一致, skuId={}, expected={}, actual={}", 
                skuId, expectedDbStock, dbStock);
            inventoryMapper.forceUpdateStock(skuId, expectedDbStock);
        }
        
        // 6. 校验 Redis 与 DB 差值
        if (redisStock != null) {
            int diff = Integer.parseInt(redisStock) - dbStock;
            if (Math.abs(diff) > 10) { // 差值超过阈值告警
                log.error("Redis与DB库存差异过大, skuId={}, diff={}", skuId, diff);
                // 可选:以 DB 为准修正 Redis
                redisTemplate.opsForValue().set("seckill:stock:" + skuId, dbStock);
            }
        }
    }
}

1.8 本地消息表补偿:MQ 发送失败的兜底

java 复制代码
@Scheduled(fixedRate = 60000) // 每分钟扫描一次
public void compensateUnsentMessages() {
    // 查找超过 30 秒仍未发送成功的消息
    List<LocalMessage> pendingMessages = localMessageMapper
        .selectPendingMessages(30);
    
    for (LocalMessage msg : pendingMessages) {
        if (msg.getRetryCount() >= 3) {
            // 超过最大重试次数,标记失败,人工介入
            localMessageMapper.updateStatus(msg.getMsgId(), "FAILED");
            alertService.send("消息发送失败超过重试上限, msgId=" + msg.getMsgId());
            continue;
        }
        
        try {
            mqProducer.send("seckill-order-topic", msg.buildMessage());
            localMessageMapper.updateStatus(msg.getMsgId(), "SENT");
        } catch (Exception e) {
            localMessageMapper.incrementRetryCount(msg.getMsgId());
            log.error("补偿发送失败, msgId={}", msg.getMsgId(), e);
        }
    }
}

1.9 完整时序图

整体时序图

bash 复制代码
用户          网关        应用服务       Redis         MQ          MySQL
 │             │            │            │            │            │
 │──秒杀请求──→│            │            │            │            │
 │             │──限流校验──→│            │            │            │
 │             │            │──Lua扣减──→│            │            │
 │             │            │←─成功(1)───│            │            │
 │             │            │──发送消息──────────────→│            │
 │←─排队中─────│←───────────│            │            │            │
 │             │            │            │            │──消费消息──→│
 │             │            │            │            │            │──UPDATE stock
 │             │            │            │            │            │  WHERE stock>=qty
 │             │            │            │            │←─扣减成功──│
 │             │            │            │            │            │──创建订单
 │             │            │            │            │            │
 │──查询结果──→│            │            │            │            │
 │←─秒杀成功──│            │            │            │            │

订单相关时序图

bash 复制代码
用户          消费者        MySQL         RocketMQ       超时消费者
 │              │            │              │              │
 │              │←─MQ消息────│              │              │
 │              │──扣减库存──→│              │              │
 │              │←─成功──────│              │              │
 │              │──创建订单──→│(status=WAIT_PAY)           │
 │              │←─成功──────│              │              │
 │              │──发送延迟消息(5min)──────→│              │
 │              │              │              │              │
 │──支付──────────────────────→│              │              │
 │              │              │←─5分钟后─────│              │
 │              │              │              │──超时消息───→│
 │              │              │              │              │──查询订单状态
 │              │              │              │              │──已支付→跳过
 │              │              │              │              │
 │              │              │              │              │
 │  (如果未支付) │              │              │              │
 │              │              │              │──超时消息───→│
 │              │              │              │              │──未支付→取消订单
 │              │              │←─取消订单────│              │
 │              │              │──回滚DB库存──│              │
 │              │              │──回滚Redis库存              │

1.10 各层职责总结

职责 技术手段
前端层 拦截 80% 无效流量 按钮置灰、验证码、CDN 静态化
网关层 限流降级 令牌桶、IP/用户维度限流
Redis 层 实时扣减,防超卖 Lua 原子脚本、一人一单去重
MQ 层 异步削峰 本地消息表 + 事务后发送
MySQL 层 最终兜底,防超卖 WHERE stock >= qty 乐观锁
对账层 修复不一致 定时对比 Redis/DB/订单明细
补偿层 兜底失败场景 延迟消息回滚库存、本地消息表重试

1.11 面试话术总结

我们的秒杀扣库存方案核心是"Redis 预扣 + MQ 异步落库 + DB 乐观锁兜底"三层架构:

第一层,秒杀前把库存预热到 Redis,请求进来后通过 Lua 脚本原子执行"库存判断 + 扣减 + 一人一单校验",保证不超卖,单节点可以扛 10 万 QPS;

第二层,Redis 扣减成功后发送 MQ 消息,消费者异步完成数据库库存扣减和订单创建,DB 层用 UPDATE WHERE stock >= qty 做最终兜底;

第三层,通过本地消息表保证消息一定能发出去,消费失败有重试和死信队列兜底,定时对账任务每 5 分钟对比 Redis、DB 和订单明细,发现差异自动修复或告警;

第四层,超时未支付的订单通过延迟消息触发库存回滚,回滚操作同样用 Lua 脚本保证幂等。

整个方案不依赖 Canal,数据流是单向的(Redis → MQ → DB),通过多层兜底实现最终一致性。

2. 分布式事务

3. 分布式 ID 生成

4. 分布式限流

5. 分布式消息的可靠性

6.

相关推荐
David猪大卫1 小时前
【C++修炼】异常
开发语言·c++·经验分享·笔记·学习·考研·面试
再吃一根胡萝卜2 小时前
05 · Agent 编排:LangGraph 状态图与降级内核
面试
再吃一根胡萝卜2 小时前
01 · 开篇:需求分析与技术选型
面试
再吃一根胡萝卜2 小时前
06 · 工具调用:Text2SQL 与四层护栏
面试
再吃一根胡萝卜2 小时前
02 · 项目总览与 5 分钟跑起来
面试
再吃一根胡萝卜2 小时前
10 · 踩坑记录与优化清单
面试
再吃一根胡萝卜2 小时前
09 · Java 双栈:Spring Boot + LangChain4j
面试
再吃一根胡萝卜2 小时前
07 · 记忆、可观测与成本控制
面试
再吃一根胡萝卜2 小时前
04 · RAG 全流程:从切分到引用溯源
面试