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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CREATE DATABASE IF NOT EXISTS seckill
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_general_ci;

USE seckill;

-- 秒杀商品:stock 是扣减的唯一真相源
CREATE TABLE IF NOT EXISTS seckill_sku (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(128) NOT NULL DEFAULT '',
stock INT NOT NULL DEFAULT 0,
total INT NOT NULL DEFAULT 0,
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_stock (stock) -- 仅用于排查,扣减走主键行锁,不靠这个索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 秒杀订单:同一用户对同一商品用 uk_user_sku 做幂等,重复下单直接唯一键冲突
CREATE TABLE IF NOT EXISTS seckill_order (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=已下单',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_sku (user_id, sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两张表,一张管"还剩多少",一张管"谁买到了"。它们之间的关系只有一条线,但这条线承载了全部的并发语义:

秒杀最小表结构:一个库存真相源 + 一个订单幂等键 seckill_sku id BIGINT PK / AUTO_INCREMENT name VARCHAR(128) stock INT ← 唯一真相源 total INT start_time DATETIME end_time DATETIME create_time DATETIME seckill_order id BIGINT PK user_id BIGINT ┐ sku_id BIGINT ┘ UK 幂等 status TINYINT(1=已下单) create_time DATETIME UNIQUE KEY uk_user_sku (user_id, sku_id) 重复下单 = 唯一键冲突,由引擎拦截 1 : N(逻辑外键,不建物理约束) 扣减路径:UPDATE ... WHERE id = ? AND stock > 0 —— 走主键,命中聚簇索引加 X 行锁 不建物理外键:避免每次 INSERT 都去父表做一致性检查

三、功能抉择一:库存字段放在哪

这是建表时第一个必须拍板的决定,三条路:

方案 做法 优点 代价
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
2
3
4
5
6
7
-- 层 1(选定):存储引擎兜底,任何并发都绕不过去
UNIQUE KEY uk_user_sku (user_id, sku_id)

-- 层 2:应用层先查后插 —— 并发下必然失效,不能单独用
SELECT COUNT(*) FROM seckill_order WHERE user_id = ? AND sku_id = ?;

-- 层 3:分布式锁包住"查 + 插" —— 正确但要引入 Redis 与锁超时/续期复杂度

选层 1,放弃的代价是"错误信息不够友好":唯一键冲突在 MySQL 里是一个异常(Duplicate entry ... for key 'uk_user_sku'),而不是一个返回值。如果直接裸 INSERT,重复下单会抛异常、被外层当成系统错误、触发事务回滚——而此时库存已经被扣掉了,一回滚库存退回,看起来"没事",但这条路径本该是业务拒绝,不该走异常通道

所以 C++ 侧我不用裸 INSERT,而是用 INSERT ... ON DUPLICATE KEY UPDATE 把冲突转成一次"空操作 UPDATE",靠 affectedRows 区分三种结果。真实代码在 src/service/SeckillService.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 步骤 1:单行原子扣减。affectedRows==0 说明 stock 已为 0(或行不存在),
// 直接回滚并判为售罄——防超卖的关键,绝不先 SELECT 再 UPDATE。
tx->execSqlAsync(
"UPDATE seckill_sku SET stock = stock - 1 "
"WHERE id = ? AND stock > 0",
[tx, userId, skuId, cb](const drogon::orm::Result &result) {
if (result.affectedRows() == 0) {
tx->rollback(); // 库存不足,快速失败,不落单
cb(false, "SOLD_OUT");
return;
}
// 步骤 2:扣减成功才落订单。与扣减在同一事务内,保证一致性。
tx->execSqlAsync(
"INSERT INTO seckill_order (user_id, sku_id, status, create_time) "
"VALUES (?, ?, 1, NOW()) "
"ON DUPLICATE KEY UPDATE id = id",
[tx, cb](const drogon::orm::Result &r2) {
// affectedRows 语义(MySQL):
// 1 = 真的插入了一条新订单
// 0 = 命中唯一键、走的 UPDATE 但值没变 = 重复下单
// 2 = 命中唯一键且 UPDATE 真的改了值(UPDATE id=id 不会触发)
if (r2.affectedRows() == 0) {
// 重复下单:回滚把步骤 1 扣掉的库存还回去,
// 否则用户每重复点一次就白白吃掉一件库存。
tx->rollback();
cb(false, "DUPLICATE_ORDER");
return;
}
// 提交路径:v1.9.10 没有 commit() 成员,事务析构时自动提交
},
/* 异常回调 */ ..., userId, skuId);
},
/* 异常回调 */ ..., skuId);

ON DUPLICATE KEY UPDATE id = id 是一个故意的空操作:命中唯一键时不报错、不改数据、affectedRows 返回 0,于是"重复下单"从一条异常路径变成了一个可判定的返回值。这是表设计和 C++ 代码之间的一次配合——索引负责正确性,代码负责可读性

这里还有个容易漏的坑:如果只判重复、不回滚,用户每点一次刷新就白吃一件库存,100 件库存能被一个人点光。

五、功能抉择三:时间、字符集与金额

几个看起来琐碎、但踩了就很难查的选择:

字段 选定 为什么不是另一个
主键 id BIGINT AUTO_INCREMENT 不用 INT:秒杀场景下订单表增长极快,INT 上限 21 亿看着多,分库分表时会成为硬伤。自增主键还保证插入是顺序写,避免 UUID 随机主键导致的页分裂
时间 start_time DATETIME 不用 TIMESTAMPTIMESTAMP 只到 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:改 ENUMALTER 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
2
3
4
5
6
7
8
-- 一次建三个 host,见 sql/init_user.sql
CREATE USER 'seckill'@'127.0.0.1' IDENTIFIED WITH mysql_native_password BY 'seckill';
CREATE USER 'seckill'@'localhost' IDENTIFIED WITH mysql_native_password BY 'seckill';
CREATE USER 'seckill'@'%' IDENTIFIED WITH mysql_native_password BY 'seckill';
GRANT SELECT, INSERT, UPDATE, DELETE ON seckill.* TO 'seckill'@'127.0.0.1';
GRANT SELECT, INSERT, UPDATE, DELETE ON seckill.* TO 'seckill'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON seckill.* TO 'seckill'@'%';
FLUSH PRIVILEGES;

为什么要三个?

  1. 不能直接用 root:Debian/Ubuntu 的 mysql-server 给 root 挂的是 auth_socket 插件,只允许"操作系统 root 身份"登录,程序用 TCP + 密码连会被拒(ERROR 1698)。
  2. host 到底是 localhost 还是 127.0.0.1:取决于客户端库连的是 Unix socket 还是 TCP,以及 MySQL 的反查行为。报错信息里出现哪个 host 就对应哪个账号,与其猜,不如三个都建。
  3. 安全性不用牺牲:MySQL 的 bind-address 默认还是 127.0.0.1,网络层只接受本机连接,'seckill'@'%' 不会把库暴露到外网。权限按最小够用原则只给 seckill 库,不给全局权限、不给 WITH GRANT OPTION

Drogon 侧的连接配置在 config.json,注意 rdbms 必须是 mysql,否则 getDbClient("default") 会在编译期没带 MySQL 后端时返回空指针:

1
2
3
4
5
6
7
8
9
10
11
12
{
"db_clients": [{
"name": "default",
"rdbms": "mysql",
"host": "127.0.0.1",
"port": 3306,
"dbname": "seckill",
"user": "seckill",
"passwd": "seckill",
"connection_number": 10
}]
}

src/main.cc 里我对它做了判空并打印诊断信息——不判空的话,首个请求会在 doSeckill 第一行空指针解引用直接 SIGSEGV,而日志里什么都没有

八、一次扣减在 InnoDB 上到底发生了什么

把上面所有设计串起来,一次秒杀请求的落库路径是这样的:

一次秒杀请求的落库路径:判断与扣减在引擎内一次完成 ① POST /api/seckill Drogon IO 线程收包 → 交给协程处理 ② BEGIN 事务 newTransactionAsync:从连接池取一条连接 ③ UPDATE seckill_sku SET stock = stock - 1 WHERE id = ? AND stock > 0 走聚簇索引命中单行 → 加 X 行锁,并发请求在此排队串行 affectedRows = 0 库存已为 0 → ROLLBACK 返回 SOLD_OUT,不落订单 快速失败:不占用后续任何资源 affectedRows = 1 INSERT ... ON DUPLICATE KEY UPDATE id = id 新增 → 事务析构自动 COMMIT → 下单成功 affectedRows=0 → ROLLBACK,库存还回 → DUPLICATE 关键:判断(stock > 0)与扣减(stock = stock - 1)是同一条 SQL,中间没有可被并发插入的窗口

注意第 ③ 步:没有 SELECTWHERE stock > 0 这个条件由 InnoDB 在持有行锁的状态下求值,判断和写入之间不存在时间窗口——这正是"防超卖"的全部秘密。反过来,SELECT stock → 应用层判 → UPDATE 这三步,无论你怎么加锁,都至少要把锁的粒度放大到"整个判断 + 写入"区间,代价完全不同。

排队的那把行锁,也是阶段一 QPS 卡在 50 的根因:100 个并发请求抢同一件商品,就是在抢同一行的 X 锁,InnoDB 让它们严格串行。这条路径上你优化 C++ 代码毫无用处——瓶颈在存储引擎的锁队列里。认清这一点,后面引入 Redis 预扣减才不是"为了用而用",而是精准地把串行点从磁盘上的行锁,搬到内存里的 Lua 原子操作

九、可运行验证步骤

在 WSL(Ubuntu)里按下面的顺序跑一遍,确认表结构和扣减语义都成立:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 0. 快捷进入数据库(以后每次想查数据,就用这一条)
mysql -h127.0.0.1 -P3306 -useckill -pseckill seckill
# 进去后常用:
# SELECT id, name, stock, total, start_time, end_time FROM seckill_sku;
# SELECT id, user_id, sku_id, status, create_time FROM seckill_order;

# 1. 建库建表
sudo mysql < sql/schema.sql

# 2. 建应用账号(必须用 sudo mysql 执行,走 socket 才绕得开 auth_socket)
sudo mysql -e "source sql/init_user.sql"
sudo mysql -e "SELECT user, host, plugin FROM mysql.user WHERE user = 'seckill';"
# 期望:127.0.0.1 / localhost / % 三行,plugin 均为 mysql_native_password

# 3. 确认库存真相源
mysql -useckill -pseckill seckill -e "SELECT id, name, stock, total FROM seckill_sku;"

# 4. 手工验证原子扣减语义(开两个会话并发执行,观察 affectedRows)
mysql -useckill -pseckill seckill -e \
"UPDATE seckill_sku SET stock = stock - 1 WHERE id = 1 AND stock > 0; SELECT ROW_COUNT();"

# 5. 手工验证一人一单(第二次执行应返回 affectedRows = 0)
mysql -useckill -pseckill seckill -e \
"INSERT INTO seckill_order (user_id, sku_id, status, create_time) \
VALUES (1, 1, 1, NOW()) ON DUPLICATE KEY UPDATE id = id; SELECT ROW_COUNT();"

第 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++ 秒杀系统实战记录,所有方案、代码与压测数据均为原创。