PostgreSQL 幂等写入踩坑:先查再插在并发下为什么会翻车

支付回调表出问题时,现象有点绕。

业务侧偶尔会收到重复回调,这件事本来正常。支付渠道为了保证通知成功,同一笔订单可能推多次。后端接口做了幂等判断,逻辑看起来也没什么问题:先按业务单号查一下,查不到再插入。

上线后还是出现了重复记录。重复数据不多,但只要支付回调这种表出现重复,后面的对账、补偿、消息投递都会变得很麻烦。

数据库是 PostgreSQL 15.6,表结构做过简化:

CREATE TABLE payment_callback (
    id             BIGSERIAL PRIMARY KEY,
    tenant_id      BIGINT      NOT NULL,
    channel        TEXT        NOT NULL,
    out_trade_no   TEXT        NOT NULL,
    callback_type  TEXT        NOT NULL,
    status         SMALLINT    NOT NULL DEFAULT 0,
    payload        JSONB       NOT NULL,
    received_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    handled_at     TIMESTAMPTZ,
    deleted_at     TIMESTAMPTZ
);

原来的幂等逻辑大概是这样:

BEGIN;

SELECT id, status
FROM payment_callback
WHERE tenant_id = 42
  AND channel = 'wechat'
  AND out_trade_no = 'P202601180001'
  AND callback_type = 'pay_success'
  AND deleted_at IS NULL;

-- 如果查不到,再插入

INSERT INTO payment_callback (
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
)
VALUES (
    42,
    'wechat',
    'P202601180001',
    'pay_success',
    0,
    '{"amount": 168.90, "trade_state": "SUCCESS"}'::jsonb,
    now()
);

COMMIT;

单线程测,这段逻辑没有问题。并发一上来,问题就藏不住了。

支付回调里翻出来的重复数据

先用一条 SQL 查重复业务键:

SELECT
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    COUNT(*) AS cnt,
    MIN(id) AS first_id,
    MAX(id) AS last_id
FROM payment_callback
WHERE deleted_at IS NULL
GROUP BY
    tenant_id,
    channel,
    out_trade_no,
    callback_type
HAVING COUNT(*) > 1
ORDER BY cnt DESC
LIMIT 20;

线上脱敏后的结果类似这样:

 tenant_id | channel | out_trade_no  | callback_type | cnt | first_id | last_id
-----------+---------+---------------+---------------+-----+----------+---------
        42 | wechat  | P202601180001 | pay_success   |   2 |  8912031 | 8912032
        42 | wechat  | P202601180019 | pay_success   |   2 |  8912457 | 8912459
        18 | alipay  | A202601180077 | pay_success   |   3 |  8913001 | 8913008

这几条记录的 received_at 相差都很小,有些甚至在同一毫秒内。应用日志里也能看到,同一个业务单号被多个请求同时打进来。

原来的代码在事务里先查再插,看起来像是加了一层保护。实际在 PostgreSQL 默认的 READ COMMITTED 隔离级别下,两个事务可能同时查不到对方还没提交的数据。

并发过程可以简化成这样:

T1: SELECT ... WHERE out_trade_no = 'P202601180001';  -- 查不到
T2: SELECT ... WHERE out_trade_no = 'P202601180001';  -- 也查不到

T1: INSERT ...
T2: INSERT ...

T1: COMMIT
T2: COMMIT

只靠应用层的 SELECT 判断,挡不住这种竞态。数据库里没有唯一约束时,两个事务都能成功提交,重复数据就这样进来了。

这里我一开始还怀疑过是不是接口重试逻辑写错了。后来压测复现后才确认,重试只是触发条件,根因在表上没有用业务唯一键兜住幂等。

让唯一索引接管幂等判断

支付回调的唯一性应该由业务字段决定:

tenant_id + channel + out_trade_no + callback_type

id 是数据库生成的主键,只能保证每行记录不同,不能保证同一笔业务只写一次。

