返回文章列表

唯一约束不是“防重复”这么简单:从 LabReserve 讲数据库约束与业务幂等

从 LabReserve 的重复预约与取消后重约出发,解释为什么应用层检查不能替代数据库唯一约束,以及生成列、NULL 唯一性和业务幂等之间的关系。

LabReserve 是我用 Go 实现的实验室设备预约后端。

在预约功能里,有一条很自然的业务规则:

同一个用户不能同时拥有同一个时间段的两条有效预约。

最直接的实现似乎也很简单。

创建预约之前,先查一下:

SELECT id
FROM reservations
WHERE user_id = ?
  AND slot_id = ?
  AND status = 'booked';

如果已经存在,就不再创建。

如果不存在,再:

INSERT INTO reservations(user_id, slot_id)
VALUES (?, ?);

单看这段代码,好像已经解决了重复预约。

但数据库里的很多并发问题,都出现在:

“查询结果正确”和“下一条操作执行时,这个结果仍然成立”之间。

这篇文章从 LabReserve 的预约设计出发,解释为什么应用层的“先查再插”不能代替数据库唯一约束,以及项目为什么使用一个生成列 active_marker 来表达“只有有效预约需要唯一”。

项目地址:theHerta27/lab-reserve

先查有没有,再插入,有什么问题

假设用户 100 想预约时间段 20。

数据库一开始没有对应记录:

user_id = 100
slot_id = 20
status = booked

不存在

请求 A 执行:

SELECT id
FROM reservations
WHERE user_id = 100
  AND slot_id = 20
  AND status = 'booked';

得到:

没有记录

于是准备创建。

但就在 INSERT 之前,同一个请求可能因为双击、客户端重试或者网络重发,又产生了请求 B。

B 也执行同样的 SELECT:

请求 A:没有预约
请求 B:没有预约

如果此时没有其他约束,两边接下来都可能执行:

INSERT INTO reservations(user_id, slot_id)
VALUES (100, 20);

最终就出现:

id = 1, user_id = 100, slot_id = 20, status = booked
id = 2, user_id = 100, slot_id = 20, status = booked

从代码角度看,两次 SELECT 当时都没有查错。

真正的问题在于:

检查
  ↓
插入

并不是一条不可分割的操作。

这种模式经常被称为:

check-then-act

也就是:

先检查某个条件,再根据检查结果执行下一步。

在没有并发时它很自然,但多个请求同时执行时,检查结果和后续操作之间存在一个竞争窗口。

事务能不能解决这个问题

上一篇讲并发预约时已经提到:

把几条 SQL 放进一个事务,不等于其他事务就不能同时执行。

例如:

事务 A                    事务 B

BEGIN                     BEGIN

SELECT
没有预约

                          SELECT
                          没有预约

INSERT
                          INSERT

COMMIT
                          COMMIT

两个事务都可以认为自己的逻辑没有问题。

因此,应用代码里的:

if reservationExists {
    return existing
}

不能成为数据最终正确的唯一保障。

这时就需要数据库自身表达:

无论上层代码经过什么路径,最终都不允许出现两个这样的有效记录。

这就是约束的意义。

最简单的唯一索引为什么不够

最容易想到的是:

UNIQUE(user_id, slot_id)

意思是:

同一个 user_id + slot_id
只能出现一次

它当然可以阻止两条有效预约。

但 LabReserve 还有另一个需求:

用户取消以后,可以重新预约同一个时间段。

假设第一次预约:

id = 1
user_id = 100
slot_id = 20
status = booked

后来用户取消:

id = 1
user_id = 100
slot_id = 20
status = canceled

这条记录不能直接删除,因为预约历史仍然有价值。

用户查询历史时应该看到:

我之前预约过这个时间段
后来取消了

但用户又可能重新预约。

于是数据库需要允许:

id = 1
user_id = 100
slot_id = 20
status = canceled

id = 2
user_id = 100
slot_id = 20
status = booked

如果索引只是:

UNIQUE(user_id, slot_id)

第二次预约会直接冲突。

所以真正的业务规则并不是:

一个用户和一个时间段只能有一条记录。

而是:

一个用户和一个时间段最多只能有一条“当前有效”的预约。

这个区别决定了数据库约束应该怎样设计。

