数据库唯一约束完全指南:从一次"明明查过了、用户却领到两张券"看懂并发插入与去重真相

2021 年我做一个电商活动的领券功能用户在活动页点一下领取系统给他发一张优惠券规则很简单一个用户一个活动只能领一张怎么保证只能领一张这件事我压根没多想我心里很省事地想插之前先查一下他领过没有没领过再插不就保证一人一张了吗第一版我做得很顺手先 SELECT 查这个用户在这个活动里有没有券查出来是 0 就 INSERT 一张查出来大于 0 就告诉他已经领过了就完事了本地开发时真不错我点一下领券成功再点一下提示已领过看着滴水不漏可等这个活动真正上线流量涌进来一串问题冒了出来第一种最先把我打懵对账时发现同一个用户同一个活动数据库里躺着两张三张券明明代码里查过了第二种最难缠它不是每次都重复绝大多数用户都正常只有一小撮出现重复毫无规律第三种最头疼我发现重复的几乎都是手快连点两下或者网络慢重复提交的用户两个请求几乎同时到第四种最莫名其妙我在本地怎么点都重现不了后来才反应过来我在本地是一下一下点的两次点击之间隔了大半秒我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为先查再插就能保证不重复这句话把查和插这两条独立的语句当成了一个连在一起不会被打断的整体可它们不是 SELECT 和 INSERT 是两条分开的语句中间隔着一道缝当两个请求几乎同时到达请求 A 执行 SELECT 看到 count 为 0 请求 B 也执行 SELECT 同样看到 count 为 0 于是 A 去 INSERT 了一张 B 也去 INSERT 了一张这个用户手里就有两张券问题的根子在于 SELECT 查到的没有是数据库某个瞬间的一张快照是对过去的描述它绝不是一把锁锁不住从现在到我 INSERT 之间别人不许插入这段未来这中间的时间窗就是并发重复的全部来源它有个名字叫 TOCTOU 检查时刻与使用时刻之间的裂缝真正保证不重复核心不是在应用代码里先查一下而是把去重这件事交给数据库的唯一约束由数据库引擎在写入的那一刻强制这一对值不许重复应用层的检查只是建议数据库的约束才是法律本文从头梳理为什么先查再插是错的为什么唯一约束才是唯一可靠的真相怎么用 ON CONFLICT 让数据库裁决冲突怎么把唯一冲突异常当成正常业务分支怎么正确实现获取或创建与 UPSERT 以及软删除脏数据加约束这些把去重做扎实要避开的坑

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

避坑清单

  1. 不要用"先 SELECT 再 INSERT"来保证不重复。两条语句之间有时间窗,并发请求会在窗口里交错,各自查到"没有"后各自插入。
  2. 给需要去重的列组合建唯一约束,这是唯一可靠的防线。即使应用代码有 bug,约束也能在数据库层挡住重复。
  3. 给唯一约束起一个有意义的名字(如 uq_coupon_user_campaign),不要用数据库自动生成的默认名,排查冲突来源时要靠这个名字。
  4. 优先用 INSERT ON CONFLICT 直接插,别在应用层先查。ON CONFLICT 后的列必须对应一个真实存在的唯一约束。
  5. 用 ON CONFLICT DO NOTHING 时务必配合 RETURNING。DO NOTHING 撞约束不报错,只有 RETURNING 有没有行能区分"插入了"和"被跳过"。
  6. 把 UniqueViolation 当成正常业务分支,用窄异常精确捕获,不要用 except Exception 笼统吞掉真故障。
  7. 捕获唯一冲突后必须先 rollback,冲突会让当前事务进入失效状态,不回滚后续语句都会失败。
  8. get-or-create 要"先插再查",不要"先查再插"。撞约束确凿说明记录已存在,这时再 SELECT 一定查得到。
  9. 软删除场景用部分唯一索引,只对 deleted_at IS NULL 的行强制唯一,否则"注销后重新注册"会被错误拦截。
  10. 给老表加约束前先清理重复数据。存量数据违反约束时 ALTER 会直接失败,必须先去重再加约束。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为"先查再插"是一个原子动作,以为 SELECT 查到的"没有"能一直管用到 INSERT 那一刻。可它们是两条分开的语句,中间隔着一道时间窗;SELECT 的结果是一张会过期的快照,它描述过去,担保不了未来。我用一张过去的照片,去为一个未来的写入背书——并发重复的全部来源,就藏在这道"检查时刻"与"写入时刻"之间的缝里。这道缝有个名字叫 TOCTOU,它不会因为你把代码写得更仔细而消失,只会变窄,而并发要的就是那一点点窄缝。

真正把去重做对,工作量根本不在"把应用层的检查写得更严",而在一次方向的扭转:把去重的责任,从分散在无数并发请求里、各看各的应用层检查,移交给数据库那个集中的、所有写入都必须穿过的、被串行裁决的唯一约束。这件事拆开并不多——给该唯一的列组合建上约束、把直接插入加上 ON CONFLICT 或用 try/except 接住 UniqueViolation、把 get-or-create 改成"先插再查"、给软删除场景换成部分唯一索引、给老表加约束前先去重。每一件都不难,难的是先承认"应用层的检查永远只是建议,数据库的约束才是法律"。

我后来常拿订酒店来想这件事。"先查再插"就像你和另一个人,同时在订房网站上看到 802 房间显示"空闲",于是你俩都点了"预订"——网站的日历是一张某个瞬间的快照,它说空闲,你们就都信了,结果两个人订了同一间房。而真正不会出错的,是酒店前台:802 的房卡只有一把,前台手里就那一把钥匙,谁先到柜台谁拿走,第二个人到了,前台只能说"这间已经有人了"。网站的日历是应用层的 SELECT,看的是过去;前台那把唯一的钥匙,才是数据库的唯一约束——它不在乎你在网站上看到了什么,它只认柜台上此刻这把钥匙还在不在。去重要可靠,就得让发钥匙这个动作收归一处、且一次只发一把,而不是让每个客人自己看日历自觉。

这类问题最咬人的地方,在于它在本地和测试环境几乎永远是"对"的:你一下一下顺序地点,两次点击之间隔着大半秒,那道窗口窄到你这辈子也撞不进去,代码看着滴水不漏。它只在真实流量、请求密集到毫秒级交错时才露出獠牙,而那时脏数据已经写进库、对账已经对不平了。所以别等线上对账才发现问题:凡是"一个东西只能有一份"的需求——一人一张券、一个邮箱一个账号、一个订单号、一次支付,第一反应不该是"我在代码里查一下",而该是"这张表上,该有一个唯一约束"。把这道防线在建表时就焊死,你才算真正跳出了那条人人都会写、却人人都会栽的"先查再插"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

向量检索踩坑完全指南:从一次"换了个 embedding 模型、整个知识库检索全乱套"看懂向量空间不兼容

2026-5-22 17:04:34

技术教程

大模型对话历史压缩完全指南:从一次"砍掉几条旧消息、Agent 就报 400 错误"看懂上下文治理

2026-5-22 17:18:19

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索