并发预约为什么不能只靠普通 SELECT:从 LabReserve 讲 MySQL 事务与行锁
从 30 个用户同时争抢 2 个名额的场景出发,解释 LabReserve 如何使用 InnoDB 事务、行锁、READ COMMITTED 与唯一约束处理并发预约。
LabReserve 是我用 Go 实现的一个实验室设备预约后端。用户可以查看设备开放时间段、预约、取消和查询历史,管理员负责维护设备与预约入口。
这个项目里最值得处理的问题其实很普通:
一个时间段只剩 2 个名额,如果 30 个用户几乎同时点击预约,怎样保证最终真的只有 2 个人预约成功?
单个用户预约并不复杂。真正的问题出现在多个请求同时执行时。
这篇文章从 LabReserve 的预约实现出发,说明为什么“先查还有没有名额,再插入预约”在并发场景下不够,以及事务、FOR UPDATE、隔离级别和唯一约束分别解决了什么问题。
先从最直观的写法开始
LabReserve 中,一个时间段 slot 有固定容量:
capacity = 2
项目没有维护一个不断减一的 remaining 字段,而是把数据库中的有效预约作为事实来源:
剩余名额 = capacity - booked 预约数
于是最直接的预约逻辑似乎可以写成:
查询当前 booked 数量
↓
如果 count >= capacity
返回“名额已满”
↓
否则 INSERT 一条预约记录
对应到 SQL,大致就是:
SELECT COUNT(*)
FROM reservations
WHERE slot_id = ?
AND status = 'booked';
INSERT INTO reservations(user_id, slot_id)
VALUES (?, ?);
单线程执行时,这套逻辑没有什么问题。
问题在于两个请求可能同时进来。
假设某个时间段容量为 1,目前还没有任何预约:
capacity = 1
booked = 0
请求 A 查询:
count = 0
它认为还有名额。
在 A 插入之前,请求 B 也进行了查询:
count = 0
B 同样认为还有名额。
接下来:
A:INSERT 成功
B:INSERT 成功
最终数据库里出现:
capacity = 1
booked = 2
发生了超卖。
可以把它画成:
sequenceDiagram
participant A as 请求 A
participant B as 请求 B
participant DB as MySQL
A->>DB: SELECT COUNT = 0
B->>DB: SELECT COUNT = 0
A->>DB: INSERT reservation
B->>DB: INSERT reservation
Note over DB: capacity = 1, booked = 2
这里最重要的一点是:
每一条 SQL 单独执行正确,不代表它们组合起来的业务操作在并发下也是正确的。
真正需要保护的是:
检查名额
+
创建预约
这一整段逻辑。
只加一个事务够不够
看到这里,很容易想到:
那我把 SELECT 和 INSERT 放进一个事务不就行了吗?
比如:
tx, err := db.BeginTx(ctx, nil)
// 查询 booked 数量
// 判断容量
// INSERT
tx.Commit()
事务当然是必要的,但只有事务边界还不够。
因为事务解决的是:
这一组操作要么一起成功,要么失败后一起回滚。
它并不会自动让两个事务排队执行。
两个事务仍然可能出现:
事务 A 事务 B
BEGIN BEGIN
SELECT count = 0
SELECT count = 0
INSERT
INSERT
COMMIT
COMMIT
两个事务各自都完整提交了。
从每个事务自己的角度看,它甚至没有做错什么。
问题在于:
两个事务在做“还有没有名额”这个判断时,没有形成互斥关系。
所以这里除了事务,还需要锁。
LabReserve 锁的并不是整张预约表
最粗暴的办法当然可以是:
有人预约时,把整个表锁住。
这样确实很容易保证正确,但并发能力也会非常差。
用户预约 A 实验室下午两点的时间段,另一个用户预约 B 实验室明天下午的时间段,本来完全没有冲突,没有必要互相等待。
LabReserve 真正需要串行化的是:
对同一个 slot 名额产生影响的操作。
因此项目首先找到对应的资源,然后锁定具体时间段:
func lockSlot(ctx context.Context, tx *sql.Tx, id int64) (lockedSlot, error) {
var v lockedSlot
if err := tx.QueryRowContext(
ctx,
"SELECT resource_id FROM slots WHERE id=?",
id,
).Scan(&v.ResourceID); err != nil {
return v, translate(err)
}
if err := tx.QueryRowContext(
ctx,
"SELECT status FROM resources WHERE id=? FOR SHARE",
v.ResourceID,
).Scan(&v.ResourceStatus); err != nil {
return v, translate(err)
}
err := tx.QueryRowContext(
ctx,
`SELECT status,capacity,start_time,end_time
FROM slots
WHERE id=?
FOR UPDATE`,
id,
).Scan(&v.Status, &v.Capacity, &v.Start, &v.End)
// ...
}
这里有两层锁。
第一层:
SELECT status
FROM resources
WHERE id = ?
FOR SHARE;
第二层:
SELECT status, capacity, start_time, end_time
FROM slots
WHERE id = ?
FOR UPDATE;
它们解决的是不同问题。
FOR UPDATE 到底做了什么
普通:
SELECT ...
主要是在读取数据。
而:
SELECT ...
FOR UPDATE;
属于锁定读。
对于这里通过主键查到的 slot 记录,事务会取得相应的排他锁。
最重要的效果可以先简单理解成:
如果事务 A 已经锁住 slot 100,那么事务 B 也想对 slot 100 执行这套预约逻辑,就需要等待 A 结束。
于是刚才的并发过程发生了变化。
sequenceDiagram
participant A as 请求 A
participant B as 请求 B
participant DB as MySQL
A->>DB: BEGIN
A->>DB: SELECT slot FOR UPDATE
DB-->>A: 获得 slot 锁
B->>DB: BEGIN
B->>DB: SELECT slot FOR UPDATE
Note over B,DB: 等待 A 释放锁
A->>DB: COUNT booked
A->>DB: INSERT
A->>DB: COMMIT
DB-->>B: 获得 slot 锁
B->>DB: COUNT booked
Note over B,DB: 现在已经能看到 A 提交的预约
假设容量还是 1。
A 获得锁以后:
count = 0
0 < 1
于是插入预约并提交。
B 原本也已经到达数据库,但它无法越过同一个 slot 的锁。
等 A 提交之后,B 才继续执行。
这时 B 再检查:
count = 1
1 >= 1
于是返回“名额已满”。
最终:
capacity = 1
booked = 1
这里真正起作用的不是一句“用了 MySQL 事务”,而是:
所有会改变同一时间段名额的写操作,都必须遵守同一套加锁协议。
只要有另一条写路径完全绕过这套协议,锁就不是魔法,它不会自动保护任意 SQL。
为什么资源用 FOR SHARE,时间段却用 FOR UPDATE
LabReserve 在锁 slot 之前,还会对它所属的 resource 做:
SELECT status
FROM resources
WHERE id = ?
FOR SHARE;
这里没有直接使用排他锁。
原因是:
一个实验室设备可以拥有很多不同时间段。
例如:
GPU-01
├── 09:00 - 10:00
├── 10:00 - 11:00
└── 14:00 - 15:00
用户 A 预约 09:00,用户 B 预约 14:00。
这两个请求实际上没有竞争同一份名额。
如果预约时直接给整个 resource 加排他锁,那么不同 slot 的预约也会被强制串行,锁的范围就太大了。
因此预约只对 resource 取得共享锁。
多个预约事务可以同时持有这个共享锁:
预约 slot A ─┐
├─ resource FOR SHARE
预约 slot B ─┘
随后各自锁自己的 slot。
但管理员如果要停用这个资源,更新 resource 时会取得排他锁。
这时:
预约请求:FOR SHARE
管理员停用:FOR UPDATE
两者会互相协调。
这样可以避免一种状态竞争:
预约请求刚确认 resource 是 active
↓
管理员同时把它改成 disabled
↓
预约请求仍然继续创建预约
所以 resource 锁负责协调:
“设备还能不能预约”
slot 锁负责协调:
“这个时间段还有没有名额”
这是两个不同层次的问题。
实际的 Reserve 做了什么
LabReserve 的预约事务显式使用:
tx, err := s.DB.BeginTx(
ctx,
&sql.TxOptions{
Isolation: sql.LevelReadCommitted,
},
)
之后调用:
v, err := lockSlot(ctx, tx, slotID)
获得锁之后,完整逻辑大致是:
BEGIN
↓
锁 resource
↓
锁 slot
↓
检查用户是否已经存在 booked 预约
↓
检查 resource / slot 状态
↓
检查时间段是否已经开始
↓
COUNT 当前 booked 数量
↓
与 capacity 比较
↓
INSERT reservation
↓
COMMIT
源码里的容量判断是:
var count int
if err = tx.QueryRowContext(
ctx,
`SELECT COUNT(*)
FROM reservations
WHERE slot_id=?
AND status='booked'`,
slotID,
).Scan(&count); err != nil {
return 0, false, err
}
if count >= v.Capacity {
return 0, false, domain.Conflict("slot is full")
}
通过检查以后才真正:
result, err := tx.ExecContext(
ctx,
"INSERT INTO reservations(user_id,slot_id) VALUES (?,?)",
userID,
slotID,
)
并且只有:
tx.Commit()
成功之后,这次预约才算真正成功。
这也是为什么项目没有另外维护一个“已占名额”变量。
事实来源始终是数据库里已经成功提交的:
status = booked
记录。
如果 INSERT 失败,事务回滚,就不会凭空损失一个名额。
为什么显式使用 READ COMMITTED
MySQL InnoDB 默认的事务隔离级别通常是 REPEATABLE READ,但 LabReserve 在预约事务里显式选择了:
sql.LevelReadCommitted
这不是因为:
REPEATABLE READ一定会导致这个项目超卖。
真正的考虑是这个事务同时使用了:
锁定读:FOR SHARE / FOR UPDATE
普通读:SELECT COUNT(...)
两类读取。
在 READ COMMITTED 下,每一次普通一致性读都会取得一个新的已提交快照。
这很符合这里的需要:
我已经等待同一个 slot 前面的事务执行完了,接下来检查 booked 数量时,希望看到此刻已经提交的数据。
这点在存在锁等待时尤其重要。
例如:
事务 A 获得 slot 锁
↓
事务 B 等待
↓
A 插入预约并 COMMIT
↓
B 获得锁
↓
B 再 COUNT booked
B 此时应该看到 A 刚刚提交的结果。
这里使用 READ COMMITTED,使这种读取语义更加明确,也避免代码依赖一个更早建立的事务快照。
所以我更愿意把这项设计理解成:
不是为了背“哪个隔离级别更高级”,而是根据这个事务真正需要看到什么数据来选择隔离级别。
另外,项目中事务内的查询都通过:
tx.Query...
tx.Exec...
执行,而不会在事务中途突然调用:
db.Query...
因为 sql.Tx 代表的是这次数据库事务。
事务中的检查和写入如果跑到了事务之外,就会直接破坏原本设计好的边界。
为什么拿到锁之后才读取当前时间
lockSlot 中还有一个容易忽略的细节:
err = tx.QueryRowContext(
ctx,
"SELECT UTC_TIMESTAMP(6)",
).Scan(&v.Now)
而这一步发生在等待并获得锁之后。
原因是锁可能会等待。
假设一个时间段:
14:00 开始
请求在:
13:59:59
进入。
如果一开始就记录应用服务器时间,然后等待数据库锁两秒钟:
13:59:59 进入
↓
等待锁 2 秒
↓
14:00:01 获得锁
这时如果继续拿最开始的时间判断:
13:59:59 < 14:00
就可能允许一个实际上已经开始的时间段继续预约。
因此项目选择:
先等待需要的锁,再读取数据库当前时间。
随后判断:
if !v.Start.After(v.Now) {
return 0, false,
domain.Conflict("slot has already started")
}
这也是并发代码里一个比较容易漏掉的边界:
等待本身会消耗时间。
行锁解决超卖,唯一约束解决的却是另一个问题
LabReserve 的 reservations 表还有这样一个设计:
active_marker TINYINT
GENERATED ALWAYS AS (
CASE
WHEN status='booked' THEN 1
ELSE NULL
END
) STORED
并建立:
UNIQUE KEY uq_reservations_active (
user_id,
slot_id,
active_marker
);
第一次看到这里,可能会觉得:
既然已经有行锁了,为什么还需要唯一索引?
因为它们根本不是在解决同一个问题。
行锁保护的是总容量
例如:
capacity = 2
最终不能出现:
3 条 booked
这是超卖问题。
唯一约束保护的是同一个用户重复占位
比如用户 100:
user_id = 100
slot_id = 20
不能同时存在两条:
booked
booked
这是重复预约问题。
因此:
行锁 + 事务
↓
保证总体名额竞争
唯一约束
↓
保证单个用户不能重复占位
数据库约束相当于最后一道防线。
应用代码当然也会先检查:
SELECT id
FROM reservations
WHERE user_id=?
AND slot_id=?
AND status='booked';
如果已经预约,就直接返回原来的预约 ID。
但并发程序不能只依赖:
先查有没有
↓
没有
↓
再插入
这样的 check-then-act 逻辑。
最终不能出现什么数据,最好让数据库本身也知道。
为什么取消以后还能再次预约
这里的唯一索引还有一个比较有意思的地方。
有效预约:
status = booked
时:
active_marker = 1
取消以后:
status = canceled
生成列变成:
active_marker = NULL
于是一个用户可以保留:
旧记录:canceled
新记录:booked
而不是取消时把旧记录 DELETE 掉。
这样既保留历史,又允许用户重新预约。
所以项目中的“重复请求”和“取消后重约”语义是不一样的:
已经 booked,再 POST
↓
认为是重试
↓
返回原来的预约 ID
已经 canceled,再 POST
↓
认为是新的预约意图
↓
创建新的预约记录
这也是为什么数据库设计里:
“不能重复预约”
并不等于:
“一个用户和一个 slot 永远只能存在一条记录”。
真正需要唯一的是:
同一时刻的有效预约。
为什么所有写操作还要保持统一的加锁顺序
LabReserve 对预约、取消等操作约定:
resource
↓
slot
↓
reservation
按这个顺序取得需要的锁。
这是为了降低死锁风险。
假设两个事务反着加锁:
事务 A:
先锁 resource
再等 slot
事务 B:
先锁 slot
再等 resource
就可能形成:
A 等 B
B 等 A
也就是死锁。
统一锁顺序的思想很简单:
大家都从同一个方向拿锁,尽量不要形成环形等待。
当然,统一顺序并不代表数据库世界里从此“绝对不可能死锁”。
项目仍然对 MySQL 的死锁和锁等待超时错误做了处理:
case 1213, 1205:
return &domain.Error{
Status: 503,
Code: "database_busy",
Message: "retry the operation",
}
这也是我对数据库锁理解上的一个变化:
正确的锁设计不是假设竞争永远不会发生,而是既减少不必要的竞争,也为真正发生的竞争准备好失败路径。
怎样证明这套逻辑真的不会超卖
并发逻辑只看代码很容易产生一种错觉:
看起来应该没问题。
但“看起来没问题”不是验证。
LabReserve 因此没有只使用 Mock 数据库测试这一部分,而是针对真正的 MySQL 编写了并发集成测试。
其中一个测试会创建:
30 个不同用户
1 个 slot
capacity = 2
然后让 30 个 goroutine 等待同一个开始信号:
start := make(chan struct{})
所有 goroutine 都创建好以后:
close(start)
让它们尽可能同时开始预约。
核心结构类似:
for _, u := range users {
wg.Add(1)
go func(uid int64) {
defer wg.Done()
<-start
_, created, err :=
s.Reserve(ctx, uid, v.ID)
if err == nil && created {
success.Add(1)
}
}(u.ID)
}
最后并不是只检查 HTTP 返回结果,而是重新查询数据库:
成功创建数 = 2
数据库 booked 数量 = 2
两个条件都必须成立。
测试日志最终记录:
30 simultaneous users,
capacity=2,
exactly 2 committed bookings
这里特意使用 30 个不同用户 也很重要。
如果让同一个用户并发请求 30 次,那么:
同用户有效预约唯一约束
+
重复请求返回原 ID
本身就可能把结果限制为 1。
那测试到的主要是“重复请求处理”,而不是“容量竞争”。
所以项目另外又有一个测试:
20 个同用户并发重试
专门验证它们最终得到相同预约 ID,并且只占用一个名额。
两个测试分别回答两个问题:
30 个不同用户
↓
容量会不会超卖?
20 个相同用户
↓
重复请求会不会重复占位?
把不同的不变量拆开验证,比简单写一句“并发测试通过”更有意义。
这个测试没有证明什么
这里也需要划清一个边界。
30 个用户竞争 2 个名额的测试能够证明:
在这个测试覆盖的并发场景和真实 MySQL 环境下,最终提交结果符合设计的不变量。
它不能证明:
系统支持 1 万 QPS
也不能证明:
所有生产环境并发情况都已经被覆盖
正确性测试和性能测试不是同一件事。
如果我要回答:
LabReserve 每秒可以处理多少次预约?
那应该另外做压测,测量:
吞吐量
p50 / p95 / p99 延迟
数据库连接池等待
行锁等待时间
而不是拿“30 个 goroutine 没超卖”来推导性能。
目前这个测试真正想证明的只有一件事:
竞争发生时,数据最终不能违反容量约束。
为什么没有直接维护 reserved_count
另一种很常见的实现是给 slots 表增加:
capacity
reserved_count
预约时直接执行类似:
UPDATE slots
SET reserved_count = reserved_count + 1
WHERE id = ?
AND reserved_count < capacity;
这种设计在高并发下可能减少反复 COUNT 的成本,也可以利用条件更新完成原子的容量竞争。
但 LabReserve 当前没有这么做。
原因之一是,增加 reserved_count 之后,它本身就变成了一份需要维护的一致性状态。
预约要:
+1
取消要:
-1
失败要确保:
ROLLBACK
历史数据迁移还需要:
重新计算计数
如果任何一条业务路径漏掉维护,就会出现:
reserved_count
和真实预约记录不一致。
所以当前项目选择了更直接的事实模型:
实际 booked 记录
↓
COUNT
↓
计算当前已预约人数
对于这个项目规模来说,实现更容易验证。
如果未来真正出现性能瓶颈,再根据压测结果考虑计数列,而不是因为“听起来性能更高”就提前增加一份需要维护的状态。
面试时我会怎样回答这个问题
如果面试官问:
你这个预约系统怎样防止并发超卖?
我现在会这样回答:
LabReserve 没有单独维护 remaining,而是以有效预约记录作为事实来源。预约时开启 READ COMMITTED 事务,先对资源做 FOR SHARE,再通过主键对对应 slot 做 FOR UPDATE。这样同一个 slot 的预约和取消会围绕这一行形成串行的名额检查。
获得锁之后,再统计已经提交的 booked 数量,与 capacity 比较,通过后才 INSERT,并且只有 Commit 成功才算预约成功。
另外,容量不超卖和同用户不重复是两个不同问题,所以数据库还通过有效预约唯一约束兜底重复占位。最后用真实 MySQL 做了 30 个不同用户同时抢 2 个名额的并发测试,最终成功数和数据库 booked 数都必须等于 2。
这段话没有特别复杂的名词,但基本能够把:
事务
行锁
隔离级别
唯一约束
并发测试
之间的关系讲清楚。
我从这个问题中理解到的事情
开始写预约功能时,很容易把数据库事务理解成:
Begin
执行几条 SQL
Commit
只要把业务代码塞进事务,好像并发问题就自然解决了。
但真正实现以后,我觉得事务本身更像是一个边界。
它告诉数据库:
这些操作属于同一个整体。
至于多个事务同时到来时如何协调,还需要进一步回答:
它们在竞争什么?
应该锁哪一行?
哪些操作必须串行?
哪些操作仍然可以并行?
普通读应该看到哪个时间点的数据?
哪些业务规则应该再交给数据库约束兜底?
LabReserve 最终采用的关系可以概括成:
事务
→ 保证一组检查和写入共同提交或回滚
slot 行锁
→ 协调同一时间段的名额竞争
resource 共享锁
→ 协调预约与管理员停用操作
READ COMMITTED
→ 在需要重新判断时读取新的已提交状态
唯一约束
→ 阻止同用户产生重复有效预约
真实 MySQL 并发测试
→ 验证这些设计组合后的实际结果
所以“防超卖”并不是某一个 SQL 关键字单独完成的。
真正重要的是先明确系统必须保持的不变量:
booked 数量不能超过 capacity
然后再让事务、锁、约束和测试分别承担自己的职责。
这也是我在这个预约功能里真正理解到的数据库并发边界。
延伸阅读
- MySQL 8.4 Reference Manual:Locking Reads
- MySQL 8.4 Reference Manual:Transaction Isolation Levels
- Go Documentation:Executing transactions