把业务规则翻译成数据库能约束的形式

LabReserve 的 reservations 表中有这样一个字段:

active_marker TINYINT
GENERATED ALWAYS AS (
    CASE
        WHEN status = 'booked' THEN 1
        ELSE NULL
    END
) STORED

它不是由 Go 代码手动写入的。

而是一个 MySQL 生成列。

它的值由 status 自动决定。

当预约有效:

status = booked

得到:

active_marker = 1

当预约已经取消:

status = canceled

得到:

active_marker = NULL

于是:

booked   → 1
canceled → NULL

接下来建立唯一索引:

UNIQUE KEY uq_reservations_active (
    user_id,
    slot_id,
    active_marker
);

这时数据库真正约束的组合变成:

user_id + slot_id + active_marker

为什么 NULL 在这里很关键

假设用户 100 当前已经预约 slot 20:

user_id = 100
slot_id = 20
active_marker = 1

数据库里已经有:

(100, 20, 1)

如果再插入一条有效预约:

(100, 20, 1)

唯一索引发生冲突。

因此不能同时存在:

booked
booked

但取消以后:

active_marker = NULL

记录就变成:

(100, 20, NULL)

MySQL 的唯一索引允许可空字段中存在多个 NULL,因此历史记录可以是:

(100, 20, NULL)
(100, 20, NULL)
(100, 20, NULL)

同时只允许存在一个:

(100, 20, 1)

于是数据库最终表达出来的规则就是:

同一个用户 + 同一个 slot

历史取消记录:
可以有多条

当前有效预约:
最多一条

这和业务需要刚好对应。

为什么使用生成列,而不是让 Go 自己维护 active_marker

也可以让应用代码自己决定:

if status == "booked" {
    activeMarker = 1
} else {
    activeMarker = nil
}

然后每次 INSERT 或 UPDATE 时维护这个字段。

但这样会增加一个新的问题:

status 和 active_marker 会不会出现不一致?

例如某条取消路径只写了:

UPDATE reservations
SET status = 'canceled'
WHERE id = ?;

却忘记:

active_marker = NULL

数据库就可能出现:

status = canceled
active_marker = 1

此时索引表达的已经不是实际业务状态。

LabReserve 因此让:

active_marker

完全由:

status

计算。

应用代码只需要维护业务事实:

booked
canceled

数据库自己推导用于约束的标记。

也就是说:

业务状态
    ↓
生成列
    ↓
唯一索引
    ↓
数据库约束

而不是要求每一条业务路径同时维护两份相互依赖的状态。

为什么应用层仍然要先 SELECT

既然数据库已经有唯一约束了,一个很自然的问题是:

那 Reserve 里为什么还要先查询已有预约?直接 INSERT,让唯一索引报错不就行了吗?

LabReserve 实际上仍然会先执行:

err = tx.QueryRowContext(
    ctx,
    `SELECT id
     FROM reservations
     WHERE user_id=?
       AND slot_id=?
       AND status='booked'`,
    userID,
    slotID,
).Scan(&id)

如果找到记录:

if err == nil {
    return id, false, translate(tx.Commit())
}

也就是说:

如果这个用户已经有有效预约,就直接返回原来的预约 ID。

这和数据库唯一约束承担的职责并不一样。

应用层查询负责的是:

给请求提供正确的业务语义。

数据库唯一约束负责的是:

无论应用代码出现怎样的并发窗口,错误状态最终不能落库。

可以理解成:

应用层检查
    ↓
尽量正常地处理重复请求

数据库约束
    ↓
保证错误数据无论如何都写不进去

两者并不是重复设计。

约束负责“不能错”,应用层负责“错了以后怎么解释”

如果 INSERT 最终违反唯一约束,MySQL 会返回 duplicate key 错误。

LabReserve 的 Store 层会把 MySQL 错误转换成领域错误:

case 1062:
    return domain.Conflict("record already exists")

这也是后端代码中我觉得比较重要的一层边界。

数据库认识的是:

1062
Duplicate entry

HTTP API 不应该把这种底层数据库错误原样暴露给客户端。

业务层真正关心的是:

这个资源已经存在
这个操作发生了冲突

所以会经过:

MySQL Error
    ↓
Store 层翻译
    ↓