加唯一索引前,要先把已经重复的数据处理掉。我的处理方式是保留最早的一条,其余记录软删除。真实项目里还要结合处理状态判断,不能简单删掉已经参与结算的数据。

WITH ranked AS (
    SELECT
        id,
        ROW_NUMBER() OVER (
            PARTITION BY tenant_id, channel, out_trade_no, callback_type
            ORDER BY received_at ASC, id ASC
        ) AS rn
    FROM payment_callback
    WHERE deleted_at IS NULL
)
UPDATE payment_callback p
SET deleted_at = now()
FROM ranked r
WHERE p.id = r.id
  AND r.rn > 1;

清理后再建部分唯一索引:

CREATE UNIQUE INDEX CONCURRENTLY uk_payment_callback_biz_active
ON payment_callback (
    tenant_id,
    channel,
    out_trade_no,
    callback_type
)
WHERE deleted_at IS NULL;

这里用了部分唯一索引,因为表里保留了软删除字段。业务上只要求未删除记录唯一,历史软删除记录不参与冲突判断。

如果表没有软删除,可以直接建唯一约束:

ALTER TABLE payment_callback
ADD CONSTRAINT uk_payment_callback_biz
UNIQUE (tenant_id, channel, out_trade_no, callback_type);

线上加索引时我用了 CONCURRENTLY,避免长时间阻塞普通读写。它不能放在事务块里执行,迁移脚本要单独拆出来。

ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block

索引创建进度可以这样看:

SELECT
    pid,
    phase,
    blocks_total,
    blocks_done,
    tuples_total,
    tuples_done
FROM pg_stat_progress_create_index;

这一步做完后,数据库才有能力从底层阻止重复写入。后面的 ON CONFLICT 才有意义。

ON CONFLICT DO NOTHING 适合首写成功

第一版改造用的是 ON CONFLICT DO NOTHING。支付回调只需要第一条记录入库,重复通知不改已有内容。

SQL 改成这样:

INSERT INTO payment_callback (
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
)
VALUES (
    42,
    'wechat',
    'P202601180001',
    'pay_success',
    0,
    '{"amount": 168.90, "trade_state": "SUCCESS"}'::jsonb,
    now()
)
ON CONFLICT (
    tenant_id,
    channel,
    out_trade_no,
    callback_type
)
WHERE deleted_at IS NULL
DO NOTHING
RETURNING
    id,
    status,
    received_at;

注意这里的冲突目标带了:

WHERE deleted_at IS NULL

因为前面建的是部分唯一索引。这个谓词要和索引匹配,否则 PostgreSQL 可能找不到可用的唯一索引,直接报错:

ERROR:  there is no unique or exclusion constraint matching the ON CONFLICT specification

这也是部分唯一索引配 ON CONFLICT 时最容易漏的地方。

应用层拿到 RETURNING 结果后分两种情况处理。

插入成功时,RETURNING 会返回新行:

 id      | status | received_at
---------+--------+-------------------------------
 8912031 |      0 | 2026-01-18 10:14:22.391+08

遇到重复回调时,DO NOTHING 不会插入,也不会返回行。这个时候再按业务键查一次已有记录:

SELECT
    id,
    status,
    received_at
FROM payment_callback
WHERE tenant_id = 42
  AND channel = 'wechat'
  AND out_trade_no = 'P202601180001'
  AND callback_type = 'pay_success'
  AND deleted_at IS NULL;

Java 里的逻辑大概是这样:

PaymentCallbackRow inserted = callbackMapper.insertIgnore(callback);

if (inserted != null) {
    return inserted;
}

PaymentCallbackRow exists = callbackMapper.selectByBizKey(
        callback.getTenantId(),
        callback.getChannel(),
        callback.getOutTradeNo(),
        callback.getCallbackType()
);

if (exists == null) {
    throw new IllegalStateException("callback insert conflict happened, but existing row was not found");
}

return exists;

