云计算百科
云计算领域专业知识百科平台

Redis面试场景实战

Redis 面试场景实战:像聊天一样把问题答明白

📌 这份文档是什么:不是教科书,也不是题库,而是一份"面试现场模拟"。每道题都还原真实的面试对话场景——面试官怎么问、你该怎么开口、说多久合适、哪些话能加分、哪些话会给自己挖坑。

每个场景包含两部分:

  • 🗣️ 口述回答:面试现场"说"出来的版本,按限时节奏组织;
  • 💻 方案细节与代码:回答背后的完整技术支撑(流程图、Lua 脚本、代码示例、对比表格),面试前复习用,被要求"写一下/画一下"时直接拿得出来。

三个使用原则:

  • 回答是"说"出来的,不是"背"出来的。每题都给了建议时长(1~3 分钟),按这个节奏组织语言,别一开口就刹不住车。
  • 有经历就讲经历。面试官最想听的不是标准答案,而是"你真的踩过这个坑吗"。文档里穿插了"我的经历"话术模板,把你的真实项目套进去,比任何八股文都有说服力。
  • 别给自己挖坑。每个回答都主动把边界情况、失败路径说清楚,让面试官没有"追问到你露馅"的机会。

  • 忽言悠语:

      面试这事儿吧,跟谈恋爱一个道理。看不上你的,说啥都是错,连呼吸都不对;看上你的,你随便聊聊他都觉得捡到宝。所以别把那些拒绝太当回事,那是人家的“口味偏好”,不是你的“价值判定”。多试几家,总能碰到那个“看你哪哪都顺眼”的东家。

    📑 场景索引

    领域场景建议时长
    库存 场景一:秒杀库存扣减,怎么防超卖 3 分钟
    库存 场景二:订单取消后库存怎么回滚 2 分钟
    库存 场景三:Redis 库存和数据库怎么保持一致 2~3 分钟
    流水 场景四:库存流水怎么设计 2 分钟
    流水 场景五:支付回调重复到达怎么幂等 2 分钟
    订单 场景六:订单 30 分钟未支付自动取消 2~3 分钟
    订单 场景七:怎么防止用户重复下单 2 分钟
    缓存 场景八:穿透、击穿、雪崩 3 分钟
    缓存 场景九:缓存和数据库的一致性 2 分钟
    防护 场景十:秒杀接口限流防刷 2 分钟
    防护 场景十一:分布式锁 3 分钟
    选型 场景十二:库存为什么用 String 不用 Hash 1~2 分钟
    可靠性 场景十三:Redis 挂了怎么办 2 分钟
    可靠性 场景十四:怎么发现和修复库存不一致 2 分钟

    场景一:秒杀库存扣减,怎么防超卖?

    🎤 面试官这么问

    “你们电商系统有秒杀吧?10 万人抢 100 件商品,库存扣减怎么做的?怎么保证不超卖?”

    ⏱️ 建议时长:3 分钟(这是重头戏,值得展开讲)

    🗣️ 你可以这么答

    先接住问题,表明思路(15 秒):

    “这个场景我之前在项目里实际做过,我按’为什么不能直接扣数据库 → 我们怎么做的 → 中间踩过什么坑’这个顺序讲一下。”

    第一段:为什么数据库扛不住(30 秒):

    “最直觉的做法是数据库里 UPDATE stock = stock – 1 WHERE stock > 0,这条 SQL 本身是原子的,也能防超卖。但 10 万并发打过来,所有请求都在抢同一行的行锁,数据库 TPS 直接掉到几百,连接池几秒钟就耗尽了,而且会把整个下单链路拖垮——不只是秒杀挂,正常订单也下不了。所以数据库不能当第一道防线。”

    第二段:我们的方案——Redis 预扣 + Lua 原子操作(1 分钟):

    "我们的做法分三步:

    第一,活动开始前把库存预热到 Redis,比如 seckill:stock:10086 设成 100。这一步是运营配置活动后由定时任务提前 5 分钟完成的。

    第二,请求进来用一段 Lua 脚本做’判断 + 扣减’。这里必须用 Lua,不能先 GET 再 DECR——两步之间有并发窗口,100 个请求同时读到 1,就都以为有库存,超卖就是这么来的。Lua 脚本在 Redis 里是单线程原子执行的,脚本里先 GET 库存,判断够不够,够就 DECRBY,不够返回售罄,整个过程不会被打断。

    第三,扣减成功后不直接建订单,而是发一条 MQ 消息,订单服务异步消费建单。因为只有 100 个人能抢到,建单压力不大,异步化可以让秒杀接口快速返回’排队中’,用户体验也更好。"

    第三段:主动交代坑和兜底(1 分钟)——这段最加分:

    "这里面有几个坑是我们实际踩过或者提前防了的:

    一是 Lua 扣成功了但 MQ 发送失败,这会导致库存扣了但订单没建,表现为少卖。我们的处理是 MQ 用同步发送,发送失败就立刻把 Redis 库存 INCRBY 加回去,然后返回用户’请重试’。

    二是 Redis 万一挂了,秒杀是强依赖 Redis 的,我们的策略是直接熔断返回’活动暂停’,绝对不穿透到数据库——因为数据库那份库存没有并发保护,放 10 万人进去就是超卖事故。宁可活动停一会儿,不能资损。

    三是数据库那边我们仍然留了最后一道防线:异步扣减 DB 库存的 SQL 也带 WHERE stock >= 数量 条件。就算上游全出了 bug,数据库层面也不会出现负库存。"

    收尾(15 秒):

    “总结一下就是四层:Redis 扛并发、Lua 保原子、MQ 做削峰、数据库兜底。每一层解决一个问题,哪层挂了都有下一层接住。”

    💻 方案细节与代码

    整体架构(分层漏斗):

    第 1 层:前端限流
    – 按钮置灰防重复点击
    – 验证码/答题拉长请求时间

    第 2 层:网关限流
    – 按用户 ID 限流(每人每秒 1 次)
    – 按 IP 限流防脚本

    第 3 层:库存预热到 Redis
    – 活动开始前将库存加载到 Redis
    – 用 Lua 脚本"判断 + 扣减"原子执行

    第 4 层:异步下单
    – 扣减成功 → 发送 MQ 消息
    – 消费者异步创建订单、扣减 DB 库存

    第 5 层:数据库兜底
    – UPDATE stock SET count = count – 1 WHERE id = ? AND count > 0

    核心 Lua 脚本(逐行注释版):

    — KEYS[1] = seckill:stock:{skuId}
    — ARGV[1] = 扣减数量(通常为 1)
    local stock = tonumber(redis.call('GET', KEYS[1]))
    if stock == nil then
    return 1 — 库存未初始化,说明预热失败,走降级
    end
    if stock < tonumber(ARGV[1]) then
    return 0 — 库存不足,已售罄
    end
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1 — 扣减成功

    设计意图逐条说明:

    • stock == nil 判断:防御性检查。预热失败或 key 被误删时返回 -1 走降级,而不是把 nil 当 0 误判售罄。
    • stock < ARGV[1] 而不是 stock <= 0:支持一次扣多件(用户买 2 件),更通用。
    • 用 DECRBY 而不是 DECR:同样为了支持多件扣减。
    • 整个脚本在 Redis 单线程中原子执行,不存在并发窗口。

    秒杀接口完整代码:

    public Result seckill(Long userId, Long skuId) {
    // 1. 限流:每人每秒最多 1 次
    if (!rateLimiterService.tryAcquire("seckill:limit:" + userId, 1, 1)) {
    return Result.fail("请求过于频繁");
    }
    // 2. Redis 原子扣减库存
    Long result = redisTemplate.execute(SECKILL_SCRIPT,
    Collections.singletonList("seckill:stock:" + skuId), "1");
    if (result == null || result == 1) {
    return Result.fail("活动未开始或已暂停"); // 预热失败降级
    }
    if (result == 0) {
    return Result.fail("已售罄");
    }
    // 3. 同步发送 MQ 异步创建订单,失败则回补库存
    try {
    rocketMQProducer.sendSync(TOPIC_ORDER, TAG_CREATE,
    new SeckillOrder(userId, skuId));
    } catch (Exception e) {
    redisTemplate.opsForValue().increment("seckill:stock:" + skuId, 1);
    return Result.fail("系统繁忙,请重试");
    }
    return Result.success("秒杀成功,订单创建中");
    }

    💡 为什么这么答

    • 开头一句"我实际做过"直接拉满可信度;
    • 主动讲坑(MQ 失败、Redis 宕机)是堵追问——面试官想问的你都先说了;
    • "宁可停活动也不穿透数据库"这种取舍判断,是区分"背题的"和"做过的"的关键。

    📖 我的经历·话术模板

    “我们当时第一次压测就发现了 MQ 发送失败导致少卖的问题——压测工具模拟了 MQ 抖动,结果 Redis 库存扣了 30 个,订单只建了 26 个。后来加了同步发送 + 失败回补,再压就对得上了。”

    (把数字换成你真实经历的,哪怕规模小也没关系,细节真实比规模宏大更重要。)


    场景二:订单取消后库存怎么回滚?

    🎤 面试官这么问

    “用户下单 30 分钟没支付,订单取消了,库存怎么加回去?用户主动取消呢?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先归类(20 秒):

    “库存回滚其实有三种触发场景:超时未支付、用户主动取消、支付失败或退款。三种场景入口不同,但回滚逻辑是同一套,所以我们抽了一个统一的回滚消费者,不同场景都发同一种 MQ 消息,避免写三份逻辑出三种 bug。”

    核心:幂等(1 分钟):

    "回滚最怕的是重复回滚。比如延迟消息重试了,或者用户点取消和超时取消恰好同时触发——如果每次都无脑 INCRBY,库存就会多加,等于凭空造出了库存,最后就是超卖。

    我们的幂等做了两层:

    第一层是订单状态机。回滚前先查订单状态,只有’待支付’才处理;更新状态用乐观锁 UPDATE … WHERE status = '待支付',影响行数是 0 就说明别人已经处理过了,直接返回。状态是业务事实,不会丢也不会过期,这是最可靠的幂等依据。

    第二层是 Redis 回滚流水 key。因为 INCRBY 本身不幂等,执行两次就加两次。所以我们用一段 Lua:先 EXISTS 检查 rollback:log:{orderId} 存不存在,不存在才执行 INCRBY 并写入这个标记,标记保留 7 天。检查和写入在一段脚本里原子完成,并发下也只有一个请求能成功。"

    顺序问题(30 秒):

    “还有个细节是先回滚 Redis 再回滚数据库。因为 Redis 是用户实际看到的’可售库存’,先恢复它,商品能尽快重新上架可售,少卖的时间窗口最短。数据库那边靠 MQ 重试保证最终会补上。”

    💻 方案细节与代码

    三种触发场景对照:

    触发场景触发时机触发方
    超时未支付 下单后 30 分钟 延迟队列消费者
    用户主动取消 用户点击取消 订单服务
    支付失败/退款 支付回调通知失败 支付服务/交易服务

    完整回滚流程:

    ① 收到回滚消息(含 orderId)
    ② 查询订单状态
    – 已是"已取消" → 已处理过,直接返回(幂等)
    – 是"待支付" → 继续
    ③ 更新订单状态为"已取消"(乐观锁:WHERE status = '待支付')
    – 影响行数 = 0 → 被其他线程先处理了,返回(幂等)
    – 影响行数 = 1 → 继续
    ④ 回滚 Redis 库存(带幂等检查的 Lua)
    ⑤ 回滚数据库库存:UPDATE inventory SET stock = stock + {数量} WHERE sku_id = ?
    ⑥ 写入库存流水(类型=回滚,关联 orderId)

    Redis 回滚幂等 Lua 脚本:

    — KEYS[1] = seckill:stock:{skuId}
    — KEYS[2] = rollback:log:{orderId}
    — ARGV[1] = 回滚数量
    if redis.call('EXISTS', KEYS[2]) == 1 then
    return 0 — 已回滚过,幂等返回
    end
    redis.call('INCRBY', KEYS[1], ARGV[1])
    redis.call('SET', KEYS[2], 1, 'EX', 86400 * 7) — 流水标记保留 7 天
    return 1

    为什么用数据库状态机做幂等而不是 Redis SETNX:

    • Redis 的 SETNX 有 TTL,过期后幂等失效;设很长(如 7 天)又占内存。
    • 订单状态本身就是最可靠的幂等依据——状态机是业务事实,不会丢、不会过期。
    • 数据库的 UPDATE … WHERE status = '待支付' 利用行锁天然防并发,影响行数就是幂等判断依据。

    💡 为什么这么答

    • "三种场景一套逻辑"体现工程抽象能力;
    • 主动指出"INCRBY 不幂等"这个细节,说明你真写过这段代码——没写过的人根本想不到这一层。

    场景三:Redis 库存和数据库怎么保持一致?

    🎤 面试官这么问

    “库存放 Redis 里,那数据库的库存怎么办?两边不一致了怎么处理?”

    ⏱️ 建议时长:2~3 分钟

    🗣️ 你可以这么答

    先纠正一个前提(30 秒):

    “首先我想澄清一下,在我们设计里,Redis 库存和数据库库存不是同一份数据的两个副本,而是职责不同的两层:Redis 是’可售库存’,负责实时扣减,扛并发;数据库是’账面库存’,负责持久化记录,供对账和报表用。所以目标不是强一致——那是分布式事务的代价——而是最终一致,允许秒级延迟,但不允许永久对不上。”

    数据流是单向的(1 分钟):

    "数据流我们设计成单向:Redis → 数据库,不做双向同步。下单时 Redis 实时扣,然后发 MQ,消费者异步扣数据库;取消时反过来加。为什么不做双向?因为双向同步会有循环更新问题——Redis 改了同步到 DB,DB 的变更又触发同步回 Redis,一旦有 bug 就是死循环,而且冲突没法解。单向流天然没这个问题。

    消费者扣数据库时有两个关键点:一是幂等,消息里带流水号,消费前先查流水表,处理过的直接跳过;二是 SQL 带 WHERE stock >= 数量 条件,防止消息乱序(比如回滚消息先到、扣减消息后到)把库存扣成负数。"

    不一致怎么发现、怎么修(1 分钟):

    “就算机制再完善,还是可能不一致——消费者宕机、消息丢失都有可能。所以我们有个定时对账任务,每 5 分钟跑一次:拿 Redis 库存和’数据库库存减去待支付订单占用’做比对。为什么要减待支付占用?因为待支付订单在 Redis 已经扣了、数据库还没扣,这部分差值是正常的。如果比对下来差值超过阈值,先告警,确认是异常就以数据库为准修正 Redis——因为数据库有完整流水,可以追溯每一笔变动,而 Redis 只是加速层,数据随时可以从数据库重建。”

    主动堵一个追问(20 秒):

    “可能有人会想到用 Canal 监听 binlog 反向同步,这个方案在商品信息那种’DB 为主、缓存为从’的场景很合适,但库存场景不行——库存的写入方向是 Redis 在前,用 Canal 反向同步会形成循环,而且 Canal 只能做赋值不能做原子扣减,并发下会互相覆盖。”

    💻 方案细节与代码

    两层库存的职责定位:

    层职责特点
    Redis 库存 可售库存(实时扣减) 高频读写,允许短暂不一致
    DB 库存 账面库存(持久化记录) 低频写入,要求最终一致

    单向数据流:

    下单时:Redis 扣减(实时)→ 发 MQ → 消费者扣减 DB(异步)
    取消时:Redis 回滚(实时)→ 发 MQ → 消费者回滚 DB(异步)

    异步落库消费者逻辑:

    ① 消费 MQ 消息(含 orderId、skuId、数量、流水号)
    ② 幂等检查:查询流水表是否已有该流水号
    – 有 → 跳过
    – 无 → 继续
    ③ 执行 DB 扣减:
    UPDATE inventory SET stock = stock – #{qty}
    WHERE sku_id = #{skuId} AND stock >= #{qty}
    ④ 检查影响行数:
    – = 1 → 扣减成功,写入流水
    – = 0 → 库存不足(异常情况),触发告警 + 补偿
    ⑤ 写入库存流水表(类型=扣减,关联 orderId)

    为什么 DB 扣减还要加 stock >= qty 条件(防御性编程):

    • MQ 消息乱序(回滚消息先到,扣减消息后到),不加条件会导致库存为负。
    • 有 bug 导致重复扣减消息(幂等失效的极端情况),这个条件是最后一道防线。
    • 数据库是最终防线,即使上游全错,这里也不能超卖。

    为什么不用 Canal:

    • 库存的写入方向是 Redis → DB(先扣 Redis),Canal 反向同步回 Redis 会形成循环。
    • 库存需要原子扣减,Canal 只能做"赋值"(SET stock = 100),无法做"扣减"(DECR),并发下会覆盖。
    • Canal 适合"DB 是主,缓存是从"的场景(如商品信息),不适合"Redis 是主,DB 是从"的库存场景。

    💡 为什么这么答

    • 开头"澄清前提"是高级技巧:把问题从"怎么做强一致"重新定义为"两层职责 + 最终一致",直接掌控了答题框架;
    • 对账等式(Redis = DB – 待支付占用)讲得出来,说明真做过对账。

    场景四:库存流水怎么设计?

    🎤 面试官这么问

    “库存每次变动都要有记录吧?流水怎么设计?怎么保证不重不漏?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先说流水的价值(20 秒):

    “流水表我们叫 inventory_log,它有三个作用:对账的依据、出问题时追溯的线索、还有幂等的天然钥匙。可以说整个库存系统的一致性,最后都压在这张表上。”

    表设计讲重点(40 秒):

    “表里有几个关键字段:流水号 log_no 加了唯一索引,这是幂等的核心;before_stock 和 after_stock 记录变动前后的库存快照,这样对账的时候可以链式校验——上一条的 after 应该等于下一条的 before,链条断了就说明有流水缺失;type 区分扣减、回滚、入库、出库;order_id 关联订单,入库这种没有订单的就留空。”

    "不重"三层保障(40 秒):

    “不重靠三层:第一层是业务前置检查,比如订单状态机保证同一订单不会触发两次回滚,这层能挡住 99% 的重复;第二层是消费者插入前先查流水号,存在就跳过,这层是性能优化,避免走到异常路径;第三层是数据库唯一索引,就算前两层都失效,插入重复流水号会直接报 Duplicate entry,这是最终防线。三层的关系是层层兜底,越往后越可靠但越少触发。”

    "不漏"两个机制(30 秒):

    “不漏靠两个机制:一是库存变更和流水写入在同一个数据库事务里,要么都成功要么都回滚,绝不会出现’库存扣了流水没记’;二是 MQ 消费失败会重试,配合幂等,重试只会补漏不会重复。极端情况比如数据库主从切换丢了几条,对账任务会发现库存和流水重算值对不上,触发告警人工介入。”

    💻 方案细节与代码

    流水表结构:

    CREATE TABLE inventory_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    log_no VARCHAR(64) NOT NULL UNIQUE, — 流水号(全局唯一,幂等键)
    sku_id BIGINT NOT NULL, — 商品 SKU
    warehouse_id BIGINT NOT NULL, — 仓库
    type TINYINT NOT NULL, — 类型:1扣减 2回滚 3入库 4出库
    quantity INT NOT NULL, — 变动数量(正数)
    before_stock INT NOT NULL, — 变动前库存
    after_stock INT NOT NULL, — 变动后库存
    order_id VARCHAR(64), — 关联订单号(可空,入库无订单)
    operator VARCHAR(64), — 操作人/系统标识
    remark VARCHAR(256), — 备注
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_sku_time (sku_id, create_time),
    INDEX idx_order (order_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    设计要点:

    • log_no 唯一索引:幂等的核心。每次操作生成全局唯一流水号,插入前靠唯一索引防重。
    • before_stock / after_stock:变动前后快照,对账时链式校验(上一条的 after 应等于下一条的 before)。
    • type 枚举:区分操作类型,便于统计和筛选。
    • order_id 可空:入库、盘点等操作没有订单关联。
    • 索引设计:idx_sku_time 支持"查某商品某时间段的流水";idx_order 支持"查某订单的所有库存操作"。

    流水号生成策略:

    格式:{类型码}{日期}{雪花ID后12位}
    示例:DJ20240101123456789012
    ↑扣减 ↑日期 ↑唯一序列

    为什么不用数据库自增 ID:

    • 自增 ID 在分库分表后会冲突;
    • 自增 ID 需要插入后才知道,无法在插入前做幂等判断;
    • 业务流水号可以携带语义(类型、日期),便于人工排查。

    事务绑定代码(不漏的关键):

    @Transactional(rollbackFor = Exception.class)
    public void deductStock(Long skuId, int qty, String logNo, String orderId) {
    // 1. 扣减库存
    int rows = inventoryDao.deduct(skuId, qty);
    if (rows == 0) throw new BusinessException("库存不足");
    // 2. 写入流水(同一事务)
    InventoryLog log = buildLog(skuId, qty, logNo, orderId);
    inventoryLogDao.insert(log);
    }

    流水的性能考量:

    • 按月分表:流水只增不改不删,历史数据查询少,按月分表(inventory_log_202401)避免单表过大。
    • 归档策略:超过 6 个月的流水归档到冷存储(如 Hive),主表只保留近期数据。
    • 异步批量(性能极敏感场景):先写内存队列,定时批量 INSERT,代价是极端情况可能丢几条(靠对账补偿)。

    📖 我的经历·话术模板

    “流水号我们最早用的是数据库自增 ID,后来发现两个问题:分库分表后会冲突,而且自增 ID 要插入之后才知道,没法在插入前做幂等判断。后来改成了’类型码 + 日期 + 雪花 ID’的格式,比如 DJ20240101 开头就是扣减流水,排查问题时肉眼就能分辨类型,挺实用的。”


    场景五:支付回调重复到达怎么幂等?

    🎤 面试官这么问

    “支付网关的回调可能重复通知,网络超时后它会重试。你怎么保证不重复处理?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先描述问题的真实样子(20 秒):

    “这个问题很典型:我们处理完回调、返回了 SUCCESS,但响应在网络上丢了,支付网关没收到确认,就会重发同一条回调。更狠的是有些网关有对账补单机制,T+1 还会再发一遍你早就处理过的回调。所以幂等不是可选项,是必选项。”

    幂等键 + 双重检查(1 分钟):

    "幂等的前提是能识别’这是同一次操作’,支付回调里的支付流水号就是天然的幂等键——同一次支付不管回调多少次,流水号不变。

    我们的处理流程是:先验签防伪造,然后查 Redis 的 pay:done:{流水号} 标记,存在就直接返回 SUCCESS——这是快速路径,99% 的重复回调在这里就被拦掉了,不用碰数据库。标记不存在就走正常流程:校验金额、更新订单状态、执行后续业务、最后写入幂等标记。

    但 Redis 标记不是最终保障——万一 Redis 挂了或者 key 过期了呢?所以订单状态更新用的是乐观锁:UPDATE order SET status='已支付' WHERE order_id=? AND status='待支付',影响行数为 0 就说明已经处理过了,直接返回成功。Redis 管性能,数据库管正确性,两层缺一不可。"

    两个容易被忽略的细节(40 秒):

    "有两个细节我想特别说一下:

    一是顺序:必须先更新订单状态、再写 Redis 幂等标记。反过来的话,万一标记写了但状态更新失败,后续重试会被标记拦住,订单就永远卡在’待支付’了。

    二是金额校验:回调里的金额必须和订单应付金额比对。这不是走形式——攻击者可能抓到回调格式后伪造一个 1 分钱的回调,试图’低价支付’。验签防的是外部伪造,金额校验防的是数据层面的篡改,两个都要有。

    另外后续业务比如发积分、发通知,如果失败了不影响给网关返回 SUCCESS——因为订单状态已经改了,返回 FAIL 让网关重试也没意义,重试进来就被幂等拦掉了,积分反而永远发不出去。正确做法是后续业务单独走补偿队列重试。"

    💻 方案细节与代码

    完整处理流程:

    ① 收到支付回调(含 paymentNo、orderId、amount)
    ② 验签(防止伪造回调)
    ③ 幂等检查:EXISTS pay:done:{paymentNo}
    – 存在 → 直接返回 SUCCESS(幂等快速路径)
    ④ 校验金额:回调金额 与 订单应付金额 是否一致
    – 不一致 → 记录异常日志 + 告警,返回 FAIL
    ⑤ 更新订单状态(数据库乐观锁):
    UPDATE order SET status='已支付' WHERE order_id=? AND status='待支付'
    – 影响行数=0 → 已被处理,返回 SUCCESS(幂等最终防线)
    ⑥ 执行后续业务:确认库存、发放积分、发送通知等
    ⑦ 记录幂等标记:SET pay:done:{paymentNo} 1 EX 604800(7天)
    ⑧ 返回 SUCCESS

    为什么幂等检查用 Redis + 数据库双重:

    • Redis 检查是快速路径:99% 的重复回调在这里被拦截,不查库。
    • 数据库乐观锁是最终防线:Redis 不可用或 key 过期时,WHERE status='待支付' 仍能保证幂等。
    • 两者互补:Redis 保证性能,数据库保证正确性。

    为什么步骤⑤在步骤⑦之前:

    先更新订单状态,再记录幂等标记。如果反过来(先记标记后更新状态),万一更新状态失败,标记已存在,后续重试会被误判为"已处理"而跳过,导致订单永远停在"待支付"。

    为什么步骤⑥失败不影响返回 SUCCESS:

    后续业务(发积分、发通知)是可补偿的异步操作。如果因为发积分失败就返回 FAIL,支付网关会重试整个回调,但订单状态已经改了,重试时幂等检查直接返回——积分就永远发不出去了。正确做法是:后续业务失败时单独重试或补偿,不影响回调主流程的确认。

    并发回调的处理(两条相同回调同时到达):

    • 两个请求都通过了 Redis 的 EXISTS 检查(此时标记还没写入);
    • 两个请求都执行步骤⑤的 UPDATE … WHERE status='待支付';
    • 数据库行锁保证只有一个成功(影响行数=1),另一个影响行数=0,走幂等返回。

    所以数据库乐观锁是并发场景的最终保障,Redis 只是加速器。

    💡 为什么这么答

    • "Redis 管性能,数据库管正确性"这种一句话总结,面试官会记住;
    • 金额校验和"后续业务失败不返回 FAIL"这两个细节,是真实做过支付对接的人才有的敏感度。

    场景六:订单 30 分钟未支付自动取消

    🎤 面试官这么问

    “用户下单 30 分钟不支付要自动取消,这个怎么实现?”

    ⏱️ 建议时长:2~3 分钟

    🗣️ 你可以这么答

    先摆方案对比,表明选择有依据(40 秒):

    "这个需求常见方案有四种,我先快速过一下为什么选了我们用的那个:

    数据库轮询——定时扫超时订单,简单但延迟高,订单表大了之后扫描本身就是压力,pass; JDK 的 DelayQueue——纯内存,重启就丢,单机也扛不住集群部署,pass; Redis 的 key 过期事件——这个要特别说一下,生产环境不能用,因为过期事件是’发布即忘’的,消费者断连那一瞬间的事件就永久丢了,而且过期删除本身是惰性加定期的,触发时间不精确; 我们最终选的是 RocketMQ 延时消息,消息持久化、消费失败自动重试,可靠性最高,而且我们技术栈里本来就有 RocketMQ,不引入新组件。"

    实现流程(40 秒):

    "实现很直接:下单成功后发一条延时 30 分钟的消息,消息体就一个订单号。30 分钟后消费者收到消息,先查订单状态——已支付就忽略,已取消也忽略,只有还是’待支付’才执行取消。取消动作是:乐观锁更新订单状态、回滚库存、释放优惠券。

    这里状态检查特别重要,因为存在一个经典并发:用户在第 29 分 59 秒完成了支付,取消消息几乎同时到达。这时候订单状态已经是’已支付’,取消逻辑一查状态就直接退出了,不会把已付款的订单取消掉。状态机天然解决了这个竞态,不需要额外的锁。"

    补充延时级别的限制(30 秒):

    “有个小限制是 RocketMQ 4.x 只支持 18 个固定延时级别,30 分钟正好是其中一级,运气不错。如果业务要 45 分钟这种非标时间,要么就近取整,要么用 Redis ZSet 自己实现:ZADD 的时候 score 存到期时间戳,后台每秒 ZRANGEBYSCORE 取已到期的,取出来后先 ZREM、用 ZREM 的返回值判断是不是自己抢到的——多实例部署时只有一个实例能删成功,天然防重复消费。这个 ZSet 方案我们后来在另一个需要任意延时精度的场景用过,也挺稳。”

    💻 方案细节与代码

    方案对比表:

    方案原理优点缺点是否选择
    数据库轮询 定时 SQL 扫描超时订单 简单 延迟高、DB 压力大、锁竞争
    JDK DelayQueue 内存延迟队列 精度高 单机、重启丢失
    Redis ZSet score=到期时间,轮询取 灵活、精度高 需自己保证可靠性 ⚠️ 备选
    Redis 过期事件 key 过期触发通知 简单 不可靠(可能丢失)
    RocketMQ 延时消息 原生延时投递 可靠、有重试 延时级别固定(4.x) ✅ 首选

    为什么不用 Redis 过期事件(必须主动说明的坑):

  • 过期事件是"发布即忘"的,如果消费者断连,事件直接丢失。
  • 过期删除是惰性+定期的,事件触发时间不精确。
  • 集群模式下,事件只在 key 所在节点发布,消费者必须连接所有节点。
  • RocketMQ 延时消息实现:

    下单时:
    ① 创建订单(状态=待支付)
    ② 发送延时消息:
    rocketMQProducer.sendDelay(TOPIC_DELAY, TAG_CANCEL, orderId, DELAY_30M)
    // DELAY_30M = 16,对应 RocketMQ 的 30 分钟延时级别

    30 分钟后:
    ③ 消费者收到消息
    ④ 查询订单状态
    – 已支付 → 忽略(用户已付款,不能取消)
    – 已取消 → 忽略(幂等)
    – 待支付 → 执行取消
    ⑤ 取消逻辑:
    a. 更新订单状态为"已取消"(乐观锁)
    b. 回滚库存(场景二的逻辑)
    c. 释放优惠券(如有)

    Redis ZSet 备选方案完整实现:

    下单时:
    ZADD order:delay {下单时间+30分钟的毫秒时间戳} {orderId}

    后台轮询(每秒一次):
    ① ZRANGEBYSCORE order:delay 0 {当前时间戳} LIMIT 0 100
    // 取出所有已到期的订单,每次最多 100 条防阻塞
    ② 对每个订单:
    a. ZREM order:delay {orderId} // 原子移除,返回值>0 才处理(防重复)
    b. 查询订单状态,执行取消

    // 1. 下单时加入延迟队列(30 分钟后过期)
    public void addDelayTask(String orderId) {
    long expireTime = System.currentTimeMillis() + 30 * 60 * 1000;
    redisTemplate.opsForZSet().add("delay:order:cancel", orderId, expireTime);
    }

    // 2. 后台轮询消费(每秒执行一次)
    @Scheduled(fixedRate = 1000)
    public void pollDelayQueue() {
    long now = System.currentTimeMillis();
    Set<Object> expiredOrders = redisTemplate.opsForZSet()
    .rangeByScore("delay:order:cancel", 0, now);
    if (expiredOrders == null || expiredOrders.isEmpty()) return;

    for (Object orderId : expiredOrders) {
    // ZREM 原子移除,返回值 > 0 才处理(多实例防重复消费)
    Long removed = redisTemplate.opsForZSet()
    .remove("delay:order:cancel", orderId);
    if (removed != null && removed > 0) {
    cancelIfUnpaid(orderId.toString());
    }
    }
    }

    ZSet 方案三个关键点:

    • ZREM 的返回值做幂等:多实例部署时,只有一个实例能 ZREM 成功,避免重复取消。
    • LIMIT 分批:防止一次取出太多导致处理超时。
    • 轮询间隔决定精度:1 秒轮询 = 最大 1 秒延迟,对订单取消完全够用。

    取消操作的并发处理:

  • 取消与支付并发:用户在第 29 分 59 秒支付了,取消消息恰好到达。此时订单状态已是"已支付",取消逻辑查到状态不是"待支付",直接忽略。状态机保证了正确性。
  • 取消消息重复投递:MQ 的 at-least-once 语义可能重复投递。通过 UPDATE … WHERE status='待支付' 的影响行数判断,第二次执行时影响行数=0,直接返回。
  • 📖 我的经历·话术模板

    “说到 Redis 过期事件,我们团队早期真有同事用它做过提醒功能,测试环境一切正常,上线后偶尔有用户收不到提醒,查了半天发现是服务发布重启的瞬间,那几秒的过期事件全丢了。从那以后我们内部定了条规矩:过期事件只准用于’丢了也无所谓’的场景,核心业务一律走延时消息。”


    场景七:怎么防止用户重复下单?

    🎤 面试官这么问

    “用户手抖双击了下单按钮,或者网络重试,会不会创建两个订单?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先区分两种"重复"(20 秒):

    “先要区分两种情况:一种是前端重复提交——双击、网络卡顿浏览器重试,这是要防的;另一种是用户真的想再买一单,这是合法行为不能拦。防重方案不能误伤后者。”

    主方案:幂等令牌(1 分钟):

    "我们用的是幂等令牌机制:用户进入订单确认页的时候,前端先调一个接口领’下单令牌’,服务端生成一个随机 token 存到 Redis,设 10 分钟过期。用户提交订单时把令牌带上,服务端用一段 Lua 做’检查并删除’——令牌存在就删掉并放行下单,不存在就拒绝。

    这里必须用 Lua 把检查和删除合成一步原子操作。如果分成 EXISTS 和 DEL 两步,双击产生的两个请求可能都通过 EXISTS 检查,然后都去创建订单,防重就失效了。

    这个方案的好处是天然区分了两种重复:同一个令牌只能用一次,双击的第二个请求必然被拒;但用户下完单再进确认页,会领到新令牌,正常下第二单完全不受影响。"

    兜底(30 秒):

    “除了令牌,还有两层兜底:一是前端提交后立刻置灰按钮,挡住大部分手抖;二是订单号有唯一索引,极端情况下重复插入会直接失败。不过要说清楚,唯一索引只是最后的保险,不能作为主要手段——因为两次双击生成的是两个不同的订单号,唯一索引根本拦不住,真正起作用的还是令牌。”

    💻 方案细节与代码

    幂等令牌完整流程:

    ① 用户进入下单确认页时,前端请求一个"下单令牌":
    GET /order/token → 返回 token
    (Redis: SET order:token:{userId}:{token} 1 EX 600)

    ② 用户提交订单时,携带令牌:
    POST /order/create {token, skuId, qty, …}

    ③ 服务端处理:
    a. 用 Lua 原子"检查并删除"令牌:
    令牌存在 → 删除,继续下单
    令牌不存在 → 重复提交,拒绝
    b. 令牌校验通过后,正常创建订单

    令牌消费 Lua 脚本:

    — KEYS[1] = order:token:{userId}:{token}
    if redis.call('EXISTS', KEYS[1]) == 1 then
    redis.call('DEL', KEYS[1])
    return 1 — 令牌有效,已消费
    else
    return 0 — 令牌无效(已用过或不存在)
    end

    令牌方案的优点:

    • 天然幂等:一个令牌只能用一次,第二次提交必然被拒。
    • 不影响合法重复下单:用户下完第一单后,再进入确认页会获取新令牌,可以正常下第二单。
    • 前端体验好:可以在提交后立即禁用按钮 + 提示"请勿重复提交"。

    补充分布式锁方案(无令牌场景,如第三方调用):

    锁的 key:order:lock:{userId}:{skuId}:{地址哈希}
    锁的超时:10 秒

    ① 尝试获取锁(SETNX + EX)
    ② 获取成功 → 创建订单 → 释放锁
    ③ 获取失败 → 返回"请勿重复提交"

    局限:锁的粒度不好把握。太粗(只按 userId)会误伤合法的多商品下单;太细(加地址哈希)可能拦不住。所以令牌方案是首选,锁方案是补充。

    💡 为什么这么答

    • 最后那句"唯一索引拦不住双击,因为订单号不同"是个反直觉的细节,能说出来证明你真的想过这个问题,而不是背了个"加唯一索引"的万能答案。

    场景八:穿透、击穿、雪崩

    🎤 面试官这么问

    “商品详情页的缓存怎么做的?穿透、击穿、雪崩分别怎么解决?”

    ⏱️ 建议时长:3 分钟(三个问题一起问,值得完整展开)

    🗣️ 你可以这么答

    先用一句话把三个概念钉死(20 秒):

    “这三个问题我先用一句话区分一下,因为很多人会混:穿透是查’根本不存在’的数据,缓存和数据库都没有,请求次次落库;击穿是’某一个热点 key’恰好过期,瞬间的并发全打到数据库;雪崩是’一大批 key’同时过期或者 Redis 整个挂了,数据库被全面冲垮。一个是查无此数据,一个是单点过热,一个是集体失效。”

    穿透(50 秒):

    "穿透我们用了三层组合:

    第一层参数校验,商品 ID 必须是正整数,负数、超长字符串直接拒掉,成本最低的一道闸。

    第二层布隆过滤器,系统启动时把全量有效商品 ID 灌进去,请求先问布隆过滤器’这个 ID 可能存在吗’,说不可能的直接返回,连 Redis 都不用查。我们的参数是预估 100 万商品、误判率万分之一,算下来位图大概 19MB,内存开销完全可以接受。

    第三层缓存空值,布隆过滤器有误判率,说’可能存在’但数据库查不到的,我们缓存一个空标记,TTL 只设 60 秒——设短是因为万一这个商品后来真的上架了,60 秒后缓存过期就能查到,不会长时间误伤。

    三层是互补关系:校验挡格式非法的,布隆挡格式合法但不存在的,空值挡布隆误判的。"

    击穿(50 秒):

    "击穿我们用互斥锁重建:缓存未命中时,先 SETNX 抢一把锁,抢到的线程去查库、重建缓存、释放锁;没抢到的线程睡 50 毫秒后重试读缓存,这时候大概率已经重建好了。

    两个细节:一是抢到锁之后要再查一次缓存(双重检查),因为可能在你抢锁的瞬间别人已经重建完了,不查这一下就白跑一趟数据库;二是锁必须带过期时间,我们设 10 秒,防止持锁线程宕机变成死锁。

    这里有个取舍可以聊:另一种方案是’逻辑过期’——缓存永不真过期,value 里存个过期时间字段,发现逻辑过期就异步重建、当前请求先返回旧数据。这个方案可用性更好但会短暂返回旧数据。商品详情里有价格,返回旧价格可能引发客诉,所以我们选了互斥锁,宁可让极少数请求等 50 毫秒,也不返回脏数据。"

    雪崩(40 秒):

    "雪崩的防御是分层的:

    第一,TTL 加随机值,我们的基础 TTL 是 30 分钟,再加 0 到 300 秒的随机数,把过期时间打散,避免同一批缓存集体阵亡。这是最简单也最有效的一招。

    第二,Redis 本身高可用,哨兵部署,单节点挂了自动切换,避免’Redis 整体不可用’这种最严重的雪崩。

    第三,限流降级兜底,万一数据库压力还是超了阈值,触发限流,部分请求快速失败返回’稍后再试’,保住数据库不被打死——数据库活着,系统就还能慢慢恢复。"

    收尾(10 秒):

    “这三个问题我们系统里是同时部署了所有方案的,不是二选一,纵深防御嘛。”

    💻 方案细节与代码

    三者本质对照:

    问题本质表现
    缓存穿透 查询不存在的数据,缓存和 DB 都没有 请求全部打到 DB
    缓存击穿 热点 key 过期,大量并发同时查 瞬间 DB 压力暴增
    缓存雪崩 大量 key 同时过期或 Redis 宕机 DB 被压垮,系统崩溃

    穿透防御完整代码:

    public Product getProduct(Long productId) {
    // 0. 参数校验:拦截非法 ID
    if (productId == null || productId <= 0) return null;

    // 1. 布隆过滤器拦截不存在的 ID
    if (!bloomService.mightContain("product:bloom", productId)) {
    return null; // 一定不存在,不查库
    }

    // 2. 查缓存
    String key = "product:detail:" + productId;
    Product product = (Product) redisService.get(key);
    if (product != null) {
    if (NULL_PLACEHOLDER.equals(product)) return null; // 空值标记
    return product;
    }

    // 3. 查库并回写缓存
    product = productDao.selectById(productId);
    if (product != null) {
    redisService.set(key, product, 30, TimeUnit.MINUTES);
    } else {
    // 缓存空值防穿透(TTL 短,60 秒)
    redisService.set(key, NULL_PLACEHOLDER, 60, TimeUnit.SECONDS);
    }
    return product;
    }

    布隆过滤器参数:预估 100 万商品,误判率 0.01%,计算得位图约 19MB,哈希函数 14 个。

    击穿防御完整代码(互斥锁 + 双重检查):

    public Product getProductWithMutex(Long productId) {
    String key = "product:detail:" + productId;
    Product product = (Product) redisService.get(key);
    if (product != null) return product;

    // 缓存未命中,尝试获取重建锁
    String lockKey = "lock:product:" + productId;
    boolean locked = redisService.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (locked) {
    try {
    // 双重检查:拿到锁后再查一次(可能别人已重建)
    product = (Product) redisService.get(key);
    if (product != null) return product;
    // 查库重建
    product = productDao.selectById(productId);
    redisService.set(key, product, 30, TimeUnit.MINUTES);
    return product;
    } finally {
    redisService.delete(lockKey);
    }
    } else {
    // 未获取到锁,短暂等待后重试
    Thread.sleep(50);
    return getProductWithMutex(productId);
    }
    }

    雪崩防御四层:

  • TTL 加随机值:30分钟 + random(0, 300秒),让过期时间分散。
  • 多级缓存:本地缓存(Caffeine,TTL 10 秒)+ Redis。Redis 不可用时本地缓存还能扛 10 秒,给降级争取时间。
  • Redis 高可用:哨兵或集群模式,单节点宕机自动切换。
  • 限流降级:DB 压力超阈值时触发限流,部分请求快速失败,保护 DB。
  • 三者方案总表:

    问题本质方案
    穿透 查不存在的数据 参数校验 + 布隆过滤器 + 缓存空值
    击穿 热点 key 过期 互斥锁重建 + 双重检查
    雪崩 大量 key 同时失效 随机 TTL + 多级缓存 + 高可用 + 限流

    💡 为什么这么答

    • 开头一句话区分三个概念,直接告诉面试官"我没混";
    • 每个方案都带参数和取舍(19MB、60 秒、50 毫秒、为什么不用逻辑过期),这些具体数字是"做过"的最强信号。

    场景九:缓存和数据库的一致性

    🎤 面试官这么问

    “运营改了商品价格,缓存里还是旧的,怎么处理?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先定调(15 秒):

    “先明确目标:商品价格这种场景我们追求的是最终一致,允许秒级的延迟窗口,但不能长时间不一致——用户看到旧价格下单,是要客诉的。”

    主方案 + 为什么(45 秒):

    "方案是经典的 Cache-Aside:先更新数据库,再删除缓存,下次读的时候自动重建。

    这里有两个’为什么’值得说:

    为什么是’删缓存’而不是’更新缓存’?因为并发写的时候,更新缓存可能互相覆盖——两个更新请求,后到的数据库更新可能先写缓存,结果缓存里是旧值。删除就没这个问题,反正下次读的时候重建的一定是数据库最新值。而且如果这个商品更新后半天没人看,删除比更新省资源。

    为什么先数据库后缓存?反过来的话,先删缓存再更新数据库,中间窗口里来个读请求,把数据库的旧值又写回缓存了,不一致会持续到 TTL 到期,窗口大得多。"

    承认残余问题 + 延迟双删(40 秒):

    "但 Cache-Aside 在极端并发下还是有个小窗口:读请求查完数据库还没写回缓存的瞬间,写请求完成了更新和删除,然后读请求把旧值写了回去。这个窗口是毫秒级的,发生条件很苛刻,但理论上存在。

    我们的加固是延迟双删:更新完数据库后,延迟 500 毫秒再删一次缓存,把窗口期内可能被写回的旧值清掉。500 毫秒这个数不是拍的——它要大于’一次读请求从查库到写缓存’的耗时,我们商品查询 P99 在 100 毫秒以内,留了 5 倍余量。"

    兜底(20 秒):

    “最后 TTL 本身也是保险:就算前面所有机制都失效了,30 分钟后缓存自然过期重建。一致性方案永远是多层叠加的,不指望任何单层百分之百可靠。如果未来商品变更频率上来了,比如要做实时调价,我们会升级成 Canal 监听 binlog 异步删缓存,业务代码零侵入,可靠性也更高——目前的变更频率用延迟双删够了,没必要上重武器。”

    💻 方案细节与代码

    四种策略对比:

    策略做法问题
    先更新缓存,再更新 DB DB 更新失败,缓存是脏数据
    先更新 DB,再更新缓存 并发下可能写入旧值
    先删缓存,再更新 DB 并发读可能把旧值写回缓存
    先更新 DB,再删缓存(推荐) 极端并发仍有短暂窗口,概率极低

    基础版代码(Cache-Aside):

    public void updateProduct(Product product) {
    // 1. 先更新数据库
    productDao.updateById(product);
    // 2. 删除缓存(下次读时重建)
    redisService.delete("product:detail:" + product.getId());
    }

    并发不一致窗口的时间线:

    T1: 线程A 读缓存未命中,查 DB 得到旧值
    T2: 线程B 更新 DB 为新值
    T3: 线程B 删除缓存
    T4: 线程A 将旧值写入缓存 ← 不一致!

    延迟双删代码:

    public void updateProductWithDoubleDelete(Product product) {
    String key = "product:detail:" + product.getId();
    // 1. 先删缓存
    redisService.delete(key);
    // 2. 更新数据库
    productDao.updateById(product);
    // 3. 延迟 500ms 再删一次(覆盖并发读写入的旧值)
    delayDelete(key, 500);
    }

    private void delayDelete(String key, long delayMs) {
    scheduledExecutor.schedule(() -> redisService.delete(key),
    delayMs, TimeUnit.MILLISECONDS);
    }

    为什么延迟 500ms:要大于"一次读请求从查库到写缓存"的耗时。通常商品查询在 100ms 以内,500ms 留了充足余量。设太短,可能旧值还没写入就删了,起不到作用;设太长,不一致窗口变大。

    Canal 方案(更高可靠性):

    DB 更新 → binlog → Canal → MQ → 缓存删除服务 → 删除 Redis

    • 优点:业务代码零侵入、可靠性高(binlog 不会丢)、解耦。
    • 缺点:架构复杂度增加、有 1~2 秒延迟。
    • 选择依据:商品更新频率低(运营操作)用延迟双删足够;变更频繁(实时调价)再升级 Canal。

    TTL 兜底:无论哪种方案,都设置 30 分钟 TTL。即使所有主动失效机制都失败,缓存最多 30 分钟后自动过期重建。TTL 是一致性的最后保险。


    场景十:秒杀接口限流防刷

    🎤 面试官这么问

    “秒杀接口怎么防脚本刷?怎么限制单个用户的频率?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先给全景(20 秒):

    “防刷不是单点做的,是一个多层漏斗:前端按钮置灰加验证码拉长请求间隔,网关层按 IP 限流挡脚本,应用层按用户维度限流,最后业务层还有库存原子扣减兜底。我重点讲应用层,这是我们自己写的部分。”

    为什么选令牌桶(30 秒):

    “限流算法我们对比过:固定窗口有临界突刺问题——窗口交界处前后各来一波,实际瞬时流量是阈值的两倍;漏桶太死板,完全不允许突发,用户正常手速快点两下都会被拒。最后选的令牌桶:桶里匀速放令牌,请求来了取令牌,取到就放行。它既能控制平均速率,又允许短时间突发,对正常用户最友好。”

    实现要点(50 秒):

    "实现是一段 Lua 脚本,核心逻辑是:记录上次访问时间和当前令牌数,请求来了先按’时间差 × 速率’补充令牌(不超过桶容量),然后看令牌够不够,够就扣一个放行,不够就拒绝。

    必须用 Lua 的原因和之前一样:'读令牌数 → 计算 → 写回’这三步如果有并发窗口,两个请求同时读到还剩 1 个令牌,就都放行了,限流形同虚设。Lua 在 Redis 里原子执行,没这个问题。而且限流状态放 Redis 而不是应用内存,多实例部署天然共享同一个桶,不会出现’每个实例各限各的、总量翻倍’的情况。

    参数上,秒杀接口我们配的是桶容量 5、每秒补 2 个令牌——意思是用户最多连点 5 下,之后每半秒才能再点一下。正常人无感,脚本刷不动。

    还有个容易漏的细节:限流的 key 也要设 TTL,比如 1 小时。不然用户走了之后,几百万个限流 key 永远躺在 Redis 里吃内存。"

    被拒绝后的体验(20 秒):

    “被限流的请求我们返回的是’活动太火爆,请稍后再试’,HTTP 状态用 429。这里有个产品细节:提示文案不能写’你被限流了’,用户会觉得自己被针对了,体验很差。”

    💻 方案细节与代码

    多层限流漏斗:

    层位置限流维度工具
    第 1 层 前端 按钮防重 + 验证码 JS
    第 2 层 网关 IP + 用户维度 Nginx/Gateway
    第 3 层 应用 用户 + 接口维度 Redis 令牌桶
    第 4 层 业务 库存维度 Redis Lua

    限流算法对比:

    算法原理问题
    固定窗口 时间窗内计数 临界突刺:窗口交界处可能 2 倍流量
    滑动窗口 细分时间片 实现复杂,精度与内存权衡
    令牌桶 匀速放令牌,请求取令牌 ✅ 平滑限流,允许适度突发
    漏桶 匀速流出 无法应对突发,过于死板

    令牌桶 Lua 脚本(逐行注释版):

    local key = KEYS[1] — 限流键,如 rate:seckill:{userId}
    local capacity = tonumber(ARGV[1]) — 桶容量(最大突发量)
    local rate = tonumber(ARGV[2]) — 填充速率(每秒令牌数)
    local now = tonumber(ARGV[3]) — 当前时间戳(毫秒)

    local tokens_key = key .. ':tokens' — 当前令牌数
    local ts_key = key .. ':ts' — 上次访问时间

    — 获取当前令牌数,不存在则初始化为容量(首次请求满桶)
    local tokens = tonumber(redis.call('GET', tokens_key) or capacity)
    — 获取上次时间戳,不存在则初始化为当前时间
    local last_ts = tonumber(redis.call('GET', ts_key) or now)

    — 计算应补充的令牌:时间差(秒) × 速率
    local delta = math.max(0, (now last_ts) / 1000.0 * rate)
    — 令牌数 = min(容量, 当前 + 补充),不超过容量
    tokens = math.min(capacity, tokens + delta)

    if tokens >= 1 then
    — 有令牌:消耗 1 个,放行
    redis.call('SET', tokens_key, tokens 1)
    redis.call('SET', ts_key, now)
    return 1
    else
    — 无令牌:拒绝(但仍更新时间戳,保证下次能正确补充)
    redis.call('SET', tokens_key, tokens)
    redis.call('SET', ts_key, now)
    return 0
    end

    关键设计点:

    • 用两个 key(tokens 和 ts):分离"令牌数"和"时间戳",逻辑清晰。
    • 拒绝时也更新时间戳:如果不更新,下次请求会计算出过大的补充量,限流失效。
    • 整个脚本原子执行:Redis 单线程保证"读-算-写"不被并发打断。

    调用代码:

    // 每个用户:桶容量 5(允许突发 5 次),每秒补充 2 个令牌
    boolean allowed = rateLimiterService.tryAcquire(
    "rate:seckill:" + userId, // 限流键
    5, // 容量
    2 // 速率
    );
    if (!allowed) {
    return Result.fail("活动太火爆,请稍后再试"); // HTTP 429
    }

    简单计数限流(固定窗口,适合短信类场景):

    // 同一手机号 60 秒内最多发 1 次短信
    public boolean simpleLimit(String key, int maxCount, long windowSeconds) {
    Long count = redisTemplate.opsForValue().increment(key);
    if (count != null && count == 1) {
    redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
    }
    return count != null && count <= maxCount;
    }

    分布式限流的一致性:限流逻辑在 Redis 中(而非应用内存),多实例部署天然一致——所有实例共享同一个令牌桶。这是 Redis 限流相比本地限流(如 Guava RateLimiter)的核心优势。


    场景十一:分布式锁

    🎤 面试官这么问

    “你们哪里用到了分布式锁?怎么实现的?有什么坑?”

    ⏱️ 建议时长:3 分钟(这题追问最多,主动把坑全说完)

    🗣️ 你可以这么答

    先说用在哪(15 秒):

    “主要两个地方:缓存击穿时的重建锁,还有订单创建时的防重锁。我按’基础实现 → 三个坑 → 我们最终怎么用’来讲。”

    基础实现(30 秒):

    “基础版就是 SET key value NX EX 10——不存在才设置,同时带 10 秒过期。这里有个新手常犯的错:分成 SETNX 和 EXPIRE 两条命令。这两步不是原子的,如果线程加锁成功后、设置过期前宕机了,这把锁就永不过期,后面的线程全卡死。所以必须用 SET 的 NX + EX 参数一条命令搞定。”

    三个坑,逐个讲(1 分半):

    "基础版有三个坑,我们逐个解决:

    坑一:误删别人的锁。 场景是:线程 A 加锁,业务执行了 12 秒,超过了 10 秒的锁超时,锁自动释放;线程 B 拿到锁开始干活;这时 A 干完了,执行 DEL——把 B 的锁删了!解决方案是加锁时 value 存一个唯一标识(比如 UUID),解锁时先比对 value 是不是自己的,是才删。而且’比对 + 删除’这两步又必须用 Lua 原子执行,不然比对通过后锁恰好过期,还是会误删。

    坑二:业务没执行完锁先过期了。 超时设短了业务跑不完,设长了持锁线程宕机后其他人要干等。这个两难靠看门狗解决——我们后来换了 Redisson,它的机制是:加锁默认 30 秒过期,同时起一个后台任务每 10 秒检查一次,线程还持有锁就自动续期回 30 秒;线程宕机了看门狗也停了,锁 30 秒后自然过期。相当于’只要人还活着,锁就一直续;人没了,锁自动释放’,把超时时间这个参数从设计里消掉了。

    坑三:主从切换丢锁。 线程 A 在主节点加锁成功,锁还没同步到从节点,主节点挂了,从节点晋升——新主上没有这把锁,线程 B 也加锁成功,两个线程同时持锁。官方有 RedLock 方案(向多个独立节点加锁,多数成功才算成功),但它依赖时钟假设,业界有争议。我们的态度是:不指望锁保证正确性。"

    最终的使用哲学(40 秒):

    “最后这点是我们踩坑之后形成的共识:分布式锁是性能优化手段,不是正确性保障。锁的作用是让 99.9% 的并发冲突在 Redis 层就解决掉,减少数据库压力;但正确性必须由业务层兜底——库存扣减靠数据库的 WHERE stock >= 数量 乐观锁,订单防重靠幂等令牌加状态机。这样即使锁因为主从切换失效了,最坏结果也只是多几个请求打到数据库被乐观锁挡回来,不会产生资损。把身家性命押在一把可能失效的锁上,才是真正危险的。”

    💻 方案细节与代码

    使用场景:

  • 库存重建锁(场景八):缓存击穿时,防止多线程同时查库重建。
  • 订单防重锁(场景七):防止同一用户并发创建重复订单。
  • 基础加锁:

    // 加锁:原子操作,10 秒超时
    // 底层是 SET key value NX EX 10,一条命令完成
    boolean locked = redisService.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);

    为什么必须一条命令:两步操作(先 SETNX 再 EXPIRE)不是原子的。如果线程 A 执行完 SETNX 后宕机,EXPIRE 没执行,锁就永不过期——死锁。

    解锁 Lua 脚本(校验持有者):

    — 只有持有者才能删除自己的锁
    if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
    else
    return 0
    end

    误删场景还原:

  • 线程 A 加锁,超时 10 秒;
  • 线程 A 业务执行了 12 秒(超时),锁自动释放;
  • 线程 B 加锁成功;
  • 线程 A 执行完毕,调用 DEL——删掉了线程 B 的锁!
  • 所以解锁必须比对 value(requestId),且"比对 + 删除"用 Lua 原子执行。

    Redisson 看门狗原理:

    1. 加锁时如果不指定 leaseTime,Redisson 默认 30 秒过期
    2. 同时启动一个后台定时任务(看门狗)
    3. 看门狗每隔 10 秒(30/3)检查一次:
    – 如果当前线程还持有锁 → 续期到 30 秒
    – 如果线程已释放锁或宕机 → 停止续期
    4. 业务执行完毕调用 unlock(),锁立即释放
    5. 如果线程宕机,看门狗随之停止,锁在 30 秒后自然过期

    Redis

    看门狗线程

    Redisson

    业务线程

    Redis

    看门狗线程

    Redisson

    业务线程

    #mermaid-svg-nMUrFe7n5vzqy9Fp{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nMUrFe7n5vzqy9Fp .error-icon{fill:#552222;}#mermaid-svg-nMUrFe7n5vzqy9Fp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nMUrFe7n5vzqy9Fp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nMUrFe7n5vzqy9Fp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nMUrFe7n5vzqy9Fp .marker.cross{stroke:#333333;}#mermaid-svg-nMUrFe7n5vzqy9Fp svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nMUrFe7n5vzqy9Fp p{margin:0;}#mermaid-svg-nMUrFe7n5vzqy9Fp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nMUrFe7n5vzqy9Fp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-nMUrFe7n5vzqy9Fp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-nMUrFe7n5vzqy9Fp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-nMUrFe7n5vzqy9Fp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-nMUrFe7n5vzqy9Fp .sequenceNumber{fill:white;}#mermaid-svg-nMUrFe7n5vzqy9Fp #sequencenumber{fill:#333;}#mermaid-svg-nMUrFe7n5vzqy9Fp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-nMUrFe7n5vzqy9Fp .messageText{fill:#333;stroke:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nMUrFe7n5vzqy9Fp .labelText,#mermaid-svg-nMUrFe7n5vzqy9Fp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .loopText,#mermaid-svg-nMUrFe7n5vzqy9Fp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-nMUrFe7n5vzqy9Fp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-nMUrFe7n5vzqy9Fp .noteText,#mermaid-svg-nMUrFe7n5vzqy9Fp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-nMUrFe7n5vzqy9Fp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nMUrFe7n5vzqy9Fp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nMUrFe7n5vzqy9Fp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nMUrFe7n5vzqy9Fp .actorPopupMenu{position:absolute;}#mermaid-svg-nMUrFe7n5vzqy9Fp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-nMUrFe7n5vzqy9Fp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nMUrFe7n5vzqy9Fp .actor-man circle,#mermaid-svg-nMUrFe7n5vzqy9Fp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-nMUrFe7n5vzqy9Fp :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    alt

    [仍持有]

    [已释放]

    loop

    [每 10 秒]

    lock()

    SET lock NX EX 30

    启动看门狗(每10秒执行)

    检查锁是否还被持有

    PEXPIRE lock 30000(续期)

    停止

    unlock()

    DEL lock

    停止看门狗

    看门狗的精妙之处:解决了"超时时间设多长"的两难——不需要预设超时,只要线程活着就自动续期,线程死了就自动过期。

    实际选择策略:

    • 简单场景(如缓存重建锁):用基础 SETNX + EX,业务耗时可预估(查库 < 1 秒),设 10 秒超时足够。
    • 复杂场景(如订单创建,多步操作):用 Redisson,看门狗自动续期。

    主从切换丢锁问题:

    1. 线程 A 在主节点加锁成功
    2. 主节点宕机,锁数据还没同步到从节点
    3. 从节点晋升为主
    4. 线程 B 在新主节点加锁成功 ← 两个线程同时持锁!

    • RedLock:向多个独立 Redis 节点加锁,多数成功才算成功。但有争议(Martin Kleppmann 指出其依赖时钟假设)。
    • 实际做法:资损敏感场景不依赖分布式锁做最终保障,用数据库乐观锁(WHERE stock >= qty)兜底。锁只是减少并发冲突的优化手段。

    💡 为什么这么答

    • 三个坑按"场景 → 后果 → 解法"讲,每个都有画面感;
    • 最后"锁是优化不是保障"的观点,是这道题的最高分答案——它展示的是分布式系统思维,而不只是 API 熟练度。

    场景十二:库存为什么用 String 不用 Hash?

    🎤 面试官这么问

    “库存存在 Redis 里用的什么结构?为什么不用 Hash 把所有 SKU 放一个 key 里?”

    ⏱️ 建议时长:1~2 分钟(这题不难,答出对比即可)

    🗣️ 你可以这么答

    "我们用的是 String,一个 SKU 一个 key,比如 seckill:stock:{skuId}。Hash 方案——所有 SKU 塞进一个大 Hash 的 field 里——看起来更’整齐’,但有三个硬伤:

    第一,过期粒度。秒杀活动结束后要清掉对应库存 key,String 方案每个 SKU 独立 TTL、独立删除;Hash 是整个 key 一个 TTL,没法让单个 field 单独过期,只能手动 HDEL,麻烦且容易漏。

    第二,大 key 风险。10 万个 SKU 参与大促,Hash 方案就是 10 万个 field 挤在一个 key 里,读写这个 key 会阻塞 Redis 单线程,影响的是整个实例上所有业务。String 方案每个 key 就存一个数字,永远不可能成为大 key。

    第三,集群分片。Cluster 模式下不同的 String key 会散落到不同槽位、不同节点,压力天然分散;Hash 方案所有 SKU 在同一个 key,只能落在一个节点上,秒杀的洪峰全砸这一个节点,等于白搭了集群。

    反过来说,Hash 适合什么?适合’一个实体的多个属性’且需要部分更新的场景,比如用户信息——改昵称不动年龄,HSET 单个 field 就行。库存就一个数值,没有’部分更新’的需求,用 Hash 是杀鸡用牛刀还把自己手割了。

    一句话总结我们的选型原则:数据形态决定结构——单值 String、对象 Hash、队列 List、去重 Set、排行 ZSet。"

    💻 方案细节与代码

    String vs Hash 全维度对比:

    维度String(stock:{skuId} = 100)Hash(stock 的 field={skuId}, value=100)
    原子扣减 DECRBY stock:{skuId} 1 ✅ HINCRBY stock {skuId} -1 ✅
    单 SKU 操作 直接定位,O(1) 需指定 key+field,O(1)
    批量操作 需 MGET 多个 key HGETALL 一次取全部
    过期控制 每个 key 独立 TTL ✅ 整个 Hash 一个 TTL ❌
    大 key 风险 无(每个 key 很小) 所有 SKU 挤一个 key,可能成大 key ❌
    集群分片 不同 SKU 分散到不同槽位 ✅ 所有 SKU 同一槽位 ❌

    数据结构选型总原则:

    数据形态选择例子
    单值 String 库存、计数器、Token
    对象属性集 Hash 用户信息、商品属性
    有序队列 List 消息队列、最新动态
    去重集合 Set 标签、抽奖池
    带分数排序 ZSet 排行榜、延迟队列

    场景十三:Redis 挂了怎么办?

    🎤 面试官这么问

    “Redis 宕机了,你们系统会怎样?有什么降级方案?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先分业务讲影响(1 分钟):

    "Redis 在我们系统里承担了好几个角色,宕机的影响要分业务说,降级策略也不一样:

    秒杀库存是强依赖,没有 Redis 就没法原子扣减。策略是快速失败:熔断器检测到 Redis 不可用,秒杀接口直接返回’活动暂停’,绝对不穿透到数据库——数据库那份库存没有高并发保护,放流量进去就是超卖。宁可活动停几分钟,不能出资损。

    商品缓存是弱依赖,降级路径是本地缓存先顶住(Caffeine 里还有几秒的数据),然后限流放行一部分请求直查数据库,超过阈值的返回兜底页。核心是限流保护数据库,别让缓存失效演变成数据库雪崩。

    登录态比较特殊:JWT 本身可以本地验签,Redis 只用于’登出吊销’检查。降级时我们选择跳过吊销检查放行——牺牲一点点’已登出 token 短暂可用’的安全性,换全站可登录,事后靠审计日志补查。这是个安全和可用性的权衡,我们内部评审过。

    分布式锁失效就退化成数据库乐观锁路径,性能差一点但正确性还在——这就是刚才说的’锁是优化不是保障’的价值所在。"

    再讲预防(40 秒):

    “不过降级是事后补救,重点其实在预防:部署上我们是哨兵模式,主节点挂了 15 到 30 秒自动切换,大部分故障用户根本无感;客户端所有 Redis 操作都设了超时——命令 2 秒、获取连接 1 秒,绝对不允许无限等待,不然 Redis 一抖动,业务线程全阻塞在等连接上,故障就从 Redis 蔓延到整个应用;再加上熔断器,连续失败 5 次就熔断 10 秒,给 Redis 喘息时间,也避免雪崩式的重试风暴。”

    恢复(20 秒):

    “恢复之后,库存数据从数据库对账后重新预热,商品缓存靠读请求自动重建不用管,限流令牌桶自动从满桶开始。整体原则是:核心数据以数据库为准重建,缓存类数据让它自然回温。”

    💻 方案细节与代码

    Redis 承担的角色与宕机影响(按严重程度排序):

    秒杀无法进行 > 下单可能重复 > 缓存穿透到 DB > 限流失效

    分场景降级矩阵:

    业务依赖强度降级策略
    秒杀/库存 强依赖 活动熔断下线(绝不穿透 DB)
    登录态 中依赖 Token 本地验签仍可用,吊销检查降级跳过 + 事后审计
    商品缓存 弱依赖 本地缓存兜底 + DB 限流直查
    排行榜/计数 弱依赖 返回静态兜底数据(如昨日快照)
    分布式锁 中依赖 退化为数据库乐观锁路径

    为什么秒杀不能降级到数据库:数据库的 UPDATE … WHERE stock > 0 虽然能防超卖,但在 10 万并发下行锁竞争会导致数据库崩溃,进而拖垮整个系统(包括正常订单)。宁可秒杀不可用,也不能拖垮全局。

    预防措施四件套:

  • Redis 高可用部署:哨兵模式(自动故障转移)或 Cluster 模式。单节点宕机时,哨兵在 10~30 秒内完成主从切换,业务几乎无感。
  • 连接池超时配置:连接超时 3 秒,命令超时 2 秒,获取连接等待 1 秒——避免 Redis 慢响应时线程堆积。
  • 熔断器:Sentinel/Resilience4j 对 Redis 操作做熔断。连续失败 5 次后熔断 10 秒,期间直接走降级逻辑。
  • 监控告警:内存、连接数、响应时间、命中率。命中率低于 90% 或响应超 10ms 就告警。
  • 数据恢复策略:

    数据类型恢复方式
    库存数据 从数据库对账后重新预热(以 DB 为准)
    缓存数据 无需预热,读请求自动重建(懒加载)
    限流数据 自动重建(令牌桶从满开始)
    Token 数据 白名单丢失 = 所有用户重新登录(安全角度甚至更好)

    关键设计:降级开关必须预先实现并可动态推送(配置中心),故障时"一键降级",而不是现场改代码发布。


    场景十四:怎么发现和修复库存不一致?

    🎤 面试官这么问

    “你说最终一致,那怎么知道什么时候不一致了?发现了怎么修?”

    ⏱️ 建议时长:2 分钟

    🗣️ 你可以这么答

    先坦白不一致的来源(20 秒):

    “先承认现实:不管机制多完善,不一致就是会发生——MQ 消息丢了、消费者有 bug、有人绕过系统直接改了数据库。所以我们的思路不是’保证永远一致’,而是’快速发现、安全修复’。”

    对账机制(1 分钟):

    "发现靠定时对账,每 5 分钟一轮。对账等式是:Redis 库存 应该等于 数据库库存 减去 待支付订单占用。为什么要减待支付占用?因为下单时 Redis 立即扣了、数据库是异步扣的,待支付订单这部分差值是正常的时间窗口,不算异常。

    差值超过阈值——我们设的是 5——就告警。为什么不是一有差值就告警?因为对账本身有时间窗口,跑对账的瞬间可能正好有订单在途,小差值是噪音,阈值是用来过滤噪音的。连续两轮对账都超阈值,才认定是真异常。

    除了库存值对账,还有更精细的流水对账:把某个 SKU 的流水按时间排序,链式校验——每条流水的 before_stock 应该等于上一条的 after_stock,最后一条的 after_stock 应该等于当前数据库库存。链条哪里断了,问题就出在哪一笔,能精确定位到具体操作,而不是只知道’总数不对’。"

    修复原则(40 秒):

    "修复有个铁律:以数据库为准。因为数据库有完整流水,每个数字都能追溯来龙去脉;Redis 只是加速层,随时可以从数据库重建。修复动作就是按对账等式重算出 Redis 应有的值,直接 SET 覆盖。

    修复时机有讲究:要么在低峰期做,要么先把这个 SKU 的秒杀暂停再修——不然你刚 SET 完,马上又来一波扣减,修了个寂寞。

    预防层面,MQ 开同步发送加消费确认,所有消费者基于流水号幂等,还有条管理红线:禁止任何人手工改数据库库存,必须走系统接口,不然流水断了链,对账就废了。"

    💻 方案细节与代码

    不一致的来源清单:

    • MQ 消息丢失或消费失败(Redis 扣了,DB 没扣)。
    • 消费者 bug 导致重复扣减或遗漏。
    • Redis 与 DB 之间的网络分区。
    • 人工操作数据库(绕过系统)。

    定时对账任务逻辑:

    对账逻辑(每 5 分钟):
    ① 获取所有参与秒杀的 SKU 列表
    ② 对每个 SKU:
    a. 读取 Redis 库存:GET seckill:stock:{skuId}
    b. 读取 DB 库存:SELECT stock FROM inventory WHERE sku_id = ?
    c. 计算"进行中订单"占用:
    SELECT SUM(qty) FROM order WHERE sku_id = ? AND status = '待支付'
    d. 校验等式:
    Redis库存 == DB库存 – 进行中订单占用
    ③ 等式不成立且差值超过阈值(如 5):
    a. 记录对账差异日志
    b. 触发告警
    c. 差值小于阈值可能是时间窗口导致,等待下一轮

    校验等式推导:

    • 用户下单时:Redis 立即扣减,DB 异步扣减。
    • 订单还在"待支付"状态:Redis 已扣,DB 可能还没扣(异步延迟)。
    • 所以 DB 库存 = Redis 库存 + 待支付订单占用。
    • 移项得:Redis 库存 = DB 库存 – 待支付占用。
    • 如果所有异步消息都已消费完毕(无积压),等式简化为 Redis == DB。

    流水链式对账(更精细):

    ① 取某 SKU 某时间段的所有流水
    ② 按时间排序,链式校验:
    第 1 条的 before_stock == 初始库存
    第 N 条的 before_stock == 第 N-1 条的 after_stock
    最后一条的 after_stock == 当前 DB 库存
    ③ 链条断裂 → 说明有流水缺失或篡改,定位到具体哪一笔

    修复策略:

    • 以 DB 为准:DB 有完整流水可追溯,Redis 只是加速层,数据可重建。
    • 修复方式:SET seckill:stock:{skuId} {DB库存 – 待支付占用}。
    • 修复时机:低峰期执行,或先暂停该 SKU 的秒杀,修复后恢复——避免修复过程中新扣减导致再次不一致。

    预防措施:

    • MQ 可靠投递:同步发送 + 消费确认(ACK),减少消息丢失。
    • 消费幂等:所有消费者基于流水号幂等,重试不会导致重复。
    • 操作审计:禁止人工直接改数据库库存,必须通过系统接口(留流水)。

    附:面试现场的几个实用心法

    心法一:回答的"黄金结构"

    每道题都按这个节奏说,稳而不乱:

    ① 接住问题(10 秒):"这个我实际做过,我按 XX 顺序讲"
    ② 给结论/方案(30 秒):先说你们怎么做的
    ③ 讲为什么(1 分钟):为什么不选别的、关键细节的取舍
    ④ 主动交代坑(30~60 秒):边界情况、失败路径、兜底
    ⑤ 一句话收尾(10 秒):总结方案要点

    心法二:怎么讲"我的经历"才可信

    不要这么说要这么说
    “我们系统 QPS 百万”(吹牛,一追问就穿帮) “我们大促峰值大概 2 万 QPS,压测时发现的这个问题”
    “我负责整个架构”(级别不符,假) “这块的库存回滚是我写的,方案是跟主程一起评审的”
    只说结果 说过程:“第一次压测对不上账,查了两天发现是 MQ 重试导致的重复回滚”

    细节越具体越可信:数字(500 毫秒、19MB、阈值 5)、排查过程(“查了两天”“看慢查询日志发现的”)、当时的纠结(“我们内部为这个争论过”)——这些是背不出来、只有经历过才有的东西。

    心法三:被问到没做过的怎么办

    别硬编。可以这么答:

    “这个场景我们项目里没直接遇到,但我理解它的核心矛盾是 XX,如果让我设计,我会 XX,可能需要注意 XX 的坑。”

    承认没做过 + 展示推理能力,比编一个漏洞百出的"经历"好一百倍。面试官阅人无数,编的经历三个追问就露馅,而露馅的代价是前面所有真话都被打折。

    心法四:主动堵坑清单

    说到这些词的时候,顺手把坑堵了,别等追问:

    你提到…顺手补一句…
    Lua 脚本 “脚本要短,执行时是阻塞其他命令的”
    分布式锁 “主从切换可能丢锁,所以正确性靠数据库乐观锁兜底”
    MQ 异步 “消息可能丢也可能重,所以消费端做了幂等,再加对账兜底”
    缓存空值 “TTL 要设短,不然数据真上来了还被空值挡着”
    延迟双删 “延迟时间要大于一次读请求的耗时,我们是按 P99 留了余量”
    令牌桶限流 “限流 key 也要设 TTL,不然全是僵尸 key 吃内存”

    心法五:高频追问预判表

    如果你的回答中提到…面试官可能追问…你应预先说明…
    Lua 脚本 “Lua 执行时阻塞其他命令吗?” 脚本要短,复杂逻辑拆分
    分布式锁 “主从切换锁丢失怎么办?” 数据库乐观锁兜底
    MQ 异步 “消息丢了怎么办?” 对账 + 重试 + 告警
    缓存空值 “空值占内存吗?” TTL 短(60秒),量可控
    令牌桶 “为什么不用漏桶?” 漏桶无法应对突发
    延迟双删 “延迟时间怎么定?” 大于读请求耗时,500ms 经验值

    这 14 个场景基本覆盖了电商库存与流水方向的 Redis 面试高频题。建议不要背,而是把每个场景的"为什么"想通,再把自己的项目经历套进"我的经历"模板里——想通了的东西,怎么问都不慌。 🚀

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Redis面试场景实战
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!