第一版把券建成一张表,每张券一行,规则(满减门槛、有效期、适用范围)直接写在行上。看起来最省事,三个问题很快暴露。
一是规则改不动。运营要把某个活动的券从「满 100 减 20」改成「满 80 减 20」,已经发出去的几万张券每张都要 update。而且改了之后用户手里的券和他领取时看到的不一样,这是会被投诉的。
二是超发。发放逻辑是「查已发数量,小于上限就插入一行」。查和插之间有并发窗口,活动开始瞬间几千个请求同时进来,计划发 1000 张实际发出 1300 张。多发的券都是真金白银。
三是叠加算不清。用户有三张券,能不能一起用、先用哪张更划算,前端算一套后端算一套,结算页显示的金额和实际扣款不一致。
所以三个改造对应三个设计:模板与实例分离(规则在模板上,实例只记「谁持有、状态、来源」)、发放量当库存扣(把「计数校验」变成「资源扣减」)、叠加规则显式建模且只由服务端计算。
DECR:扣成功才继续,扣成负数就回补并返回领完。这是把「校验问题」转成「资源扣减问题」,后者有原子操作可用,前者没有。template_id + user_id + 序号 的唯一索引兜底。Redis 和应用层的判断都可能因为异常、重启、缓存丢失而失效,只有数据库的唯一约束是绝对可靠的。撞索引说明重复,捕获后回补发放量。为什么不做通用规则引擎。评估过用 DSL 或者脚本让运营自由配规则,放弃了。原因是能力过剩而风险极高:让运营写表达式意味着线上金额计算逻辑可以被非工程人员改动,一个写错的条件就是资损。我们的选择是把规则收敛成有限的几种类型(满减、折扣、直减、包邮),每种类型的参数固定。新增类型要发版,但发版频率远低于配活动的频率,而且改动经过评审。金额相关的东西,可配置性要让位于可控性。
最优组合为什么不追求全局最优。严格的全局最优需要考虑「这张券这单不用留到下单更划算」这类跨订单决策,那是运筹问题,成本极高而用户感知很弱。我们只做当前订单内的最优,并且在券数量很多时降级为「按预估抵扣额贪心选」。要主动说明这个取舍——声称做了最优但说不清计算复杂度,一问就穿。
踩过的坑:回补发放量导致的双重回补。落库撞唯一索引时回补 Redis 发放量,但如果这个回补本身重试了两次,发放量就多回补一次,最终发出的券会超过上限。修法是回补也要幂等——以「用户 + 模板 + 令牌」记一条回补流水,已回补过的不再回补。补偿动作本身也需要幂等,这是异步链路里最容易漏的一环。
踩过的坑二:券的成本归属没设计,财务对不上。一张券抵扣了 20 元,这 20 是平台补贴还是商家承担、跨境场景下用哪个币种记账,最初完全没考虑,结果对账时商家结算金额算不清。修法是券模板上必须声明成本承担方与分摊比例,结算时按这个比例拆分记账。这不是技术问题但会拖垮上线,面试时提一句能显示你考虑过业务闭环。
没做的部分:没做券的转赠与二级流转。产品提过「券可以送朋友」,涉及风控(薅羊毛团伙互相倒券)和资金合规,当时判断收益不明确、风险不可控,没做。
领券接口 P95:压测工具按 1000 / 2000 / 3000 QPS 梯度压,请求要打散到多个用户 ID,同时保留一个热点模板承接高比例流量——只压随机用户压不出模板维度的热点。3000 QPS 下 P95 约 45ms。要能说出压测的数据规模(模板数、用户数)。
重复领取:限速后对同一用户同一模板连点 5 次,检查数据库产生了几条券实例。改造前每轮可复现重复,改造后未再复现。这类正确性问题用可复现的测试说明,比线上比率可信——线上「重复领取率」的分母根本说不清。
超发:设一个发放量 1000 的模板,用 3000 并发去抢,跑多轮,检查最终发出的券数量是否超过 1000。说「多轮压测未出现超发」而不是「超发为 0」——前者说明你测过,后者是绝对化断言,只要有一个反例就被推翻。
最优组合计算耗时:造一个持有 30 张券的用户,量结算接口里组合计算这一段的耗时。这个数字值得单独报,因为面试官会怀疑枚举会不会很慢,有数字就能直接回答。
不要报「优惠券带来 GMV 提升 X%」。那是运营策略的结果,不是券系统的技术成果,而且受活动力度、商品、时机影响。把工程指标和业务指标分清楚。
where status = '锁定'),谁先执行谁生效,后者影响行数为 0 就走各自的补偿逻辑(支付成功但券已解锁的情况,要么重新锁定要么按无券金额补差,这个要和业务定规则)。能主动说出这个竞态,说明真做过。
最初就是一条 SQL:update sku set stock = stock - 1 where id = ? and stock > 0。这条语句本身是原子的、不会超卖,很多人以为够了。三个问题让它撑不住。
一是热点行锁。爆款商品同一秒几千个请求更新同一行,行锁排队,接口耗时从几毫秒涨到几百毫秒,数据库连接被占满,其他业务跟着一起慢。
二是「什么时候扣」这个问题没答案。下单就扣,那用户不付款库存就白占着(而且要靠取消订单来回补,取消失败就永久少卖);支付成功才扣,那下单到支付这十几分钟里库存是不受控的,一百个人都能下单但只有十个有货,支付成功后才告诉用户没货是最差的体验。
三是跨服务。库存在商品域、订单在订单域,一次下单要跨两个服务,没有分布式事务的话中间任何一步失败都会留下不一致。
所以设计成两段式:Redis 预扣(快,抗并发,占住名额)+ 数据库最终扣减(准,作为账实相符的依据),中间用预占记录和 TTL 把「占了没付」这个状态显式管理起来。
where stock >= n,影响行数为 0 说明库存不够——理论上不该发生(Redis 已经拦过了),发生了就是链路有 bug,要告警。这一层不是为了正常流程,是为了让异常暴露出来。超卖和少卖,我们明确选择宁可少卖。超卖的后果是用户付了钱拿不到货,要退款、要道歉、可能面临监管问题;少卖的后果只是少赚一点。所以所有取舍都朝「不超卖」倾斜:Redis 扣减失败宁可拒绝下单、异常时宁可不回补库存等对账处理。面试时要能说出这个方向以及理由,说「两个都要保证」是不现实的——异步链路必然有不一致窗口,只能选一个方向倾斜。
为什么不用数据库乐观锁(版本号)扛这个并发。乐观锁在高并发下失败率极高:一千个请求抢同一行,只有一个能成功,其余全部要重试,重试又继续冲突,整体吞吐反而更差,而且数据库压力没减。乐观锁适合冲突概率低的场景,抢购正好相反。
踩过的坑:Redis 主从切换导致预扣丢失,出现超卖。主节点扣减后还没同步到从节点就挂了,从节点升主,可用量回到了扣减前的值,于是超发了一批。这个坑说明「Redis 不是绝对可靠的库存来源」。我们的处理不是追求 Redis 强一致(那会牺牲性能),而是承认它可能不准,靠数据库条件扣减和每日对账兜住——落库时 where stock >= n 会挡下超出的部分,那些订单走取消退款流程。关键是要有对账和告警,而不是假装不会发生。
踩过的坑二:售罄标记没及时清除,货还有却卖不出去。预占回滚后回补了可用量但忘了清售罄标记,商品明明有库存但所有请求都在第一步被短路了,损失了一整个活动的销量才被发现。修法是回补和清标记必须在同一个 Lua 脚本里,并且加监控:可用量大于 0 但售罄标记存在,直接告警。
没做的部分:没做多仓库存与就近分配。跨境业务实际有多个海外仓,同一 SKU 在不同仓的库存应该分开管理并按用户位置分配。当时是单一虚拟库存,这个问题没遇到,但它是这个方案往下走必然要面对的复杂度。
超卖验证:设一个库存 100 的 SKU,用 2000 并发下单,跑多轮,检查最终成功的订单数是否超过 100。同时要测异常场景——压测中途重启 Redis、断开一个从节点,看是否超卖、对账能否发现。只在正常路径下压测得出「不超卖」是不够的,面试官会直接问「Redis 挂了呢」。
接口 P95:压测梯度到 2000 QPS,取服务端埋点。要区分有货和售罄两种情况分别报——售罄时走短路,耗时会明显更低,混在一起算会让数字偏好看。
预占回收:造一批下单不支付的订单,量从预占过期到 Redis 可用量回补的延迟(取决于补偿任务的扫描周期)。要说出扫描周期,否则「回收延迟」这个数字没有意义。
需人工介入的量:每日对账产生的偏差记录数。用绝对数「每日个位数以内」而不是「一致率 99.99%」——后者的分母(总订单数)会让分子看起来微不足道,反而掩盖问题,而且一被追问统计方式就说不清。
秒杀的本质要先看清:极短时间内海量请求争抢极少资源,注定绝大多数请求是失败的。1000 件商品面对 10 万请求,99% 的请求从一开始就没有希望。
第一版没有认识到这一点,所有请求都走完整的下单流程——查商品、查库存、算优惠、创建订单,走到最后一步才发现没货了。结果是系统在为注定失败的请求做了全部的工作,数据库和缓存被打满,连那 1% 能成功的请求也超时了。
所以核心设计原则是「让注定失败的请求尽早失败,且失败的成本尽可能低」。每一层都往外推:能在 CDN 挡掉的不进机房,能在网关挡掉的不进业务,能读一个标记判断的不查数据库。
第二个认识是秒杀不该和普通下单共用一套代码路径。普通下单要考虑购物车、多商品、复杂优惠;秒杀是单商品单件、优惠极简。混在一起会让秒杀背上不必要的复杂度,也会让普通下单被秒杀的流量拖垮。
SADD 返回 0 说明已参与,直接返回。但 Redis 判重可能因为重启失效,所以订单表要有「活动 ID + 用户 ID」的唯一索引。两层配合,前者抗量后者保正确。异步下单的体验代价。用户点了之后不是立刻知道结果,而是「排队中」,要等几秒。这在体验上不如同步返回,但秒杀场景用户对排队有心理预期,而且同步返回在这个量级下会大面积超时——超时的体验比排队差得多。折中做法是库存充足的普通活动走同步,真正的秒杀走异步,按活动类型配置。
限流阈值怎么定,以及它必然误杀。阈值是压测出来的:找到系统在可接受延迟下的最大吞吐,取其八成作为限流阈值。限流必然会误杀一部分正常用户,这是设计上接受的——保住系统不崩比让所有人都能提交更重要。要主动承认这一点,说「限流不影响正常用户」是不成立的。
为什么不做验证码或答题。这两个是防黄牛的经典手段,也能天然削峰。我们没做,原因是转化率损失太大——国际业务里验证码的识别成本和多语言适配都是问题,而且正常用户流失明显。替代方案是用户资格预校验(活动只对注册满一定时间、有过真实消费的账号开放),把门槛前置到活动之前而不是点击那一刻。说出「为什么不做」以及替代方案,比说做了一堆手段更有说服力。
踩过的坑:本地缓存预热在扩容时失效。大促前扩容了一批实例,新实例启动时没有触发预热,秒杀开始后这些实例每个请求都去查数据库,把数据库打出一波慢查询。修法是把预热放进启动健康检查——预热完成前实例不加入负载均衡。这个坑的教训是:任何「启动时加载」的逻辑都要和健康检查绑定,否则扩容和重启时会暴露。
踩过的坑二:消息积压导致排队结果迟迟不出。异步下单的消费端速率设得太保守,消息积压了几万条,用户排队十几分钟才出结果,大量投诉。修法是消费端速率要按数据库实际承载能力动态调整,并且监控队列积压量和最老消息年龄,积压超阈值就告警并临时扩容消费者。削峰不等于可以无限慢,用户的等待耐心是有上限的。
没做的部分:没做多活动之间的资源隔离。同时跑多个秒杀活动时,它们共用同一套限流和队列,一个热门活动会挤占另一个的资源。当时活动是串行安排的,没遇到,但这是明显的设计缺口。
峰值 QPS 与 P95:压测按 2000 / 5000 / 8000 QPS 梯度加压,要模拟真实的请求构成——大部分是售罄后的无效请求、少部分是有效抢购、还有一部分是重复点击。只用有效请求压测得出的数字不真实,因为真实流量里短路路径占绝大多数。
「进入下单逻辑的请求量」:在第 4 层入口埋计数器,对比网关入口的总请求数。改造前两者持平(所有请求都走到底),改造后进入下单逻辑的请求降到和库存量同一数量级。这是这个模块最有说服力的数字,它直接说明分层拦截生效了。用「同一数量级」而不是精确比率——精确比率取决于压测构成,换个构成就变了。
数据库写 QPS:压测期间看数据库监控。改造前随请求量飙升,改造后稳定在 200 上下(由消费端速率决定)。被追问时要能说出消费端的并发与批量参数,这个数字是配置出来的,不是自然形成的。
排队等待时长:要主动报这个,否则会被质疑「你只是把慢转移到了后台」。量从投递消息到订单创建完成的耗时分布,说明在可接受范围。
不要报「秒杀活动 GMV」或「参与人数」。那是运营成果。也不要报「系统零故障」——绝对化表述一问就穿,改成「活动期间未发生影响下单的故障,有 N 次告警均在影响用户前处理」这种可核查的描述。
SADD,原子操作,返回 1 才继续,返回 0 说明已参与——这一层挡掉并发请求里的绝大多数。第二层是订单表上「活动 ID + 用户 ID」的唯一索引,撞索引就说明重复,回补库存。为什么需要第二层:Redis 可能重启丢数据、可能主从切换、集合可能被误清,只有数据库唯一约束是绝对可靠的。这里要注意顺序——先判重再扣库存,不然会出现「库存扣了但判重失败」,虽然有回补但增加了不必要的竞争。
没有匹配的内容,换个关键词试试。
项目拆解 · 营销与优惠券(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据