Domain Error
    ↓
HTTP 状态和统一错误响应

数据库负责保证数据正确。

应用负责把数据库结果转换成业务语义。

这算不算幂等

LabReserve 中,如果用户已经预约成功,再次发送同样的预约请求:

POST /slots/20/reservations

不会再次占用一个名额。

而是返回原来的预约 ID:

第一次:
id = 123
created = true

再次请求:
id = 123
created = false

从业务效果上说,这次重复操作不会继续改变系统状态。

因此这里可以称为一种:

业务幂等。

不过它和通用的:

Idempotency-Key

机制并不是一回事。

ModelGate 里的 Idempotency-Key 更接近:

客户端为一次逻辑请求提供唯一 key
        ↓
服务端记录执行状态和结果
        ↓
同 key 重试时复用原结果

而 LabReserve 没有维护一套通用的幂等键存储。

它利用的是业务本身天然存在的唯一身份:

user_id + slot_id + 当前有效状态

因此这里更准确的理解是:

利用业务唯一性实现重复请求不重复占位。

而不是:

实现了一个通用幂等框架。

为什么取消后再次 POST 不应该返回旧 ID

这里还有一个很容易混淆的问题。

假设:

第一次预约:
reservation_id = 123
status = booked

用户取消:

reservation_id = 123
status = canceled

随后又重新预约同一个 slot。

这时候 LabReserve 不会把:

123

重新改回 booked。

而是创建:

reservation_id = 456
status = booked

于是历史变成:

123 → canceled
456 → booked

原因是:

取消后的再次预约已经不是第一次请求的“重试”,而是一个新的业务意图。

这两个情况虽然 HTTP 请求看起来都可能是:

POST /slots/20/reservations

但语义完全不同。

第一种:仍然 booked 时再次 POST

我刚才点了一次
不知道有没有成功
所以又点了一次

这是重复请求。

应该得到:

同一个 reservation_id
不增加占位

第二种:已经 canceled 后再次 POST

我之前预约过
后来明确取消
现在重新决定预约

这是新操作。

应该得到:

新的 reservation_id

所以“幂等”不能机械地理解成:

相同 HTTP 请求永远返回完全相同的结果。

真正需要看的是:

当前系统状态下,这次请求表达的是同一个逻辑操作,还是一个新的业务意图。

为什么不直接把 canceled 记录删掉

另一种非常简单的实现是:

DELETE FROM reservations
WHERE id = ?;

取消以后记录直接不存在。

这样:

UNIQUE(user_id, slot_id)

似乎也能工作。

因为旧记录删除以后,新预约就可以重新 INSERT。

但这样会损失历史信息。

例如:

这个用户什么时候预约过?
什么时候取消?
过去有多少次取消?

都无法再从预约表中还原。

LabReserve 因此选择:

取消 = 状态转换

而不是:

取消 = 删除数据

预约记录的状态变化是:

booked
   ↓
canceled

并通过:

canceled_at

保存取消时间。

数据库还有一个 CHECK:

CONSTRAINT ck_reservations_canceled CHECK (
    (status='booked' AND canceled_at IS NULL)
    OR
    (status='canceled' AND canceled_at IS NOT NULL)
)

它进一步保证:

booked
→ canceled_at 必须为空

canceled
→ canceled_at 必须存在

这又是一个类似的思想:

如果一条规则可以由数据库明确表达,而且无论哪条应用路径都必须满足,那么它就适合成为数据库约束的一部分。

唯一索引和普通索引有什么区别

从查询角度看,索引通常被理解成:

提高查询速度。

比如 LabReserve 中:

INDEX idx_reservations_slot_status (
    slot_id,
    status
)

可以帮助按 slot 和状态查预约。

但:

UNIQUE KEY uq_reservations_active (...)

除了可以被数据库用于查询之外,还多承担了一件事情:

约束数据。

普通索引:

相同 key 可以重复

唯一索引:

相同的非 NULL 唯一 key 不能重复

因此在这里,唯一索引的主要价值不是:

“让 SELECT 更快。”

而是:

把业务不变量下沉到数据库。

这也是我后来觉得“索引”这个词容易造成误解的地方。

索引不只是性能工具。

某些索引同时也是数据正确性工具。

