秒杀系统高并发优化实战(C++ / Drogon):数据库表设计
10000 个人抢 100 件商品,最后卖出去 137 件——这不是段子,是把"查库存、判库存、扣库存"写成三条 SQL 之后的必然结果。很多人第一反应是"加锁",但锁加在哪一层、加在什么时候,其实在建表的那一刻就已经决定了。表结构不是一个中性的容器,它直接决定了你的并发上限:库存字段放在哪张表、有没有那条唯一索引、时间字段用什么类型,每一个选择都在后面以"QPS"或"Bug"的形式还回来。
本文是「秒杀系统(C++ / Drogon)」系列的第二章。配套仓库
seckill-cpp(GitHub: https://github.com/Hespethorn/seckill-cpp),本文对应阶段一的v0.1.1,表结构见sql/schema.sql、应用账号见sql/init_user.sql。真实编译在 WSL 完成。
一、秒杀系统的表设计:是什么、坑在哪、本质一句话
- 是什么:秒杀场景下,表设计要回答三个问题——库存记在哪(真相源)、谁买过怎么判(幂等)、活动什么时候有效(时间边界)。
- 坑在哪:绝大多数超卖事故不是"忘了加锁",而是把库存读到了应用层再算——
SELECT stock拿到 100,两个请求各自算出 99,各自写回,库存凭空消失一件。这类 Bug 在压测量小的时候完全不出现,一上并发就现形。 - 本质一句话:秒杀的表设计,核心是让"判断 + 扣减"在数据库内部一次完成,并且让"重复下单"由唯一索引而不是应用逻辑来兜底——能下沉给存储引擎的约束,绝不上提到应用代码里。
二、最小可用表结构(阶段一)
阶段一刻意不引入缓存和消息队列,先把最朴素的两张表立起来,让瓶颈暴露得足够干净。完整脚本见仓库 sql/schema.sql:
1 | CREATE DATABASE IF NOT EXISTS seckill |
两张表,一张管"还剩多少",一张管"谁买到了"。它们之间的关系只有一条线,但这条线承载了全部的并发语义:
三、功能抉择一:库存字段放在哪
这是建表时第一个必须拍板的决定,三条路:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| A. 库存挂在商品表(选定) | seckill_sku.stock 单行 |
扣减 = 一次主键 UPDATE,天然被 InnoDB 行锁串行化;无跨表事务 | 库存与商品信息耦合,秒杀活动多时需按活动拆行 |
| B. 独立库存表 | seckill_stock(sku_id, stock) 单独一张 |
商品信息与库存读写分离,行更窄、缓存更友好 | 每次扣减要么跨表事务,要么接受"库存表改了、商品表没改"的短暂不一致 |
| C. 流水累加式 | 只记扣减流水,stock = total - SUM(流水) |
天然审计、可对账、可回滚单笔 | 每次读库存都是一次聚合;高并发下 SUM 走不上索引,直接打死库 |
我选 A,放弃的代价是"审计能力":A 方案下你只能看到"现在剩多少",看不到"每一件是被谁在第几毫秒扣掉的"。真要对账得靠订单表反推——total - COUNT(seckill_order)。这对阶段一完全够用;等到后面引入 Redis 预扣减,库存真相源会分裂成"Redis 里的预扣数 + DB 里的最终数",那时候才需要更严谨的对账手段。
为什么阶段一必须让 DB 当唯一的真相源:因为我想先拿到一个干净的、无缓存遮蔽的性能基线。如果一开始就上 Redis,压测打出来的数字是"Redis 的 QPS",而不是"我这套扣减逻辑的 QPS"。先把裸 MySQL 的 ~50 QPS 测出来,后面每加一层优化才知道这一层到底买到了多少。
四、功能抉择二:一人一单,靠唯一索引还是靠代码查
防重复下单有三层可选:
1 | -- 层 1(选定):存储引擎兜底,任何并发都绕不过去 |
选层 1,放弃的代价是"错误信息不够友好":唯一键冲突在 MySQL 里是一个异常(Duplicate entry ... for key 'uk_user_sku'),而不是一个返回值。如果直接裸 INSERT,重复下单会抛异常、被外层当成系统错误、触发事务回滚——而此时库存已经被扣掉了,一回滚库存退回,看起来"没事",但这条路径本该是业务拒绝,不该走异常通道。
所以 C++ 侧我不用裸 INSERT,而是用 INSERT ... ON DUPLICATE KEY UPDATE 把冲突转成一次"空操作 UPDATE",靠 affectedRows 区分三种结果。真实代码在 src/service/SeckillService.cc:
1 | // 步骤 1:单行原子扣减。affectedRows==0 说明 stock 已为 0(或行不存在), |
ON DUPLICATE KEY UPDATE id = id 是一个故意的空操作:命中唯一键时不报错、不改数据、affectedRows 返回 0,于是"重复下单"从一条异常路径变成了一个可判定的返回值。这是表设计和 C++ 代码之间的一次配合——索引负责正确性,代码负责可读性。
这里还有个容易漏的坑:如果只判重复、不回滚,用户每点一次刷新就白吃一件库存,100 件库存能被一个人点光。
五、功能抉择三:时间、字符集与金额
几个看起来琐碎、但踩了就很难查的选择:
| 字段 | 选定 | 为什么不是另一个 |
|---|---|---|
主键 id |
BIGINT AUTO_INCREMENT |
不用 INT:秒杀场景下订单表增长极快,INT 上限 21 亿看着多,分库分表时会成为硬伤。自增主键还保证插入是顺序写,避免 UUID 随机主键导致的页分裂 |
时间 start_time |
DATETIME |
不用 TIMESTAMP:TIMESTAMP 只到 2038-01-19,且受 time_zone 影响、跨时区部署会出现"活动开始时间漂移"。DATETIME 存的是字面量,所见即所得 |
| 金额 | 阶段一暂不设金额字段 | 真要加必须是 DECIMAL 或"以分为单位的 BIGINT",绝不用 FLOAT/DOUBLE——二进制浮点无法精确表示 0.1,累加必错 |
| 字符集 | utf8mb4 |
不用 utf8:MySQL 的 utf8 是 3 字节的残缺实现,emoji 和部分生僻字存不进去,报 Incorrect string value 时你还在查代码 |
status |
TINYINT + 注释 |
1 字节够用。用注释而不是 MySQL 原生 ENUM:改 ENUM 要 ALTER TABLE 锁表,改注释只需改代码 |
六、索引的取舍:为什么 idx_stock 标注为"仅用于排查"
seckill_sku 上我建了一个 KEY idx_stock (stock),但注释写的是"仅用于排查"。这是刻意的:
- 扣减不走它:
WHERE id = ? AND stock > 0的条件里,id是主键,优化器直接走聚簇索引定位到那一行,加 X 行锁;stock > 0是在行上做的过滤,不需要索引。 - 它真正的作用:运营问"还有哪些商品库存低于 10"时,这类低频的运维查询能走索引,不至于全表扫。
- 它的代价:每多一个二级索引,
UPDATE就要多维护一棵 B+ 树。秒杀的扣减是极高频的单行 UPDATE,任何多余的索引都是在给这条最热的路径加写放大。高频写表上,索引不是越多越好,是越少越好。
同理,seckill_order 上只有一个唯一索引 uk_user_sku。它既是业务约束(一人一单),也是查询索引(查某人买了啥),一份代价买两件事——这是我最愿意付的索引成本。
七、建库与账号:那个三个 host 的坑
表建好之后,应用账号这一步在 Ubuntu 上几乎必踩:
1 | -- 一次建三个 host,见 sql/init_user.sql |
为什么要三个?
- 不能直接用 root:Debian/Ubuntu 的
mysql-server给 root 挂的是auth_socket插件,只允许"操作系统 root 身份"登录,程序用 TCP + 密码连会被拒(ERROR 1698)。 - host 到底是 localhost 还是 127.0.0.1:取决于客户端库连的是 Unix socket 还是 TCP,以及 MySQL 的反查行为。报错信息里出现哪个 host 就对应哪个账号,与其猜,不如三个都建。
- 安全性不用牺牲:MySQL 的
bind-address默认还是127.0.0.1,网络层只接受本机连接,'seckill'@'%'不会把库暴露到外网。权限按最小够用原则只给seckill库,不给全局权限、不给WITH GRANT OPTION。
Drogon 侧的连接配置在 config.json,注意 rdbms 必须是 mysql,否则 getDbClient("default") 会在编译期没带 MySQL 后端时返回空指针:
1 | { |
src/main.cc 里我对它做了判空并打印诊断信息——不判空的话,首个请求会在 doSeckill 第一行空指针解引用直接 SIGSEGV,而日志里什么都没有。
八、一次扣减在 InnoDB 上到底发生了什么
把上面所有设计串起来,一次秒杀请求的落库路径是这样的:
注意第 ③ 步:没有 SELECT。WHERE stock > 0 这个条件由 InnoDB 在持有行锁的状态下求值,判断和写入之间不存在时间窗口——这正是"防超卖"的全部秘密。反过来,SELECT stock → 应用层判 → UPDATE 这三步,无论你怎么加锁,都至少要把锁的粒度放大到"整个判断 + 写入"区间,代价完全不同。
排队的那把行锁,也是阶段一 QPS 卡在 50 的根因:100 个并发请求抢同一件商品,就是在抢同一行的 X 锁,InnoDB 让它们严格串行。这条路径上你优化 C++ 代码毫无用处——瓶颈在存储引擎的锁队列里。认清这一点,后面引入 Redis 预扣减才不是"为了用而用",而是精准地把串行点从磁盘上的行锁,搬到内存里的 Lua 原子操作。
九、可运行验证步骤
在 WSL(Ubuntu)里按下面的顺序跑一遍,确认表结构和扣减语义都成立:
1 | # 0. 快捷进入数据库(以后每次想查数据,就用这一条) |
第 4 步是最值得单独跑一次的:把库存改成 1,然后开两个终端同时执行,必然只有一个拿到 ROW_COUNT() = 1,另一个拿到 0。这就是整套防超卖机制的最小复现,不需要写一行 C++。
十、设计取舍总结
| 决策点 | 选定方案 | 放弃方案 | 放弃的代价 |
|---|---|---|---|
| 库存位置 | 挂在 seckill_sku.stock |
独立库存表 / 流水累加 | 缺少逐笔审计;改靠订单表反推 |
| 防重复下单 | UNIQUE KEY uk_user_sku |
应用层先查后插 / 分布式锁 | 冲突表现为异常,需 ON DUPLICATE 转成返回值 |
| 扣减方式 | UPDATE ... WHERE stock > 0 单条 SQL |
先 SELECT 再 UPDATE | 无法在扣减前拿到库存做复杂业务判断 |
| 主键类型 | BIGINT AUTO_INCREMENT |
INT / UUID |
主键占 8 字节;分布式ID 需另行设计 |
| 时间类型 | DATETIME |
TIMESTAMP |
占 8 字节(TIMESTAMP 仅 4),且不自动时区转换 |
| 索引 | 仅 uk_user_sku + 排查用 idx_stock |
给查询字段都建索引 | 运营类查询可能慢;换来高频 UPDATE 零额外写放大 |
| 外键 | 不建物理外键 | FOREIGN KEY 约束 |
一致性靠应用层保证;换来 INSERT 不做父表检查 |
一句话收尾:阶段一的表设计不是"最终形态",而是"能稳定跑出基线的最简形态"。它的价值在于——当后面把库存搬到 Redis、把下单搬到 MQ 时,这套"判断与写入合一、约束下沉到引擎"的思路会原封不动地被复用,只是执行的场所从 InnoDB 的行锁,换成了 Redis 的 Lua 解释器。
配套仓库:
https://github.com/Hespethorn/seckill-cpp(本文对应v0.1.1,表结构sql/schema.sql、账号脚本sql/init_user.sql、扣减逻辑src/service/SeckillService.cc)。本系列是作者个人的 C++ 秒杀系统实战记录,所有方案、代码与压测数据均为原创。