这里我没有把插入和查询硬塞进同一条 CTE 里。READ COMMITTED 下单条语句共用一个快照,极端并发时可能出现 ON CONFLICT 已经判断有冲突,但同一语句里的后续 SELECT 还看不到那条记录的情况。拆成两条语句后,第二条查询会拿新的快照,处理起来更稳。

这版上线后,重复记录没有继续增长。压测时同一个业务键并发打 1000 次,最终也只会保留一条未删除记录。

DO UPDATE 别随手当成返回已有行的工具

有些场景不适合 DO NOTHING。比如渠道重复回调时,业务希望更新最新的原始报文、最近接收时间,或者把失败状态改成待处理状态。这时可以用 DO UPDATE

INSERT INTO payment_callback (
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
)
VALUES (
    42,
    'wechat',
    'P202601180001',
    'pay_success',
    0,
    '{"amount": 168.90, "trade_state": "SUCCESS"}'::jsonb,
    now()
)
ON CONFLICT (
    tenant_id,
    channel,
    out_trade_no,
    callback_type
)
WHERE deleted_at IS NULL
DO UPDATE
SET
    payload = EXCLUDED.payload,
    received_at = EXCLUDED.received_at,
    status = CASE
        WHEN payment_callback.status = 2 THEN payment_callback.status
        ELSE EXCLUDED.status
    END
RETURNING
    id,
    status,
    received_at;

EXCLUDED 表示这次准备插入但因为冲突没能直接插入的那行数据。

上面这个写法里有一个状态保护:

status = CASE
    WHEN payment_callback.status = 2 THEN payment_callback.status
    ELSE EXCLUDED.status
END

支付成功已经处理完成后,重复回调不能把状态改回待处理。这个保护很重要,尤其是支付、发货、积分发放这类状态机业务。幂等写入只解决“同一业务键写一条”的问题,状态流转还要自己兜住。

DO UPDATE 可以直接 RETURNING 已有行,用起来很方便,但我不会为了少写一次 SELECT 就无脑使用它。即使更新成相同的值,PostgreSQL 仍然会产生新的行版本,还可能触发 UPDATE 触发器,热表上会带来额外膨胀。

如果业务只是首写成功,重复请求直接复用已有结果,DO NOTHING + 再查一次 更干净。

如果重复请求确实要刷新字段,再用 DO UPDATE。这个边界要在接口设计时想清楚。

唯一键里的 NULL 会绕过很多预期

后面排查另一个幂等表时,又遇到一个更隐蔽的问题。接口有一个 idempotency_key,但字段允许为空:

