唯一约束不是“防重复”这么简单:从 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 来表达“只有有效预约需要唯一”。
先查有没有,再插入,有什么问题
假设用户 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