既然已经有 slot 行锁,唯一索引是不是多余了

上一篇里,LabReserve 已经通过:

SELECT ...
FROM slots
WHERE id = ?
FOR UPDATE;

让同一个 slot 的预约操作形成串行的容量检查。

那么:

既然所有预约都已经排队了,重复预约不是也能在 SELECT 时发现吗?

在当前这套实现路径中,行锁确实已经大幅缩小了重复插入的竞争窗口。

但我仍然更愿意保留唯一约束。

因为两者保护的是不同的不变量。

slot 行锁关心

这个 slot 总共有多少条 booked?

例如:

capacity = 2

不能最终出现 3 条有效预约。

唯一约束关心

同一个用户是不是占了两次?

即使 capacity 是:

100

同一个用户也不能产生:

user 1 → booked
user 1 → booked

更重要的是,数据库约束不依赖“所有未来代码永远正确遵守当前 Store 的流程”。

以后如果新增:

后台补录接口
管理员操作
数据迁移脚本
新的业务写入路径

应用层锁协议可能被错误绕过。

唯一索引仍然会在数据库最后一层拒绝非法状态。

因此我更愿意把两者理解成:

锁
→ 协调并发操作的执行过程

约束
→ 限制数据库最终允许存在的状态

这两个层次并不相同。

怎样测试重复请求和取消后重约

LabReserve 不只测试了:

30 个不同用户
同时抢 2 个名额

还专门测试了同一个用户的并发重复请求。

测试会启动:

20 个 goroutine

使用同一个:

user_id
slot_id

同时调用:

s.Reserve(ctx, users[1].ID, v.ID)

所有请求完成后,测试检查:

每个请求返回的 reservation ID
必须相同

并检查:

数据库有效预约数量 = 1

也就是:

20 次并发请求
        ↓
只有一个实际占位
        ↓
其他请求得到同一个有效预约

接下来测试取消:

s.Cancel(ctx, users[1].ID, original)

再取消一次:

s.Cancel(ctx, users[1].ID, original)

第二次取消仍然可以正常结束。

随后重新预约:

next, created, err :=
    s.Reserve(ctx, users[1].ID, v.ID)

要求:

created = true
next != original

也就是说:

重复预约
→ 返回旧 ID

重复取消
→ 不重复改变状态

取消后重约
→ 创建新 ID

最后还会重新查询旧记录:

original.status = canceled

同时确认当前有效预约数量仍然只有:

1

这个测试实际上验证的不只是“唯一索引能不能报错”。

它验证的是一整套业务语义:

重复请求
取消
重复取消
保留历史
重新预约
容量统计

能够同时成立。

如果网络在 Commit 之后断了怎么办

重复请求还有一个很现实的来源:

客户端没有收到成功响应。

假设服务端执行:

INSERT
  ↓
COMMIT 成功

数据库已经有预约了。

但就在 HTTP 响应返回之前:

网络断开

客户端看到的可能只是:

请求失败

它并不知道:

数据库实际上已经提交成功

如果客户端因此重试,服务端就必须正确处理。

LabReserve 的做法是:

再次 Reserve
    ↓
查询当前有效预约
    ↓
已经存在
    ↓
返回原 reservation ID

因此不会额外占用名额。

这也让我更容易理解为什么:

网络错误并不能告诉你数据库事务最终一定失败了。

客户端看到 timeout,只代表:

客户端没有确定拿到结果

并不等价于:

服务端一定没有 Commit

对于这类操作,一个可靠的系统必须考虑:

“操作已经成功,但成功响应丢了”之后怎么办。

幂等设计很大一部分价值就在这里。

为什么这里没有使用 Idempotency-Key

既然网络重试是问题,也可以要求客户端发送:

Idempotency-Key: 8b8c...

服务端维护:

key
状态
响应结果

这样可以实现更通用的请求幂等。

ModelGate 中我确实实现过类似机制。

但 LabReserve 当前没有必要为了每一个预约再引入一套额外的幂等状态。

因为预约本身已经存在很自然的业务唯一键:

用户
+
时间段
+
当前有效状态

如果当前已经 booked:

重复 POST

本身就可以解释为:

返回当前有效预约

因此这里选择的是更简单的业务幂等。

这也是工程里一个很实际的取舍:

不是遇到“幂等”两个字,就一定要上一套通用 Idempotency-Key。

先看业务本身有没有足够明确的唯一语义。

数据库约束不能替代所有业务逻辑

反过来也不能认为:

既然数据库有约束,所有校验都交给数据库就好了。

例如 LabReserve 在创建预约之前还需要判断:

resource 是否 active
slot 是否 open
slot 是否已经开始
当前 booked 是否达到 capacity
用户是否已经有有效预约

其中有些属于业务流程。

例如:

slot has already started

需要给客户端清楚的冲突原因。

如果什么都等数据库最后报错,应用层会失去大量业务语义。

所以我现在更倾向于这样的分工:

应用层
→ 判断业务条件
→ 给出可理解的错误
→ 控制正常流程

数据库
→ 外键保证引用有效
→ CHECK 限制字段组合
→ UNIQUE 保证唯一性
→ 事务保证提交边界

不是把正确性只放在一层。

而是让不同层承担自己最适合承担的部分。

面试时我会怎样回答

如果面试官问:

你已经在代码里查过用户有没有预约了,为什么数据库还要唯一索引?

我会回答:

因为“先查再插”属于 check-then-act,应用层检查本身不能作为最终一致性约束。虽然我的预约流程还会通过 slot 行锁协调同一个时间段的操作,但总容量不超卖和同用户不重复是两个不同的不变量,所以数据库再用唯一索引兜底有效预约唯一性。

另外我不能简单做 UNIQUE(user_id, slot_id),因为用户取消后要保留历史并允许重新预约。所以项目加了一个生成列 active_marker:booked 时是 1,canceled 时是 NULL,再对 user_id、slot_id、active_marker 建唯一索引。MySQL 唯一索引允许多个 NULL,所以可以保留多条 canceled 历史,但同一时刻最多只能有一条 booked。

应用层仍然会先查已有预约,因为重复请求时我希望直接返回原 reservation ID,而不是依赖数据库报 duplicate key。数据库约束负责保证不能出现非法数据,应用逻辑负责给这个操作正确的业务语义。

如果继续追问:

这是不是幂等?

我会补一句:

属于业务幂等,但不是通用 Idempotency-Key。用户仍有有效预约时重复 POST 不会增加占位,而取消以后再次 POST 是新的预约意图,所以会生成新的 reservation ID。

我从这个问题中理解到的事情

以前看到数据库唯一索引,我更容易把它理解成:

某个字段不能重复

或者:

一种特殊的索引

但在实现预约以后,我觉得更重要的问题其实是:

数据库最终允许存在什么状态?

LabReserve 真正需要表达的不是:

user_id + slot_id 永远唯一

而是:

同一个 user
+
同一个 slot
+
最多一个当前有效预约

为了表达这件事,最后组合起来的是:

status
    ↓
active_marker 生成列
    ↓
NULL / 1
    ↓
UNIQUE(user_id, slot_id, active_marker)

它让我意识到:

数据库约束并不是在替应用代码“再检查一次”。

应用代码和数据库约束看到的是两个不同的问题。

应用代码问:

这次请求现在应该怎么处理?

数据库约束问:

无论代码经过哪条路径,
最后有什么数据绝对不能存在?

前者决定业务行为。

后者守住数据边界。

而幂等又是第三个问题:

如果同一个逻辑操作因为重试执行多次,
系统最终应该表现成什么样?

在 LabReserve 中,它们最终形成的关系是:

应用层已有预约查询
→ 重复请求返回原 ID

唯一约束
→ 阻止重复有效预约落库

生成列
→ 将“有效状态”转换为可约束的数据

NULL 唯一性
→ 保留多次取消历史

取消状态转换
→ 历史记录不丢失

重新预约创建新记录
→ 新业务意图拥有新的 ID

所以“防重复”只是表面现象。

真正值得设计的是:

怎样把业务中的不变量,准确地表达成数据库也能够理解和强制执行的规则。

延伸阅读

  • MySQL 8.4 Reference Manual:CREATE INDEX Statement
  • MySQL 8.4 Reference Manual:CREATE TABLE and Generated Columns
  • MySQL 8.4 Reference Manual:InnoDB Constraints
  • Go Documentation:Executing transactions