分布式优惠券系统主链路总整合
整合来源:day1.md(查券 + 领券链路)与 2-汇总.md(分发 + 结算 + 状态机 + 搜索),两份原文档内容已全部并入本文后删除,本文即唯一底稿。 代码约定:所有代码直接内联贴出,不使用本地文件链接(方便上传网站阅读)。 文档结构(三层,按用途找章节):① 速背速查层 = 《简历亮点 ↔ 底稿速查总表》+ §8.1 速记卡 + §8.5 锚点速查(面试前 10 分钟用);② 学习讲解层 = 第一~七部分(链路细节,平时学);③ 深挖弹药层 = §8.2 已知问题 + §8.8 踩坑故事 + 第九部分答辩(被追问用)。 冲突裁决(已对代码核实):
t_user_coupon分表数为 32 张(ds_${0..1}.t_user_coupon_${0..31},engine/distribution 两模块一致);早期笔记中”16 张”为笔误。- 券状态枚举有两个:engine 模块
UserCouponStatusEnum(UNUSED=0 / LOCKING=1 / USED=2 / EXPIRED)与 distribution 模块CouponStatusEnum(EFFECTIVE=0 / ENDED=1)——两个模块各自定义,落库值都是 0,语义都是”可用”,不是冲突。 修订记录 v2:§2.5 延迟关券正文与 §8.2 已知问题 #13/#14 对齐——源码走读证实该链路当前实现 100% 失效(UserContext.getUserId()在 MQ 消费线程恒为 null),新增缺陷框与”修复三步走”面试话术;§3.1 ④、§3.4、§8.1 速记卡 9 同步标注。 修订记录 v3(2026-09-24 文档整合):新增第九部分(面试深挖答辩与避坑),合并自《项目深度分析与简历面试指南》§4.4/§5 与《分析项目并结合文档》三点提醒;两份旧文档独有内容已全部并入本文后删除。压测数据单独保留在压测/压测报告.md。 修订记录 v4(2026-09-24 求职启动日):新增 §9.6 分库分表深挖速答(技术栈列 ShardingSphere 后补齐答辩)、§9.7 微服务拆分答辩(简历列 Spring Cloud 后补齐答辩,口径与当日 HR 初筛书面回复一致);新增 §8.11 实习网申问答 + OOM 内存深挖链(多益职位问答口径冻结)、§8.12 实习公司背景设定与开发流程(小厂口径:公司 100-300 人 / 小组 6 人 / 一两周迭代)。 修订记录 v5(2026-10-02 文档拆分):§8.9-8.13(实习三问 / 细问题库 / 网申问答 / 公司背景设定 / 简历逐行预判表)拆分至《实习经历背诵底稿.md》,本文件仅保留优惠券项目内容(8.1-8.8 + 第九部分)。 修订记录 v6(2026-10-02 简历定稿对齐):新增《简历亮点 ↔ 底稿速查总表》(速背速查层,对齐 简历.md 最终版);速记卡补第 11 条压测数字卡(含三方对账、“落库一个数量级”口径,旧”约 60 倍”弃用);§9.7 补”5 个微服务”枚举答法;修正两处:头部旧文档”保留不动”过时表述、〇 表格”Pipeline + CompletableFuture 并行”误导表述(并行版实测更慢、未采用)。 修订记录 v7(2026-10-02 简历原文对照 + 一致性话术):速查总表左列补简历五条亮点原文逐句对照;§3.1 新增”双写一致性开场总答”分层话术(第一层即时手段 → 第二层定时对账 → 第三层人工兜底 + 主动暴露 + 钩子回收)——针对 9-30 面试”一致性答散”卡点的补课成稿。 修订记录 v8(2026-10-02 代码核对修正,以代码为准):①§2.1 Lua 位压缩实为 16 位(Java 解码 14 位,位宽不一致是真实缺陷,新增 §8.2 #18,§2.1 已知边界/§2.2 场景表/锚点速查同步标注);②§3.4 卡包 Key 更正为 user-template-list;③§4.2 任务状态枚举第 4 个代码拼写为 CANAL;④§6.1/6.2②/6.3/6.6 Q5 “processPayment 不动 Redis”更正为”不动券包 ZSet、仍持分布式锁”。 修订记录 v9(2026-10-03 二次代码复核,25 项硬事实独立验证、22 项与代码一致):①Key 名补全模块前缀(§1.3 模板/空值 Key、§3.4/§6.5 卡包 Key:one-coupon_engine:…);②§4.2 断点续传进度 Key 实为 one-coupon_distribution;③§1.2 补布隆参数 RBloomFilter.tryInit(640, 0.001);④§6.5 券状态枚举补 EXPIRED(3)/REVOKED(4);⑤§9.2 五处证据行号按当前代码重标(旧行号漂移 27-38 行)。三个”主动交代”缺陷(Lua/Java 位宽 16 vs 14、EXPIRE 收毫秒、延迟关券 UserContext null 整链失效)全部经代码再次证实,可放心背。
简历亮点 ↔ 底稿速查总表(速背速查层,面试前 10 分钟用这张)
左列是
docs/简历.md项目经历原文(2026-10-02 定稿),中列是 30 秒速背版,右列是被追问时的深挖章节。面试官指着简历哪一句,就从哪一行进入背诵。
项目简介(简历原文)
优惠券全生命周期营销系统,覆盖商家建券、用户抢券、运营批量发券、交易核销结算等核心场景。按业务域拆分为 5 个微服务,核心链路经 JMeter 压测:抢券 1600+ TPS、结算试算 4600+ QPS、百万级名单分发约 15 分钟。
- 追问”5 个微服务是哪五个”:网关、商家后台(merchant-admin)、分发(distribution)、引擎(engine)、结算(settlement)——不含 search(ES 已从简历删除,不主动提);完整答法见 §9.7。
- 追问”数字怎么来的”:先报环境(本地单实例 R7 6800H / 16G,全组件同机)再报数,口径见速记卡 11 与压测报告。
亮点五条 ↔ 30 秒速背 ↔ 深挖章节
| 简历亮点(原文) | 30 秒速背版 | 深挖章节 |
|---|---|---|
| ① 券模板高频查询,防穿透与击穿 ”恶意 ID 或热点失效瞬间请求直穿 DB 会拖垮抢券活动;布隆过滤器 + 空值缓存拦截非法 ID,Redisson 分布式锁 + 双重判定保证仅单请求回源重建,TTL 锚定活动结束时间防雪崩。“ | 六层结构把 DB 压力压到接近零:Hash 命中 99% 止步一条 HGETALL;布隆 + 空值缓存把非法 ID 挡在库外;热点失效分布式锁 + 双重判定只放一刀进库重建;TTL 用 EXPIREAT 锚定活动结束、Lua 原子写防半成品 | 第一部分 §1.1-1.7;速记卡 1/2;§9.2 |
| ② 领取链路双方案:DB 直写与 MQ 异步削峰 ”瞬时高并发直写 DB 会因库存行锁拖垮落库;低流量走 Redis Lua 原子扣减直写(条件更新 + 唯一索引兜底),峰值场景 RocketMQ 削峰 + 消费幂等。压测实测 1600+ TPS,三方对账零超发、零重复领取。“ | Lua 一道原子闸门(判库存+扣库存+判上限+计数)挡掉无效流量,之后分叉:低流量同步版编程式事务直写给真实结果;峰值 MQ 版削峰异步落库、消费幂等。压测 100 并发 1600+ TPS,Redis 扣减量 = DB 落库行数 = MQ 消费条数三方对账一致 | §2.2/2.3;速记卡 5/6/7/11;§9.2 问题一 |
| ③ 防超领与海量券过期治理 ”超领直接造成营销资损;Lua 限领校验 + DB 复合唯一索引双层兜底;券到期以 RocketMQ 延迟消息先移卡包、再条件更新关券,不误改已锁定/已使用的券。“ | Lua 判上限是性能闸门,DB 复合唯一索引 (user_id, coupon_template_id, receive_count) 是物理兜底——第 N 次领取全库仅一条;过期用延迟消息先删卡包 ZSet、再 status=UNUSED 条件更新关券。⚠ 延迟关券实现有笔误,被问主动交代”设计对、实现有 bug” | §2.1;§2.5(缺陷框);§8.2 #13/#14 |
| ④ 海量领取记录分库分表 ”百万级分发写入百万行,单表索引持续退化;ShardingSphere 2 库水平拆分,用户券按 user_id、模板按 shop_number 分片,读写精确路由单表,分表不破坏唯一索引防重约束。“ | 2 库三张分片表:用户券按 user_id(32 表)、模板按 shop_number(16 表)、结算单按 user_id(16 表);选键标准 = 交易主链路最高频查询维度;唯一索引最左列就是分片键,分表不破坏防重;不带分片键的查询广播聚合,低频可接受 | 〇 分库分表拓扑;§9.6 |
| ⑤ 后管运营与结算 ”百万级名单逐条同步发券需小时级,两级 MQ + EasyExcel 流式 + Lua 预扣攒批落库后实测 100 万行约 15 分钟,落库吞吐提升一个数量级;结算试算 Redis Pipeline 合并网络往返,80 并发 4600+ QPS;核销分布式锁 + 状态机 + 条件更新三层防重。“ | 百万名单逐条同步处理(每行 3 次 DB 往返约 200 行/秒)需小时级;两级 MQ 把读 Excel 与落库解耦、Lua 预扣攒批 5000 落库,100 万行约 15 分钟(约 1100 行/秒),落库吞吐提升一个数量级,失败落表可对账;结算试算 Pipeline 把 N 次往返合并为 1 次,80 并发 4600+ QPS;核销分布式锁 + 状态机 + 条件更新三层防重 | 第四部分;第五部分 §5.3;第六部分;压测报告 §6.6 |
数字口径铁律:简历上的每个数字都有实测锚点(压测报告);AI 给的”约 60 倍”等作者宣称值一律不用,统一用”一个数量级 / 约 6 倍”本机口径,被问”为什么不是 60 倍”见压测报告 §6.6 话术。
〇、项目全景:一张券的一生
用用户”小张”的经历串起全部链路:
【查券】小张点开活动页,看到"满100减20"模板 → 缓存三件套秒回 │【领券】小张点"领取" → Lua 原子预扣 → 同步落库 / MQ 异步落库 │ (另一条来路:老王水果店上传 12000 人 Excel 批量分发) v小张的券包里多了一张券(DB: t_user_coupon + Redis: user:{userId} ZSet) │【结算查询】小张下单一单 80 元的订单,结算页问: │ "这单哪些券能用?最多省多少?" → 展示可用/不可用列表 v【用券状态机】小张选中那张券下单: │ 券状态 UNUSED → LOCKING(锁定) → USED(已用) │ (退款则 USED → UNUSED 还回去) v【ES 搜索】小张另一次主动搜"水果券", 搜索结果来自 ES,ES 的数据由 Canal 监听 DB 变更自动同步| 链路 | 业务问题 | 核心技术 | 所在模块 |
|---|---|---|---|
| 查券(模板查询) | 热点模板查询怎么不打垮 DB | 缓存三件套 + 双重判定 + Lua 原子写 | engine |
| 领券(同步/MQ 两版) | 用户怎么把券抢到手且不超发 | Lua 原子扣减 + 事务 + 唯一索引 + 幂等切面 | engine |
| 批量分发 | 怎么给百万人安全发券 | EasyExcel 流式读 + Lua 原子扣减 + 攒批 + 两级 MQ | distribution |
| 结算查询 | 下单时怎么快速算出可用券 | Redis Pipeline 合并往返(并行版 A/B 实测更慢,未采用,口述弹药) | settlement |
| 用券状态机 | 一张券从锁定到核销怎么不出错 | 分布式锁 + 条件更新(CAS) + 编程式事务 | engine |
| ES 搜索(简历未写,仅防追问) | 模板变了搜索怎么近实时更新 | Canal 监听 Binlog + MQ + ES | search |
分库分表真实拓扑(ShardingSphere,读配置核实)
# engine 模块 shardingsphere-config.yamlt_coupon_template: ds_${0..1}.t_coupon_template_${0..15} # 2 库 16 表,按 shop_numbert_user_coupon: ds_${0..1}.t_user_coupon_${0..31} # 2 库 32 表,按 user_idt_coupon_settlement: ds_${0..1}.t_coupon_settlement_${0..15} # 2 库 16 表,按 user_id面试一句话:模板按店铺分片(16 表),用户券/结算单按用户分片(用户券 32 表、结算单 16 表),每张 t_user_coupon_N 都带
(user_id, coupon_template_id, receive_count)复合唯一索引兜底防重。
一图流:查券 + 领券(Day1 主线)
用户请求领券 │ ▼┌─────────────── 查券链路(findCouponTemplate)───────────────┐│ Hash 命中? ──是──► 返回(99% 流量止步于此,一条 HGETALL) ││ │否 ││ 布隆说不存在? ──是──► 抛异常(DB 零压力) ││ │说存在(可能误报) ││ 空值缓存存在? ──是──► 抛异常 ││ │否 ││ 拿锁 → 锁内再查空值缓存、再查 Hash(双重判定)→ 都落空才查 DB ││ DB 有 → Lua 原子写 Hash 缓存(TTL=活动结束) ││ DB 无 → 写空值缓存 30 分钟 │└──────────────────────────┬─────────────────────────────────┘ │ 返回模板数据 ▼┌─────────────── 领券链路 ───────────────────────────────────┐│ 时间校验(不在活动期抛异常) ││ Lua 原子四件事:判库存+扣库存(HINCRBY)+判上限+计数(INCR) ││ 失败 → 抛异常(事务未开启,DB 零压力) ││ 成功 → 返回位压缩值(Lua 按 ×2^16 打包,Java 按 2^14 解码,缺陷 #18)││ ││ ── 分叉点:Lua 成功之后的那一行 ── ││ 同步版: TransactionTemplate 编程式事务 ││ ⑤a 条件UPDATE扣DB库存 → ⑤b INSERT用户券 → ⑤c ZADD卡包 ││ → ⑤d 发延迟关闭消息 → 提交 → 用户拿到真实结果 ││ MQ 版: 发 UserCouponRedeemEvent → 立即返回"受理成功" ││ 消费者: 幂等切面(SET NX GET PX) → @Transactional 事务 ││ → 条件UPDATE → INSERT → ZADD → 发延迟消息 → 标记CONSUMED │└────────────────────────────────────────────────────────────┘领券的本质(一句话):三个资源上的三个动作——扣减模板配额(Redis 先扣挡流量、DB 条件 UPDATE 兜底)、铸造用户券实例(唯一索引防重)、维护卡包索引(ZSet + 写后查询)。模板 ≠ 券:模板是商家的规则和配额计数器,用户真正拥有的是 t_user_coupon 那一行。
第一部分:查券链路(findCouponTemplate 缓存三件套)
命名纠正:这条链路的准确说法是查优惠券模板链路——查的是模板(t_coupon_template 的规则 + 配额计数器 + 活动时间),不是用户手中那张券实例(t_user_coupon,由领券链路铸造)。模板 = 规则与配额;券 = 用户名下的一行记录。
1.1 十步完整流程(背诵版)
── 锁外 ──────────────────────────────1. 查 Hash 缓存 命中 → 直接返回2. 查布隆 说不存在 → 抛异常3. 查空值缓存 存在 → 抛异常4. 拿锁排队── 锁内(每个拿到锁的线程都跑)─────────5. 再查空值缓存 存在 → 抛异常 ← 第 1 道6. 再查 Hash 命中 → 直接返回 ← 第 2 道7. 两道都落空 → 才查 DB8. DB 有 → Lua 原子写 Hash 缓存9. DB 无 → 写空值缓存 30 分钟 → 抛异常10. 释放锁一句话定位:用”Hash 缓存 + 布隆过滤器 + 空值缓存 + 分布式锁 + 双重判定 + Lua 原子写”六层结构,把热点模板查询的 DB 压力压到接近零。
核心代码(findCouponTemplate,精简注释版):
public CouponTemplateQueryRespDTO findCouponTemplate(CouponTemplateQueryReqDTO requestParam) { String couponTemplateCacheKey = StrUtil.nullToEmpty(requestParam.getCouponTemplateId()); Map<Object, Object> couponTemplateCacheMap = stringRedisTemplate.opsForHash().entries(couponTemplateCacheKey);
// 如果存在直接返回,不存在需要通过布隆过滤器、缓存空值以及双重判定锁的形式读取数据库中的记录 if (MapUtil.isEmpty(couponTemplateCacheMap)) { // 判断布隆过滤器是否存在指定模板 ID,不存在直接返回错误 if (!couponTemplateQueryBloomFilter.contains(requestParam.getCouponTemplateId())) { throw new ClientException("优惠券模板不存在"); }
// 查询 Redis 缓存中是否存在优惠券模板空值信息,如果有代表模板不存在,直接返回 String couponTemplateIsNullCacheKey = ...; Boolean hasKeyFlag = stringRedisTemplate.hasKey(couponTemplateIsNullCacheKey); if (hasKeyFlag) { throw new ClientException("优惠券模板不存在"); }
// 获取优惠券模板分布式锁 RLock lock = redissonClient.getLock(String.format(EngineRedisConstant.LOCK_COUPON_TEMPLATE_KEY, requestParam.getCouponTemplateId())); lock.lock();
try { // 双重判定空值缓存是否存在,存在则继续抛异常 hasKeyFlag = stringRedisTemplate.hasKey(couponTemplateIsNullCacheKey); if (hasKeyFlag) { throw new ClientException("优惠券模板不存在"); }
// 通过双重判定锁优化大量请求无意义查询数据库 couponTemplateCacheMap = stringRedisTemplate.opsForHash().entries(couponTemplateCacheKey); if (MapUtil.isEmpty(couponTemplateCacheMap)) { LambdaQueryWrapper<CouponTemplateDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateDO.class) .eq(CouponTemplateDO::getShopNumber, Long.parseLong(requestParam.getShopNumber())) .eq(CouponTemplateDO::getId, Long.parseLong(requestParam.getCouponTemplateId())) .eq(CouponTemplateDO::getStatus, CouponTemplateStatusEnum.ACTIVE.getStatus()); CouponTemplateDO couponTemplateDO = couponTemplateMapper.selectOne(queryWrapper);
// 优惠券模板不存在或者已过期加入空值缓存,并且抛出异常 if (couponTemplateDO == null) { stringRedisTemplate.opsForValue().set(couponTemplateIsNullCacheKey, "", 30, TimeUnit.MINUTES); throw new ClientException("优惠券模板不存在或已过期"); }
// 通过将数据库的记录序列化成 JSON 字符串放入 Redis 缓存 CouponTemplateQueryRespDTO actualRespDTO = BeanUtil.toBean(couponTemplateDO, CouponTemplateQueryRespDTO.class); Map<String, Object> cacheTargetMap = BeanUtil.beanToMap(actualRespDTO, false, true); Map<String, String> actualCacheTargetMap = cacheTargetMap.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, entry -> entry.getValue() != null ? entry.getValue().toString() : "" ));
// 通过 LUA 脚本执行设置 Hash 数据以及设置过期时间(详见 1.6) String luaScript = "redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) " + "redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])";
List<String> keys = Collections.singletonList(couponTemplateCacheKey); List<String> args = new ArrayList<>(actualCacheTargetMap.size() * 2 + 1); actualCacheTargetMap.forEach((key, value) -> { args.add(key); args.add(value); }); // 优惠券活动过期时间转换为秒级别的 Unix 时间戳 args.add(String.valueOf(couponTemplateDO.getValidEndTime().getTime() / 1000));
stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), keys, args.toArray()); // ... 返回缓存数据 } } finally { lock.unlock(); } } return BeanUtil.toBean(couponTemplateCacheMap, CouponTemplateQueryRespDTO.class);}1.2 缓存三件套对三类问题的分工
| 问题 | 定义 | 解法 |
|---|---|---|
| 穿透 | 查不存在的数据,缓存永远不命中 | 布隆 + 空值缓存双层 |
| 击穿 | 热点 Key 失效瞬间并发打 DB | 分布式锁 + 双重判定 |
| 雪崩 | 大批 Key 同时失效 | EXPIREAT 各模板自己的活动结束时刻,天然错开 |
布隆过滤器两个特性(必背):说”不存在”→ 一定不存在(无漏报);说”存在”→ 可能不存在(有误报率)。实现是 Redisson RBloomFilter,tryInit(640, 0.001)——预期插入 640、误判率 0.1%(被问”布隆参数怎么配”时的硬数字)。误报由空值缓存接住。布隆不能删元素 → “模板被删”场景必须靠空值缓存兜底。
两层否定怎么分工(顺序不能反:布隆在前、空值在后):
| 层 | 挡谁 | 凭什么判 | 特性 |
|---|---|---|---|
| 布隆 | 挡”从来没存在过”的 ID | 从未被预热过(无成本判死刑) | 无漏报:说没有一定没有 |
| 空值缓存 | 挡”存在过但已没了”(status≠ACTIVE 过期 / 被删) | 查过 DB 确认没有才写 | 事实性否定 |
DB(status='ACTIVE') | 最终确认 | where 带活动状态 | 过期模板在 DB 侧也返回 null |
关键:布隆对被删掉的模板无能为力(删不了元素),所以”模板被删/过期”必须由空值缓存兜底。DB 查询条件里的 status='ACTIVE' 让”过期”和”不存在”都落空值缓存——两条路径语义对齐。
1.3 两个 Redis Key 的身份证(易混点)
| Hash 缓存 | 空值缓存 | |
|---|---|---|
| Key 模板 | one-coupon_engine:template:{模板ID} | one-coupon_engine:template_is_null:{模板ID} |
| Redis 类型 | Hash | String |
| 里面装什么 | 模板 13 个字段(真实数据) | 空字符串 ""(只是个标记,值毫无意义) |
| 什么时候写 | DB 里查得到 | DB 里查不到 |
| TTL | EXPIREAT 活动结束时刻 | 固定 30 分钟 |
分工语义:布隆挡”从来没存在过”(对删掉的模板无能为力),空值缓存挡”查过 DB 确认没有”(含 status≠ACTIVE 的过期活动)。99% 流量在第 1 步 Hash 命中返回;布隆拦截的从来是不存在的攻击流量。
redis-cli 实景(对同一模板 ID,两个 Key 最多只有一个存在,互斥):
── 模板存在 ──127.0.0.1:6379> HGETALL one-coupon_engine:template:1813157245480796160 1) "id" "1813157245480796160" 2) "name" "满100减20" 3) "stock" "99" ← 领券 Lua HINCRBY 扣的就是它 ...(共 13 个 field,全为字符串,null → "")
── 模板不存在 ──127.0.0.1:6379> GET one-coupon_engine:template_is_null:9999999999999999999"" ← 值是空串,没人关心它是什么127.0.0.1:6379> TTL one-coupon_engine:template_is_null:9999999999999999999(integer) 1798 ← 约 30 分钟后自动消失为什么空值不写进 Hash 里(如加个 "isNull":"true" 字段)?会污染快路径——HGETALL 返回非空 Map → 代码以为命中 → 转出残废 Bean → NPE。必须独立 Key + 独立类型物理分开。
Hash 的 Value 精确表述:不是”DB 整行记录”,而是 DB 行 → DO → DTO → Map 三跳后的形态(13 字段、全字符串、null 转空串)。多出的 DTO 一跳为了裁剪(审计字段不进缓存)、类型友好(Long→String)、分层隔离。
1.4 双重判定(最难理解的点)
核心:你在锁外看到的”缓存没有数据”,等你排到队拿到锁时,可能已经不是事实了——排队期间,前面的线程可能已经把缓存建好了。
反例推演(无锁内重查,1000 并发):1000 个线程都发现缓存为空 → 全部排队抢锁 → 线程 A 拿锁查 DB 建缓存 → 线程 B 拿锁不重查直接查 DB……终态:DB 挨 1000 刀——锁只做到”排队挨刀”,没做到”免刀”。
正解:锁内两道检查,各接住第一个持锁线程的一种结局——
| 线程 A 的结局 | A 留下了什么 | 后续线程靠哪道接住 |
|---|---|---|
| 查 DB 有 | Hash 缓存 | 锁内重查 Hash → 命中 |
| 查 DB 无 | 空值缓存 | 锁内重查空值 → 存在 |
认知纠偏:① 锁外还有一次空值缓存检查——不存在模板的请求连锁都不用排;② 锁内两道对所有线程一视同仁,“第一个/后续”的区别是运行时涌现的;③ “双重判定”本意 = 同一个检查锁外锁内各做一遍(与单例 DCL 的 if(instance==null) 出现两次完全同构)。
两处易讲歪的点:
- 空值缓存未命中 ≠ 模板一定存在:它只说明”缓存层没拦下”。可能是第一次来的请求、也可能是布隆误报后第一次路过——不能断言”一定有”,只能得出”该拿锁去 DB 确认了”。
- 双重判定防的不是排队串行(排队串行是分布式锁的固有代价,省不掉),防的是”串行拿到锁后每个人仍去查 DB”。精确表述:双重判定让后拿锁的线程在锁内重查缓存直接返回,DB 只被第一个线程查一次——1000 个请求照样排队,但 DB 从挨 1000 刀变成挨 1 刀。
生活类比(食堂打饭):路过窗口看一眼没饭(Hash)→ 排队催厨师(拿锁)→ 轮到你时再看一眼窗口(锁内重查 Hash)——前面的人已经催出一锅饭了,别再让厨师做一锅。“厨师说没这菜”→ 前面的人贴了告示(锁内空值缓存),看告示就走。厨窗排队是分布式锁的固有代价省不掉,双重判定省的是”排到队后还让厨师各做一次饭”。
1.5 TTL 策略
| Key | 过期策略 |
|---|---|
| Hash 缓存 | EXPIREAT 到活动结束时刻(秒级绝对时间戳) |
| 空值缓存 | 固定 30 分钟(防御强度 vs 时效性的权衡) |
| 分布式锁 | 无显式 TTL——Redisson 看门狗续期 |
| 布隆 | 永不过期 |
追问:Hash 为什么锚定活动结束时刻? ① 业务同生共死;② 各模板 validEndTime 天然不同 → 防雪崩;③ EXPIREAT(绝对)优于 EXPIRE(相对)——重建时刻不同过期点不漂移。
TTL 来源别讲串:模板 Hash 的过期 = EXPIREAT 锚定活动结束(绝对时间戳);而”当前时间 + 有效期”这种相对值(EXPIRE)是用户券的过期时刻(领券时刻 + validityPeriod,延迟关券消息用它),跟模板缓存不是一个东西。口诀:模板缓存放”活动结束”,用户券放”领券时刻 + 效期”。
追问:活动结束瞬间发生什么? Hash 蒸发 → 首请求查 DB → status 已不是 ACTIVE(查询条件)→ null → 写空值缓存 30 分钟 → 之后所有请求空值命中直接拒绝。status=ACTIVE 条件让”活动结束”在 DB 侧也表现为”不存在”,两条路径语义对齐。
追问(最高分点):stock 是半缓存半状态
Hash 缓存 13 个字段里 12 个是纯缓存(DB 只读副本,删了重建无损),stock 是状态——领券 Lua 的 HINCRBY -1 在持续原子扣减的预扣计数器。
Redis: stock 100 → 99 → 98 → ... (Lua 秒扣)DB: stock 100 ......... → 99 (滞后落地) ↑ 窗口内 Redis 值 < DB 值,差值 = 在途预扣量删缓存重建(初始 100,Redis 已扣到 0,DB 只落地 50):重建查 DB 得 50 写回 → 在途 50 个扣减被”还回去” → Redis 再放 50 人 → 到 DB 条件 UPDATE 时库存已 0 → 抛”已被领取完啦”。不是超卖(DB 条件 UPDATE 兜底),是尾部用户空欢喜的体验事故。
措辞提醒:别把”Redis 库存回涨”说成”多发券 / 超卖”——DB 条件 UPDATE WHERE stock>=1 兜底,超卖不可能发生。严谨说法:Redis 放行量被回退(在途预扣还回去)→ Redis 显示可领但 DB 已没货 → 尾部用户过了闸门却领不到(空欢喜)。 只伤尾部体验、不超卖。
本质区分:缓存 = 可随时从源头重建的数据副本;状态 = 承载了源头没有的增量信息的业务数据。“已扣未落地”信息只存在于 Redis,DB 不知道,所以不可重建。
1.6 为什么用 Lua 包 HMSET + EXPIREAT
拆成两条命令,中间崩溃会留下没有 TTL 的永生 Key。Lua 保证”写数据 + 设过期”原子完成。getTime()/1000 转秒是正确写法,与领券 Lua 毫秒直传 EXPIRE 的 Bug 形成同仓库对照组(见第二部分已知边界)。
-- 查券链路的缓存回填 Lua(Java 内拼字符串)redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) -- 写 13 个字段redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV]) -- 最后一个参数是秒级时间戳1.7 查券链路流程图与时序图
图①:findCouponTemplate 缓存三件套完整流程
图②:双重判定防击穿时序图(1000 并发,DB 只挨 1 刀)
🔗 双写视角(本链路涉及:⑤):讲完查券顺势就能讲——商家改模板后 DB 更新了、Redis Hash 缓存却不失效,不一致窗口 = 活动剩余时长;而且 stock 是预扣计数器,删缓存重建会把放行量重置。每处一句防戳破的话见第三部分 3.2 ⑤。
第二部分:领券链路(同步版 + MQ 版)
2.0 领券链路全景流程图
2.1 Lua 脚本(第一道闸门)
完整脚本(engine/src/main/resources/lua/stock_decrement_and_save_user_receive.lua):
-- Lua 脚本: 检查用户是否达到优惠券领取上限并记录领取次数-- KEYS[1]: 优惠券库存键(模板 Hash,含 stock 字段)-- KEYS[2]: 用户领取记录键-- ARGV[1]: 优惠券有效期结束时间 (timestamp)-- ARGV[2]: 用户领取上限 (limit)
local function combineFields(firstField, secondField) local SECOND_FIELD_BITS = 16 -- secondField 最大为 65535(压测时支持 65535 以内样本数) local shiftedFirstField = firstField * (2 ^ SECOND_FIELD_BITS) -- Lua 5.1 无位运算,乘法模拟左移 return shiftedFirstField + secondFieldend
-- 获取当前库存local stock = tonumber(redis.call('HGET', KEYS[1], 'stock'))
-- 判断库存是否大于 0if stock <= 0 then return combineFields(1, 0) -- 库存不足end
-- 获取用户领取的优惠券次数local userCouponCount = tonumber(redis.call('GET', KEYS[2]))if userCouponCount == nil then userCouponCount = 0end
-- 判断用户是否已经达到领取上限if userCouponCount >= tonumber(ARGV[2]) then return combineFields(2, userCouponCount) -- 用户已经达到领取上限end
-- 增加用户领取的优惠券次数if userCouponCount == 0 then redis.call('SET', KEYS[2], 1) -- 首次:SET 1 redis.call('EXPIRE', KEYS[2], ARGV[1]) -- ⚠️ 毫秒传给秒参数的 Bug 来源else redis.call('INCR', KEYS[2]) -- 后续:只 INCR 不刷新 TTLend
-- 减少优惠券库存redis.call('HINCRBY', KEYS[1], 'stock', -1)
return combineFields(0, userCouponCount + 1)参数映射
| Lua 参数 | Java 变量 | 运行时实际值 |
|---|---|---|
KEYS[1] | couponTemplateCacheKey | 模板 Hash(含 stock 字段)——正是查券链路建的缓存 |
KEYS[2] | userCouponTemplateLimitCacheKey | 用户次数 String |
ARGV[1] | validEndTime().getTime() ⚠️ | 毫秒时间戳(单位 Bug 来源) |
ARGV[2] | receiveRule.limitPerPerson | 限领数 |
返回值位压缩
Lua 打包: 返回值 = firstField × 2^16 + secondField ← Lua 脚本 SECOND_FIELD_BITS = 16Java 解码:firstField = 返回值 >> 14 & 0b11 ← Java 工具类 SECOND_FIELD_BITS = 14 secondField = 返回值 & 163830=成功 / 1=库存不足 / 2=已达限领十进制类比:门牌号 = 楼层 × 100 + 房间号。⚠ 两边位宽不一致——真实缺陷(§8.2 #18):成功路径(firstField=0、次数 < 16384)恰好解码正确,压测全绿;但错误码经 16 位移位后(库存不足 = 1×2^16 = 65536),Java 按 14 位解码 65536>>14 & 3 = 0 = 成功——“库存不足/已达限领”信号在 Java 侧全部失真为成功,全靠 DB 条件 UPDATE(库存场景提示碰巧仍是”已被领取完啦”)和唯一索引(限领场景提示错乱成”用户重复领取”,MQ 版会撞索引烧 16 次重试进死信)兜住。防线没破,但 Lua 闸门在错误场景失效、错误文案错乱——和延迟关券一样是”主动交代”素材。
术语辨析:KEYS/ARGV 是输入(Java → Lua,“你问 Redis 什么”),firstField/secondField 是输出(Lua → Java,“Redis 答了你什么”)。项目中 “field” 一词出现三次,别混:① Redis Hash 的 field(如 'stock');② 返回值的 firstField/secondField(状态码/次数);③ KEYS/ARGV 的形参约定。
关键设计
- 原子性:判断+扣减必须在同一脚本内(check-then-act 竞态 → 超卖/超领)
- 首次
SET 1 + EXPIRE,后续只INCR:INCR 不设 TTL(避免每次刷新变滑动窗口);只在首次锚定 = 固定窗口跟随活动结束 - 成功返回新次数(
count+1):写入t_user_coupon.receive_count,参与复合唯一索引——Lua 计数与 DB 唯一索引通过该字段咬合
已知边界
| 问题 | 位置 | 说明 |
|---|---|---|
| EXPIRE 单位 Bug | Lua 脚本 EXPIRE 行 | 毫秒传给秒参数 → 约 5 万年 TTL |
| stock nil 防御缺失 | Lua 脚本 HGET 行 | Hash Key 不存在 → Lua 运行时错误 |
| Redis Cluster 不可用 | — | 两个 Key 无公共 hash tag → CROSSSLOT |
| 代码重复 | 两个领券方法间 | Lua 加载+组 Key+执行逐行复制 |
| Lua/Java 位宽不一致 | Lua SECOND_FIELD_BITS=16 vs Java 工具类 14 | 错误码被 Java 误读为成功,靠 DB 兜底(§8.2 #18) |
2.2 同步版 redeemUserCoupon(五阶段)
@Overridepublic void redeemUserCoupon(CouponTemplateRedeemReqDTO requestParam) { // ① 模板缓存查询 + 领取时间校验(事务外,纯读) CouponTemplateQueryRespDTO couponTemplate = couponTemplateService.findCouponTemplate(...); boolean isInTime = DateUtil.isIn(new Date(), couponTemplate.getValidStartTime(), couponTemplate.getValidEndTime()); if (!isInTime) { throw new ClientException("不满足优惠券领取时间"); }
// ② Lua 脚本懒加载(Hutool Singleton + EVALSHA) DefaultRedisScript<Long> buildLuaScript = Singleton.get(STOCK_DECREMENT_AND_SAVE_USER_RECEIVE_LUA_PATH, () -> { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(STOCK_DECREMENT_AND_SAVE_USER_RECEIVE_LUA_PATH))); redisScript.setResultType(Long.class); return redisScript; });
// ③ 执行 Lua:原子预扣 Redis 库存 + 次数校验 Long stockDecrementLuaResult = stringRedisTemplate.execute( buildLuaScript, ListUtil.of(couponTemplateCacheKey, userCouponTemplateLimitCacheKey), String.valueOf(couponTemplate.getValidEndTime().getTime()), limitPerPerson );
// ④ 解码返回值,失败快速抛出(事务未开启,DB 零压力) long firstField = StockDecrementReturnCombinedUtil.extractFirstField(stockDecrementLuaResult); if (RedisStockDecrementErrorEnum.isFail(firstField)) { throw new ServiceException(RedisStockDecrementErrorEnum.fromType(firstField)); }
// ⑤ TransactionTemplate 编程式事务 long extractSecondField = StockDecrementReturnCombinedUtil.extractSecondField(stockDecrementLuaResult); transactionTemplate.executeWithoutResult(status -> { try { // ⑤a 条件 UPDATE 扣 DB 库存 int decremented = couponTemplateMapper.decrementCouponTemplateStock( Long.parseLong(requestParam.getShopNumber()), Long.parseLong(requestParam.getCouponTemplateId()), 1L); if (!SqlHelper.retBool(decremented)) { throw new ServiceException("优惠券已被领取完啦"); }
// ⑤b 插入用户券 Date now = new Date(); DateTime validEndTime = DateUtil.offsetHour(now, JSON.parseObject(couponTemplate.getConsumeRule()).getInteger("validityPeriod")); UserCouponDO userCouponDO = UserCouponDO.builder() .couponTemplateId(Long.parseLong(requestParam.getCouponTemplateId())) .userId(Long.parseLong(UserContext.getUserId())) .source(requestParam.getSource()) .receiveCount(Long.valueOf(extractSecondField).intValue()) .status(UserCouponStatusEnum.UNUSED.getCode()) .receiveTime(now) .validStartTime(now) .validEndTime(validEndTime) .build(); userCouponMapper.insert(userCouponDO);
// ⑤c direct 模式写 ZSet 卡包 + 写后查询 if (StrUtil.equals(userCouponListSaveCacheType, "direct")) { String userCouponListCacheKey = String.format(EngineRedisConstant.USER_COUPON_TEMPLATE_LIST_KEY, UserContext.getUserId()); String userCouponItemCacheKey = StrUtil.builder() .append(requestParam.getCouponTemplateId()).append("_").append(userCouponDO.getId()) .toString(); stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime());
// 写后查询:防 Redis 主从异步复制的静默丢失 Double scored; try { scored = stringRedisTemplate.opsForZSet().score(userCouponListCacheKey, userCouponItemCacheKey); if (scored == null) { stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime()); } } catch (Throwable ex) { log.warn("查询Redis用户优惠券记录为空或抛异常,可能Redis宕机或主从复制数据丢失,基础错误信息:{}", ex.getMessage()); stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime()); }
// ⑤d 发送到期关闭延迟消息 UserCouponDelayCloseEvent userCouponDelayCloseEvent = UserCouponDelayCloseEvent.builder() .couponTemplateId(requestParam.getCouponTemplateId()) .userCouponId(String.valueOf(userCouponDO.getId())) .userId(UserContext.getUserId()) .delayTime(validEndTime.getTime()) .build(); SendResult sendResult = couponDelayCloseProducer.sendMessage(userCouponDelayCloseEvent);
// 发送消息失败:打印日志并报警,通过日志搜集并重新投递 if (ObjectUtil.notEqual(sendResult.getSendStatus().name(), "SEND_OK")) { log.warn("发送优惠券关闭延时队列失败,消息参数:{}", JSON.toJSONString(userCouponDelayCloseEvent)); } } } catch (Exception ex) { status.setRollbackOnly(); // ⑤e 异常翻译 + 回滚 if (ex instanceof ServiceException) { throw (ServiceException) ex; } if (ex instanceof DuplicateKeyException) { log.error("用户重复领取优惠券,用户ID:{},优惠券模板ID:{}", UserContext.getUserId(), requestParam.getCouponTemplateId()); throw new ServiceException("用户重复领取优惠券"); } throw new ServiceException("优惠券领取异常,请稍候再试"); } });}事务圈内圈外(全项目最重要的图)
┌─ 事务圈外(占时间不占事务)─────┐ ┌─ 事务圈内 ─────────────────┐│ ① 查模板缓存(Redis 读) │ │ ⑤a 扣 DB 库存 ←可回滚 ✓ ││ ③ Lua 预扣(Redis 写) │ │ ⑤b 插用户券 ←可回滚 ✓ ││ ④ 解码返回值 │ │ ⑤c 写 ZSet ←❌回滚不了 ││ │ │ ⑤d 发延迟消息 ←❌回滚不了 │└──────────────────────────────────┘ └─────────────────────────────┘- 圈内 ≠ 能回滚:事务原子性只对 ⑤a⑤b 两条 MySQL 语句生效
- ⑤c⑤d 为什么在圈内:依赖 ⑤b 插入后回填的自增 ID(
userCouponDO.getId()) - 代价:⑤d 抛未捕获异常 → DB 回滚但 ZSet 已写 → 幻影券
- 为什么用 TransactionTemplate 不用 @Transactional:事务前有大量 Redis IO,注解会白占 DB 连接;编程式边界精确可控
四道防线
| 防线 | 防什么 | 类型 |
|---|---|---|
| ① Lua 原子校验 | 超卖、超领,挡掉 99% 无效请求 | 性能闸门 |
| ② DB 条件 UPDATE | Redis/DB 库存漂移时防超卖 | 正确性 |
| ③ 复合唯一索引 | Redis 计数丢失时防重复领取 | 正确性(物理约束) |
| ④ 写后查询 | Redis 主从复制静默丢失 | 提高成功率(非保证) |
UPDATE t_coupon_template SET stock = stock - 1WHERE shop_number = #{shopNumber} AND id = #{couponTemplateId} AND stock >= 1 -- 扣减条件写进 WHERE,防 check-then-act 竞态四个场景例子(用户 1024,模板 T,库存 100,限领 3)
| 场景 | 拦截者 | 结果 |
|---|---|---|
| Happy Path 首次领取 | — | Lua 返回 1 → DB 扣到 99 → 插券 → ZSet → 延迟消息 → 成功 |
| 想领第 4 次 | Lua 闸门(⚠ 因 #18 位宽 Bug 实际由唯一索引兜住,提示错乱为”重复领取”) | firstField=2 → 抛异常,事务未开启 DB 零压力 |
| Redis 计数丢失后重复领 | 唯一索引 | Lua 放行 count=1 → INSERT 撞 (1024,T,1) → “用户重复领取” |
| Redis 有货 DB 没货 | 条件 UPDATE | UPDATE 0 行 → “已被领取完啦”(MQ 版同场景是静默 return) |
🔗 双写视角(本节涉及:①②③④):刚背的五步就是 4 处双写的案发现场——① Lua 扣了 Redis 库存、DB 事务回滚 → 少卖(欠库存不欠用户);② 计数 +1 了、INSERT 失败回滚 → 白扣配额(伤用户无兜底);③ DB 回滚但 ZSet 已写 → 幻影券(ID 回填那条窄缝);④ 延迟消息 SEND_FAIL 只 log.warn → 券永不过期。每处完整成因与缺口见第三部分 3.2。
2.3 MQ 版领券(一条消息的一生)
本节按”一条消息的一生”线性顺序讲,背的时候顺着走不要跳:生产端发消息 → 发送失败两分支 → 消息里带了什么(快照)→ 消费端落库 → 三个出口 → 什么时候重投 → 三个典型场景。
30 秒主线(先背这个,再看代码)
分叉点:Lua 预扣成功之后,同步版开事务写 DB;MQ 版只发一条消息就返回”受理成功”。剩下四件事(扣 DB 库存、插用户券、写卡包、发延迟关券消息)全部由消费者异步做。
理解消费端只靠一个词:ACK(确认):
- 消费者正常 return(包括
return null)= ACK = broker 认为处理完毕,不再发这条消息 - 消费者抛异常 / 超时 / 崩溃 = broker 收不到 ACK = 自动重投:10s → 30s → 1m → 2m …最多 16 次,耗尽进死信队列
- 代码里所有”抛异常”都是故意的——故意不 ACK,等 MQ 稍后再发一次
一句话总纲:MQ 保证”至少送一次”(所以会重投);幂等切面挡住重复,让用户”看起来只送一次”;唯一索引兜住切面漏网的(Key 过期、崩溃窗口)。
生产端:Lua 成功 → 发消息 → 返回”受理成功”
同一个”领券”业务的两套完整实现,前半段完全共享(查券链路拿模板 → 时间校验 → 同一个 Lua 脚本预扣),分叉点就在 Lua 返回成功之后的那一行——
| 同步版 | 异步版 | |
|---|---|---|
| Lua 成功后下一行 | transactionTemplate.executeWithoutResult | userCouponRedeemProducer.sendMessage |
| 用户拿到什么 | 真实结果(成/败都知道) | 只有”受理成功”(落库成败无感知) |
| 库存扣失败时 | 抛”已被领取完啦” | log.warn + 静默 return |
| 事务写法 | TransactionTemplate 编程式 | @Transactional 声明式 |
| 适用场景 | 低并发、需要强结果反馈 | 秒杀式高并发,MQ 削峰,最终一致 |
生产端代码(与同步版前半段相同,只贴分叉后):
@Overridepublic void redeemUserCouponByMQ(CouponTemplateRedeemReqDTO requestParam) { // ① ~ ④ 与同步版完全相同:查模板缓存 → 时间校验 → Lua 懒加载 → 执行 Lua + 解码 // ...... // ⑤ 分叉点:组装事件发 MQ(不碰 DB、不开事务) UserCouponRedeemEvent userCouponRedeemEvent = UserCouponRedeemEvent.builder() .requestParam(requestParam) .receiveCount((int) StockDecrementReturnCombinedUtil.extractSecondField(stockDecrementLuaResult)) // Lua 第二字段 .couponTemplate(couponTemplate) // 整个模板塞进消息(快照) .userId(UserContext.getUserId()) .build(); SendResult sendResult = userCouponRedeemProducer.sendMessage(userCouponRedeemEvent); // 发送消息失败:打印日志并报警,通过日志搜集并重新投递(不抛异常) if (ObjectUtil.notEqual(sendResult.getSendStatus().name(), "SEND_OK")) { log.warn("[生产者] 用户兑换优惠券 - 发送优惠券兑换消息失败,消息参数:{}", JSON.toJSONString(userCouponRedeemEvent)); } // 方法正常返回 → 用户看到"受理成功"}- receiveCount 从 Lua 第二字段(extractSecondField)取,塞进消息,由消费者写进用户券参与复合唯一索引——Lua 计数与 DB 防重通过这个字段咬合(与同步版同口径)
- couponTemplate 整个塞进消息(快照):消费者不用再查一次缓存;代价是消息体变大、用的是生产时刻的旧快照
- 为什么加 MQ:秒杀场景 MySQL 写是瓶颈(事务持锁耗时)→ 异步把 2 次 DB 写从请求链路拆到消费端 → RT 低、吞吐高、能削峰填谷、可水平扩消费者。代价:用户响应里拿不到最终结果(最终一致性窗口)+ 要保证消费幂等
生产端发送失败的两分支(互斥,高频追问)
redeemUserCouponByMQ 的 sendMessage 没有 try-catch,一次发送只可能落入两支之一:
| 分支 A:真抛异常 | 分支 B:非 SEND_OK | |
|---|---|---|
| 触发 | broker 不可达/超时(syncSend 抛异常) | 消息已到 broker 但落盘/同步存疑(syncSend 正常返回,如 FLUSH_DISK_TIMEOUT) |
| 走哪 | 生产者基类 log.error 后 rethrow 上抛 | log.warn + 报警 |
| 用户看到 | 失败 | 成功(方法正常 return) |
| Redis 状态 | 已预扣 → 少卖(无兜底,人工对账) | 已预扣 → 一致;消息可能丢 → 日志兜底重投 |
“日志兜底重投”指把这条领券事件重新投递到同一个 topic 让消费者再处理一次,来源有二:自动(消费失败 MQ 重投同一条)与手动(非 SEND_OK 时运维/脚本从日志捞消息体重发)。
“事务管不到 MQ”:同步版发延迟消息失败也不触发 DB 回滚(非 SEND_OK 只 log.warn 不抛);setRollbackOnly() 只在抛了异常时才执行。区分”失败是不是异常”决定走”抛”还是”记日志”。
消息快照:消费者为什么”零查询”
生产端塞进事件里的三样东西,就是消费端敢”零查询”的全部原因:
| 事件字段 | 谁用 | 干什么 |
|---|---|---|
couponTemplate 整个模板 | INSERT 用户券时取 consumeRule 算有效期 | 消费者不用回查模板缓存 |
receiveCount(Lua 第二字段) | 写进用户券、参与复合唯一索引 | Lua 计数与 DB 防重靠它咬合 |
userId + requestParam | 扣库存、INSERT 用户券定位 | 不依赖线程上下文(MQ 消费线程没有 UserContext) |
代价:消息体变大、用的是生产时刻的旧快照。面试主动说这句权衡:“我用消息快照换掉了消费端所有回查,代价是消息体和时效性。“
消费端:先过切面,再进业务(时间线四步)
消费者注解栈(三层盔甲):
| 注解 | 作用 |
|---|---|
@RocketMQMessageListener | 声明式订阅 topic + 消费组 |
@NoMQDuplicateConsume | 消息级幂等:SpEL 取 messageWrapper.keys,Lua SET NX GET PX 抢占,TTL 600 |
@Transactional(rollbackFor) | 声明式事务包全程 |
t1 消息到达 → 切面先执行:SET NX 抢"消费许可证"t2 三分支:抢到(nil) → 进业务;旧值"1"已消费 → 跳过 + ACK;旧值"0"别人在消费 → 抛异常等重投t3 业务(事务内):条件 UPDATE 扣库存 → 插用户券 → 写卡包 ZSet → 发延迟关券消息t4 三个出口:全部成功 / 任意异常 / DB 没货(见下节)- 为什么消费者敢用 @Transactional:方法第一行就是 DB 操作、全程零缓存查询(快照在消息里)——整个方法都是事务该包的内容
消费端完整代码
@NoMQDuplicateConsume( keyPrefix = "user-coupon-redeem:", key = "#messageWrapper.keys", keyTimeout = 600)@Transactional(rollbackFor = Exception.class)@Overridepublic void onMessage(MessageWrapper<UserCouponRedeemEvent> messageWrapper) { log.info("[消费者] 用户兑换优惠券 - 执行消费逻辑,消息体:{}", JSON.toJSONString(messageWrapper));
// 全部来自消息体(快照),不依赖线程上下文、不查缓存 CouponTemplateRedeemReqDTO requestParam = messageWrapper.getMessage().getRequestParam(); CouponTemplateQueryRespDTO couponTemplate = messageWrapper.getMessage().getCouponTemplate(); String userId = messageWrapper.getMessage().getUserId();
// 条件 UPDATE 扣 DB 库存 int decremented = couponTemplateMapper.decrementCouponTemplateStock( Long.parseLong(requestParam.getShopNumber()), Long.parseLong(requestParam.getCouponTemplateId()), 1L); if (!SqlHelper.retBool(decremented)) { // 0 行:静默 return(DB stock=0 是永久状态,抛异常只会无限重试进死信) log.warn("[消费者] 用户兑换优惠券 - 执行消费逻辑,扣减优惠券数据库库存失败,消息体:{}", JSON.toJSONString(messageWrapper)); return; }
// 插入用户券(receiveCount 用消息里那个) Date now = new Date(); DateTime validEndTime = DateUtil.offsetHour(now, JSON.parseObject(couponTemplate.getConsumeRule()).getInteger("validityPeriod")); UserCouponDO userCouponDO = UserCouponDO.builder() .couponTemplateId(Long.parseLong(requestParam.getCouponTemplateId())) .userId(Long.parseLong(userId)) .source(requestParam.getSource()) .receiveCount(messageWrapper.getMessage().getReceiveCount()) .status(UserCouponStatusEnum.UNUSED.getCode()) .receiveTime(now) .validStartTime(now) .validEndTime(validEndTime) .build(); userCouponMapper.insert(userCouponDO);
if (ObjectUtil.notEqual(userCouponListSaveCacheType, "binlog")) { // 写 ZSet 卡包 String userCouponListCacheKey = String.format(EngineRedisConstant.USER_COUPON_TEMPLATE_LIST_KEY, userId); String userCouponItemCacheKey = StrUtil.builder() .append(requestParam.getCouponTemplateId()).append("_").append(userCouponDO.getId()) .toString(); stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime());
// 写后查询:防主从异步复制静默丢失 Double scored; try { scored = stringRedisTemplate.opsForZSet().score(userCouponListCacheKey, userCouponItemCacheKey); if (scored == null) { stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime()); } } catch (Throwable ex) { log.warn("[消费者] ... 可能Redis宕机或主从复制数据丢失,基础错误信息:{}", ex.getMessage()); stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime()); } }
// 发延迟关券消息 UserCouponDelayCloseEvent userCouponDelayCloseEvent = UserCouponDelayCloseEvent.builder() .couponTemplateId(requestParam.getCouponTemplateId()) .userCouponId(String.valueOf(userCouponDO.getId())) .userId(userId) .delayTime(validEndTime.getTime()) .build(); SendResult sendResult = couponDelayCloseProducer.sendMessage(userCouponDelayCloseEvent); if (ObjectUtil.notEqual(sendResult.getSendStatus().name(), "SEND_OK")) { log.warn("[消费者] 用户兑换优惠券 - 发送优惠券关闭延时队列失败,消息参数:{}", JSON.toJSONString(userCouponDelayCloseEvent)); }}三个出口:谁 ACK 终结,谁等重投
| 出口 | 动作 | 消息去向 |
|---|---|---|
| 全部成功 | 事务提交 + Key 置 “1” + 正常 return | ACK,消息终结 |
| 任意一步异常 | 事务回滚 + 切面删 Key + 异常上抛 | 不 ACK → 10s 后自动重投 → 回到 t1 |
| DB 库存 = 0 | 静默 return | ACK,消息终结(止损) |
- 为什么没有切面会死循环:反事实——第一次消费已成功落库,这条消息因 ACK 丢失被 MQ 重投。没切面:业务再执行 → INSERT 撞唯一索引(那行已永久在库)→ 异常 → MQ 再投 → 再撞……每次必败,白烧 16 次重试额度进死信。有切面:Key 已是 “1”,重投直接跳过 + ACK,进不了业务。切面不是优化,是止损。(撞索引后异常是被切面 catch 接住的——
NoMQDuplicateConsumeAspect的catch (Throwable) { delete(uniqueKey); throw ex; },见 2.4 切面核心代码) - 静默 return 的原因:DB stock=0 是永久状态,抛异常只会白白烧光 16 次重试额度进死信。代价:用户以为领到了(Redis 次数已 +1),DB 永远没有这张券(已知缺陷,§8.2)
重投全景(“什么时候重投”一张表说清)
触发重投只有三种情况,全是”消费者没正常 ACK”:
| 触发 | 代码表现 | 设计意图 |
|---|---|---|
| 业务抛异常 | 删 Key + 重抛 | 可恢复错误(网络抖、锁超时),等重投再试 |
| 切面见旧值 “0” | 直接抛异常 | 别人正在消费,不能替它下结论(见下) |
| 消费中 JVM 崩溃 | Key 卡在 “0”,catch 没机会跑 | 只能等 TTL 600s 过期自愈 |
CONSUMING 为什么抛异常而不是静默跳过(rebalance 场景):A 正在消费,B 同时收到同一条消息。B 若静默 return null → MQ ACK 掉消息 → A 失败 → 消息已被 ACK → 永久丢失。所以 B 抛异常让 MQ 延迟重投。“正在消费”是唯一不能替别人下结论的状态,只能等。
文档里三种”重投”字眼,其实说的是三种东西(看串了就回来查这张表):
| 写法 | 本质 | 谁干的 |
|---|---|---|
| ”MQ 重投” | broker 自动重发 | broker |
| ”延迟重投” | 同一种自动重发,触发原因是”别人正在消费” | broker |
| ”日志兜底重投” | 消息根本没进 broker,MQ 无从重投 | 人工/脚本从日志捞消息体重发 |
三个典型场景(面试快答)
| 场景 | 过程 | 终态 |
|---|---|---|
| 消息重投两次 | 第 1 次 SET NX 成功 → 落库 → CONSUMED;第 2 次 SET NX 失败 → 跳过 | ✅ 数据库只有一条记录 |
| Redis 有货 DB 没货 | 生产端 Lua 成功 → 用户收”受理成功”;消费端 UPDATE 0 行 → 静默 return | ❌ 用户视角领到、系统没有、无补偿 |
| 幂等 Key 已设、事务提交前崩溃 | SET NX 成功 → JVM 崩溃(事务未提交)→ MQ 重投 → 切面跳过 | ❌ 消息永远不再处理(“标记先于提交”固有窗口) |
🔗 双写视角(本节新增:⑥):MQ 版比同步版多一处双写——Redis 幂等 Key ↔ DB 消费事务:
proceed()成功到写 “1” 之间崩溃,Key 卡 CONSUMING、事务没提交 → 重投撞唯一索引烧重试额度;①③④ 与同步版同构。见第三部分 3.2 ⑥。
2.4 幂等切面 NoMQDuplicateConsumeAspect(防重复消费)
定位(一句话)
RocketMQ 只承诺至少送达一次(网络闪断 ACK 丢失、消费超时重投、rebalance、生产端重发——重复投递不是异常是常态),不防则领 1 次拿 2 张券。切面就是消费者自己记的**“签收账本”**:Redis 里存的不是消息本体,是用消息自带的 keys 字段(UUID)当条目。它抢的不是优惠券库存,是”这条消息归谁处理”——消息级锁,跟业务无关。
2.3 的时间线(t1 抢占 → t2 三分支 → t4 三出口)就是切面和业务的配合全程;本节讲切面自身的细节:怎么抢(Lua 一条命令)、为什么失败要删 Key、和唯一索引怎么分工。
切面核心代码
@Aspect@RequiredArgsConstructorpublic final class NoMQDuplicateConsumeAspect {
private final StringRedisTemplate stringRedisTemplate;
// 一条命令完成"抢占 + 读旧值 + 设 TTL"(NX + GET + PX) private static final String LUA_SCRIPT = """ local key = KEYS[1] local value = ARGV[1] local expire_time_ms = ARGV[2] return redis.call('SET', key, value, 'NX', 'GET', 'PX', expire_time_ms) """;
@Around("@annotation(com.nageoffer.onecoupon.framework.idempotent.NoMQDuplicateConsume)") public Object noMQRepeatConsume(ProceedingJoinPoint joinPoint) throws Throwable { NoMQDuplicateConsume noMQDuplicateConsume = getNoMQDuplicateConsumeAnnotation(joinPoint); // SpEL 从消息体取 key(如 #messageWrapper.keys) String uniqueKey = noMQDuplicateConsume.keyPrefix() + SpELUtil.parseKey(noMQDuplicateConsume.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
// 抢占:返回 nil = 抢到;返回旧值 = 已被占用("0" 消费中 / "1" 已消费) String absentAndGet = stringRedisTemplate.execute( RedisScript.of(LUA_SCRIPT, String.class), List.of(uniqueKey), IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), String.valueOf(TimeUnit.SECONDS.toMillis(noMQDuplicateConsume.keyTimeout())) );
if (Objects.nonNull(absentAndGet)) { boolean errorFlag = IdempotentMQConsumeStatusEnum.isError(absentAndGet); // 只认 "0" if (errorFlag) { // CONSUMING:别人正在消费 → 抛异常让 MQ 延迟重投(不能替别人 ACK) throw new ServiceException(String.format("消息消费者幂等异常,幂等标识:%s", uniqueKey)); } // CONSUMED:已消费完 → 静默跳过(return null = ACK) return null; }
Object result; try { result = joinPoint.proceed(); // 执行业务 // 成功 → 置 "1"(CONSUMED),TTL 同 keyTimeout stringRedisTemplate.opsForValue().set(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode(), noMQDuplicateConsume.keyTimeout(), TimeUnit.SECONDS); } catch (Throwable ex) { // 失败 → 删 Key(归还许可证)+ 重抛 → MQ 重投可重新抢 stringRedisTemplate.delete(uniqueKey); throw ex; } return result; }}幂等 Key 三状态机
Lua: SET NX "0" Key 不存在 ────────────────────────► [CONSUMING "0"] 消费中 ▲ │ │ proceed() 成功 │ proceed() 抛异常 │ SET "1" │ DELETE │ ▼ │ [CONSUMED "1"] 已消费 │ │ └────────────── TTL 到期自动蒸发 ◄──────┘SET NX GET PX(切面的心脏)
redis.call('SET', key, value, 'NX', 'GET', 'PX', expire_time_ms)| 选项 | 含义 | 管什么 |
|---|---|---|
| NX | 不存在才设置 | 互斥——“只有一个能进” |
| PX | 毫秒 TTL(600s) | 崩溃自愈——“守门人死了门自动开” |
| GET | 返回 Key 旧值 | 状态可见(Redis 7.0+) |
行为矩阵(背下这张表):
| 执行前 Key 状态 | 返回值 | 切面动作 |
|---|---|---|
| 不存在 | nil | 抢占成功,执行业务 |
存在 = "0"(CONSUMING) | "0" | 抛异常 → MQ 延迟重投 |
存在 = "1"(CONSUMED) | "1" | return null 跳过 → ACK |
NX 失败时命令是”纯读旧值”——不覆盖、不重置 TTL(不续命)。
与真实代码的出入:源码
NoMQDuplicateConsumeAspect.java的 Lua 是GET+SET PX两步(脚本内执行,整体仍原子),语义与SET NX GET PX等价——存在返回旧值、不存在则抢占。面试统一讲”一条 Lua 脚本原子地完成抢占 + 读旧值 + 设 TTL”即可,两种说法都对得上代码。
为什么必须一条命令:Spring setIfAbsent() 只返回 boolean 表达不了组合;“查+占”两步走有 check-then-act 缝隙——A 查到 nil 的瞬间 B 抢先 SET,A 的判断作废,两人同时处理同一条消息。原子性防的是”插队”不是崩溃,与领券 Lua 预扣同一个道理。
幂等切面抢的不是优惠券库存,是”这条 MQ 消息归谁处理”——消息级锁,跟业务无关。
为什么消费异常时要删除幂等 Key
幂等 Key 是”消费许可证”不是”消费记录”。消费成功 → CONSUMED 永久生效挡重投;消费失败 → DELETE 归还,否则消息被自己的保险丝卡死。
反事实时间线(keyTimeout=600s,RocketMQ 重投 10s/30s/1m/2m…最多 16 次):
实际实现(删 Key):失败 → DELETE → 重抛 → t=10s 重投 → 成功 ✅ 恢复 10 秒假如不删:Key 卡 "0" 600s,重投全被弹回烧重试额度 → t≈9 分钟才恢复, 连续失败两个窗口 → 耗尽 16 次 → 进死信崩溃情形(catch 没机会跑):Key 停 “0” 只能等 TTL——DELETE 负责”活人快速归还”,TTL 负责”死人迟到归还”,600s = 崩溃恢复时间上界。能主动管的都主动(成功改1、失败删),只有拦不住崩溃才靠 TTL。
两套幂等,各管一个维度(不可互替)
@NoMQDuplicateConsume(消息级) | 复合唯一索引(业务级) | |
|---|---|---|
| 防什么 | 同一条消息重复投递 | 同一用户同一模板重复领取 |
| 键 | messageWrapper.keys | (user_id, template_id, receive_count) |
| 实现层 | Redis Lua SET NX,TTL 600 | MySQL 唯一索引,永久 |
| 失效场景 | TTL 过期后重投、崩溃时序窗口 | 无(物理约束) |
唯一索引:t_user_coupon 分表(32 张,每张都有)上的 UNIQUE KEY idx_user_id_coupon_template_receive_count (user_id, coupon_template_id, receive_count)——同一用户对同一模板的第 N 次领取全库只能一条。
唯一约束 vs 唯一索引:约束是逻辑规则(目的),索引是物理结构(手段)。MySQL 两者完全等价(没有独立约束对象);PG/Oracle/SQL Server 才有区别——约束可被 FK 引用、可 DEFERRABLE 延迟检查,索引支持条件唯一/表达式唯一。主键 = 唯一约束 + 非空约束 的特例(InnoDB 里即聚簇索引)。
同一切面在两次消费的使用(对比,面试加分)
@NoMQDuplicateConsume 全项目只在两处落库消费者上出现过,参数随”兜底对象不同”而变:
领券 UserCouponRedeemConsumer | 批量分发 CouponTaskExecuteConsumer | |
|---|---|---|
| 幂等 Key | messageWrapper.keys(消息内 UUID) | 业务任务 ID couponTaskId |
| keyTimeout | 600s | 120s |
| 第二层兜底 | 复合唯一索引 (user_id, template_id, receive_count) | DB 任务状态校验 status=IN_PROGRESS |
- 幂等 Key 的选取:消息级用消息 UUID,整批任务用业务任务 ID——因为后者要防的是”同一任务”重投,而不是”同一条消息”
- 第二层兜底都落在 MySQL,但形态不同:领券靠”单条落库撞唯一索引”,分发靠”整批任务用状态标记做没做”——一个兜单条、一个兜整批
- 为什么第二层必须在 MySQL:Redis 幂等 Key 有 TTL 会过期,只有 MySQL 持久化的唯一约束/状态校验 才是任何重投都不可逾越的底线——Redis 只挡短期,MySQL 才是终极兜底
两种消息的幂等分治
判断标准:把消费逻辑重复执行一遍,状态会不会变。
落库消息 UserCouponRedeemEvent | 延迟关券消息 UserCouponDelayCloseEvent | |
|---|---|---|
| 本质 | 干活的指令(削峰) | 闹钟(延迟级别当定时器) |
| 重复消费后果 | INSERT 两行、扣两次库存 → 状态会变 | ZREM 返回 0 早退、带守卫 UPDATE 影响 0 行 → 状态不变 |
| 幂等方案 | 切面 + 唯一索引 | 不需要——结构性幂等 |
核心认知:幂等不是”加了防重复的机制”,而是”操作本身可重入”。能用结构性幂等就不引入状态标记——标记本身又是一个双写点,少一个状态就少一处不一致。
2.5 延迟关券消息(券的生命周期收尾)
消费者完整代码:
@Overridepublic void onMessage(MessageWrapper<UserCouponDelayCloseEvent> messageWrapper) { log.info("[消费者] 延迟关闭用户已领取优惠券 - 执行消费逻辑,消息体:{}", JSON.toJSONString(messageWrapper)); UserCouponDelayCloseEvent event = messageWrapper.getMessage();
// ① 先删 Redis 卡包里的这条券 // ⚠ 致命缺陷:UserContext.getUserId() 在 MQ 消费线程恒为 null(ThreadLocal 无人写入), // 拼出的 key 是 user-template-list:null,ZREM 必落空(应改用 event.getUserId(),下方 ② 已是正确用法) String userCouponListCacheKey = String.format(EngineRedisConstant.USER_COUPON_TEMPLATE_LIST_KEY, UserContext.getUserId()); String userCouponItemCacheKey = StrUtil.builder() .append(event.getCouponTemplateId()) .append("_") .append(event.getUserCouponId()) .toString(); Long removed = stringRedisTemplate.opsForZSet().remove(userCouponListCacheKey, userCouponItemCacheKey); if (removed == null || removed == 0L) { return; // 设计意图是"早已删过(结构性幂等)",但与上面的 ⚠ 叠加后 removed 恒为 0 → ② 永远不执行 }
// ② 再改 DB 状态:UNUSED → EXPIRED(WHERE 守卫:只动未使用的券) UserCouponDO userCouponDO = UserCouponDO.builder() .status(UserCouponStatusEnum.EXPIRED.getCode()) .build(); LambdaUpdateWrapper<UserCouponDO> updateWrapper = Wrappers.lambdaUpdate(UserCouponDO.class) .eq(UserCouponDO::getId, event.getUserCouponId()) .eq(UserCouponDO::getUserId, event.getUserId()) .eq(UserCouponDO::getStatus, UserCouponStatusEnum.UNUSED.getCode()) // ← 最后一道保险 .eq(UserCouponDO::getCouponTemplateId, event.getCouponTemplateId()); userCouponMapper.update(userCouponDO, updateWrapper);}要点(⚠ 先读:下面”先删再销账”是设计意图,源码走读证实当前实现 100% 失效,见本节末缺陷框与 §8.2 #13/#14):
- 每张券发自己的过期时刻(now + validityPeriod 小时,不是活动结束——领得越晚过期越晚)
- 设计上是先删缓存(门口摘牌)再销账(DB):先让用户卡包里立刻看不到,再落账。反过来会有一小段”DB 已过期但 Redis 还在”
WHERE status=UNUSED守卫:券已被用过(结算成 USED)→ update 影响 0 行,不会把用过的券错标成过期——这就是”结构性幂等”,重复消费也安全- 发送失败只 log.warn 不回滚:券已合法铸造,为”葬礼通知”回滚领取不值得。代价:最坏券永不过期
- RocketMQ 4.x 只有 18 个固定延迟级别(最长 2h),任意时间戳延迟需 5.x 定时消息
- 它自己的双写洞:ZREM 成功 → UPDATE 前崩溃 → 重投时 ZREM 返回 0 早退 → UPDATE 永远不执行(DB status 永远 UNUSED)——
removed==0把”已处理完”和”处理一半崩了”混为一谈
⚠ 缺陷框:该链路当前实现 100% 失效(与 §8.2 #13/#14 对齐)
失效链条(四步):
UserContext是TransmittableThreadLocal,只有 Web 层过滤器写入;MQ 消费是独立线程,getUserId()恒为null;- ZREM 打到错误 key
one-coupon_engine:user-template-list:null,返回removed = 0; removed == 0提前 return → ② 的 DBUNUSED → EXPIRED永远不执行(② 里的event.getUserId()是对的,但代码走不到那);- 终态:券过期后卡包 ZSet 成员常驻(结算页仍显示可用)、DB 状态永远 UNUSED,整条到期兜底形同虚设。
与上面”双写洞”的关系:双写洞原本是小概率崩溃窗口;叠加 ThreadLocal 缺陷后,removed==0 每条消息都命中——洞从”边角料”升级为”必然”,这就是 #13 与 #14 叠加的完整后果。
修复三步走(面试讲法:当前实现有缺陷 → 我的修复):
- 改对数据源:① 改用
event.getUserId()——消息体自带 userId(同方法 ② 已在正确使用,属笔误级缺陷); - 幂等解耦:ZREM 与 DB UPDATE 不做串行强依赖,
removed==0不再早退、继续执行更新——WHERE status=UNUSED本身就是结构性幂等,重复消费安全; - 对账兜底:XXL-Job 定时扫
status=UNUSED AND valid_end_time < NOW(),批量标记 EXPIRED + 清卡包 ZSet 成员,覆盖消息发送失败(只 log.warn)与消费失败的残余。
一句话话术:“我在源码走读时发现到期关券消费者用 ThreadLocal 用户上下文取 userId,MQ 消费线程里这个值恒为 null,ZREM 打到错误 key 触发 removed==0 提前返回,整条过期兜底链路失效。修复分三步:改用消息体里的 userId、把 ZREM 和 DB 更新解耦成各自幂等、再加定时对账兜底。“
三个”过期”别混(易卡壳点)
| 对象 | 有没有过期 | 依据 | |
|---|---|---|---|
| ① 用户券(DB 数据) | ✅ 有 | validEndTime = 领券时刻 + validityPeriod 小时 | |
| ② 模板缓存 Hash | ✅ 有 | EXPIREAT 锚定模板 validEndTime(活动结束) | |
| ③ 用户券包 Redis ZSet | ❌ 无 | 整个 key 没设 EXPIRE;ZSet member 无按时间自动过期,score 只是排序分数不是过期时间 |
- 关键:券的”过期”(①)存在于 DB
validEndTime,但不会自动传导去删 Redis 券包 member(③)——ZSet 不支持”某个 member 到某时刻自动消失”。所以③的删除动作是手动触发的:延迟关券消息到期 remove 掉那条 member + DB 状态改EXPIRED(⚠ 这个”手动触发”当前实现 100% 失效,见 2.5 缺陷框——此处描述的是设计意图) - 消息丢了券会残留:券包 ZSet 是永久 key,member 会一直躺在那、DB 状态永远 UNUSED → 理想兜底是定时任务扫 DB 已过期 + 删 Redis(演进点,代码没做自动对账)
- 口诀:用户券放”领券时刻 + 效期”,模板缓存放”活动结束”,Redis 券包靠延迟消息删。
🔗 双写视角(本节是 ④ 的下游放大):延迟消息发送失败只 log.warn(④),叠加消费端取不到 UserContext 的缺陷,整条到期关券链路当前 100% 失效——这是”主动交代已知问题”时最硬的加分素材(§2.5 ⚠ + §8.2 #13/#14)。
2.6 领券链路时序图
同步版:redeemUserCoupon
异步版:redeemUserCouponByMQ + Consumer
第三部分:双写一致性全景(贯穿查券 + 领券的横切面)
3.1 总纲:什么是双写、6 处在哪
总纲:事务的原子性只能覆盖单一资源——MySQL。Redis 和 RocketMQ 都不参与 ACID。凡是”事务里夹着 Redis 写或 MQ 发送”的地方,全是双写。
| # | 双写的两个资源 | 失败方向 | 现有缓解 | 缺口 |
|---|---|---|---|---|
| ① | Redis 库存(Lua HINCRBY)↔ DB 库存(条件 UPDATE) | Lua 扣了、DB 失败 → Redis 比 DB 少卖 | 条件 UPDATE 兜住不超卖 | 无回补、无对账 |
| ② | Redis 领取次数(Lua INCR)↔ DB receive_count + 唯一索引 | 计数 +1 了、DB 插入失败 → 配额白扣 | 唯一索引防计数丢失方向 | 计数虚高方向无兜底 |
| ③ | DB 用户券 INSERT ↔ Redis ZSet 卡包 | DB 成功 ZSet 丢 → 缓存少券;DB 回滚 ZSet 已写 → 幻影券 | 写后查询补写;binlog 模式 | 写后查询非强保证;binlog 仅处理 INSERT |
| ④ | DB 事务 ↔ MQ 发送 | 延迟消息 SEND_FAIL → 券永不过期 | 都只 log.warn | 无事务消息、无 Outbox、无补偿;且消费端另有致命缺陷,链路当前 100% 失效(§2.5 ⚠) |
| ⑤ | DB 模板记录 ↔ Redis Hash 模板缓存 | 商家改模板 → 缓存不失效 | EXPIREAT 自然蒸发 | 无写侧删缓存;stock 删了重建重置预扣量 |
| ⑥ | Redis 幂等 Key ↔ DB 消费事务结果 | 标记先于提交的窗口;崩溃卡 CONSUMING | DELETE + TTL | 提交了没置 CONSUMED → 重投撞唯一索引烧重试额度 |
6 处的共同形状:写 A(Redis/MQ)成功 + 写 B(DB)失败 → A 撤不回、B 补不上 → 两本账不一样。项目策略不是消灭双写(分布式事务代价极高),而是分层兜底正确性(条件 UPDATE、唯一索引、幂等三态)+ 接受完整性瑕疵(少卖、白扣——账对不上但不出错),终极一致交给”未来可加的对账”。
背法:3.1 的表是”地图”,3.2 每处一句”防戳破”边界是”台词”。先记地图,再背台词,两层的量都不大。
开场总答:“你怎么保证缓存和数据库的一致性?“(分层话术,60 秒必背)
这是通用题的开场框架,先于 6 处细节。分三层说,说完主动把 6 处数一遍回收钩子——上次面试就是缺这个分层结构导致答散。
立总纲:“事务的原子性只覆盖 MySQL——Redis 和 MQ 都不参与 ACID,所以双写一致性没有银弹,我的思路是分层兜底:”
- 第一层(即时手段,日常兜住绝大多数):不同写对用不同策略——模板缓存 Cache Aside 按需加载 + TTL 锚定活动结束自然过期;卡包 ZSet 写后查询(ZADD 后回查补写);库存条件 UPDATE 拦死超卖;防重复合唯一索引;消息幂等三态机。
- 第二层(定时对账,兜残留差异):定时任务比对两本账——DB 剩余库存 vs Redis 预扣量、DB 用户券数 vs ZSet 成员数,差值回补。
- 第三层(人工兜底):对账差值超阈值告警,失败记录落表可导出,人工介入处置。
主动暴露(诚实加分,别硬撑):“第一层是我代码里落地的;第二三层是我识别的演进方向,已列在项目已知问题清单(§8.2)里。整个系统一共 6 处双写,我可以逐个讲。”
钩子回收:对方接”6 处你说说” → 回 3.1 地图 + 3.2 台词;接”对账具体怎么对” → 路径 A 第三层;接”模板缓存怎么一致” → 路径 B(stock 是状态不是缓存,最高分点)。
双写 6 处窗口在领券链路的位置
3.2 六处逐个钉死(每处一句,防被追问戳破)
面试最怕把 6 处的”失败因果”说串。每处只钉死最容易被想歪的那一条:
① Redis 库存 ↔ DB 库存:少卖是”欠库存”,不是”欠用户”
少卖不是欠用户,是欠库存。Redis 扣了、DB 回滚、接口已给用户报失败 → 用户那单本就没成,谈不上补偿用户;要还的是 Redis 那 -1 在场量(对账差值加回)。
② Redis 领取次数 ↔ DB receive_count:两个方向各说各的
同一份”用户领过几次”的账记两处,追问时必须分开说:
- 方向一(计数虚高 → 白扣配额,伤用户,无兜底):Lua +1 了 DB 插入失败回滚,计数回不去 → 限领 2 实际只领到 1。和①同构,但①伤商家、②伤用户
- 方向二(计数丢失,有兜底且自愈):Redis 重启丢数据,第二次领取 Lua 返回又是 1 → INSERT 撞唯一索引 → “重复领取”异常;用户重试时计数已被推回 1,INSERT count=2 成功,计数自愈
③ DB INSERT ↔ ZSet 卡包:幻影券完整成因链
member 的券 ID 来自 userCouponDO.getId()(MyBatis INSERT 成功回填的主键)→ INSERT 撞唯一索引那种根本不产生幻影(INSERT 已失败,ZADD 一行都不会跑);幻影只在”INSERT 成功 + ID 已回填 + ZADD 已写 + 事务在其后因其它异常回滚”这条窄缝里发生——MySQL 撤销了那行,Redis 卡包却因 ID 合法回填而永久留下 → DB 查无此券。
④ DB 事务 ↔ MQ 发送:重投 ≠ 清幻影
log.warn + 日志重投救的是**“券永不过期”;幻影券清不清是延迟关券消息”到点拿着那个 ID 去 ZSet.remove”的偶然副产物**——只有消息成功投出才可能,发送失败的幻影券没有保障性兜底(真正根治是 binlog 模式)。不要把”④重投”说成”专门用来清幻影券”。
⑤ DB 模板 ↔ Redis Hash 缓存:stock 不能删缓存重建
Redis Hash 里 stock 是预扣计数器(放行量),DB 的 stock 才是真实账本。套用”改 DB 删缓存”重建会把在途预扣量还回去 → Redis 放行量虚高 → 尾部用户过了闸门领不到。除 stock 外 12 个字段删了重建无损;因为 stock 不能删,整体才”不主动删缓存、全等 TTL 自然过期”。
⑥ Redis 幂等 Key ↔ DB 消费事务:就是幂等切面的双写视角
同一机制两个身份(切面视角讲防重,双写视角讲错位窗口)。双写角度只强调那个错位窗口:proceed() 成功到写 "1" 之间崩溃 → Key 卡 CONSUMING / 事务没提交 → 重投撞唯一索引烧重试额度。
3.3 面试实战:三条真实提问路径
6 处全景是弹药库不是背诵稿。①⑤ 必问、③④ 中频主动露半句加分、②⑥ 低频但追问到第三层时的答案。表是地图,不是台词。
路径 A(命中最高):领券主流程 → Redis 扣了 DB 没扣成怎么办
"领券的流程讲一下" → "库存是在哪扣的?为什么 Redis 先扣?" → "Redis 扣成功了,DB 更新失败怎么办?" → "那用户不就少券了?怎么补偿?" → "你说对账,具体对什么?" ← 深水区,分水岭- 第一层:“Lua 在 Redis 原子地判库存+扣库存+判上限+累加计数,不满足直接返回打不到 DB;成功后事务里条件 UPDATE 扣 DB、INSERT 用户券、维护卡包、发延迟消息。Redis 是放行闸门扛并发,DB 是最终账本保正确。”
- 第二层:“这里是双写。Redis 扣了、事务失败,量回不去——不是超卖是少卖。超卖被条件 UPDATE 拦死,正确性有保证,缺的是完整性——少卖量无回补。”
- 第三层:“根治靠定时对账:扫 DB 剩余库存和 Redis 的差值还回去。项目没做,是我识别的改进点;另一方案是事务消息驱动扣减,Redis 只挡板不记账,但吞吐会降。”
路径 B(命中第二):缓存一致性
- 第一层:“模板缓存查询侧按需加载:Hash → 布隆 → 空值缓存 → 锁 + 双重判定 → DB 回写,EXPIREAT 锚定活动结束。”
- 第二层:“这是明确取舍——写侧什么都没做,不一致窗口 = 活动剩余时长。营销场景改模板频率极低,用 TTL 自然过期兜底,因为引入写侧失效有个坑——”
- 第三层(最高分点):“13 个字段里 12 个是纯缓存副本删了无损,但 stock 是状态不是缓存——预扣计数器,Redis 值领先 DB 值。套用’更新 DB 后删缓存’,重建用在途预扣量被还回去,Redis 放行量超真实剩余,尾部用户过了闸门却领到’已被领取完啦’。不超卖但体验事故——所以 stock 不能走常规缓存策略。”
路径 C:消息重复消费 → 幂等窗口
- 第一层:“切面用
SET NX GET PX一条命令:NX 互斥;GET 拿旧值分状态——nil 执行、‘1’ 跳过、‘0’ 抛异常等重投。” - 第二层:“幂等 Key 和 DB 事务也是双写窗口:proceed() 成功到写 ‘1’ 之间崩了,Key 卡 CONSUMING 烧重试额度;600s 后重跑靠唯一索引拦重复。三层防线:切面挡正常重复、索引兜漏网、死信兜最终失败。“
3.4 卡包缓存(ZSet)与写后查询
- Key:
one-coupon_engine:user-template-list:{userId}(常量名 USER_COUPON_TEMPLATE_LIST_KEY;distribution 模块为同前缀的尾拼接形式),member={模板ID}_{用户券ID},score=领取时刻毫秒 - 为什么 DB 插了还要写缓存:结算模块查”我的卡包”直接读 ZSet 不查库(用户券表按 user_id 分 32 张表)——DB 是事实,ZSet 是面向查询的索引
- 写后查询:ZADD 后立即 ZSCORE 回查,null 则补写——针对 Redis 主从异步复制的静默丢失。只能提高成功率不能保证一致
- 配置开关
save-cache.type:direct(事务内直写)vsbinlog(Canal 消费 INSERT 事件异步写,仅处理 INSERT 是局限)。direct 用可用性换时效,binlog 用时效换解耦
边界:领券链路卡包的防线只到”写后查询”(
direct模式),不含自己实现的 Canal——binlog 属于项目整体一致性基建(Canal 细节见第七部分;ES 已从简历移除,该部分仅防追问),不是领券链路自身闭环。
3.5 用户券的完整生命周期(跨模块)
Lua 预扣(Redis 计数+1) → INSERT t_user_coupon(status=UNUSED) → ZADD 卡包→ settlement 结算 zrange 读 ZSet → Pipeline 批量读模板 Hash → 并行算优惠→ 到期延迟消息 → ZREM + UPDATE(UNUSED→EXPIRED)⚠ 图中最后一环”到期延迟消息 → ZREM + UPDATE(UNUSED→EXPIRED)“当前实现 100% 失效(消费者误用
UserContext.getUserId(),MQ 线程恒为 null;细节与修复话术见 §2.5 缺陷框)。此图画的是设计意图的生命周期,面试先讲设计、再主动亮缺陷与修复,反而是加分点。
第四部分:批量分发(运营侧发券)
4.1 业务是什么(人话版)
券有两种来路:用户自己抢(查券+领券链路)和平台主动发(本链路)。
商家在运营后台上传一个 Excel 名单(userId、手机号、邮箱,可能多达百万人),选一个优惠券模板,点发送——批量分发就是把这批券不超发、不漏发、可追溯地塞进每个用户的券包。
典型场景:双 11 老用户召回、坏订单客服补偿、新店开业撒券。
三个硬要求 → 三个技术手段:
| 业务要求 | 技术手段 |
|---|---|
| 百万 Excel 不能撑爆内存 | EasyExcel 流式逐行读(读一行处理一行,不是全加载) |
| 不能超发 | Redis Lua 原子扣减 + DB 条件更新兜底 + 唯一索引 |
| 谁没收到要知道 | 失败落表 + 生成失败 Excel 供运营下载 |
4.2 完整数据流(一个例子走全程)
设定:老王水果店(shopNumber=13800),模板”满50减10”库存 10000 张,运营小美上传 12000 人名单,立即发送。
第 1 步:创建任务(merchant-admin 模块)
这一步只做”创建任务”——开个工单、记一下应发人数、把工单派给工人。真正的”发券”在 distribution 模块的三个文件里:
| 文件 | 定位 | 真正做了什么 |
|---|---|---|
CouponTaskExecuteConsumer | 一级消费者 | 收到 MQ → 校验 → 开始读 Excel |
ReadExcelDistributionListener | Excel 监听器 | 逐行读 → Lua 扣 Redis 库存 → Set 攒批 |
CouponExecuteDistributionConsumer | 二级消费者 | 扣 DB 库存 → 批量 insert 用户券 → 写券包 |
@Transactional(rollbackFor = Exception.class)@Overridepublic void createCouponTask(CouponTaskCreateReqDTO requestParam) { // 校验模板是否存在 CouponTemplateQueryRespDTO couponTemplate = couponTemplateService .findCouponTemplateById(requestParam.getCouponTemplateId()); if (couponTemplate == null) { throw new ClientException("优惠券模板不存在"); }
// ───── 事件A:构建任务记录,落库 ───── CouponTaskDO couponTaskDO = BeanUtil.copyProperties(requestParam, CouponTaskDO.class); couponTaskDO.setBatchId(IdUtil.getSnowflakeNextId()); // 雪花批次号,失败表关联用 couponTaskDO.setOperatorId(Long.parseLong(UserContext.getUserId())); couponTaskDO.setShopNumber(UserContext.getShopNumber()); couponTaskDO.setStatus( Objects.equals(requestParam.getSendType(), CouponTaskSendTypeEnum.IMMEDIATE.getType()) ? CouponTaskStatusEnum.IN_PROGRESS.getStatus() // 立即→执行中 : CouponTaskStatusEnum.PENDING.getStatus() // 定时→待执行 ); couponTaskMapper.insert(couponTaskDO); // 入 MySQL: t_coupon_task
// ───── 事件B:异步统计 Excel 行数,用于事后对账 ───── // 为什么异步?100万行同步数要4秒,用户不能干等 JSONObject delayJsonObject = JSONObject .of("fileAddress", requestParam.getFileAddress(), "couponTaskId", couponTaskDO.getId()); executorService.execute(() -> refreshCouponTaskSendNum(delayJsonObject));
// ───── 事件B的兜底:Redisson延迟队列20秒后补数 ───── // 假设刚提交异步任务就宕机,线程池任务丢了 → 20秒后守护线程检查 // 如果 sendNum 还是 NULL,就补数一次 RBlockingDeque<Object> blockingDeque = redissonClient .getBlockingDeque("COUPON_TASK_SEND_NUM_DELAY_QUEUE"); RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque); delayedQueue.offer(delayJsonObject, 20, TimeUnit.SECONDS);
// ───── 事件C:发MQ触发真正的发券流程 ───── // 事件B(数行数)和事件C(发MQ)是并行的,互不依赖 if (Objects.equals(requestParam.getSendType(), CouponTaskSendTypeEnum.IMMEDIATE.getType())) { CouponTaskExecuteEvent couponTaskExecuteEvent = CouponTaskExecuteEvent.builder() .couponTaskId(couponTaskDO.getId()) .build(); couponTaskActualExecuteProducer.sendMessage(couponTaskExecuteEvent); }}面试高频追问:为什么既有异步数行数,又发 MQ?它们不是重复的吗?
不是。这是两件完全不同的事:
事件B(异步数行数)→ 统计"Excel里一共多少行",回填 sendNum,方便事后对账事件C(发MQ) → 通知下游消费者开始读Excel、扣库存、落库,这才是真正的发券类比:快递站到了一车货。事件B = 先数一下一共多少箱(记在账本上);事件C = 通知工人开始卸货派送。数箱子不等于送货。
面试高频追问:事件B和延迟队列兜底会不会重复统计两次?
会,但结果幂等,不影响正确性。两条路径都读同一个 Excel、都得到 12000、都写同一个字段。结果一样,没坏账,只是浪费一次 IO。 作者注释说明”这是兜底策略,一般来说不会执行”。
任务表的字段(t_coupon_task):
| 字段 | 值(例子) | 说明 |
|---|---|---|
| id | 9527 | 任务主键(雪花) |
| batchId | 666612340001 | 雪花批次号,关联失败表的钥匙 |
| shopNumber | 13800 | 店铺编号 |
| fileAddress | /oss/users.xlsx | 名单文件——“发给谁”不存表里,在文件里 |
| sendNum | NULL → 12000 | 应发数量,异步数行数后回填 |
| status | 1 → 3 | 1执行中 → 3执行成功 |
| failFileAddress | /tmp/失败Excel.xlsx | 失败清单 Excel 路径 |
| couponTemplateId | 2026082811 | 发哪个模板的券 |
三个面试设计点:
- 数行数为什么异步? 100 万行同步数要 4 秒+,用户点”发送”不能干等 → 丢进线程池,延迟队列兜底
- “任务发给谁”为什么不存表? 100 万行存不进一个字段;Excel 本身就是名单的事实来源;结果才需要持久化——成功的在 t_user_coupon(带 batchId),失败的在 t_coupon_task_fail(带 batchId)
- sendNum 为什么存 MySQL 不存 Redis? 对账数据是持久化需求,Redis 挂了或过期就查不到了
第 2 步:一级消费者:三层校验 + 触发流式读取(distribution 模块)
@NoMQDuplicateConsume( // ← 第一层:幂等保护(切面细节见第二部分 2.4) keyPrefix = "coupon_task_execute:idempotent:", key = "#messageWrapper.message.couponTaskId", // 幂等键 = 任务ID(业务级) keyTimeout = 120 // 120秒过期)@Overridepublic void onMessage(MessageWrapper<CouponTaskExecuteEvent> messageWrapper) { var couponTaskId = messageWrapper.getMessage().getCouponTaskId(); var couponTaskDO = couponTaskMapper.selectById(couponTaskId);
// 第二层:任务状态校验 → 必须是执行中才放行 if (ObjectUtil.notEqual(couponTaskDO.getStatus(), CouponTaskStatusEnum.IN_PROGRESS.getStatus())) { log.warn("任务状态异常:{},已终止推送", couponTaskDO.getStatus()); return; // ❌ 已完成/已取消/待执行都拦掉 }
// 第三层:模板状态校验 → 必须是生效中才放行 var couponTemplateDO = couponTemplateMapper.selectOne(queryWrapper); if (ObjectUtil.notEqual(couponTemplateDO.getStatus(), CouponTemplateStatusEnum.ACTIVE.getStatus())) { log.error("优惠券模板状态:{},已终止推送", status); return; // ❌ 下架/过期模板拦掉 }
// 全部校验通过 → 创建监听器,开始流式读 Excel ReadExcelDistributionListener listener = new ReadExcelDistributionListener( couponTaskDO, couponTemplateDO, couponTaskFailMapper, stringRedisTemplate, couponExecuteDistributionProducer ); EasyExcel.read(couponTaskDO.getFileAddress(), CouponTaskExcelObject.class, listener) .sheet().doRead(); // 流式读,不加载全部数据到内存}任务状态枚举(CouponTaskStatusEnum,5 种):
| 状态 | 数字 | 什么时候变到这 |
|---|---|---|
| PENDING | 0 | 定时发送任务创建时,还没到时间 |
| IN_PROGRESS | 1 | 立即发送任务创建时,或定时任务到时间触发时 |
| FAILED | 2 | 执行过程中出异常 |
| SUCCESS | 3 | 所有用户处理完,收尾完成 |
| CANAL | 4 | 运营手动取消任务(⚠ 代码里拼写就是 CANAL——CANCEL 的笔误,口头答”取消”即可,被要求拼枚举名时别露馅) |
一级消费者只放行 status = 1(执行中),其他状态直接终止。
为什么两层保护缺一不可(高频追问):
| 场景 | 第一层(Redis SET NX 120s) | 第二层(DB 状态校验) | 结果 |
|---|---|---|---|
| 10秒内重投(第一次还在执行) | 幂等Key存在 → 拦住 | - | ✅ 不会重复执行 |
| 130秒后重投(第一次已走完) | 幂等Key过期 → 放行 | 状态=SUCCESS → 拦住 | ✅ 不会重复执行 |
| 150秒后重投(第一次还在走,卡了) | 幂等Key过期 → 放行 | 状态还是=IN_PROGRESS → 放行 | 概率极低,可接受 |
这一步只做校验 + 触发,真正的发券不在这里。类比:班长接工单,检查活没被取消、材料(模板)还在,然后通知工人开始干活。
第 3 步:逐行读 Excel,每行执行一个 Lua
每读一行(如 userId=10001),Redis 里发生:
coupon_template:2026082811 (Hash)└─ stock: 10000 → 9999 ← 扣 1 张
distribution:batch_user_set:9527 (Set,攒人用)└─ SADD {"userId":"10001","rowNum":2}
one-coupon_distribution:template-task-execute-progress:9527 = "1" ← 进度 Key,支持宕机断点续传Lua 核心逻辑(stock_decrement_and_batch_save_user_record.lua):
local stock = tonumber(redis.call('HGET', key, 'stock')) -- 读库存if stock == nil or stock <= 0 then -- 库存没了:返回 (false, Set当前大小),Java侧记失败表 return combineFields(false, redis.call('SCARD', userSetKey))endredis.call('HINCRBY', key, 'stock', -1) -- 原子扣 1redis.call('SADD', userSetKey, userIdAndRowNum) -- 用户进待落库集合return combineFields(true, redis.call('SCARD', userSetKey))为什么不超发的保证在这:判库存、扣库存、记用户三步在一个 Lua 里,Redis 单线程执行,中间没有并发插队的窗口。
分流:
- 读到第 10001 行时 stock=0 → 该行写失败表(cause=优惠券模板无库存,共 2000 条)
- Set 攒满 5000 人 → 发一条消息给二级 Topic
- Excel 读完 → 发
distributionEndFlag=TRUE收尾消息(不满 5000 的尾巴也要落库)
为什么设攒批 5000?
| 不攒批(一行一发) | 攒批 5000 |
|---|---|
| 10 万行 → 10 万次 DB 插入 | 10 万行 → 20 次批量插入 |
| 10 万行 → 10 万条 MQ 消息 | 10 万行 → 20 条 MQ 消息 |
| 10 万次 Redis 网络往返 | 1 次 Lua 批量搞定 5000 个用户 |
核心思想:把多次小操作拼成一次大操作,减少网络往返,提升吞吐量。两级 MQ 是削峰 + 职责分离(一级 IO 密集读文件,二级批量写 DB),不是为了重试。
第 4 步:二级消费者落库
第一批 5000 人到达,六个动作:
① DB扣库存: UPDATE t_coupon_template SET stock = stock - 5000 WHERE id=2026082811 AND stock >= 5000 -- 10000→5000 失败(DB只剩3000)→ 查实际值,按3000递归重试 → 以DB为准裁剪本批② SPOP 弹出5000人(弹出的才落库,弹多少=DB实扣多少)③ 构建5000个UserCouponDO批量insert到 t_user_coupon (雪花ID, validEndTime = now + 72h 领到那刻才起算, source=平台分发)④ 假设10333之前抢过这张券 → 唯一索引冲突 → 整批失败 → 降级逐条insert → 4999成功 + 1失败记表⑤ 执行第二个Lua: 每个用户 ZADD 券记录到 user:{userId} + INCR 限领Key⑥ 差额回滚: 5000-4999=1 → Redis和DB各加回1张 (10333没拿到券,这1张不回滚就永久蒸发了)DB 扣库存代码(不足时递归裁剪):
private Integer decrementCouponTemplateStock(CouponTemplateDistributionEvent event, Integer decrementStockSize) { Long couponTemplateId = event.getCouponTemplateId(); int decremented = couponTemplateMapper.decrementCouponTemplateStock( event.getShopNumber(), couponTemplateId, decrementStockSize);
// 如果修改记录失败,意味着优惠券库存已不足,需要重试获取到可自减的库存数值 if (!SqlHelper.retBool(decremented)) { LambdaQueryWrapper<CouponTemplateDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateDO.class) .eq(CouponTemplateDO::getShopNumber, event.getShopNumber()) .eq(CouponTemplateDO::getId, couponTemplateId); CouponTemplateDO couponTemplateDO = couponTemplateMapper.selectOne(queryWrapper); // 递归重试,按 DB 实际库存裁剪本批 return decrementCouponTemplateStock(event, couponTemplateDO.getStock()); } return decrementStockSize;}关键点问答:
- Q:为什么先扣 DB 再 SPOP? 如果反过来先弹再扣,弹出来 5000 人但 DB 只够 3000,那 2000 人已经被 SPOP 删除回不去 Set 了,就丢了。先扣 DB,锁定多少弹多少,保证不丢不漏。
- Q:DB 库存不足为什么递归重试,不直接回滚 Redis? Redis 预扣已经做了,直接以 DB 实际能扣多少为准裁剪本批,剩下的留在 Set 里后续记失败表即可。以 DB 为准裁剪,不是回滚 Redis。
SPOP 弹出用户(随机弹出,弹多少 = DB 实扣多少):
String batchUserSetKey = String.format(DistributionRedisConstant.TEMPLATE_TASK_EXECUTE_BATCH_USER_KEY, event.getCouponTaskId());List<String> batchUserMaps = stringRedisTemplate.opsForSet().pop(batchUserSetKey, couponTemplateStock);构建 UserCouponDO 批量插入(雪花 ID 提前在 Java 层生成):
for (String each : batchUserMaps) { JSONObject userIdAndRowNumJsonObject = JSON.parseObject(each); DateTime validEndTime = DateUtil.offsetHour(now, JSON.parseObject(event.getCouponTemplateConsumeRule()).getInteger("validityPeriod")); UserCouponDO userCouponDO = UserCouponDO.builder() .id(IdUtil.getSnowflakeNextId()) // 雪花 ID 作为主键 .couponTemplateId(event.getCouponTemplateId()) .rowNum(userIdAndRowNumJsonObject.getInteger("rowNum")) .userId(userIdAndRowNumJsonObject.getLong("userId")) .receiveTime(now) .receiveCount(1) // 第一次领取该优惠券 .validStartTime(now) .validEndTime(validEndTime) // 有效期从"领到这一刻"开始算 .source(CouponSourceEnum.PLATFORM.getType()) // 平台分发 .status(CouponStatusEnum.EFFECTIVE.getType()) // 生效中(值=0,与 engine 的 UNUSED 同义) .build(); userCouponDOList.add(userCouponDO);}Q:为什么用雪花 ID,不用数据库自增 ID? 批量插入后,需要拿这个 ID 拼成 模板ID_雪花ID 写入 Redis ZSet。MyBatis-Plus 批量插入不回填自增 ID,所以用 Java 生成雪花 ID。
Q:唯一索引怎么判断重复领取? 同一用户 + 同一模板 + 同一领取次数,只能有一条记录。三列组合支持限领多张(限领 3 张时,receive_count 分别是 1/2/3,都能插入)。
批量插入 + 唯一索引冲突降级(伪代码浓缩真实逻辑):
try { userCouponMapper.insert(userCouponDOList, userCouponDOList.size()); // 尝试批量插入} catch (Exception ex) { if (cause instanceof BatchExecutorException) { // 唯一索引冲突 → 整批失败 // 降级:逐条插入 userCouponDOList.forEach(each -> { try { userCouponMapper.insert(each); } catch (Exception ignored) { // 查到用户已领取 → 记失败表,从列表移除 couponTaskFailDOList.add(CouponTaskFailDO.builder() .batchId(couponTaskBatchId) .jsonObject(JSON.toJSONString(MapUtil.builder() .put("rowNum", each.getRowNum()) .put("cause", "用户已领取该优惠券") .build())) .build()); toRemove.add(each); } }); couponTaskFailMapper.insert(couponTaskFailDOList, couponTaskFailDOList.size()); // 批量记失败表 userCouponDOList.removeAll(toRemove); // 移除重复的,剩下成功的 }}第二个 Lua(batch_user_coupon_list.lua)批量写券包 + 限领 Key:
local userIds = cjson.decode(ARGV[1]) -- 用户ID集合local couponIds = cjson.decode(ARGV[2]) -- 优惠券ID集合(模板ID_雪花ID)local userIdPrefix = KEYS[1] -- 用户前缀local limitKeyPrefix = KEYS[2] -- 限领前缀local couponTemplateId = KEYS[3] -- 模板IDlocal currentTime = tonumber(ARGV[3]) -- 当前时间戳local couponTemplateValidEndTime = tonumber(ARGV[4]) -- 过期时间
for i, userId in ipairs(userIds) do local key = userIdPrefix .. userId -- user:10001 local couponId = couponIds[i] redis.call('ZADD', key, currentTime, couponId) -- ZADD 到用户券包
local limitKey = limitKeyPrefix .. userId .. '_' .. couponTemplateId redis.call('INCR', limitKey) -- INCR 限领次数 redis.call('EXPIRE', limitKey, couponTemplateValidEndTime)end差额回滚(只回滚库存,不回滚用户):
int originalUserCouponSize = batchUserMaps.size(); // SPOP 弹出人数 5000int availableUserCouponSize = userCouponDOList.size(); // 真正成功落库人数 4999int rollbackStock = originalUserCouponSize - availableUserCouponSize; // = 1if (rollbackStock > 0) { // Redis 回滚:库存加回(HINCRBY 对称加减,不是事务回滚) stringRedisTemplate.opsForHash().increment( String.format(EngineRedisConstant.COUPON_TEMPLATE_KEY, event.getCouponTemplateId()), "stock", rollbackStock ); // DB 回滚:库存加回 couponTemplateMapper.incrementCouponTemplateStock(event.getShopNumber(), event.getCouponTemplateId(), rollbackStock);}关键点问答:
- Q:差额回滚回滚什么?用户需要放回 Set 吗? 只回滚库存,不回滚用户。用户已经确定重复领取,记失败表就完了,不需要放回 Set,放回下次还是失败。
- Q:Redis 没有回滚机制,差额是怎么”加回”的? 补偿 = HINCRBY 对称加减,不是事务回滚。Redis 是”哑巴存储”,加减多少由业务代码算好告诉它(弹出人数 − 成功落库人数,内存里算,不查库)。查 MySQL 只发生在”DB 条件更新失败时取实际库存做裁剪”。
第 5 步:收尾 + 对账闭环
收尾消息分支:Set 剩余的(库存不足没发出去的)全记失败表 → 分批查库生成失败 Excel → 任务置 status=3 成功 + 记完成时间。
最终对账(闭环验证):
| 项 | 数量 | 来源 |
|---|---|---|
| 应发 | 12000 | sendNum |
| 实发成功 | 9999 | t_user_coupon(batchId 关联) |
| 失败-无库存 | 2000 | t_coupon_task_fail |
| 失败-重复领取 | 1 | t_coupon_task_fail |
| 剩余库存 | 1 | Redis + DB |
12000 = 9999 + 2000 + 1,账对得上,闭环。
4.3 面试复述版(背这段,30 秒)
批量分发分五步:建任务只做落库+发MQ,Excel 行数异步统计、Redisson 延迟队列 20 秒兜底、数行数幂等;一级消费者做三层校验(SETNX 幂等、任务必须执行中、模板必须生效),过了就 EasyExcel 流式读;逐行处理用 Lua 原子扣 Redis 库存、用户进临时 Set 攒批、进度 Key 支持断点续传;攒满 5000 发二级 MQ(削峰+职责分离),二级消费者落库——DB 条件扣库存不足就按实际值裁剪、SPOP 弹对应人数、批量 insert 用户券表、唯一索引冲突降级逐条、先落 MySQL 再写 Redis 券包、失败差额回滚 Redis+DB;收尾生成失败 Excel、任务转 SUCCESS、应发实发对账闭环。
锚点卡(讲的时候只看这五行):
① 建任务 → 落库+发MQ | 异步数行数+延迟兜底② 一级校验 → 幂等SETNX | 任务status=1 | 模板ACTIVE → 触发流式读③ 逐行Lua → EasyExcel流式读 | 原子扣Redis库存 | Set攒批 | 进度Key断点续传④ 二级落库 → 扣DB库存(不足裁剪) | SPOP弹对应人数 | 批量insert+唯一索引降级 | 先MySQL后Redis写券包 | 差额回滚(Redis+DB)⑤ 收尾对账 → 失败Excel | 任务SUCCESS | 应发vs实发闭环4.4 口述纠偏(易错点对照)
| # | 容易说错 | 正确认知 |
|---|---|---|
| 1 | 一级消费者做了”逐行 Lua 扣库存” | 一级消费者只做三层校验 + 触发读取;逐行 Lua 是第 3 步 ReadExcelDistributionListener |
| 2 | ”给每个用户判断能不能领券” | Lua 判断的是库存是否充足,不是”能不能领” |
| 3 | ”一级就写 ZSet 用户券包” | ZSet 券包是第 4 步二级消费者第二个 Lua 写的,一级只攒临时 Set |
| 4 | ”先插 Redis 卡包再落库” | 顺序必须先落库 MySQL(拿雪花主键)→ 再写 Redis ZSet(member=templateId_snowflakeId) |
| 5 | ”库存不足 → 回滚 Redis” | 是”以 DB 为准裁剪”:查实际值按它重试、SPOP 少弹;回滚只发生在唯一索引拦截的差额 |
| 6 | ”任务转成 4 个 size” | 是 SPOP 弹出攒批 Set 里的用户,弹多少 = DB 实扣多少 |
| 7 | 两级 MQ 是为了重试 | 是削峰 + 职责分离(一级 IO 密集读文件,二级批量写 DB) |
第五部分:结算查询(下单时算券)
5.1 业务是什么
小张下了一单 80 元的水果,结算页要回答两个问题:
- 小张的券包里哪些券这单能用?(满 50 减 10 → 能用;满 100 减 20 → 不能用)
- 按优惠力度从大到小排好给用户选。
难点:用户可能有几十上百张券,每张都要查模板详情 + 算一遍规则。如果一张一张查 Redis,几十次网络往返,结算页就慢了。
5.2 完整数据流(六步)
① 取券包 → ZRANGE user:{userId} 0 -1 | member=模板ID_券ID | score=领取时间② Pipeline → split取模板ID | 拼Hash Key | HGETALL一次往返批量查详情③ 分区 → partitioningBy(goods) | 空=全场券(整单金额) | 非空=商品券(商品金额,不在单直接不可用)④ 并行算券 → CompletableFuture两组+组内 | 线程池=CPU核数+SynchronousQueue+CallerRunsPolicy | synchronizedList⑤ 合流 → allOf().join() 阻塞等待所有子任务完成 | 之后再排序⑥ 排序返回 → 优惠金额降序 | 返回 可用/不可用 两个列表核心代码(精简注释版):
// ① 从用户券包 ZSet 拿到所有券(member 格式:模板ID_券ID)Set<String> rangeUserCoupons = stringRedisTemplate.opsForZSet() .range(String.format(USER_COUPON_TEMPLATE_LIST_KEY, userId), 0, -1);
// ② 提取模板ID,拼出每个模板 Hash 的 KeyList<String> couponTemplateIds = rangeUserCoupons.stream() .map(each -> StrUtil.split(each, "_").get(0)) // 取模板ID .map(each -> prefix + String.format(COUPON_TEMPLATE_KEY, each)) .toList();
// ③ Pipeline:所有 HGETALL 打包,一次网络往返全部带回List<Object> rawCouponDataList = stringRedisTemplate.executePipelined(connection -> { couponTemplateIds.forEach(each -> connection.hashCommands().hGetAll(each.getBytes())); return null;});
// ④ 按 goods 字段分区:空 = 全场/店铺券(按订单金额算),非空 = 商品专属券(按商品金额算)Map<Boolean, List<CouponTemplateQueryRespDTO>> partitioned = ...partitioningBy(c -> StrUtil.isEmpty(c.getGoods()));
// ⑤ 两组并行 + 组内并行,用线程池跑判断逻辑CompletableFuture<Void> emptyGoodsTasks = CompletableFuture.allOf( goodsEmptyList.stream() .map(each -> CompletableFuture.runAsync(() -> { handleCouponLogic(详情, 规则, 订单金额, 可用列表, 不可用列表); }, executorService)) .toArray(CompletableFuture[]::new));
// ⑥ 等全部算完,可用列表按优惠力度降序排(业内标准:最大优惠置顶引导使用)CompletableFuture.allOf(emptyGoodsTasks, notEmptyGoodsTasks) .thenRun(() -> availableCouponList.sort((c1, c2) -> c2.getCouponAmount().compareTo(c1.getCouponAmount()))) .join();判断逻辑 handleCouponLogic:
switch (券类型) { case 0: // 立减券:无门槛,直接可用,优惠 = maximumDiscountAmount case 1: // 满减券:订单金额 >= termsOfUse 才可用,否则进不可用列表 case 2: // 折扣券:订单金额 >= termsOfUse 时, // 优惠 = min(订单金额 × discountRate, maximumDiscountAmount)}逐步详解(背这块就够了)
① 为什么用 ZSet 存券包、还要拆字符串?
- 选 ZSet 而不是 List/Set:成员是
模板ID_券ID,Score 存领取时间,天然按时间排序;ZSet 本身还可去重,一套结构搞定”顺序 + 去重”。 ZRANGE user:{userId} 0 -1一次把全部券捞出来,不给 DB 任何压力。- 成员是”模板ID + 下划线 + 券ID”的复合字符串。为了查模板详情,得先
split("_").get(0)把模板 ID 抠出来,再拼各自的模板 Hash Key。
② 为什么能”一次 Pipeline 带回所有模板”?
- 每张券的模板详情是单独一个 Redis Hash(Key 含模板 ID)。查详情 = 对每个 Hash 发一次
HGETALL。 - 若 for 循环逐张查:n 张券 = n 次网络往返,结算页就卡在网络上了。
- Pipeline 把 n 条
HGETALL命令攒成一个请求发出去,Redis 逐条执行后一次打包返回 → 永远是 1 次网络往返。命令条数没少,往返趟数少了。 - 返回的
List<Object>下标顺序 = 塞命令的顺序。
③ 为什么先分区(partitioningBy)?
goods为空 → 店铺/全场券,用整单金额orderAmount(满 50 减 10,看整单 80 元)。goods非空 → 商品专属券,用该商品金额goodsAmount;若该商品根本不在订单里 → 直接判不可用,连算都不算。
④ 并行怎么并发?
- 线程池(CPU 密集):用
Runtime.availableProcessors()当核心/最大线程数(纯计算不占 IO)、SynchronousQueue(无多余缓冲)+CallerRunsPolicy(线程池满了退回调用线程跑,保证不丢任务、不抛 RejectedExecutionException)。 - 并发写两个列表:必须用
Collections.synchronizedList(new ArrayList<>())包一层。
⑥ 合流排序那步很容易被忽略:先 join() 等所有子任务结束,再 sort,否则会在一边写一边排。
5.3 为什么快?(面试点)
| 手段 | 解决什么 | 怎么做 |
|---|---|---|
| Redis ZSet 存券包 | 不查 DB | 用户券列表直接从 Redis 读,Score 存领取时间天然有序 |
| Pipeline | 减少网络往返 | 10 张券 10 次 HGETALL 打包成 1 次请求一起发 |
| CompletableFuture 并行 | CPU 密集的规则判断 | 两组 + 组内每张券并行跑,而不是 for 串行 |
| CPU 线程池 | 匹配计算场景 | 线程数 = CPU 核心数,SynchronousQueue + CallerRunsPolicy 兜底不丢任务 |
| synchronizedList | 多线程写安全 | 两个并行列表用线程安全容器,排序放 join() 之后 |
| goods 分区裁剪 | 减少无用计算 | 专属券商品不在单里 → 直接判不可用,不用算门槛 |
前置知识:数据为什么在 Redis 里?——批量分发/领券链路把券 ZADD 进了
user:{userId},模板详情 Hash 是查券链路维护的缓存。结算查询本身不查 DB,全部读缓存。
5.4 面试追问答疑
Q1:Pipeline 是什么?(面试高频)
Pipeline(流水线)是 Redis 客户端的优化手段:把 N 条命令打包成一个请求一次发出,Redis 逐条执行后又一次打包返回。
- 它不是 Redis 服务端新功能,只是传输层面的拼接,靠**减少网络往返(RTT)**提速。
- 注意一个坑:Pipeline 牺牲了”发一条看一条结果”的即时反馈,要等全部发完才有结果。适合互相独立、不依赖前面结果的命令(如批量查模板);有依赖/要原子的逻辑(如判库存)要用 Lua。
三种”批量/原子”手段区分(面试爱对比):
- Pipeline:只减少网络往返,命令并不原子,无中间结果透出时用。
- Lua 脚本:整个脚本原子执行、中间不透出,适合”get→判断→set”有依赖逻辑。
- Redis 事务 Multi:打包执行、中间可透出,但不保证原子回滚。
Q2:这单能用几张券?全场券/专属券如何选?(重要业务边界)
结论:这个查询接口不决定”选哪张、用几张、能不能叠加”——它只返回候选,最终选券是前端业务的事。
- 返回两个列表:
availableCouponList(能用)+notAvailableCouponList(不能用,置灰也返回前端)。 - 全场/店铺券(goods 为空)→ 用整单金额判断;业务上一单通常取最合适的一张。
- 商品专属券(goods 非空)→ 用该商品金额判断;单据惯例是每个商品各对应一张专属券。
- 金额计算是一次只算一张券:传一张券 + 一个金额,返回这张的优惠。项目里没有”把可用券全部叠加求和”的逻辑。
Q3:线程安全怎么处理的?(本项目已实践)
两个并行列表是多个子任务同时写入,必须用线程安全容器,不能裸用 ArrayList:
List<QueryCouponsDetailRespDTO> availableCouponList = Collections.synchronizedList(new ArrayList<>());List<QueryCouponsDetailRespDTO> notAvailableCouponList = Collections.synchronizedList(new ArrayList<>());
synchronizedList对每个”单个方法调用”加锁,不影响CompletableFuture的并发提交;排序在allOf(...).join()等全部子任务结束后再对可用列表做,保证不会一边写一边排。
5.5 口述纠偏(易错点对照)
| # | 容易说错 | 正确认知 |
|---|---|---|
| 1 | 券包是 Redis Set | 是 ZSet:member=模板ID_券ID,score=领取时间,排序 + 去重一套搞定 |
| 2 | goods 判断”值为 no” | 是”goods 是否为空”:空=全场/店铺券,非空=商品专属券 |
| 3 | ”把两个列表合并后再排序” | 排序对象就是 availableCouponList 本身,没有合并动作;分区只是”计算时”的分组 |
| 4 | join = 合并 | join() = 阻塞等待所有子任务完成(Java 语义),不是合并数据 |
| 5 | ”不可用的券不显示” | 放进 notAvailableCouponList 也返回前端(置灰),不是删除 |
| 6 | 线程池”立即拒绝” | CallerRunsPolicy 是”调用者执行”不丢不抛;真正”拒绝+抛异常”的是 AbortPolicy |
5.6 面试复述版(30 秒)
结算查询全程读 Redis 不碰 DB:①从用户券包 ZSet
ZRANGE取出所有券(member=模板ID_券ID,score=领取时间);②split 出模板 ID,用 Pipeline 把所有模板 Hash 的HGETALL打包成一次请求,n 张券只有 1 次网络往返;③按goods字段分区——空=全场券看整单金额,非空=商品专属券看商品金额,商品不在订单里直接判不可用;④两组并行、组内每张券并行,线程池=CPU 核数+SynchronousQueue+CallerRunsPolicy,结果写线程安全的synchronizedList;⑤allOf().join()阻塞等待所有子任务算完,之后再按优惠金额降序排序;⑥返回可用/不可用两个列表,选哪张由前端决定。
第六部分:用券状态机(锁定 → 核销 / 退款)
6.1 业务是什么
小张选中那张满 50 减 10 的券下单。经典难题:
如果刚把券标记为”已使用”,支付失败了怎么办?券就废了。 如果等支付成功才标记,支付成功的瞬间用户又用这张券下了另一单怎么办?券被用了两次。
解法:引入中间状态 LOCKING(锁定),一张券的生命周期:
UNUSED(未使用,0) │ ① createPaymentRecord:下单选券(事务内锁券 + 事务外删缓存) vLOCKING(锁定中,1) │ ② processPayment:支付成功核销(只改 DB 状态,不动券包 ZSet) vUSED(已使用,2)──③ processRefund:订单退款(事务内还券 + 事务外加回缓存)──> UNUSED注意:支付失败/超时并不会自动回到 UNUSED——事务回滚只会让券停在 LOCKING,而 processRefund 只处理”已支付(结算单=2)“的订单,匹配不上失败的单。这个释放缺口见 6.6 的 Q7/Q8。
结算单 t_coupon_settlement 同步流转:0 锁定(占用) → 2 已支付 → 3 已退款(1 已取消已定义但当前代码未使用)。
6.2 三个方法 + 完整数据流
① 创建结算单(UNUSED → LOCKING)→ createPaymentRecord
1. Redisson 分布式锁 tryLock(key = couponId)→ 拿不到说明同一张券正在被处理2. 查结算单表:该券存在 status=0(使用中) 或 2(已使用) 的记录 → 拒绝(防重复使用)3. 校验券:存在、没过期、状态是 UNUSED4. 计算折扣金额(重复校验前端传的金额,防篡改)5. 编程式事务里两步(要么都成功要么都回滚): a. 插入结算单(status=0) b. 条件更新券状态:UPDATE ... SET status=LOCKING WHERE status=UNUSED ← CAS6. 事务外删缓存:从 user:10001 的 ZSet 里移除该券(券包里不可见了)RLock lock = redissonClient.getLock("...lock:coupon-settlement:888888"); // 1. 锁粒度=单张券if (!lock.tryLock()) throw new ClientException("正在创建优惠券结算单,请稍候再试");// 2. 防重复:查结算单 status in (0,2) 存在则拒绝// 3. 校验券存在/未过期/UNUSED,算折扣金额transactionTemplate.executeWithoutResult(status -> { // 4. 编程式事务(范围最小) couponSettlementMapper.insert(结算单(status=0)); // a. 插结算单 userCouponMapper.update(券.setStatus(LOCKING), ...eq(status, UNUSED)); // b. CAS 锁券});stringRedisTemplate.opsForZSet().remove("user:10001", "2026082811_888888");// 5. 事务外删缓存② 支付成功核销(LOCKING → USED)→ processPayment
transactionTemplate.executeWithoutResult(status -> { // a. 结算单 0→2(已支付),WHERE status=0,更新0行=异常回滚 int r1 = couponSettlementMapper.update(结算单.setStatus(2), ...eq(status, 0)); // b. 券 LOCKING→USED,WHERE status=1,同理 int r2 = userCouponMapper.update(券.setStatus(USED), ...eq(status, LOCKING));});// 不动券包 ZSet(第一步已移除);同样持有 couponId 粒度的 Redisson 锁(tryLock),锁在事务内 finally 释放(早于提交,§9.4 已列)③ 退款(USED → UNUSED,券还回去)→ processRefund
transactionTemplate.executeWithoutResult(status -> { // a. 结算单 2→3(已退款),WHERE status=2 couponSettlementMapper.update(结算单.setStatus(3), ...eq(status, 2)); // b. 券 USED→UNUSED,WHERE status=2 userCouponMapper.update(券.setStatus(UNUSED), ...eq(status, USED));});// 事务提交后:查回券 → ZADD 加回券包stringRedisTemplate.opsForZSet().add("user:10001", "2026082811_888888", receiveTime);6.3 用具体例子走一遍(小张满 50 减 10)
场景:小张(userId=10001)用”满 50 减 10”店铺券(券 ID=888888,模板 ID=2026082811)买 100 元商品、实付 90 元。
初始状态:
| 存储 | 内容 |
|---|---|
MySQL t_user_coupon | id=888888, user_id=10001, status=0(UNUSED) |
MySQL t_coupon_settlement | 无记录 |
Redis 券包 user:10001(ZSet) | member=2026082811_888888 |
| 阶段 | 结算单.status | 券.status | Redis 操作 |
|---|---|---|---|
| 下单选券 createPaymentRecord | 0(创建) | 1=LOCKING | 事务提交后 ZSet remove(券包不可见) |
| 支付成功 processPayment | 2(已支付) | 2=USED | 不动券包 ZSet(第一步已移除;仍持有同粒度 Redisson 锁) |
| 退款 processRefund | 3(已退款) | 0=UNUSED | 事务提交后 ZSet add(重新可见) |
一句话:改 LOCKING 是①创建结算单时的事务;支付核销②只会把券改成 USED,支付失败也只是回滚回 LOCKING,永远不碰 UNUSED;想让券回 UNUSED 只能走③退款(要求先 USED)。
6.4 为什么这样设计?(面试点)
| 设计 | 原因 |
|---|---|
| LOCKING 中间状态 | 支付是异步的,“先锁券再核销”,支付失败本应退回 UNUSED(当前实现缺口:不会自动回,见 Q7) |
| 条件更新(WHERE status=旧状态) | 数据库层面的 CAS,即使分布式锁失效,状态也改不过去(改 0 行即失败)——双保险 |
| 编程式事务而非 @Transactional | 把校验、金额计算等只读操作排除在事务外,事务只包两条写 SQL,减小事务范围和锁持有时间 |
| 先事务后删缓存 | 删缓存失败只是暂时的不一致(券多显示一会),反过来会出现”券没了但状态没锁”的资损 |
| 锁粒度 = couponId | 不同用户不同券完全并行,性能不受影响 |
6.5 状态全景:MySQL 与 Redis 各存什么(面试必背)
一句话:MySQL 存状态(事实来源),Redis 存视图(可从 DB 重建)。
| 状态/数据 | 存在哪 | 结构/取值 | 说明 |
|---|---|---|---|
| 券状态 | MySQL t_user_coupon.status | 0=UNUSED 1=LOCKING 2=USED(枚举另有 3=EXPIRED、4=REVOKED 已撤回,当前主链路未流转) | 唯一事实来源,锁券/核销/退款都靠它 |
| 结算单状态 | MySQL t_coupon_settlement.status | 0=锁定 1=已取消 2=已支付 3=已退款 | 记录”每次用券行为”的流水进度 |
| 券包视图 | Redis one-coupon_engine:user-template-list:{userId}(ZSet) | member=模板ID_券ID,score=领取时间 | 只有可用券;锁券 remove、退款 add;无 status 字块 |
| 领取次数 | Redis user-template-limit:{userId}_{templateId}(String) | 次数 | 领券/分发防超领 |
| 模板详情 | Redis template:{模板ID}(Hash) | 含模板 status(生效中/已结束) | 是模板的状态,不是用户券的状态,别混淆 |
| 分布式锁 | Redis lock:coupon-settlement:{couponId} | 锁 | 状态机并发控制第一道防线 |
结论:Redis 里没有”券状态”的 key。券的状态(UNUSED/LOCKING/USED)只在 MySQL 一份;Redis 券包靠”成员在不在 ZSet 里”间接表达可用性。所以这条链路根本不是”Redis 与 MySQL 双写一致性”,而是 Cache-Aside(先更新 DB、再删/建缓存);真正的双写最终一致是 ES 搜索链路(MySQL→Canal→MQ→ES)。
6.6 面试追问答疑
Q1:锁 key 为什么只有 couponId,不带 userId?
因为 couponId 就是单张券实例的主键(雪花全局唯一),唯一对应一个用户。加 userId 不改变粒度(冗余)。若用模板 ID 当锁 key,同一模板下所有用户的券会被串行,并发直接崩掉。真正正确性靠 DB 条件更新(WHERE 带 userId + 旧状态)兜底。
Q2:券包展示时已把券藏了,下单 Step 2 为什么还要查 DB?
缓存不权威,DB 才权威。 两种必须查 DB 的场景:
- 删缓存失败:券在 DB 已 LOCKING,但 ZSet 没删掉,界面还显示 → 不查 DB 就会把已锁的券再用一遍。
- 并发双端下单:手机+电脑同时点,两个请求都看到”可用” → 只有 DB 检查能拦下第二个。
Step 2 查 t_coupon_settlement.status in (0,2) = 这张券有”使用中/已使用”记录就拒绝;status=3(已退款)不在里面,允许再用。
Q3:编程式事务(TransactionTemplate)和 @Transactional 区别?
| 维度 | @Transactional(声明式) | TransactionTemplate(编程式) |
|---|---|---|
| 粒度 | 整个方法 | 精确圈住任意代码块 |
| 事务范围 | 大(含只读校验/算金额/缓存操作) | 只包”两条写 SQL” |
| 同类自调用 | 不生效(不走代理) | 生效 |
事务只包”插结算单 + 锁券”两条 SQL,把校验、金额计算、删缓存全踢出事务,缩短事务和分布式锁的持有时间。catch 里 status.setRollbackOnly() + throw ex 双保险。
Q4:DB 能回滚,Redis 怎么回滚?
Redis 不需要回滚,因为它是缓存不是事实来源,且操作顺序是”先 DB 事务、后动缓存”(Cache-Aside):
| 失败场景 | 结果 | 需要回滚吗 |
|---|---|---|
| DB 事务失败 | 券还是 UNUSED,缓存没动 | 不用(DB 自己回滚) |
| DB 成功、删缓存失败 | 券 DB 已 LOCKING,缓存还显示 → 下单被 Step 2 拦 | 不用,短暂不一致,Canal 自愈 |
为什么不能反过来”先写 Redis 再写 DB”:Redis 无事务回滚,先写 Redis、DB 失败后无法撤销 → 永远不一致,且错的还是权威数据。项目里 CanalBinlogSyncUserCouponConsumer 监听 t_user_coupon binlog 重建缓存做自愈。
Q5:支付成功那步为什么不碰券包缓存?
processPayment 只有两条 DB UPDATE,不再动券包 ZSet——方法开头同样会 tryLock 拿 couponId 粒度的 Redisson 锁,但锁是并发保护不是缓存操作。因为券在第一步下单时就已经从 ZSet 移除了,支付成功后券包本来就没有它,无需再动缓存。全链路只有 createPaymentRecord(删)和 processRefund(加)动 ZSet,且都在事务提交之后。
Q6:支付失败(processPayment 抛异常)会走退款吗?回滚和退款啥区别?
不会走退款,回滚 ≠ 退款:
| DB 事务回滚 | processRefund(退款) | |
|---|---|---|
| 触发 | processPayment 内部抛异常自动发生 | 外部订单退款了才被 HTTP 回调 |
| 做什么 | 两条 UPDATE 还原成旧值(结算单→0、券→LOCKING) | 结算单 2→3、券 USED→UNUSED、加回缓存 |
支付失败 → 事务回滚 → 券停在 LOCKING、结算单停在 0;异常返回给订单系统,由它重试或走补偿。
Q7:支付失败后券会不会卡死?要不要人工?(重要缺口)
会卡死,且当前代码没有自动释放逻辑。 原因:
- 支付失败 → 券永久停在 LOCKING(券包不可见、下单被拦)→ 用户”少一张券”。
- processRefund 救不了:它要求
结算单 status=2+券 USED,而失败的是0+LOCKING,WHERE 匹配不上。 - 结算单定义了”1=已取消”但代码里没人把结算单置为 1、也没人释放 LOCKING → 该补偿链路未实现。
生产正确方案(面试可讲):
- 事件驱动释放:订单支付失败/超时 → 订单系统发”订单关闭”事件(MQ)→ engine 消费后:
结算单 0→1(已取消)、券 LOCKING→UNUSED、ZADD 加回券包。 - 定时任务兜底:XXL-Job 定期扫”长时间 LOCKING 且订单未支付”的结算单,批量释放。
- 再加日志/告警,兜底人工改库。
结论:当前实现下支付失败确实会”少券”;面试时主动指出这个缺口并给出”事件驱动 + 定时任务”方案是加分项。
Q8:为什么支付失败”不能回滚”到未使用?(事务边界)
看事务在哪开,就知道为什么救不回来:
createPaymentRecord 编程式事务:插结算单(占用中) + 券 未使用→锁定 ↑ 这个事务【提交】了 → 锁定固化、持久生效支付失败 → 发生在【下单事务之后】,不在锁券事务里 → 没有任何针对"支付失败"的事务去回滚券 → 券保持锁定,永不回未使用- 关键:锁券的下单事务早已提交,支付失败是独立事件,不在那个事务里 → “回滚到未使用”无从谈起。
- 即便 processPayment 自己的事务失败回滚,恢复的也只是”事务开始前的状态 = 锁定”(因为开始前券就是锁定)。
6.7 口述训练卡
口述练习时请用文字状态:券表(未使用/锁定/已使用)、结算单流水(占用中/已支付/已退款),别念 0/1/2 数字,面试也更听得懂。
本次口述暴露的误区
| # | 容易说错 | 正确认知 |
|---|---|---|
| 1 | ”数据库没扣券,所以券还锁定” | 是”锁定已生效(下单事务已提交)→ 核销没完成 → 没人释放”,不是”没扣到” |
| 2 | CS / Compare-and-Swap | 是 CAS(Compare And Swap):UPDATE 券 SET 锁定 WHERE 未使用,改 0 行即失败 |
| 3 | 券状态和结算单状态混为一谈 | 券状态=这张券现在什么情况;结算单=这张券的占用流水,一次用券插一行 |
| 4 | ”退款后券还能用 = 已使用” | 反了:已使用=用完;退款是让券回到未使用(还能用) |
30 秒版
支付是异步的,所以下单时不能”直接用掉券”,要引入锁定中间态。下单选券:分布式锁(锁这张券)→ 查结算单流水(有”占用中/已支付”就拒,防重复用)→ CAS(只在”未使用”时才锁)→ 通过后插一条”占用中”结算单 + 券未使用→锁定,藏起券包;支付核销:结算单→已支付、券→已使用,不动券包 ZSet(仍有分布式锁);退款:结算单→已退款、券→未使用,加回券包。一张券不被用两次靠三层防线:锁挡并发、流水挡占用(写入即存在删不掉)、CAS 挡状态竞争。缺口:支付失败券停在锁定没人释放,退款救不了(要求已支付+已使用),需”订单关闭事件释放 + 定时任务兜底 + 人工”。
第七部分:ES / Canal(简历已移除,仅防主动追问)
2026-09-29 起 ES 已从简历撤下。口径:不主动讲、不写进自我介绍、不放进”项目有几条链路”的回答里。 只有面试官主动问到”搜索 / 数据同步 / Canal / ES”时,才用下面的 30 秒版接住,答完把话题领回主链路。
7.1 30 秒防御版(被问到才说)
搜索场景走 ES(分词、倒排索引、相关性排序),但数据主人在 MySQL。用 Canal 伪装成 MySQL 从库订阅 binlog,任何增删改都变成消息投进 RocketMQ,search 模块消费后按 INSERT/UPDATE/DELETE 同步 ES——业务代码零侵入、连运维直接改库都不漏。UPDATE 分支:先看模板是否已过期/已删除——是就从 ES 删掉(不让用户搜到失效券);否则再看 ES 里有没有这篇文档,没有就转 save 自愈补漏、有就 update。MQ 异步所以是最终一致,搜索场景晚几秒无资损,可接受。
7.2 两个必守纠偏(说错就露馅)
| # | 容易说错 | 正确认知 |
|---|---|---|
| 1 | ”kernel 伪装成 MySQL 从库” | 是 Canal(阿里开源 binlog 订阅组件),kernel 是操作系统内核 |
| 2 | 把”过期判断”提到最外层 | 外层先 switch 操作类型;过期/删除判断只在 UPDATE 分支内部第一步,然后才是 exists 判断 |
7.3 被深挖时的兜底句(防止被拖进不熟悉的深水区)
“这块简历上没展开,我了解到项目里用的是 Canal → RocketMQ → search 消费者按事件类型同步;更深的死信、补偿、全量重建方案我没有实践过,思路是失败落表 + 定时全量重建对账。”
诚实划边界,比不懂装懂安全;答完主动拉回:“回到领券主链路的话,双写一致性我更有体感的是 Redis 和 DB 这六处。“
第八部分:面试速记 + 已知问题 + 自测 + 打卡
8.1 面试速记卡(11 条)
- 两条链路一句话:查券链路”六层结构把 DB 压力压到接近零,99% 止步一条 HGETALL”;领券链路”Redis 先抢名额(快、原子),DB 再落事实(事务一致)”
- 为什么锁内要重查:“锁外观察和拿到锁之间有时间窗口,期间别人可能已重建缓存。锁内不重查,锁就退化成’把并发查库串行化’而非’消除查库’——1000 刀变 1 刀”
- 为什么这个缓存不能随便删:“stock 是状态不是缓存——Lua 在持续 HINCRBY 扣它,删了重建会把在途预扣量重置回 DB 值,尾部用户会领到’已被领取完’“(最高分点)
- 领券的本质:“三个资源三个动作——扣配额、铸券实例(唯一索引防重)、建卡包索引(ZSet+写后查询)。模板≠券”
- 四道防线:Lua 原子校验(性能闸门)→ DB 条件 UPDATE(防超卖)→ 复合唯一索引(防重复)→ 写后查询(防缓存丢失)
- 为什么两套实现并存:同步版适合低并发强结果反馈;MQ 版削峰高并发,代价是最终一致 + 失败静默
- 两套幂等不可互替:消息级(keys)挡重复投递,业务级(唯一索引)挡重复领取;消息级必须挡在事务前面否则死循环
- 两种事务写法各自正确:同步版编程式(事务前有 Redis IO),消费者声明式(消息快照、零缓存查询)
- 两种消息幂等分治:落库消息不幂等需切面+索引;延迟关券消息结构性幂等不需要——判断标准”重复消费一次结果变不变”(⚠ 注意:幂等设计没错,但该链路当前实现 100% 失效,见 §2.5 缺陷框——“设计对、实现有笔误”要分开讲)
- 双写一致性总纲:“事务只覆盖 MySQL,Redis 和 MQ 的写都是双写,我项目里一共 6 处”——能数出来就赢了 80% 的候选人
- 压测数字卡(先报环境再报数:本地单实例 R7 6800H / 16G,MySQL/Redis/RocketMQ 同机):抢券 100 并发 1600+ TPS;结算试算 80 并发 4600+ QPS;百万行分发约 15 分钟(约 1100 行/秒;逐条同步约 200 行/秒 → 攒批落库提升一个数量级,“约 60 倍”为教程作者宣称值、已弃用);每轮压测后三方对账(Redis 扣减量 = DB 落库行数 = MQ 消费条数)一致才算通过。QPS vs TPS:无状态查询报 QPS、一次请求对应一次事务报 TPS。
30 秒电梯版(项目总纲):
这个项目的优惠券体系分两半:用户侧领券用券、运营侧发券管券。 领券侧我做了 Lua 原子扣减防超发、缓存三件套防穿透击穿;用券侧通过”锁定-核销”状态机 + 分布式锁 + 条件更新保证一张券不会被重复使用。 运营侧批量分发用 EasyExcel 流式读百万名单、Redis 攒批、两级 MQ 削峰落库,失败可追溯。
8.2 已知问题清单(主动交代素材,14 条)
| # | 问题 | 位置 | 影响 |
|---|---|---|---|
| 1 | EXPIRE 毫秒/秒单位 Bug | 领券 Lua EXPIRE 行 | 次数 Key 约 5 万年 TTL |
| 2 | Lua stock nil 防御缺失 | 领券 Lua HGET 行 | 缓存缺失时脚本异常 |
| 3 | Redis 预扣后 DB 失败无回补 | 整条领券链路 | Redis/DB 库存漂移(对账缺失核心) |
| 4 | ⑤c⑤d 在事务内但不可回滚 | 同步版事务 | 幻影券窗口 |
| 5 | 延迟消息发送失败无补偿 | log.warn 处 | 券永不过期 |
| 6 | MQ 版 DB 扣失败静默 return | 消费者 | 用户以为领到实际没有 |
| 7 | 幂等标记先于事务提交 | 切面机制 | 崩溃窗口内消息永久丢失 |
| 8 | 切面与事务顺序无 @Order 保证 | 切面机制 | 事务包切面时提交失败则消息丢失 |
| 9 | Lua 加载代码两处复制 | 两个领券方法 | 维护性 |
| 10 | 脚本不兼容 Redis Cluster | 无 hash tag | CROSSSLOT |
| 11 | 模板更新不失效缓存 | 查券链路 | 不一致窗口 = 活动剩余时长 |
| 12 | 缓存 Key 不含店铺号 | 查券链路 | 命中路径不校验店铺,隔离缺口 |
| 13 | 延迟关券 removed==0 结果歧义 | 延迟关券消费者 | DB status 永远 UNUSED |
| 14 | UserContext 在消费线程取不到 | 延迟关券消费者 | ZREM 打到 null key → removed==0 早退 → DB 永远 UNUSED,到期关券链路 100% 失效(与 #13 叠加;修复话术见 §2.5 ⚠) |
Day2 补充缺口(主动交代加分):
| # | 问题 | 影响 |
|---|---|---|
| 15 | 支付失败券停在 LOCKING 无自动释放 | 用户”少一张券”,退款救不了,需事件释放+定时任务兜底 |
| 16 | 150秒后重投且第一次还在执行(批量分发) | 极低概率重复执行,可接受 |
| 17 | binlog 模式仅处理 INSERT | UPDATE/DELETE 的卡包变更不重建 |
| 18 | Lua/Java 位宽不一致(Lua 16 位打包、Java 14 位解码) | Lua 错误码(库存不足/已达限领)被 Java 误读为成功:Lua 闸门在错误场景失效,靠 DB 条件 UPDATE 与唯一索引兜底;限领提示错乱为”用户重复领取”,MQ 版撞索引烧重试进死信。主动交代 + 修复(两端统一位宽常量)是加分素材 |
8.3 终极串联:一张券的完整一生(数据闭环)
┌────────── MySQL(事实来源)──────────┐ │ t_coupon_template t_user_coupon │ │ t_coupon_task t_coupon_task_fail│ └──┬───────────┬───────────┬──────────┘ Binlog│ 落库/更新 查询兜底 │ │ │ Canal↓ 分发/领券↓ ↓查券链路模板缓存 RocketMQ (批量落库) Redis Hash模板详情 │ │ │ ES搜索↓ 领券/分发↓ │ ES搜索 Redis user:{userId} ZSet 券包 <─────────────── 结算查询读这里 ──────────┘ │ 状态机↓ UNUSED → LOCKING → USED(核销/退款)一句话:MySQL 存真相,Redis 存热点(券包+模板),ES 存搜索视图,MQ 负责让它们之间最终一致。
8.4 自测清单(画不出来 = 白学)
- 默画查券十步流程(含布隆在前、空值在后的顺序)
- 说清 stock 为什么是”状态不是缓存”、删了重建会怎样
- 默画领券同步版事务圈内圈外图(哪些可回滚哪些不行)
- 说清分叉点:同步版开事务 vs MQ 版发消息,各自下一行是什么
- 说清 MQ 版消费者注解栈三层盔甲 + 为什么静默 return
- 默画幂等切面三状态机 + 行为矩阵 + 为什么失败要删 Key
- 数出双写 6 处 + 每处的失败方向
- 说清幻影券的完整成因链(为什么 INSERT 撞索引不产生幻影)
- 默画批量分发五步图(含 12000 人、10000 库存的数字流转)
- 说清 Redis 扣 5000 / DB 只剩 3000 时的处理(以 DB 为准裁剪)
- 说清唯一索引拦截后回滚 1 张库存的完整过程
- 默画结算查询的数据流(ZSet → Pipeline → 分区 → 并行判断 → 排序)
- 默画券状态机三个状态 + 结算单状态联动
- 说清为什么需要 LOCKING 中间状态 + 支付失败为什么救不回来
- (选做:简历已无 ES,仅防追问)说清 Canal 链路为什么是最终一致、UPDATE 分支的 exists 自愈
8.5 锚点速查(六块浓缩,复习时看着它默讲)
【查券链路】布隆 → 挡"从来没存在过",无漏报,判死刑空值 → 挡"存在过但已没了"(过期/被删),事实性否定DB → WHERE status='ACTIVE',让过期也返回 null双重判定 → 防串行后每人仍查库 → DB 只挨 1 刀Lua → HMSET + EXPIREAT 原子回填,防永生 Keystock → 状态非缓存,删了重建 → Redis 放行量回退、尾部空欢喜
【领券链路】Lua 四件事 → 判库存+扣库存+判上限+计数,位压缩返回(Lua 按 2^16 打包 / Java 按 2^14 解码,位宽不一致缺陷 #18)同步版 → 事务圈:⑤a扣DB库存(可回滚) ⑤b插券(可回滚) ⑤c ZSet(不可) ⑤d延迟消息(不可)MQ 版 → 分叉点后:组事件发MQ即返回;消费者幂等切面+@Transactional落库四道防线 → Lua原子 / 条件UPDATE / 唯一索引 / 写后查询幂等切面 → SET NX GET PX 一条命令;nil执行 0抛异常 1跳过;成功置1 失败删+重抛 崩溃等TTL双层兜底 → Redis挡短期(600s/120s),MySQL终极兜底(唯一索引/任务状态)
【批量分发】① 建任务 → 落库+发MQ | 异步数行数+延迟兜底② 一级校验 → 幂等SETNX | 任务status=1 | 模板ACTIVE → 触发流式读③ 逐行Lua → EasyExcel流式读 | 原子扣Redis库存 | Set攒批 | 进度Key断点续传④ 二级落库 → 扣DB库存(不足裁剪) | SPOP弹对应人数 | 批量insert+唯一索引降级 | 先MySQL后Redis写券包 | 差额回滚(Redis+DB)⑤ 收尾对账 → 失败Excel | 任务SUCCESS | 应发vs实发闭环
【结算查询】① 取券包 → ZRANGE user:{userId} | member=模板ID_券ID | score=领取时间② Pipeline → split取模板ID | HGETALL一次往返批量查详情③ 分区 → partitioningBy(goods) | 空=全场券(整单金额) | 非空=商品券(商品金额,不在单直接不可用)④ 并行算券 → CompletableFuture两组+组内 | CPU核数+SynchronousQueue+CallerRunsPolicy | synchronizedList⑤ 合流 → allOf().join() 阻塞等待所有子任务完成 | 之后再排序⑥ 排序返回 → 优惠金额降序 | 返回 可用/不可用 两个列表
【用券状态机】① 为什么LOCKING → 支付异步 | 不能直接用掉券 | 需中间态② 下单选券 → 分布式锁(couponId) | 查结算单流水(占用中/已支付即拒) | CAS锁券(未使用→锁定) | 插结算单(占用中) | 藏券包③ 支付核销 → 结算单→已支付 | 券→已使用 | 不动券包ZSet(仍有分布式锁)④ 退款 → 结算单→已退款 | 券→未使用 | 加回券包⑤ 缺口 → 支付失败券停锁定 | 退款救不了 | 需事件释放+定时任务+人工
【ES 搜索】(简历已移除,仅防追问,见第七部分)① 为什么ES → 分词/倒排/相关性排序 | 但数据主人在MySQL② 双写弊端 → 侵入大 | 一处忘写不一致③ Canal方案 → 伪装从库读binlog | 投RocketMQ | 消费者同步ES | 零侵入+不漏④ 分支处理 → INSERT→save | UPDATE→先判过期/删除(delete),否则exists(有update/无save自愈) | DELETE→delete⑤ 一致性 → 异步MQ=最终一致 | 搜索场景无资损可接受8.6 复习打卡表(遗忘曲线版)
复习不是重看文档,是”合上文档,用锚点默讲,卡壳才翻书”。遗忘曲线:24 小时忘掉 70%+,所以要在遗忘临界点前按间隔复习,不是天天通读。
每次复习必做四步:
- 合上文档,用「锚点速查」把对应链路默讲一遍(手机录音)
- 卡壳的点记进「卡壳本」
- 卡壳处回看该链路对应小节,补完立刻重讲这一段
- 全部过完 → 默画「终极串联」图(30 秒)
| 复习次数 | 时间 | 复习内容 | 打卡 |
|---|---|---|---|
| 第 1 次 | 练完当天(睡前) | 全链路锚点默讲一遍 | [ ] |
| 第 2 次 | 第 2 天 | 只讲”卡壳过”的步骤 | [ ] |
| 第 3 次 | 第 3~4 天 | 完整默讲 + 默画数据流图 | [ ] |
| 第 4 次 | 第 7 天 | 自测清单全过 + 30 秒版复述 | [ ] |
| 第 5 次 | 第 14 天 | 30 秒电梯版整体复述(不卡壳=面试稳) | [ ] |
卡壳本(复习时只看这里,不重读全文):
| 日期 | 链路 | 卡壳的点 | 已解决 |
|---|---|---|---|
| [ ] | |||
| [ ] | |||
| [ ] |
8.7 查券链路口述纠偏精选(Q&A)
Q1:项目到底有几条链路? 答:查券(查优惠券模板)、领券(同步版 / MQ 版,是同一条链路的两种实现)、批量分发、结算查询、用券状态机,一共五条。幂等切面 / 延迟关券 / 双写一致性是横切面。别把”两种实现”数成”两条链路”。(ES/Canal 简历上没写,别主动报进”几条链路”里;被问到再用第七部分 30 秒防御版接。)
Q2:为什么查链里会有 Lua”写”操作?(明明是查)
答:Lua 写的是 Redis 缓存副本,不是业务写库。查链只查一次 DB(WHERE status='ACTIVE'),把结果 + TTL 用 HMSET + EXPIREAT 原子回填到 Redis。不用 Lua 则拆成两条 Redis 命令,中间崩溃会留下没有 TTL 的永生 Key。
Q3:空值缓存未命中 = 这个模板一定存在? 答:不是。 空值未命中只说明”缓存层没拦下它”。只能得出”该拿锁去 DB 最终确认”,不能断言”一定有”。布隆说没有才是一锤定音(无漏报)。
Q4:布隆之后先查什么?空值和布隆谁先谁后? 答:布隆在前、空值在后。 布隆挡”从来没存在过”;空值挡”存在过但已没了”。
Q5:双重判定防的是”排队串行”吗? 答:不是。 排队串行是分布式锁的固有代价。双重判定防的是”串行拿到锁后,每个人仍都去查 DB”——1000 请求照样排队,但 DB 从挨 1000 刀变挨 1 刀。
Q6:模板 Hash 缓存的 TTL 是”当前时间 + 有效期”吗? 答:不是。 模板 Hash 是 EXPIREAT 锚定活动结束时刻(绝对时间戳);“当前时间 + 有效期”是用户券的过期时刻。口诀:模板缓存放活动结束,用户券放领券时刻 + 效期。
Q7:删缓存重建后 Redis 库存回涨,算超卖吗? 答:不算。 DB 条件 UPDATE 兜底,多发券不可能。严谨措辞:尾部用户过了闸门却领不到(空欢喜),只伤体验不产生超卖。
Q8:查券链路的”六层”到底是哪六层? 答:三件套缓存 + 两件并发 + 一件原子写:Hash(快路径)、布隆(挡从未存在)、空值(挡过期/删除)、分布式锁、双重判定、Lua 原子写。
Q9:缓存三问题 ↔ 解法怎么分配? 答:穿透 → 布隆 + 空值双层;击穿 → 分布式锁 + 双重判定;雪崩 → EXPIREAT 锚定各模板自己的活动结束,天然错开。穿透与锁无关,击穿才用锁,雪崩靠错开。
8.8 真实卡壳点 → 面试踩坑故事(“最难问题”专用弹药)
面试官必问:“项目里遇到最难的问题 / 印象最深的 bug?” 速通项目最怕这类问题答得太”标准”。以下三个故事全部来自真实卡壳经历的包装,按”现象 → 排查 → 根因 → 修复”四段讲,细节的琐碎程度 = 真实度。
故事一:卡包里的”幽灵券”(对应双写一致性第③处)
- 现象:自测领券时发现卡包 ZSet 里偶尔出现 DB 查不到的券——用户看到自己有券,点进去详情却是空的。
- 排查:先怀疑是 ZADD 写错了 key,对着日志一条条核对 member 格式(templateId_snowflakeId)没问题;后来在事务里打了断点才发现——INSERT 成功后 MyBatis 回填了自增 ID,ZADD 拿着这个 ID 写进 Redis,之后事务抛异常回滚了,DB 记录没了,Redis 却留下了。
- 根因:事务边界圈住了不可回滚的操作。MySQL 的 INSERT/UPDATE 能回滚,Redis 的 ZADD 不能。
- 修复与兜底:短期靠延迟关券消息”顺带”清理(到期 ZREM 时把幽灵券一并移除);根因上承认这是弱一致——写后查询补写只是尽力自愈,不承诺强一致。面试讲到”我知道它为什么发生、也知道兜底不完美”,比背标准答案值钱得多。
故事二:幂等切面”先查后设”的并发漏洞(对应 2.4 幂等切面)
- 现象:压测领券时,同一条 MQ 消息偶发被消费两次——日志里出现两次消费记录,第二次以 DuplicateKeyException 回滚,之后 MQ 反复重投这条消息,重试次数打满、触发死信告警。注意:DB 没有落两条一样的券——消息快照里 receiveCount 是固定值,两次 INSERT 三列完全相同,第二个必撞复合唯一索引,连同它扣的库存一起事务回滚。
- 排查:第一反应是切面没生效,单测打断点明明拦得住。后来看并发日志才复现:rebalance 期间两个消费者同时收到这条消息,两个线程同时执行
GET都拿到 nil → 都认为自己是第一个 → 都SET→ 都放行。典型的 check-then-act 竞态。切面确实失效了,但唯一索引兜住了正确性——脏的是重试,不是数据。 - 根因:判重和占位是两条命令,中间有窗口。
- 修复:换成
SET NX GET PX一条原子命令——nil 说明没人抢过(执行),返回 ‘0’ 说明有人正在处理(抛异常阻断),返回 ‘1’ 说明已处理完(跳过)。从此理解了分层:唯一索引保证”不会脏”(正确性,物理约束永不失效),切面保证”不白干”(止损——不撞索引、不烧 16 次重试、不进死信)。“防重”和”防并发重”是两回事。
故事三:幂等键过期时间 < 消费耗时,键先没了(对应 6.4 已知问题)
- 现象:批量发券的重试测试中,一个任务明明消费成功了,过了一会儿又被执行了一遍。
- 排查:起初以为是消费失败触发的 MQ 重投,翻消费者日志没看到异常;后来对时间戳才发现——任务消费耗时约 150 秒,幂等键 TTL 只配了 120 秒,消息还没消费完,键先过期了,重投进来时键已不存在,判重形同虚设。
- 根因:keyTimeout 必须大于最大消费时长,这是个配置语义问题不是代码 bug。
- 修复:keyTimeout 拉大到 600 秒,并约定配置公式:TTL ≥ 最大消费时长的 2~3 倍。顺带梳理出两层保护的设计初衷——Redis 键只是短期防重,真正的最终防线是 DB 任务状态检查(只有 IN_PROGRESS 才能执行)。
使用说明
- 三个故事覆盖三个模块(卡包一致性 / 消费幂等 / 批量任务),面试任选其一都能讲 2 分钟。
- 讲的时候先讲现象再讲排查弯路(“我一开始以为是 X,后来才发现是 Y”)——弯路是真实感的来源,别一上来就报正确答案。
- 与 8.2 已知问题清单的区别:8.2 是”主动交代”的防守素材,本节是”被问难忘经历”时的进攻素材。
第九部分:面试深挖答辩与避坑(合并自旧文档 2026-09-24)
本部分由《项目深度分析与简历面试指南》§4.4/§5 和《分析项目并结合文档》三点提醒合并而来,两份旧文档已删除。压测数据见
压测/压测报告.md(本机实测,未被删除)。
9.1 三分钟项目介绍(整体版,开口第一段)
这是一个基于 Java 17、Spring Boot 3 的 Maven 多模块优惠券系统,覆盖商家模板管理、批量分发、用户领券、结算核销。
系统通过 Gateway 路由到商家后台、优惠券引擎、分发、结算模块。商家后台创建模板和分发任务;分发模块用 EasyExcel 读取用户数据,通过 Redis、RocketMQ 和数据库批量写入用户券;引擎模块用 Redis Lua 处理高并发领券的库存和领取次数判断,并通过 MQ 异步落库;结算模块从 Redis 获取用户券并做规则计算。
技术上:模板查询用 Bloom Filter、空值缓存和分布式锁防穿透击穿;批量分发用两级 MQ 拆分长流程;结算查询用 Redis Pipeline 减少网络往返;用户券缓存与数据库的双写一致性通过 direct/binlog 双模式和写后查询补写缓解。要主动说明:Redis、DB、MQ 之间没有统一 Outbox 或补偿闭环,不能把系统说成”完整分布式事务”。
收益要报自己的实测数字(报数字先报环境,全部组件挤在一台 16G 笔记本上):抢券 1600+ TPS、券列表查询 4600+ QPS、百万行分发约 15 分钟,详见压测报告。
9.2 核心深挖问题 Top 6(为什么问/代码事实/权衡/边界/兜底/追问)
问题一:为什么领券使用 Redis Lua?
- 为什么问:库存并发、原子性、Redis/DB 一致性。
- 代码事实:Lua 同时判断库存、用户领取次数,再递增次数、扣减库存。
- 原理:Lua 在 Redis 内部原子执行,避免判断和修改之间被其他请求插入。
- 权衡:减少 DB 热点压力,但 Redis 成为关键依赖,Redis 预扣与 DB/MQ 非原子。
- 边界:库存 Hash 缺失可能脚本异常;EXPIRE 毫秒/秒单位 Bug(§8.2 #1);返回编码有位宽假设。
- 生产兜底:当前无完整回补对账。演进:预扣记录、Outbox、库存对账、有上限补偿。
- 追问:Redis 成功 DB 失败怎么办?MQ 发送失败如何回补?为什么不用 DB 乐观锁?
- 证据:
UserCouponServiceImpl.java:99-123(Lua 构建/执行/解码);stock_decrement_and_save_user_receive.lua:11-58。
问题二:为什么批量分发采用两级 RocketMQ?
- 为什么问:长任务异步化、批量聚合、失败恢复。
- 代码事实:第一级读 Excel、预扣库存、写 Redis Set;第二级从 Set 取数批量落库。
- 原理:文件解析和 DB 写入拆成独立阶段,同步请求不承载整个耗时流程。
- 权衡:便于批量写入,但引入消息重复、Set pop 丢失、任务状态一致性问题。
- 边界:第一级有 120 秒幂等,第二级无统一 MQ 幂等;SPOP 后 DB 失败无自动恢复(压测丢 5000 条的根因);递归重试无上限。
- 生产兜底:业务幂等键、处理中集合、失败状态、死信与人工重试。
- 追问:应用宕机如何继续?批量事务回滚后怎么恢复?重复用户如何处理?
- 证据:
ReadExcelDistributionListener.java:43-138;CouponExecuteDistributionConsumer.java:150-345。
问题三:模板查询如何防穿透和击穿?
- 为什么问:代码同时用了 Bloom Filter、空值缓存、分布式锁三件套。
- 代码事实:先查 Hash → Bloom Filter → 空值缓存 → 锁内双重判定回源。
- 权衡:降低 DB 压力,但 Redis 故障无明显降级;缓存 Key 未含店铺号(§8.2 #12)。
- 生产兜底:店铺号纳入 Key、锁超时、缓存重建、Redis 降级。
- 追问:Bloom Filter 误判怎么办?模板状态变化如何失效缓存?(§8.2 #11:更新不失效缓存)
- 证据:
CouponTemplateServiceImpl.java:48-134;EngineRedisConstant.java:31(卡包 Key)等。
问题四:为什么结算查询使用 Pipeline 和 CompletableFuture?
- 为什么问:网络往返、并行计算、线程池治理。
- 代码事实:Pipeline 批量读 Hash,CompletableFuture 并行试算,最后 join()。
- 权衡:实测 CPU 轻量任务并行反而更慢(4385 vs 4687 QPS,见压测报告 §4.3),保留 Pipeline + 串行方案。
- 边界:线程池非 Spring Bean、无显式关闭;饱和时 CallerRunsPolicy 占用请求线程。
- 生产兜底:统一规则实现、任务超时、单券异常隔离、线程池指标。
- 追问:Pipeline 保证原子性吗(不保证,只省往返)?线程池满了怎么办?
- 证据:
CouponQueryServiceImpl.java:49-130(线程池 49-56、Pipeline 78-81、并行 97-122)。
问题五:如何保证数据库与 Elasticsearch 一致?(简历已无 ES,仅防追问,别主动引出)
- 为什么问:Canal → RocketMQ → ES,最终一致性的真实边界。
- 代码事实:消费者按 INSERT/UPDATE/DELETE 调 save/update/delete。
- 权衡:业务请求不直接承担 ES 写入,但需处理消息丢失、重复、乱序和 ES 故障。
- 边界:无死信、重试、补偿或全量重建;无显式过滤 DDL 和目标表。
- 生产兜底:失败表、重试、死信、索引重建、事件版本。
- 追问:Binlog 丢失怎么办?乱序怎么办?为什么不在事务里直接写 ES?
- 证据:
CanalBinlogSyncCouponTemplateConsumer.java:63-107。
问题六:用户券缓存和数据库的双写一致性怎么处理?(高频)
- 代码事实:两层手段——
save-cache.type支持 direct(流程内直写)/ binlog(Canal 消费 INSERT 异步写)双模式;direct 模式写 ZSet 后回查 score,为空补写(写后查询)。 - 原理:Redis 异步复制下主节点写入成功不代表从节点已同步,主从切换后写入可能丢失;写后查询把”静默丢失”变成”可检测、可补写”。
- 权衡:写后查询成本一次 RTT,只能提高成功率不能保证一致;binlog 模式解耦更彻底但仅处理 INSERT(§8.2 #17)。
- 生产兜底:延时队列重试 + 缓存 TTL 兜底 + 定时对账(比对 DB 与 ZSet 成员数)。
- 追问:为什么不用延迟双删?binlog 模式和延迟双删各自适合什么场景?
- 证据:
UserCouponServiceImpl.java:150-187(ZADD + 写后查询);CanalBinlogSyncUserCouponConsumer.java:66-130。
9.3 简历/口述禁用表述对照表(防吹过头被戳穿)
| 不建议表述 | 保守改写 |
|---|---|
| 支撑近十万 QPS | 报自己的实测值并说明环境(见压测报告) |
| 实现了完整分布式事务 | 通过局部 DB 事务、Redis Lua 和 MQ 幂等完成部分一致性控制,跨资源补偿待完善 |
| 实现可靠消息最终一致性 | RocketMQ 异步解耦,部分消费者幂等;可靠投递和补偿待补充 |
| 实现网关限流熔断 | 当前只有 Gateway 路由、日志和 traceId,限流/认证/熔断未实现 |
| 对接短信/邮件/微信多渠道通知 | 通知策略框架已搭建但发送方法均为空实现,未对接真实渠道 |
| 实现高可用生产部署 | 具备多模块配置和路由,无生产拓扑 |
| 通过策略模式完成全部优惠券计算 | 策略模式已定义,但查询主链路仍是 switch(type) |
| 使用悲观锁解决库存并发 | 带库存条件的原子 UPDATE;无 SELECT … FOR UPDATE |
9.4 避坑与故障排查清单(深挖失守时的防线)
边界预警(多数与 §8.2 已知问题对应,面试可主动交代):
- 性能:结算线程池自建无生命周期管理;批量失败退化逐条写;SQL 日志已开启(生产应收紧)。
- 并发:定时任务查询和状态更新非原子;第二级分发消费者无 MQ 幂等;幂等 TTL 可能短于业务执行;核销锁可能早于事务提交释放。
- 一致性:Redis 预扣失败无回补;SPOP 后落库失败无自动恢复;ES 同步无补偿;写后查询在 Redis 整体宕机时退化为同路径重试,非强保证。
- 压测口径:报数字先报并发数、机器配置、数据规模、P95/P99、错误率,不凭代码宣称倍数。
故障排查八步(被问”线上出问题你怎么查”):
- 请求链路:Gateway URI/方法/traceId/耗时日志 → 确认路由和实例
- 应用日志:GlobalExceptionHandler → Controller/Service/Consumer 错误,按业务 ID 定位
- 线程池:活跃数、拒绝数、任务耗时
- 缓存:Key 结构、Lua 返回值、库存与过期时间
- MQ:发送结果、Topic 积压、重试与死信
- 数据库:分片路由、条件更新影响行数、唯一索引冲突、事务回滚
- 外部依赖:Redis/RocketMQ/ES/Nacos 连通性与超时
- 部署环境:实例数、配置中心、文件路径共享
9.5 三点关键提醒(原《分析项目并结合文档》独有内容)
- 报数字先报环境——脱离环境谈 QPS/TPS 都是耍流氓;所有实测值来自 16G 笔记本全家桶同机部署,属保守值。
- 最大面试雷区是一致性——“Redis 扣了 DB 没扣怎么办”主动承认是已知边界并给演进方案(Outbox/对账),比不懂装懂强得多(对应问题一/二边界)。
简历模块待确认已解决:主讲 engine 模块,压测数据口径已定(1600+ TPS / 4600+ QPS / 15 分钟)。
9.6 分库分表深挖速答(ShardingSphere,技术栈已列、必问概率高)
代码事实(engine 模块 shardingsphere-config.yaml): 两个物理库 one_coupon_0 / one_coupon_1,三张分片表,均为”哈希取模”自定义算法(CLASS_BASED,DBHashModShardingAlgorithm 定库 + TableHashModShardingAlgorithm 定表):
| 逻辑表 | 分片键 | 拓扑 | 为什么这个键 |
|---|---|---|---|
| t_user_coupon 用户券 | user_id | 2 库 × 32 表 | 卡包/核销/结算全部按用户查询,精确路由单表 |
| t_coupon_settlement 结算单 | user_id | 2 库 × 16 表 | 同上,结算天然按用户维度 |
| t_coupon_template 模板 | shop_number | 2 库 × 16 表 | 商家后台查模板全按商家维度 |
必问问题与答法:
- 为什么分库分表? 用户券记录无限增长(百万级名单一次分发即写入百万行),单表千万行级后 B+ 树层高增加、索引效率下降,抢券高峰写并发又集中——按数据增长和写压做的水平拆分。
- 分片键怎么选的? 选最高频的查询维度:用户侧所有读写都带 user_id,路由精确落单表不广播;模板表选 shop_number 同理(商家维度查询)。选错分片键的代价就是全分片广播。
- 不带分片键的查询怎么办? 管理后台统计类查询会广播所有逻辑分片再聚合,频率低可接受;答”选分片键的优先级是交易主链路,后台查询为次”。
- 唯一索引和分表冲突吗?(高频深挖)不冲突:复合唯一索引 (user_id, coupon_template_id, receive_count) 的最左列就是分片键 user_id,同一用户的记录必然落在同一张物理表,唯一性约束不会被分表打破。
- 分库分表带来什么新问题? ① 跨库 JOIN/事务复杂化——项目用”分片内本地事务 + MQ 幂等”规避跨库事务;② 扩容需迁移数据——答”当前量级 2 库够用,成倍扩容 + 数据迁移方案了解过未实施”;③ 非分片键查询广播(见问题 3)。
- ShardingSphere 怎么接入的? JDBC 驱动层增强(shardingsphere-jdbc),应用代码无感知,YAML 配数据源与分片规则,sql-show 开启时可在日志看到实际路由的物理表。
9.7 微服务拆分答辩(简历写了 Spring Cloud,必问”服务怎么拆的”)
开场标准答法(30 秒): 按业务域拆分。引擎服务承担高并发抢券与领取落库,分发服务承担运营侧批量发券,结算服务负责订单核销与用券状态机——读写特性差异大,独立部署、分别扩容;网关作为统一入口,服务间通过 OpenFeign 调用,Nacos 做服务注册与发现。
⚠ 口径修正(v6):旧版开场写”引擎承担高并发抢券与批量分发”,与模块划分不符(批量分发属 distribution);HR 初筛书面回复若已用过旧句,后续统一按新版。
追问”5 个微服务是哪五个”(简历简介已写”按业务域拆分为 5 个微服务”,必问): 网关(gateway,统一入口路由)、商家后台(merchant-admin,模板创建与分发任务管理)、分发(distribution,批量发券)、引擎(engine,抢券领取/延迟关券)、结算(settlement,结算查询与用券状态机)。⚠ 不提 search——ES 已从简历删除,主动说等于自加钩子。记忆口诀:按简介的业务顺序数——商家建券(商家后台)→ 用户抢券(引擎)→ 批量发券(分发)→ 核销结算(结算),外加统一入口(网关)。
为什么这么拆(拆分依据,三个理由按顺序说):
- 读写特性差异:抢券是流量洪峰型(高并发读 + 原子扣减),核销是事务密集型(低频但要求状态强一致)——耦合在一个进程里,扩容只能整体扩,资源浪费
- 故障隔离:结算出问题不拖死抢券主链路,反之亦然
- 迭代隔离:引擎链路需求变化频繁,结算相对稳定,各自发布互不影响
组件答法(全部对应简历技术栈点,背熟):
- Nacos:服务启动时注册,客户端 5 秒一次心跳;15 秒无心跳标记不健康,30 秒剔除下线;消费方本地缓存服务列表 + 订阅变更推送
- OpenFeign:接口 + @FeignClient 注解,启动时扫描生成动态代理,方法调用被翻译成 HTTP 请求,配合 LoadBalancer 客户端负载均衡选实例
- Gateway:编程式网关,按服务名把请求路由到 engine / settlement 等模块,过滤器链里能写 Java 业务逻辑(鉴权、改报文);与 Nginx 的区别——Gateway 可编程、过滤器可写业务,Nginx 静态配置纯流量转发
追问预案:
- “为什么不用单体?” → 初期开发确实快,但流量上来后只能整体扩容、故障不隔离;主动说拆分的代价——跨服务事务复杂化(项目用 MQ 异步 + 幂等消费做最终一致)和运维成本上升。收尾句:“拆是为了读写特性和伸缩差异,不是为了简历好看”
- “服务间同步调用失败怎么办?” → 核心链路尽量不同步调用:领券后落库走 MQ 异步削峰(消费侧 SET NX 幂等 + 失败重投兜底),OpenFeign 只承担低频内部接口调用
- “拆分方案是你定的?“(边界防吹,和 §9.3 禁用表口径一致)→ “拆分方案是团队评审定的,我负责引擎和结算两条核心链路的开发实现”——别揽架构权
证据: gateway 配置按服务名路由到各模块(gateway/src/main/resources/application.yaml);Nacos / OpenFeign 依赖与配置分布在 framework 与各服务模块
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时