怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

项目背景设定 国际虚拟商品平台的营销与交易保障侧,Java + Spring Cloud + Redis。核心场景是优惠券的发放与结算库存扣减限时活动的流量承接
为什么选这三个模块 这三个是电商后端面试问得最深也最容易问穿的地方:优惠券考的是复杂规则怎么建模、库存考的是并发正确性、秒杀考的是流量分层。共同点是都有明确的对错,不是「我用了 Redis」能糊过去的——面试官会直接构造并发场景问你结果是什么。

模块一:优惠券体系

  1. 优惠券体系(模板与实例分离 + 幂等发放 + 叠加规则)★★★
    简历这样写 优惠券系统设计与落地(Spring Boot + MySQL + Redis + 幂等令牌):把券拆成「模板(规则)」与「实例(用户持有)」两层,发放量当作库存来扣减以防超发,领取走幂等令牌保证重复请求只得一张;结算时按互斥组与优先级计算可用组合,计算过程留痕可回放。压测 3000 QPS 下领券接口 P95 约 45ms,同一用户重复领取由每轮可复现降至未再复现,多轮压测未出现超发。
    展开完整拆解
    为什么要这么设计

    第一版把券建成一张表,每张券一行,规则(满减门槛、有效期、适用范围)直接写在行上。看起来最省事,三个问题很快暴露。

    一是规则改不动。运营要把某个活动的券从「满 100 减 20」改成「满 80 减 20」,已经发出去的几万张券每张都要 update。而且改了之后用户手里的券和他领取时看到的不一样,这是会被投诉的。

    二是超发。发放逻辑是「查已发数量,小于上限就插入一行」。查和插之间有并发窗口,活动开始瞬间几千个请求同时进来,计划发 1000 张实际发出 1300 张。多发的券都是真金白银

    三是叠加算不清。用户有三张券,能不能一起用、先用哪张更划算,前端算一套后端算一套,结算页显示的金额和实际扣款不一致。

    所以三个改造对应三个设计:模板与实例分离(规则在模板上,实例只记「谁持有、状态、来源」)、发放量当库存扣(把「计数校验」变成「资源扣减」)、叠加规则显式建模且只由服务端计算

    整体链路
    数据模型(两层) │ ├─ coupon_template 券模板:规则的唯一来源 │ 门槛 / 优惠方式(满减·折扣·直减)/ 适用范围 / 有效期规则 │ 发放总量 / 每人限领 / 互斥组 / 优先级 / 状态 │ └─ coupon_instance 券实例:用户持有的那一张 template_id / user_id / 状态(未用·锁定·已用·已过期) 领取时间 / 生效与失效时刻(领取时按模板规则算好并固定下来) 订单号(用掉时回填) 唯一索引:template_id + user_id + 序号 ← 每人限领由它兜底 领券 │ ├─ 校验:模板状态 / 活动时间 / 用户资格(新客·等级·地区) │ ├─ 幂等:以「用户 + 模板 + 幂等令牌」查是否已领 → 已领直接返回那张 │ ├─ 扣发放量:Redis 原子 DECR(发放量当库存用) │ └─ 扣成负数 → 立即回补并返回已领完 │ ├─ 落库券实例(唯一索引兜底,撞了就说明重复,回补发放量) │ └─ 返回券实例(有效期此刻固定,之后改模板不影响已发出的券) 结算(金额只由服务端算) │ ├─ 取用户可用券 → 按适用范围过滤(商品·品类·地区·支付方式) │ ├─ 按互斥组分组:同组内只能选一张,跨组可叠加 │ ├─ 组合求解:各组内枚举,组间取最优(组数有上限,可穷举) │ ├─ 输出:最优组合 + 每张券的抵扣明细 + 计算依据(落日志可回放) │ └─ 下单时锁定这几张券(状态改锁定),支付成功才置已用 支付失败或超时 → 解锁回到未用
    分步拆解
    1. 模板与实例分离是整个设计的地基。规则只存在模板上,实例只记「谁持有、什么状态」。但有个例外必须处理:有效期要在领取时算好并固定到实例上。因为「领取后 7 天有效」这种规则,每个人的失效时刻不同,不能每次读取时用模板重算——否则改模板会让已发出的券集体变化。规则里「跟人跟时间」的部分要落到实例,其余留在模板,这个切分是最容易出错的地方。
    2. 发放量当库存扣,而不是查计数。「查已发数量再判断」有并发窗口,无论加多少校验都堵不住。改成 Redis 的原子 DECR:扣成功才继续,扣成负数就回补并返回领完。这是把「校验问题」转成「资源扣减问题」,后者有原子操作可用,前者没有。
    3. 唯一索引是最后一道防线。每人限领 N 张,用 template_id + user_id + 序号 的唯一索引兜底。Redis 和应用层的判断都可能因为异常、重启、缓存丢失而失效,只有数据库的唯一约束是绝对可靠的。撞索引说明重复,捕获后回补发放量。
    4. 幂等令牌解决客户端重试。用户网络慢连点三次、客户端超时自动重试,服务端要保证只发一张。以「用户 + 模板 + 令牌」为幂等键,重复请求返回第一次的结果而不是报错。
    5. 叠加规则用「互斥组 + 优先级」建模。同一互斥组内的券只能选一张(比如所有「满减券」互斥),不同组可以叠加(满减券 + 运费券 + 支付渠道券)。不要用「券 A 不能和券 B 一起用」这种两两关系,那是 N 平方的配置量,运营配不对也维护不了。
    6. 最优组合用有限枚举,不做全排列。互斥组的数量是有限的(通常三五个),每组内的候选券也有限,枚举组合数在可接受范围。关键是要给组合数设上限并降级——用户手里有几十张券时,先按面值预筛每组的前几张再枚举,避免计算爆炸。
    7. 计算过程必须落日志。「为什么这单只抵扣了 20 而不是 30」是客服高频问题。把参与计算的券、每张的判定结果(为什么不可用)、最终组合都记下来,能回放才能解释。这一步在出问题时的价值远超它的成本。
    8. 券要有「锁定」中间态。下单时锁定、支付成功才置已用、支付失败解锁。没有锁定态会出现两种事故:一是下单就置已用,用户没付款券也没了;二是支付成功才置已用,那么下单到支付这段时间里同一张券可以被多个订单同时使用。
    关键决策与取舍

    为什么不做通用规则引擎。评估过用 DSL 或者脚本让运营自由配规则,放弃了。原因是能力过剩而风险极高:让运营写表达式意味着线上金额计算逻辑可以被非工程人员改动,一个写错的条件就是资损。我们的选择是把规则收敛成有限的几种类型(满减、折扣、直减、包邮),每种类型的参数固定。新增类型要发版,但发版频率远低于配活动的频率,而且改动经过评审。金额相关的东西,可配置性要让位于可控性。

    最优组合为什么不追求全局最优。严格的全局最优需要考虑「这张券这单不用留到下单更划算」这类跨订单决策,那是运筹问题,成本极高而用户感知很弱。我们只做当前订单内的最优,并且在券数量很多时降级为「按预估抵扣额贪心选」。要主动说明这个取舍——声称做了最优但说不清计算复杂度,一问就穿。

    踩过的坑:回补发放量导致的双重回补。落库撞唯一索引时回补 Redis 发放量,但如果这个回补本身重试了两次,发放量就多回补一次,最终发出的券会超过上限。修法是回补也要幂等——以「用户 + 模板 + 令牌」记一条回补流水,已回补过的不再回补。补偿动作本身也需要幂等,这是异步链路里最容易漏的一环。

    踩过的坑二:券的成本归属没设计,财务对不上。一张券抵扣了 20 元,这 20 是平台补贴还是商家承担、跨境场景下用哪个币种记账,最初完全没考虑,结果对账时商家结算金额算不清。修法是券模板上必须声明成本承担方与分摊比例,结算时按这个比例拆分记账。这不是技术问题但会拖垮上线,面试时提一句能显示你考虑过业务闭环

    没做的部分:没做券的转赠与二级流转。产品提过「券可以送朋友」,涉及风控(薅羊毛团伙互相倒券)和资金合规,当时判断收益不明确、风险不可控,没做。

    数字是怎么测的

    领券接口 P95:压测工具按 1000 / 2000 / 3000 QPS 梯度压,请求要打散到多个用户 ID,同时保留一个热点模板承接高比例流量——只压随机用户压不出模板维度的热点。3000 QPS 下 P95 约 45ms。要能说出压测的数据规模(模板数、用户数)。

    重复领取:限速后对同一用户同一模板连点 5 次,检查数据库产生了几条券实例。改造前每轮可复现重复,改造后未再复现。这类正确性问题用可复现的测试说明,比线上比率可信——线上「重复领取率」的分母根本说不清。

    超发:设一个发放量 1000 的模板,用 3000 并发去抢,跑多轮,检查最终发出的券数量是否超过 1000。说「多轮压测未出现超发」而不是「超发为 0」——前者说明你测过,后者是绝对化断言,只要有一个反例就被推翻。

    最优组合计算耗时:造一个持有 30 张券的用户,量结算接口里组合计算这一段的耗时。这个数字值得单独报,因为面试官会怀疑枚举会不会很慢,有数字就能直接回答。

    不要报「优惠券带来 GMV 提升 X%」。那是运营策略的结果,不是券系统的技术成果,而且受活动力度、商品、时机影响。把工程指标和业务指标分清楚。

    面试追问
    Q:一万个人同时抢 1000 张券,你怎么保证不超发? A:三层。第一层是 Redis 原子 DECR,把发放量当库存扣,扣成负数就回补并返回领完——这一层挡掉绝大部分并发。第二层是数据库唯一索引(模板 + 用户 + 序号),防的是同一用户的重复领取,也是 Redis 失效时的兜底。第三层是发放总量的最终校验,落库时用条件更新检查已发数量。为什么要三层:Redis 可能重启丢数据、可能主从切换丢写入,只靠它不够;但只靠数据库的话热点行锁扛不住这个并发量。Redis 负责扛量,数据库负责保证正确性,两者职责不同。
    Q:用户手里有满 100 减 20、满 100 减 15、九折券,一单 200 块,怎么算? A:先看互斥组。前两张都是满减券,通常在同一互斥组里,只能选一张;折扣券如果是另一组,可以和满减叠加。所以候选组合是「满 20 + 九折」和「满 15 + 九折」,还要考虑叠加顺序——先减后折还是先折后减,结果不一样(200 减 20 再九折是 162,200 九折再减 20 是 160)。顺序必须在规则里明确定义,不能由代码实现顺序偶然决定,否则同一组券在不同版本算出不同金额。我们定的是先减后折。最后取抵扣最多的组合,并把「为什么另一张没用上」记进日志。
    Q:券锁定了但用户一直不付款,也不取消,券就永久锁死了? A:不会,锁定必须带超时。两个机制:一是锁定时记下过期时刻(跟随订单的支付超时,通常十几分钟),二是定时任务扫超时的锁定券并解锁。这里有个必须注意的竞态——解锁和支付成功可能同时发生:定时任务判定超时要解锁,而支付回调正好到了要置已用。解法是两边都用条件更新(where status = '锁定'),谁先执行谁生效,后者影响行数为 0 就走各自的补偿逻辑(支付成功但券已解锁的情况,要么重新锁定要么按无券金额补差,这个要和业务定规则)。能主动说出这个竞态,说明真做过。
    Q:运营发现券配错了,已经发出去几万张,怎么办? A:分情况,不能一律作废。如果是「优惠力度配大了」,作废已发出的券会引发大量投诉甚至舆情,通常的处理是先停止发放(模板置失效,不影响已发出的),然后评估损失,力度可承受就认了。如果是「适用范围配错」导致券能用在不该用的商品上,那就紧急调整适用范围——注意适用范围是留在模板上的,改模板立刻生效,这正是模板实例分离带来的好处。如果必须回收,要走补偿而不是直接删除:作废券的同时给用户发一张等价或更优的替代券,并且要有审批和留痕。这个问题问的是事故处理意识,答「直接改数据库把券删了」会被直接否掉。

