未分类

(二):前端思维写后端的死代码,缺点在哪里,如果服务端思维应该怎么写

秒杀场景,目标不是“写得优雅”,而是:**高并发不超卖、DB 不被打穿、可降级、可恢复**。你贴的那段在秒杀里会被瞬间打爆:读写分离、无原子、无事务、无幂等、无限流。 ## 秒杀最常见的死法(你那段会触发的) 1. **超卖**:`SELE...

发布
作者
汪苗
阅读
约 5 分钟
更新
(二):前端思维写后端的死代码,缺点在哪里,如果服务端思维应该怎么写
READING BGM

文章配乐

点击播放,边听边读。

秒杀场景,目标不是“写得优雅”,而是:高并发不超卖、DB 不被打穿、可降级、可恢复。你贴的那段在秒杀里会被瞬间打爆:读写分离、无原子、无事务、无幂等、无限流。

秒杀最常见的死法(你那段会触发的)

  1. 超卖SELECT + UPDATE 分离(TOCTOU)。
  2. DB 打穿:每个请求都直连 MySQL 查库存。
  3. 热点行锁/死锁:大量请求更新同一行库存,锁竞争极高。
  4. 重复下单:重试/连点导致多次扣库存、插多单。
  5. 缓存击穿:Redis miss 或过期瞬间洪峰打到 DB。
  6. 服务不一致:扣库存成功但订单未落库、或落库失败没补偿。

秒杀推荐架构:Redis 预扣 + Lua 原子校验 + 异步落库 + MySQL 最终一致

关键思想:把“能不能买到”的判定放在 Redis(内存)做原子裁决;MySQL 只做最终落库。

请求链路(高并发版)

  1. 网关/应用层限流(令牌桶/漏桶,按活动/商品维度)

  2. Redis Lua:原子判断 + 预扣库存 + 去重

    • 库存不足:直接失败(不进 DB)
    • 已买过:直接失败/返回已成功(幂等)
    • 成功:写入消息队列/Stream(异步)
  3. 异步消费者:从队列取消息 → MySQL 事务落库 → 标记成功

  4. 客户端轮询/回调:查询下单结果(秒杀常见 202 Accepted)


1) Redis Lua 原子脚本(秒杀核心)

要实现三个能力:

  • 库存 > 0 才扣
  • 同一用户/幂等键只能成功一次
  • 把成功请求写入队列(或 Stream)

示例(Redis Lua,伪 keys/args):

  • KEYS[1] = stockKey 例如 seckill:stock:productId
  • KEYS[2] = userSetKey 例如 seckill:users:productId
  • KEYS[3] = streamKey 例如 seckill:stream:productId
  • ARGV = userId, idemKey, now

逻辑:

  1. SISMEMBER userSet userId 已成功过直接返回
  2. GET stock <=0 返回售罄
  3. DECR stock
  4. SADD userSet userId
  5. XADD stream ... 写入消息

返回:0售罄 / 2重复 / 1成功

这一步是秒杀“后端思维”的分水岭:一次原子脚本完成校验 + 扣减 + 入队,避免并发缝隙。


2) MySQL 落库策略(最终一致 + 不超卖兜底)

消费者侧(单线程/多分区)落库时:

  • 幂等唯一键 防重复:(product_id, user_id)idem_key 唯一
  • 用事务插入订单 + 写明细(必要时)
  • 库存同步到 MySQL 有两种策略:

策略 A(常用):MySQL 只做订单,不在高峰实时扣 MySQL 库存

  • 秒杀库存以 Redis 为准
  • 活动结束后批量把 Redis 扣减结果落到 MySQL(或写库存流水汇总)
  • 适合:活动库存是独立池(秒杀专用库存)

策略 B(双写兜底):落库时也扣 MySQL 库存(更安全,吞吐更低)

UPDATE products
SET count = count - 1
WHERE id = ? AND count > 0;

如果 affectedRows=0:说明 Redis 与 MySQL 不一致(极少见,说明初始化/回滚有问题),这笔订单要标记异常并补偿(通常回滚用户资格/补偿库存)。

秒杀一般用 A(MySQL 不承受热点扣减),MySQL 作为最终事实账本。


3) 必备:幂等与唯一约束(秒杀不做这个=必翻车)

订单表:

CREATE TABLE seckill_orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  product_id BIGINT NOT NULL,
  user_id BIGINT NOT NULL,
  idem_key VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_pu (product_id, user_id),
  UNIQUE KEY uk_idem (idem_key),
  KEY idx_product (product_id),
  KEY idx_user (user_id)
) ENGINE=InnoDB;

Redis 侧去重(userSet)是“快挡”,MySQL 唯一键是“最终挡”。


4) 回滚与补偿(你必须设计)

秒杀里最容易忽略:Redis 预扣成功了,但消费者落库失败怎么办?

做法:

  • 消费失败重试(限定次数)

  • 超过次数进入 DLQ(死信队列),人工/定时补偿

  • 补偿动作通常是:

    • INCR stock
    • SREM userSet userId
    • 标记订单失败(或根本没插入)

注意:补偿必须也是幂等的(重复执行不出错)。


5) 返回协议:不要同步返回“下单成功”

秒杀不要“接口里等 DB 插入完成”,否则把自己拖死。

建议:

  • Lua 成功后:返回 202 Accepted + requestId/idemKey

  • 提供 /seckill/result?idemKey=... 查询结果:

    • PENDING(排队中)
    • SUCCESS(返回 orderId)
    • FAIL_SOLD_OUT / FAIL_DUPLICATE / FAIL_SYSTEM

6) 最简“能上线版”(如果你现在就要改那段代码)

即使不引入 MQ,至少做到 MySQL 层原子扣减 + 事务 + 幂等(抗压一般,但不至于超卖):

  • UPDATE ... WHERE count > 0
  • 事务内插订单
  • 唯一键兜底重复
  • 限流 + 排队(哪怕是内存队列)减压

但真正秒杀,高峰还是建议 Redis Lua + 异步。