CREATE TABLE api_request_log (
    id               BIGSERIAL PRIMARY KEY,
    tenant_id         BIGINT      NOT NULL,
    idempotency_key   TEXT,
    request_path      TEXT        NOT NULL,
    body_hash         TEXT        NOT NULL,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

当时建了唯一索引:

CREATE UNIQUE INDEX CONCURRENTLY uk_api_request_idem
ON api_request_log (tenant_id, idempotency_key);

结果同一个租户下仍然插入了多条 idempotency_key IS NULL 的记录。原因是 PostgreSQL 里普通唯一索引会把多个 NULL 当成不同值处理。

如果幂等键必须有值,最直接的处理是把字段改成 NOT NULL,在入口就拒绝空值:

ALTER TABLE api_request_log
ALTER COLUMN idempotency_key SET NOT NULL;

如果业务上确实要把 NULL 当成同一个值,PostgreSQL 15 可以使用 NULLS NOT DISTINCT

CREATE UNIQUE INDEX CONCURRENTLY uk_api_request_idem
ON api_request_log (
    tenant_id,
    idempotency_key
)
NULLS NOT DISTINCT;

不过我更倾向于让幂等键明确非空。支付单号、请求幂等键、外部流水号这些字段一旦参与唯一判断,就不适合放任空值进入数据库。空字符串也要处理,最好在应用入口统一 trim 和校验,不要让 NULL''' ' 混在一起。

批量写入时先去重

还有一个批量写入的坑。

有些任务会一次性写入多条回调或多条请求日志,SQL 写成:

INSERT INTO payment_callback (
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
)
VALUES
    (42, 'wechat', 'P202601180001', 'pay_success', 0, '{"amount": 168.90}'::jsonb, now()),
    (42, 'wechat', 'P202601180001', 'pay_success', 0, '{"amount": 168.90}'::jsonb, now())
ON CONFLICT (
    tenant_id,
    channel,
    out_trade_no,
    callback_type
)
WHERE deleted_at IS NULL
DO UPDATE
SET
    payload = EXCLUDED.payload,
    received_at = EXCLUDED.received_at;

同一批数据里如果有重复业务键,DO UPDATE 可能报错:

ERROR:  ON CONFLICT DO UPDATE command cannot affect row a second time

这类数据要先在输入侧去重。如果用 SQL 处理,可以先用 CTE 做 DISTINCT ON

WITH input_rows (
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
) AS (
    VALUES
        (42, 'wechat', 'P202601180001', 'pay_success', 0, '{"amount": 168.90}'::jsonb, now()),
        (42, 'wechat', 'P202601180001', 'pay_success', 0, '{"amount": 168.90}'::jsonb, now())
),
dedup AS (
    SELECT DISTINCT ON (
        tenant_id,
        channel,
        out_trade_no,
        callback_type
    )
        tenant_id,
        channel,
        out_trade_no,
        callback_type,
        status,
        payload,
        received_at
    FROM input_rows
    ORDER BY
        tenant_id,
        channel,
        out_trade_no,
        callback_type,
        received_at DESC
)
INSERT INTO payment_callback (
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
)
SELECT
    tenant_id,
    channel,
    out_trade_no,
    callback_type,
    status,
    payload,
    received_at
FROM dedup
ON CONFLICT (
    tenant_id,
    channel,
    out_trade_no,
    callback_type
)
WHERE deleted_at IS NULL
DO UPDATE
SET
    payload = EXCLUDED.payload,
    received_at = EXCLUDED.received_at;

DISTINCT ON 保留哪一条,取决于后面的 ORDER BY。这里保留同一业务键里 received_at 最新的一条。真实业务里也可以按消息版本号、渠道通知时间、重试次数来决定保留规则。

上线后的效果

这次改造分了三步上线。

先清理历史重复数据,再建部分唯一索引,最后把应用里的先查再插改成 ON CONFLICT

压测时用同一个支付单号并发请求 1000 次,结果变成:

场景 结果
原先先查再插,没有唯一索引 偶尔出现多条未删除记录
只有唯一索引,应用仍然普通 INSERT 重复请求会抛唯一冲突异常
唯一索引 + ON CONFLICT DO NOTHING 只插入一条,重复请求返回已有记录
唯一索引 + ON CONFLICT DO UPDATE 只保留一条,并按规则刷新字段

线上观察一周后,重复业务键没有继续增加,支付补偿任务里的异常也少了很多。这个结果比单纯在 Java 里加锁更稳,因为锁范围不依赖某台应用实例。多实例部署、请求重试、消息重复投递,都交给数据库唯一索引兜底。

总结

PostgreSQL 里做幂等写入,不能只靠“先查再插”。并发请求进来时,两个事务都可能查不到对方还没提交的数据。业务唯一性要落到唯一索引或唯一约束上,再配合 ON CONFLICT 处理重复写入。

DO NOTHING 适合首写成功、重复复用已有记录的场景。DO UPDATE 适合重复请求确实要刷新字段的场景。部分唯一索引要在 ON CONFLICT 里带上对应谓词,nullable 字段要提前处理好,批量写入前也要先去重。

支付回调、消息消费、接口幂等、第三方流水同步,都可以按这个思路处理。数据库负责保证同一业务键只落一条,应用层再处理状态流转和返回结果,这样并发下会稳很多。

posted @ 2026-05-09 22:47  小雨青年  阅读(10)  评论(0)    收藏  举报