模块二:库存预占与超卖防护

  1. 库存预占与超卖防护(Redis 预扣 + 落库扣减 + TTL 回滚)★★★
    简历这样写 交易库存扣减链路(Redis + Lua 原子预扣 + 消息驱动落库 + 补偿任务):下单走 Redis 原子预扣并写预占记录,支付成功后由消息驱动数据库最终扣减,未支付的预占由 TTL 与补偿任务回收;对超卖与少卖做了明确取舍,热点商品按库存分桶打散。压测 2000 QPS 抢购下多轮未出现超卖,预占泄漏由补偿任务回收,需人工介入的每日在个位数以内。
    展开完整拆解
    为什么要这么设计

    最初就是一条 SQL:update sku set stock = stock - 1 where id = ? and stock > 0。这条语句本身是原子的、不会超卖,很多人以为够了。三个问题让它撑不住。

    一是热点行锁。爆款商品同一秒几千个请求更新同一行,行锁排队,接口耗时从几毫秒涨到几百毫秒,数据库连接被占满,其他业务跟着一起慢

    二是「什么时候扣」这个问题没答案。下单就扣,那用户不付款库存就白占着(而且要靠取消订单来回补,取消失败就永久少卖);支付成功才扣,那下单到支付这十几分钟里库存是不受控的,一百个人都能下单但只有十个有货,支付成功后才告诉用户没货是最差的体验

    三是跨服务。库存在商品域、订单在订单域,一次下单要跨两个服务,没有分布式事务的话中间任何一步失败都会留下不一致。

    所以设计成两段式:Redis 预扣(快,抗并发,占住名额)+ 数据库最终扣减(准,作为账实相符的依据),中间用预占记录和 TTL 把「占了没付」这个状态显式管理起来。

    整体链路
    下单(预占) │ ├─ 读缓存里的售罄标记 → 已售罄直接返回,不进后续逻辑 │ ├─ Lua 脚本原子执行:判断可用量 → 扣减 → 写预占记录 │ 一个脚本内完成,避免「判断」和「扣减」之间的并发窗口 │ 预占记录:订单号 / SKU / 数量 / 过期时刻 │ ├─ 扣减后剩余为 0 → 打上售罄标记(后续请求在第一步就被短路) │ ├─ 创建订单(状态待支付,带支付超时时刻) │ └─ 预扣成功但建单失败 → 立即回补 Redis(同一订单号幂等回补) 支付成功(落库) │ ├─ 支付成功消息 ─▶ 库存域 │ ├─ 数据库条件扣减: │ update sku set stock = stock - n, sold = sold + n │ where id = ? and stock >= n │ (消息可能重复,用订单号做幂等表,已处理的直接跳过) │ └─ 预占记录置已确认 超时未支付(回滚) │ ├─ 预占记录到期 → 补偿任务扫出来 │ ├─ 校验订单确实未支付(防止和支付回调竞态) │ ├─ 回补 Redis 可用量 + 清除售罄标记 │ └─ 预占记录置已回滚(幂等:已回滚的不再处理) 对账(每日) └─ 以订单为准重算应扣量,与数据库库存、Redis 可用量三方核对 偏差超阈值 → 告警 + 人工介入(不自动改数) 热点商品:库存分桶 └─ 1000 件拆成 10 个桶各 100 件,请求按用户哈希落桶 桶空了但总量还有 → 跨桶借用(有并发成本,只在必要时触发)
    分步拆解
    1. Lua 脚本保证「判断 + 扣减 + 写预占」的原子性。分成三个 Redis 命令的话,判断和扣减之间就有窗口。Redis 执行 Lua 是单线程原子的,把这三步塞进一个脚本是这个方案成立的前提
    2. 售罄标记做短路,收益极大。商品卖完之后,剩下的请求不需要执行 Lua、不需要碰预占逻辑,读一个标记就返回。秒杀场景下大部分请求都是注定失败的,让它们尽早失败且成本最低,是抗住流量的关键。
    3. 预占记录必须持久化,不能只在 Redis 里。它是「谁占了多少、什么时候过期」的凭证,Redis 丢了就无法回滚也无法对账。做法是Redis 存可用量(要快),数据库存预占明细(要可靠),两者职责分开。
    4. 落库扣减用条件更新做第二道防线。where stock >= n,影响行数为 0 说明库存不够——理论上不该发生(Redis 已经拦过了),发生了就是链路有 bug,要告警。这一层不是为了正常流程,是为了让异常暴露出来。
    5. 消息幂等靠订单号。支付成功消息会重复投递,用订单号建幂等表(唯一索引),已处理的直接跳过。不能靠「判断订单状态是否已扣减」,那又是查再改的并发窗口。
    6. 回滚要校验订单真实状态。补偿任务扫到过期预占,不能直接回补——可能支付回调正在处理中。必须先确认订单确实未支付,而且回补和支付落库两边都用条件更新,谁先谁生效。
    7. 热点商品做库存分桶。把总量拆成若干桶,请求按用户 ID 哈希落到某个桶,把单点竞争分散开。代价是碎片——某个桶空了但其他桶还有,用户会看到售罄。所以要有跨桶借用:桶空了去别的桶借,借用逻辑有并发成本,只在必要时触发。
    8. 退款要不要回补库存,取决于商品类型。虚拟商品(卡密)已发货的不能回补(卡密已经暴露给用户了),实物退货入库后才回补。这个规则要写在 SKU 属性上,不能由代码默认某种行为。
    关键决策与取舍

    超卖和少卖,我们明确选择宁可少卖。超卖的后果是用户付了钱拿不到货,要退款、要道歉、可能面临监管问题;少卖的后果只是少赚一点。所以所有取舍都朝「不超卖」倾斜:Redis 扣减失败宁可拒绝下单、异常时宁可不回补库存等对账处理。面试时要能说出这个方向以及理由,说「两个都要保证」是不现实的——异步链路必然有不一致窗口,只能选一个方向倾斜。

    为什么不用数据库乐观锁(版本号)扛这个并发。乐观锁在高并发下失败率极高:一千个请求抢同一行,只有一个能成功,其余全部要重试,重试又继续冲突,整体吞吐反而更差,而且数据库压力没减。乐观锁适合冲突概率低的场景,抢购正好相反。

    踩过的坑:Redis 主从切换导致预扣丢失,出现超卖。主节点扣减后还没同步到从节点就挂了,从节点升主,可用量回到了扣减前的值,于是超发了一批。这个坑说明「Redis 不是绝对可靠的库存来源」。我们的处理不是追求 Redis 强一致(那会牺牲性能),而是承认它可能不准,靠数据库条件扣减和每日对账兜住——落库时 where stock >= n 会挡下超出的部分,那些订单走取消退款流程。关键是要有对账和告警,而不是假装不会发生。

    踩过的坑二:售罄标记没及时清除,货还有却卖不出去。预占回滚后回补了可用量但忘了清售罄标记,商品明明有库存但所有请求都在第一步被短路了,损失了一整个活动的销量才被发现。修法是回补和清标记必须在同一个 Lua 脚本里,并且加监控:可用量大于 0 但售罄标记存在,直接告警。

    没做的部分:没做多仓库存与就近分配。跨境业务实际有多个海外仓,同一 SKU 在不同仓的库存应该分开管理并按用户位置分配。当时是单一虚拟库存,这个问题没遇到,但它是这个方案往下走必然要面对的复杂度。

    数字是怎么测的

    超卖验证:设一个库存 100 的 SKU,用 2000 并发下单,跑多轮,检查最终成功的订单数是否超过 100。同时要测异常场景——压测中途重启 Redis、断开一个从节点,看是否超卖、对账能否发现。只在正常路径下压测得出「不超卖」是不够的,面试官会直接问「Redis 挂了呢」。

    接口 P95:压测梯度到 2000 QPS,取服务端埋点。要区分有货售罄两种情况分别报——售罄时走短路,耗时会明显更低,混在一起算会让数字偏好看。

    预占回收:造一批下单不支付的订单,量从预占过期到 Redis 可用量回补的延迟(取决于补偿任务的扫描周期)。要说出扫描周期,否则「回收延迟」这个数字没有意义。

    需人工介入的量:每日对账产生的偏差记录数。用绝对数「每日个位数以内」而不是「一致率 99.99%」——后者的分母(总订单数)会让分子看起来微不足道,反而掩盖问题,而且一被追问统计方式就说不清。

    面试追问
    Q:为什么不直接用数据库的 update stock = stock - 1 where stock > 0?它本身不会超卖。 A:正确性没问题,问题在性能和业务语义。性能上,爆款商品几千并发更新同一行,行锁串行排队,接口耗时飙升并且占满数据库连接,会拖垮同库的其他业务。业务语义上,这条语句只解决了「扣」,没解决「什么时候扣、占了不付怎么办」——下单就扣要靠取消订单回补,取消流程失败就永久少卖;支付才扣则下单阶段库存不受控。所以需要预占这个中间状态。低并发场景下这条 SQL 完全够用,不要为了显得复杂而过度设计——这句话也要说出来,说明你知道方案要匹配量级。
    Q:Redis 挂了,整个下单是不是就不能用了? A:要看降级策略,而且这个策略必须按商品价值分档。对普通商品,可以降级为直接走数据库条件扣减(牺牲性能保可用,此时热点商品会慢但能用)。对秒杀活动,Redis 挂了应该直接停止活动而不是降级——因为降级到数据库根本扛不住秒杀的量,硬降级会把数据库打挂,影响面更大。「有些场景的正确降级是拒绝服务」,这个判断很重要,不是所有故障都要想办法继续提供服务。另外 Redis 本身要有高可用部署,主从加哨兵或者集群,单点故障的概率要先压下来。
    Q:库存分桶之后,某个桶卖完了但其他桶还有货,用户看到售罄,怎么办? A:这是分桶的固有代价,处理办法是跨桶借用:请求落到的桶空了,就按顺序去其他桶尝试扣减。但借用会引入新的竞争(大家都去借同一个桶),所以要限制——只在自己的桶空了才借,且借用时随机选起点桶避免都从第一个开始。另一种更简单的思路是动态再平衡:定时任务把各桶余量重新摊平。两种都可以,取决于活动时长——短促销用借用(没时间做再平衡),长期在售用再平衡。还有个前提要说清:分桶只对极热点商品做,普通商品分桶是徒增复杂度。
    Q:怎么知道你的库存到底准不准? A:靠每日对账,而且要三方核对:以订单为准算出应扣总量数据库里的库存与已售数Redis 里的可用量。三者应该自洽,偏差超阈值就告警。关键是对账发现偏差后不自动改数——自动改会掩盖 bug,而且改错方向会造成二次损失。正确做法是告警加人工核查,找到根因再修。另外要有单独的监控看「Redis 可用量与数据库剩余量的差」,正常情况下这个差等于未确认的预占量,如果长期偏离就说明预占回收有问题。

