返回文章列表

并发预约为什么不能只靠普通 SELECT:从 LabReserve 讲 MySQL 事务与行锁

从 30 个用户同时争抢 2 个名额的场景出发,解释 LabReserve 如何使用 InnoDB 事务、行锁、READ COMMITTED 与唯一约束处理并发预约。

LabReserve 是我用 Go 实现的一个实验室设备预约后端。用户可以查看设备开放时间段、预约、取消和查询历史,管理员负责维护设备与预约入口。

这个项目里最值得处理的问题其实很普通:

一个时间段只剩 2 个名额,如果 30 个用户几乎同时点击预约,怎样保证最终真的只有 2 个人预约成功?

单个用户预约并不复杂。真正的问题出现在多个请求同时执行时。

这篇文章从 LabReserve 的预约实现出发,说明为什么“先查还有没有名额,再插入预约”在并发场景下不够,以及事务、FOR UPDATE、隔离级别和唯一约束分别解决了什么问题。

项目地址:theHerta27/lab-reserve

先从最直观的写法开始

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