2021 年我做一个电商活动的领券功能:用户在活动页点一下"领取",系统给他发一张优惠券,规则很简单——一个用户、一个活动,只能领一张。怎么保证"只能领一张"?这件事我压根没多想。我心里很省事地想:插之前先查一下他领过没有,没领过再插,不就保证一人一张了吗?第一版我做得很顺手:先 SELECT count(*) 查这个用户在这个活动里有没有券,查出来是 0,就 INSERT 一张,查出来大于 0,就告诉他"已经领过了"。就完事了。本地开发时——真不错:我点一下领券,成功;再点一下,提示"已领过"。看着滴水不漏。我心里很踏实:"先查再插嘛,逻辑这么直白,能有什么问题?"可等这个活动真正上线、流量涌进来,一串问题冒了出来。第一种最先把我打懵:对账时发现,同一个用户、同一个活动,数据库里躺着两张、三张券——明明代码里查过了。第二种最难缠:它不是每次都重复,绝大多数用户都正常,只有一小撮出现重复,毫无规律,我盯着代码怎么看都看不出错。第三种最头疼:我发现重复的几乎都是手快连点两下、或者网络慢重复提交的用户——两个请求几乎同时到。第四种最莫名其妙:我在本地怎么点都重现不了——后来才反应过来,我在本地是一下一下点的,两次点击之间隔了大半秒。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"先查再插,就能保证不重复"。这句话把"查"和"插"这两条独立的语句,当成了一个连在一起、不会被打断的整体。可它们不是。我脑子里,"先 SELECT 确认没有、再 INSERT 写入"是一个原子动作,中间不会有别人插进来。可数据库根本不是这样跑的。SELECT 和 INSERT 是两条分开的语句,中间隔着一道缝。当两个请求几乎同时到达,它们会像这样交错:请求 A 执行 SELECT,看到 count=0;请求 B 也执行 SELECT,同样看到 count=0——因为此刻 A 还没 INSERT;于是 A 去 INSERT 了一张,B 也去 INSERT 了一张。两个请求,各自都"查过了、确认没有",然后各自插了一张,最后这个用户手里就有两张券。问题的根子在于:SELECT 查到的"没有",是数据库某个瞬间的一张快照,是对过去的描述;它绝不是一把锁,锁不住"从现在到我 INSERT 之间,别人不许插入"这段未来。我用一张过去的快照,去担保一个未来的承诺——这中间的时间窗,就是并发重复的全部来源,它有个名字,叫 TOCTOU,检查时刻与使用时刻之间的裂缝。真正保证不重复,核心不是"在应用代码里先查一下",而是把去重这件事,交给数据库的唯一约束:由数据库引擎在写入的那一刻、由那个把所有写入排成一列的组件,来强制"这一对值不许重复"。应用层的检查只是建议,数据库的约束才是法律。这篇文章就把数据库唯一约束这个坑梳理一遍:为什么"先查再插"是错的、为什么唯一约束才是唯一可靠的真相、怎么用 ON CONFLICT 让数据库裁决冲突、怎么把唯一冲突异常当成正常业务分支、怎么正确实现 get-or-create 与 UPSERT,以及软删除、脏数据加约束这些把去重做扎实要避开的坑。
问题背景
这个坑之所以普遍,是因为"先查再插"在单线程的脑子里推演,逻辑完美无瑕:查到没有,才会插;查到有,就不插。每个写过几行代码的人,第一反应都是这么写。它错得隐蔽,是因为它在低并发下几乎永远是对的——本地一个人慢慢点、测试环境零星几个请求,两条语句之间那道缝窄到可以忽略,你永远撞不进去。它只在真实流量、请求密集到两个请求会在毫秒级交错时才暴露,而那时数据已经脏了。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:同一个用户、同一个活动,数据库里出现多张券;重复是偶发的,只在高并发、用户连点、重复提交时出现;本地和测试环境几乎无法复现。
- 错误认知一:以为"先 SELECT 再 INSERT"是一个原子操作,中间不会被别的请求插进来。真相是它是两条独立语句,中间存在时间窗,并发请求会在窗口里交错。
- 错误认知二:以为 SELECT 查到的"不存在"能担保到 INSERT 那一刻。真相是 SELECT 的结果是过去某个瞬间的快照,它不是锁,管不住此后到 INSERT 之间发生的任何写入。
- 错误认知三:以为在应用代码里把检查写得更严、加几个 if 就能堵住。真相是只要去重判断发生在应用层、和写入分成两步,就一定有窗口;能真正去重的,只有数据库引擎自己在写入瞬间强制的唯一约束。
- 真相:唯一约束是数据库引擎层面的一条规则,所有写入都要经过它、并且是被串行裁决的。它不在乎你之前查没查、查到了什么,它只在 INSERT 落盘的那一刻检查"这一对值是不是已经有了",有就拒绝。它是唯一一个能跨越并发、给出确定性保证的地方。
一、为什么"先查再插"是错的
先把第一版的代码摆出来。它的逻辑就是字面意思:查一下,没有就插。
import psycopg2
def claim_coupon(conn, user_id: int, campaign_id: int) -> dict:
"""用户领券:第一版——先查再插(反面教材)。"""
with conn.cursor() as cur:
# 第一步:查一下这个用户在这个活动里领过没有
cur.execute(
"SELECT count(*) FROM coupons "
"WHERE user_id = %s AND campaign_id = %s",
(user_id, campaign_id),
)
already = cur.fetchone()[0]
if already > 0:
return {"ok": False, "reason": "已经领过了"}
# 第二步:没领过,那就插一张
cur.execute(
"INSERT INTO coupons (user_id, campaign_id, amount) "
"VALUES (%s, %s, %s)",
(user_id, campaign_id, 10),
)
conn.commit()
return {"ok": True}
这段代码在单个用户一次一次顺序调用时,完全正确。它的破绽,只在两个请求同时跑这个函数时才会显形。设想 A、B 两个请求几乎同时进来,执行顺序可能交错成:A 跑 SELECT,看到 count=0;B 也跑 SELECT,同样看到 count=0(因为 A 还没 INSERT、更没 commit);A 通过了 if 判断,去 INSERT;B 也通过了同一个 if,也去 INSERT。结果:两条 INSERT 都成功,用户领到两张券。
光说不算,写个并发测试把它逼出来。下面这段用 20 个线程,同时对同一个用户、同一个活动调 claim_coupon。
import threading
def test_concurrent_claim(make_conn):
"""同一个用户、同一个活动,20 个线程同时领券。"""
user_id, campaign_id = 1001, 7
results = []
def worker():
conn = make_conn()
try:
results.append(claim_coupon(conn, user_id, campaign_id))
finally:
conn.close()
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
granted = sum(1 for r in results if r["ok"])
print(f"成功领券次数={granted}(期望 1)")
# 实际跑出来:granted 往往是 3、5、甚至十几张
# 20 个线程几乎同时跑 SELECT,都看到 count=0,于是都去 INSERT
assert granted == 1, f"出现重复领券:{granted} 张"
这里要建立的第一个、也是最重要的认知是:"先查再插"的正确性,建立在一个你从来没说出口、却又根本不成立的假设上——"在我 SELECT 完到 INSERT 完这段时间里,没有别人动这张表"。在单线程里这个假设碰巧成立,所以你写得心安理得;一上并发它立刻垮掉。SELECT 返回的那个"没有",本质是数据库在某个时间点对表的一次拍照,它描述的是过去;而你的 INSERT 发生在未来。你拿一张过去的照片,去为未来的一个动作背书,中间这段时间数据库照样在接收别人的写入。这道"检查时刻"和"写入时刻"之间的裂缝,就是 TOCTOU(Time-Of-Check to Time-Of-Use)。只要去重的"检查"和"写入"是两条分开的语句,这道缝就一定存在,你在应用层加再多 if、查得再仔细,都只是把缝改窄了一点点,而并发要的就是那一点点。这个认知一旦立住,你就会明白:解决方向根本不在"把检查写得更好",而在"取消应用层这场检查,把去重交给一个能在写入瞬间做裁决的地方"。
二、唯一约束才是唯一可靠的真相
那个"能在写入瞬间做裁决的地方",就是数据库的唯一约束。它和应用层检查有一个本质区别:应用层的 SELECT 是"我先看一眼、再决定要不要写",看和写是分开的;而唯一约束是数据库引擎在执行 INSERT 的那一条语句内部完成的检查——写入和检查是同一个不可分割的动作。更关键的是,数据库对同一份数据的写入是串行裁决的:两个 INSERT 撞同一个唯一键,引擎会让它们排队,一个成功,另一个必然收到冲突错误。它不在乎你之前 SELECT 看到了什么。
给领券表建一个复合唯一约束:(user_id, campaign_id) 这一对不许重复。
-- 优惠券表:一个用户在一个活动里只能有一张券
CREATE TABLE coupons (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
campaign_id bigint NOT NULL,
amount numeric(10, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
-- 复合唯一约束:(user_id, campaign_id) 这一对不允许重复
CONSTRAINT uq_coupon_user_campaign
UNIQUE (user_id, campaign_id)
);
-- 如果表已经存在,用 ALTER 补一个唯一约束:
ALTER TABLE coupons
ADD CONSTRAINT uq_coupon_user_campaign
UNIQUE (user_id, campaign_id);
建好这个约束后,即使你的应用代码还是那段有 bug 的"先查再插",并发下两个请求都通过了 if 判断、都去 INSERT,数据库也只会让第一条成功,第二条会直接报错被拒。换句话说:唯一约束是你的最后一道、也是唯一一道真正靠得住的防线。注意约束起了名字 uq_coupon_user_campaign——这个名字后面排查冲突来自哪个约束时要用到,别让数据库自动生成那种难认的默认名。
这里要建立的认知是:唯一约束不是"锦上添花的双保险",它是去重这件事在整个系统里唯一可信的真相来源。把它和应用层检查比一下就清楚了:应用层检查是分布在无数个并发请求里、各看各的、互不知情的"自觉";唯一约束是一个集中的、所有写入都必须穿过的、且被串行化的"关卡"。自觉挡不住并发,关卡才能。所以正确的心智模型要倒过来:不是"应用层负责去重,数据库约束兜底",而是"数据库约束负责去重,应用层的任何检查都只是为了提前给用户一个友好提示、顺便省掉一次注定失败的写入"。一旦你接受了"约束才是法律",接下来所有的代码——ON CONFLICT、捕获冲突异常、get-or-create——就都只是在回答同一个问题:当数据库这道关卡把我的写入挡下来时,我的代码该怎么优雅地接住它。建约束这一步本身,就已经把"会不会重复"这个问题永久关掉了;剩下的全是体验问题。
三、用 ON CONFLICT 让数据库裁决冲突
有了唯一约束,最干净的写法是根本不查,直接插,并用 INSERT ... ON CONFLICT 告诉数据库:撞上约束了别报错,按我说的办。DO NOTHING 的意思是"撞了就静默跳过这一行"。再配合 RETURNING,就能知道这次到底是真插进去了、还是被跳过了。
def claim_coupon_on_conflict(conn, user_id: int, campaign_id: int) -> dict:
"""让数据库裁决冲突:插进去就是领到了,插不进去就是领过了。"""
with conn.cursor() as cur:
cur.execute(
"INSERT INTO coupons (user_id, campaign_id, amount) "
"VALUES (%s, %s, %s) "
"ON CONFLICT (user_id, campaign_id) DO NOTHING "
"RETURNING id",
(user_id, campaign_id, 10),
)
row = cur.fetchone()
conn.commit()
if row is None:
# RETURNING 没有返回行 —— 说明这一行没插进去,撞上了唯一约束
return {"ok": False, "reason": "已经领过了"}
return {"ok": True, "coupon_id": row[0]}
这段代码和第一版最大的不同是:它没有 SELECT。它不再"先看一眼",而是直接把这一行扔给数据库,让数据库这道关卡去判。判断"领到没有"的依据,不再是一个会过期的快照,而是 RETURNING 有没有返回行——这是和写入同一条语句、原子产生的结果,没有任何时间窗。两个并发请求同时跑这段代码,数据库会保证只有一个拿到 RETURNING 的行,另一个拿到空。整个领券流程在数据库里的走向是这样的:
这里要建立的认知是:ON CONFLICT 的价值,在于它把"判断重复"和"写入数据"压成了一条不可拆分的语句,从根上消灭了 TOCTOU 那道缝。第一版是"查、想、再写"三拍,缝就藏在拍子之间;ON CONFLICT 是"写,撞了你看着办"一拍到底。还有一个容易忽略但很重要的细节:ON CONFLICT 后面括号里写的列,必须和某个唯一约束(或唯一索引)的列完全对应——它不是随便写个列名就行,你是在告诉数据库"按哪一个约束来判定冲突"。如果你写的列组合上没有唯一约束,这条语句会直接报错。所以 ON CONFLICT 和唯一约束是一对必须配套出现的东西:约束定义了"什么叫重复",ON CONFLICT 定义了"重复了怎么办"。另外务必记住用 RETURNING 来区分结果——只看 INSERT 没报错就以为成功了是危险的,因为 DO NOTHING 撞了约束也不会报错、它"安静地什么都没做",你必须靠 RETURNING 有没有行才能分清这两种情况。
四、把唯一冲突异常当成正常的业务分支
除了 ON CONFLICT,还有一种同样可靠、而且更通用的写法:直接 INSERT,什么都不加,然后用 try/except 把唯一冲突异常接住。当一行撞上唯一约束,数据库会抛出一个错误,在 PostgreSQL 里 psycopg2 把它映射成 UniqueViolation(各语言各驱动有对应的 IntegrityError 一类异常)。
import psycopg2
from psycopg2 import errors
def claim_coupon_safe(conn, user_id: int, campaign_id: int) -> dict:
"""直接插,把唯一冲突当成一个正常的业务分支来接。"""
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO coupons (user_id, campaign_id, amount) "
"VALUES (%s, %s, %s)",
(user_id, campaign_id, 10),
)
conn.commit()
return {"ok": True}
except errors.UniqueViolation:
# 唯一约束挡下了重复插入 —— 这不是系统错误,是预期内的"已领过"
conn.rollback()
return {"ok": False, "reason": "已经领过了"}
这里有一个心态上的转弯要拐过来:很多人把"数据库抛异常"一律当成系统故障,要么让它冒上去变成 500,要么用一个大 except Exception 笼统吞掉。但 UniqueViolation 不是故障——它恰恰是你的唯一约束正常工作、成功挡下一次重复的信号。它是一个业务结果,和"用户已领过"是同一件事。所以要专门 except 它,把它翻译成一个友好的业务返回。一张表上常常不止一个唯一约束(主键、邮箱、这个复合键……),撞了哪个得分清楚,这要靠异常诊断信息里的约束名:
def violated_constraint(exc: psycopg2.Error) -> str:
"""从唯一冲突异常里取出到底是哪个约束被撞了。"""
diag = exc.diag
# constraint_name 直接告诉你撞的是哪个约束
name = diag.constraint_name or ""
mapping = {
"uq_coupon_user_campaign": "duplicate_claim", # 重复领券
"coupons_pkey": "duplicate_id", # 主键撞了
}
return mapping.get(name, "unknown")
def claim_with_reason(conn, user_id: int, campaign_id: int) -> dict:
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO coupons (user_id, campaign_id, amount) "
"VALUES (%s, %s, %s)",
(user_id, campaign_id, 10),
)
conn.commit()
return {"ok": True}
except errors.UniqueViolation as exc:
conn.rollback()
kind = violated_constraint(exc)
# 不同的约束冲突对应不同的业务处理,绝不能笼统当成一种
return {"ok": False, "reason": kind}
这里要建立的认知是:违反唯一约束抛出的异常,是你的数据完整性防线在正常履职,而不是程序出了 bug。把它和真正的系统异常(连接断了、磁盘满了、SQL 语法错)区分开,是写对这类代码的关键。真正的系统异常你无能为力,只能记录、报警、返回 500;而 UniqueViolation 是一个你完全预期到、并且有明确业务含义的结果——它就是"这条记录已经存在"。所以处理它的正确姿势是窄异常捕获:精确地 except UniqueViolation,而不是一个 except Exception 把所有东西连真故障一起吞掉。捕获之后有两件事必做:一是 rollback,因为冲突发生后当前事务已进入失效状态,不回滚后续语句都会失败;二是借 constraint_name 分清撞的是哪个约束,因为一张表多个唯一约束时,"重复领券"和"主键冲突"是完全不同的两件事,不能糊成一个"反正插不进去"。当你能坦然地把 UniqueViolation 写进 try/except 当作一个 if 分支来用,你才算真正接受了"数据库约束才是法律"——你的代码不再试图抢在数据库前面判断,而是老实地执行、然后优雅地接住数据库的裁决。
五、正确实现 get-or-create 与 UPSERT
"先查再插"最常见的变体,是"获取或创建"——比如按邮箱拿用户,有就返回、没有就建。如果你写成"先 SELECT 查、查不到再 INSERT",它和领券是一模一样的并发 bug。正确的写法是反过来:先尝试插,撞约束了再回头查。因为"撞约束"恰好说明"已经有了",这时候去查一定查得到。
def get_or_create_user(conn, email: str) -> tuple:
"""获取或创建用户:先插,撞约束了再查。返回 (user_id, created)。"""
with conn.cursor() as cur:
# 先尝试插入,撞 email 唯一约束就静默跳过
cur.execute(
"INSERT INTO users (email) VALUES (%s) "
"ON CONFLICT (email) DO NOTHING "
"RETURNING id",
(email,),
)
row = cur.fetchone()
if row is not None:
conn.commit()
return row[0], True # 真的是这次新建的
# 没插进去 —— 说明已经有人用这个 email 了,把那一行查出来
cur.execute("SELECT id FROM users WHERE email = %s", (email,))
row = cur.fetchone()
conn.commit()
return row[0], False # 是已存在的
另一个常见需求是 UPSERT——"插入,如果已存在就改成更新"。比如做库存计数,同一个 (sku, warehouse) 第一次出现就插入,之后再来就把数量累加上去。这正是 ON CONFLICT ... DO UPDATE 的用武之地:
-- UPSERT:库存计数,(sku, warehouse) 不存在就插入,已存在就累加
INSERT INTO stock (sku, warehouse, qty, updated_at)
VALUES (%s, %s, %s, now())
ON CONFLICT (sku, warehouse)
DO UPDATE SET
qty = stock.qty + EXCLUDED.qty,
updated_at = now();
这里 EXCLUDED 是个关键字,它指代"本来打算插入、但因冲突被挡下的那一行"。stock.qty 是表里已有的旧值,EXCLUDED.qty 是这次想加的增量,两者相加再写回。整条语句是原子的:不存在就插,存在就在已有行上累加,并发再多也不会算错或重复。
这里要建立的认知是:get-or-create 的正确实现,核心是把动作的顺序倒过来——不是"查不到再创建",而是"先创建,创建不了说明已存在,再去拿"。为什么这个顺序如此关键?因为"先查再创建"的查,和领券的查一样,是一张会过期的快照;而"先插",是直接去敲数据库那道串行化的关卡,关卡的裁决是确定的、无窗口的。撞约束这个"失败",在 get-or-create 里不是错误,它是一个携带了确切信息的信号——它确凿地告诉你"这条记录此刻一定存在",于是你紧接着的 SELECT 一定能查到,不会扑空。UPSERT 则更进一步:它把"判断存在、然后决定插还是更新"这三件事彻底融进一条语句,连应用层的分支都省了。这两个模式背后是同一条原则:凡是"根据存在性决定下一步"的逻辑,都不要在应用层用 SELECT 去判断存在性,而要让写入动作本身去撞约束,用撞或没撞的结果来驱动分支。你会发现,一旦接受这条原则,绝大多数"先查再改"的并发 bug 都会自动消失,因为你根本不再给 TOCTOU 留出那道缝。
六、工程里那些"约束之外"的坑
把唯一约束用起来之后,还有几个真实项目里反复出现、不踩一次想不到的坑。
第一个是软删除和唯一约束打架。很多系统不真删数据,而是打一个 deleted_at 标记。这时如果给 email 加一个普通唯一约束,会出问题:一个用户注册、注销(软删)、想用同一个邮箱再注册——会被拦住,因为那行旧数据还在,只是被标了删除。解法是用部分唯一索引,只对"还活着的"行强制唯一:
-- 软删除场景:只让 deleted_at IS NULL 的"活着的"行 email 唯一
-- 普通唯一约束会把"注册-注销-再注册"也拦下来
CREATE UNIQUE INDEX uq_users_email_alive
ON users (email)
WHERE deleted_at IS NULL;
第二个是给一张已经有脏数据的表加约束会失败。线上跑了一年的 coupons 表早就有重复券了,直接 ADD CONSTRAINT 数据库会因为"现有数据已经违反约束"而拒绝。必须先清掉重复,再加约束:
-- 第一步:先把重复行查出来,确认重复的规模
SELECT user_id, campaign_id, count(*)
FROM coupons
GROUP BY user_id, campaign_id
HAVING count(*) > 1;
-- 第二步:每组重复只保留 id 最小的一行,其余删掉
DELETE FROM coupons a
USING coupons b
WHERE a.user_id = b.user_id
AND a.campaign_id = b.campaign_id
AND a.id > b.id;
-- 第三步:数据干净了,再加约束
ALTER TABLE coupons
ADD CONSTRAINT uq_coupon_user_campaign
UNIQUE (user_id, campaign_id);
第三个是 NULL 在唯一约束里的特殊行为:在标准 SQL 里,多个 NULL 之间互不视为相等,所以一个唯一列上可以躺着任意多行 NULL,约束不会拦。如果你指望"某列为空时也唯一",普通唯一约束做不到,得另想办法(比如用一个固定占位值代替 NULL,或新版数据库的 NULLS NOT DISTINCT 选项)。第四个是复合唯一约束的列顺序:UNIQUE (user_id, campaign_id) 底层会建一个按这个顺序排的索引,它能被 WHERE user_id = ? 的查询复用,但帮不上只按 campaign_id 过滤的查询——定义复合约束时,顺便想一想这个索引还能不能服务你的高频查询。
这里要建立的认知是:唯一约束不是"加上就一劳永逸"的开关,它和你的业务语义、历史数据、查询模式都纠缠在一起。软删除的坑告诉你:"什么算重复"是业务定义的——是"邮箱在全表唯一",还是"邮箱在未删除的行里唯一"?这个定义不同,约束就得不同,部分唯一索引正是用来表达"带条件的唯一"。脏数据的坑告诉你:约束是一条会回头检查全部存量数据的规则,给老表加约束本质是一次数据治理,必须先让存量数据满足约束,顺序错了 ALTER 直接失败。NULL 的坑告诉你:唯一约束对 NULL 的处理违反直觉,凡是唯一列允许为空,都要专门确认这是不是你想要的。把这些合起来看,一个工程上靠谱的唯一约束,是在三个问题都想清楚之后才落地的:这张表"什么叫重复"在业务上到底怎么定义、存量数据是否已经满足这个定义、这个约束附带的索引能不能顺便服务查询。想清楚再加,它就是你最可靠的一道防线;想都不想随手加,它要么加不上,要么在某个软删除场景里冷不丁拦错人。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 先查再插 / TOCTOU | SELECT 检查与 INSERT 写入是两条独立语句,中间有时间窗 | 检查的是过去的快照,挡不住并发,窗口里会交错 |
| 唯一约束 UNIQUE | 数据库引擎在写入那一刻强制的"该列组合不可重复"规则 | 唯一真正可靠的去重保证,写入被串行裁决 |
| 唯一索引 | 唯一约束底层依赖一个唯一索引来实现 | 加约束即建索引,大表上加约束会锁表,需评估 |
| 复合唯一约束 | 多列组合不允许重复,如 (user_id, campaign_id) | 列顺序决定底层索引能否被高频查询复用 |
| ON CONFLICT DO NOTHING | INSERT 撞约束时静默跳过该行,不报错 | 必须配合 RETURNING 判断是否真的插入成功 |
| ON CONFLICT DO UPDATE | 撞约束时改为更新已有行,即 UPSERT | EXCLUDED 指代试图插入的那一行 |
| UniqueViolation / IntegrityError | 违反唯一约束时数据库抛出的异常 | 是约束正常工作的信号,应作业务分支捕获而非 500 |
| constraint_name | 异常诊断信息里指明被撞的具体约束名 | 一表多约束时用它区分冲突来源,需手动命名约束 |
| 部分唯一索引 | 带 WHERE 条件的唯一索引 | 软删除场景下只对未删除行强制唯一 |
| NULL 在唯一约束中 | 标准 SQL 里多个 NULL 互不相等、不算重复 | 想让 NULL 也唯一需占位值或 NULLS NOT DISTINCT |
避坑清单
- 不要用"先 SELECT 再 INSERT"来保证不重复。两条语句之间有时间窗,并发请求会在窗口里交错,各自查到"没有"后各自插入。
- 给需要去重的列组合建唯一约束,这是唯一可靠的防线。即使应用代码有 bug,约束也能在数据库层挡住重复。
- 给唯一约束起一个有意义的名字(如 uq_coupon_user_campaign),不要用数据库自动生成的默认名,排查冲突来源时要靠这个名字。
- 优先用 INSERT ON CONFLICT 直接插,别在应用层先查。ON CONFLICT 后的列必须对应一个真实存在的唯一约束。
- 用 ON CONFLICT DO NOTHING 时务必配合 RETURNING。DO NOTHING 撞约束不报错,只有 RETURNING 有没有行能区分"插入了"和"被跳过"。
- 把 UniqueViolation 当成正常业务分支,用窄异常精确捕获,不要用 except Exception 笼统吞掉真故障。
- 捕获唯一冲突后必须先 rollback,冲突会让当前事务进入失效状态,不回滚后续语句都会失败。
- get-or-create 要"先插再查",不要"先查再插"。撞约束确凿说明记录已存在,这时再 SELECT 一定查得到。
- 软删除场景用部分唯一索引,只对 deleted_at IS NULL 的行强制唯一,否则"注销后重新注册"会被错误拦截。
- 给老表加约束前先清理重复数据。存量数据违反约束时 ALTER 会直接失败,必须先去重再加约束。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为"先查再插"是一个原子动作,以为 SELECT 查到的"没有"能一直管用到 INSERT 那一刻。可它们是两条分开的语句,中间隔着一道时间窗;SELECT 的结果是一张会过期的快照,它描述过去,担保不了未来。我用一张过去的照片,去为一个未来的写入背书——并发重复的全部来源,就藏在这道"检查时刻"与"写入时刻"之间的缝里。这道缝有个名字叫 TOCTOU,它不会因为你把代码写得更仔细而消失,只会变窄,而并发要的就是那一点点窄缝。
真正把去重做对,工作量根本不在"把应用层的检查写得更严",而在一次方向的扭转:把去重的责任,从分散在无数并发请求里、各看各的应用层检查,移交给数据库那个集中的、所有写入都必须穿过的、被串行裁决的唯一约束。这件事拆开并不多——给该唯一的列组合建上约束、把直接插入加上 ON CONFLICT 或用 try/except 接住 UniqueViolation、把 get-or-create 改成"先插再查"、给软删除场景换成部分唯一索引、给老表加约束前先去重。每一件都不难,难的是先承认"应用层的检查永远只是建议,数据库的约束才是法律"。
我后来常拿订酒店来想这件事。"先查再插"就像你和另一个人,同时在订房网站上看到 802 房间显示"空闲",于是你俩都点了"预订"——网站的日历是一张某个瞬间的快照,它说空闲,你们就都信了,结果两个人订了同一间房。而真正不会出错的,是酒店前台:802 的房卡只有一把,前台手里就那一把钥匙,谁先到柜台谁拿走,第二个人到了,前台只能说"这间已经有人了"。网站的日历是应用层的 SELECT,看的是过去;前台那把唯一的钥匙,才是数据库的唯一约束——它不在乎你在网站上看到了什么,它只认柜台上此刻这把钥匙还在不在。去重要可靠,就得让发钥匙这个动作收归一处、且一次只发一把,而不是让每个客人自己看日历自觉。
这类问题最咬人的地方,在于它在本地和测试环境几乎永远是"对"的:你一下一下顺序地点,两次点击之间隔着大半秒,那道窗口窄到你这辈子也撞不进去,代码看着滴水不漏。它只在真实流量、请求密集到毫秒级交错时才露出獠牙,而那时脏数据已经写进库、对账已经对不平了。所以别等线上对账才发现问题:凡是"一个东西只能有一份"的需求——一人一张券、一个邮箱一个账号、一个订单号、一次支付,第一反应不该是"我在代码里查一下",而该是"这张表上,该有一个唯一约束"。把这道防线在建表时就焊死,你才算真正跳出了那条人人都会写、却人人都会栽的"先查再插"。
—— 别看了 · 2026