模块三:限时秒杀与大促削峰

  1. 限时秒杀与大促削峰(分层限流 + 异步下单 + 静态化)★★★
    简历这样写 秒杀与大促流量承接(Nginx/网关限流 + Redis + 消息队列削峰 + CDN 静态化):按「静态化拦截 → 网关限流 → 用户频控 → 售罄短路 → 异步下单」分层收敛流量,让注定失败的请求在最外层以最低成本被拒绝;活动页与库存状态静态化并由事件驱动刷新。压测峰值 8000 QPS 下秒杀接口 P95 约 60ms,真正进入下单逻辑的请求由与总请求量持平降到与库存量同一数量级,数据库写 QPS 稳定在 200 上下
    展开完整拆解
    为什么要这么设计

    秒杀的本质要先看清:极短时间内海量请求争抢极少资源,注定绝大多数请求是失败的。1000 件商品面对 10 万请求,99% 的请求从一开始就没有希望。

    第一版没有认识到这一点,所有请求都走完整的下单流程——查商品、查库存、算优惠、创建订单,走到最后一步才发现没货了。结果是系统在为注定失败的请求做了全部的工作,数据库和缓存被打满,连那 1% 能成功的请求也超时了。

    所以核心设计原则是「让注定失败的请求尽早失败,且失败的成本尽可能低」。每一层都往外推:能在 CDN 挡掉的不进机房,能在网关挡掉的不进业务,能读一个标记判断的不查数据库。

    第二个认识是秒杀不该和普通下单共用一套代码路径。普通下单要考虑购物车、多商品、复杂优惠;秒杀是单商品单件、优惠极简。混在一起会让秒杀背上不必要的复杂度,也会让普通下单被秒杀的流量拖垮。

    整体链路
    流量从外到内,每层都在减量 第 0 层 客户端 ├─ 按钮点击后立即禁用 + 倒计时由服务端时间校准 └─ 活动未开始时按钮不可点(挡掉大量提前点击) 第 1 层 CDN / 静态化 ├─ 活动页 HTML、图片、JS 全部静态化推 CDN ├─ 库存状态做成静态 JSON 定时刷新(秒级),客户端先读它 └─ 结果:页面浏览的流量压根不进机房 第 2 层 网关 ├─ 单 IP / 单用户维度限流(令牌桶) ├─ 活动未开始或已结束 → 直接拒绝 └─ 非法请求过滤(无签名、UA 异常、参数不合法) 第 3 层 应用(读缓存即可判断的都在这层拦) ├─ 售罄标记 → 命中直接返回,不查库存不查商品 ├─ 用户频控:一人一单,Redis 记录已参与 → 命中直接返回 └─ 活动预热数据全在本地缓存(商品信息、活动配置) 第 4 层 库存预扣(真正的资源争抢,只有极少量请求到这里) ├─ Lua 原子扣减 + 写预占 └─ 扣成负数 → 回补 + 打售罄标记(后续在第 3 层就被拦) 第 5 层 异步下单 ├─ 预扣成功 → 投递下单消息,接口立即返回「排队中 + 排队号」 ├─ 消费端按正常速率创建订单(数据库写入被削峰成平稳流量) └─ 客户端轮询排队结果 → 成功则跳支付页 兜底 ├─ 预扣成功但下单消息丢失 → 预占 TTL 到期回补库存 ├─ 排队超时未出结果 → 明确告知,不让用户重复点 └─ 活动结束后统一清理未消费的消息与残留预占
    分步拆解
    1. 活动页和库存状态静态化,这是减量最多的一步。秒杀期间绝大部分请求是「刷页面看还有没有货」,把页面和库存状态做成静态资源推 CDN,客户端定时拉,这部分流量根本不进机房。库存状态有秒级延迟,用户可能看到有货点进去发现没了——这个体验代价是可接受的,秒杀场景用户对此有预期。
    2. 倒计时用服务端时间校准。客户端本地时间不可信(可以改),而且时钟偏差会导致一部分用户提前点。做法是页面加载时取一次服务端时间算出偏移量,倒计时按校准后的时间走。服务端也要独立校验活动时间,不能信客户端说「现在开始了」。
    3. 网关限流按用户和 IP 双维度。只按 IP 会误伤同一出口的正常用户(学校、公司),只按用户挡不住批量账号。两个维度都限,阈值不同。限流的返回要明确——返回「请稍后重试」而不是 500,客户端才能正确提示。
    4. 售罄标记短路是应用层减量的核心。库存扣完的那一刻打标记,之后的请求读一个 Redis key 就返回,不查商品、不查库存、不进 Lua。标记要能被回滚流程正确清除(前面模块讲过的坑)。
    5. 一人一单用 Redis 集合判重,数据库唯一索引兜底。SADD 返回 0 说明已参与,直接返回。但 Redis 判重可能因为重启失效,所以订单表要有「活动 ID + 用户 ID」的唯一索引。两层配合,前者抗量后者保正确。
    6. 活动数据全部预热到本地缓存。商品名、价格、活动规则在活动开始前就加载到应用本地内存,秒杀期间零远程调用。要处理预热失败和实例扩容——新扩容的实例启动时要能自己拉一次预热数据,否则会出现部分实例没数据。
    7. 异步下单把数据库写入削平。预扣成功后不同步创建订单,而是投消息返回「排队中」,消费端按平稳速率写库。这是数据库写 QPS 从峰值降到 200 的原因。代价是用户要等结果,所以要给排队号和明确的等待提示。
    8. 排队结果要能查询,且有超时兜底。客户端轮询「我的排队结果」,退避间隔。超时未出结果不能显示失败(可能已经下单成功了),要显示「处理中,稍后在订单列表查看」——和支付链路的处理原则一致。
    9. 秒杀走独立的服务或至少独立的线程池与数据库连接池。否则秒杀的流量会把普通下单的资源占满。隔离是大促保障的基本要求,不是优化项。
    关键决策与取舍

    异步下单的体验代价。用户点了之后不是立刻知道结果,而是「排队中」,要等几秒。这在体验上不如同步返回,但秒杀场景用户对排队有心理预期,而且同步返回在这个量级下会大面积超时——超时的体验比排队差得多。折中做法是库存充足的普通活动走同步,真正的秒杀走异步,按活动类型配置。

    限流阈值怎么定,以及它必然误杀。阈值是压测出来的:找到系统在可接受延迟下的最大吞吐,取其八成作为限流阈值。限流必然会误杀一部分正常用户,这是设计上接受的——保住系统不崩比让所有人都能提交更重要。要主动承认这一点,说「限流不影响正常用户」是不成立的。

    为什么不做验证码或答题。这两个是防黄牛的经典手段,也能天然削峰。我们没做,原因是转化率损失太大——国际业务里验证码的识别成本和多语言适配都是问题,而且正常用户流失明显。替代方案是用户资格预校验(活动只对注册满一定时间、有过真实消费的账号开放),把门槛前置到活动之前而不是点击那一刻。说出「为什么不做」以及替代方案,比说做了一堆手段更有说服力。

    踩过的坑:本地缓存预热在扩容时失效。大促前扩容了一批实例,新实例启动时没有触发预热,秒杀开始后这些实例每个请求都去查数据库,把数据库打出一波慢查询。修法是把预热放进启动健康检查——预热完成前实例不加入负载均衡。这个坑的教训是:任何「启动时加载」的逻辑都要和健康检查绑定,否则扩容和重启时会暴露。

    踩过的坑二:消息积压导致排队结果迟迟不出。异步下单的消费端速率设得太保守,消息积压了几万条,用户排队十几分钟才出结果,大量投诉。修法是消费端速率要按数据库实际承载能力动态调整,并且监控队列积压量和最老消息年龄,积压超阈值就告警并临时扩容消费者。削峰不等于可以无限慢,用户的等待耐心是有上限的。

    没做的部分:没做多活动之间的资源隔离。同时跑多个秒杀活动时,它们共用同一套限流和队列,一个热门活动会挤占另一个的资源。当时活动是串行安排的,没遇到,但这是明显的设计缺口。

    数字是怎么测的

    峰值 QPS 与 P95:压测按 2000 / 5000 / 8000 QPS 梯度加压,要模拟真实的请求构成——大部分是售罄后的无效请求、少部分是有效抢购、还有一部分是重复点击。只用有效请求压测得出的数字不真实,因为真实流量里短路路径占绝大多数。

    「进入下单逻辑的请求量」:在第 4 层入口埋计数器,对比网关入口的总请求数。改造前两者持平(所有请求都走到底),改造后进入下单逻辑的请求降到和库存量同一数量级。这是这个模块最有说服力的数字,它直接说明分层拦截生效了。用「同一数量级」而不是精确比率——精确比率取决于压测构成,换个构成就变了。

    数据库写 QPS:压测期间看数据库监控。改造前随请求量飙升,改造后稳定在 200 上下(由消费端速率决定)。被追问时要能说出消费端的并发与批量参数,这个数字是配置出来的,不是自然形成的。

    排队等待时长:要主动报这个,否则会被质疑「你只是把慢转移到了后台」。量从投递消息到订单创建完成的耗时分布,说明在可接受范围。

    不要报「秒杀活动 GMV」或「参与人数」。那是运营成果。也不要报「系统零故障」——绝对化表述一问就穿,改成「活动期间未发生影响下单的故障,有 N 次告警均在影响用户前处理」这种可核查的描述。

    面试追问
    Q:秒杀最关键的一步是什么?如果只能做一件事,你做哪个? A:售罄短路。因为它以极低的成本(读一个 Redis key)拦掉了占比最大的那部分流量——库存卖完之后涌进来的请求。静态化和限流也重要,但如果只能做一件,短路的投入产出比最高,改动量小、效果立竿见影。更本质地说,秒杀优化的核心思路只有一句:让注定失败的请求尽早失败,售罄短路是这句话最直接的实现。能给出优先级排序而不是罗列所有手段,是这道题的关键。
    Q:一人一单,用户用脚本并发发 100 个请求,怎么保证只成功一次? A:两层。第一层是 Redis 的 SADD,原子操作,返回 1 才继续,返回 0 说明已参与——这一层挡掉并发请求里的绝大多数。第二层是订单表上「活动 ID + 用户 ID」的唯一索引,撞索引就说明重复,回补库存。为什么需要第二层:Redis 可能重启丢数据、可能主从切换、集合可能被误清,只有数据库唯一约束是绝对可靠的。这里要注意顺序——先判重再扣库存,不然会出现「库存扣了但判重失败」,虽然有回补但增加了不必要的竞争。
    Q:异步下单返回「排队中」,用户等了 30 秒还没结果,你怎么处理? A:不能显示失败,因为订单可能已经创建成功了,显示失败会引导用户再抢一次(然后被一人一单拦住,或者更糟——重复下单)。做法是显示「处理中,结果将在订单列表更新」,同时:推送兜底(订单创建成功后推一条消息)、订单列表每次进入刷新客户端启动时主动查一次待确认的排队记录。这和支付链路的「未知态」处理原则完全一致——客户端超时不等于服务端失败,超时只意味着「我不知道结果」,此时唯一正确的动作是去查询而不是判定。
    Q:秒杀和普通下单要不要用同一套代码? A:不要,至少要独立部署。三个理由。一是复杂度不同:普通下单要处理购物车、多 SKU、复杂优惠叠加、多种配送方式;秒杀是单商品单件、优惠极简,硬用同一套会让秒杀背上不必要的开销,而且每次改普通下单都要担心影响秒杀。二是资源隔离:秒杀的流量必须不能挤占普通下单的线程池、连接池、缓存,否则大促期间正常交易全挂。三是优化方向相反:普通下单追求功能完整和一致性,秒杀追求极致的拒绝速度,很多秒杀的优化(比如短路、异步、静态化)在普通下单里是不合适的。代价是两套代码有重复逻辑要维护,这个代价我认为值得,但要说出来而不是假装没有。

没有匹配的内容,换个关键词试试。

项目拆解 · 营销与优惠券(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据