PAYATHON 2026

Redis SET NX 为何在 Node.js 多实例并发写入时仍产生重复交易?

排障赵师傅

当前代码存在竞态:GET 和 SET 分开执行,SET 也没有使用 NX。两个实例可能同时读到 key 不存在,随后都返回 true。

写入 PostgreSQL 的交易,建议通过数据库唯一约束保证同一业务请求只创建一条记录,并将相关写入放在同一个数据库事务中。Redis 可以缓存结果、减少重复请求,但成功写入 Redis 幂等键,并不代表 PostgreSQL 中的交易已经提交。

仅凭现有代码,还无法判断此前使用 SET NX、Lua 等方案后仍然重复的原因。排查时需要分别确认:两个请求是否都通过了 Redis 检查,以及重复的交易写入是否绕过了数据库约束。

当前代码为什么会放过两个请求?

两个请求可以按以下顺序执行:

实例 A:GET → key 不存在
实例 B:GET → key 不存在
实例 A:SET → 成功,返回 true
实例 B:SET → 覆盖已有值,返回 true

EX 86400 只设置过期时间,不能让前面的读取和后面的写入成为一个原子操作。

单实例也可能出现同样的问题。Node.js 在 await 等待 Redis 响应时,可以继续处理另一个请求。单实例测试中没有观察到重复,只能说明测试没有触发这种执行顺序。

使用 ioredis 时,可以先改为:

async checkIdempotency(
  key: string,
  tenantId: string,
): Promise<boolean> {
  const redisKey = `idempotency:${tenantId}:${key}`;

  const result = await this.redis.set(
    redisKey,
    '1',
    'EX',
    86400,
    'NX',
  );

  return result === 'OK';
}

必须检查返回值。普通 SET ... NX EX ... 成功时返回 OK,条件不满足时返回空回复,ioredis 通常将其表示为 null。Redis SET 文档

只要 Redis 主节点正常服务、两个请求使用完全相同的 key,且期间 key 没有消失,两个并发的 SET NX 就不会都成功。多个 Node.js 实例不会改变这条命令的原子性。

这个函数只能判断是否成功占用了一个 key,无法区分“交易正在处理”“交易已经完成”和“占用 key 后进程崩溃”。

为什么正确使用 SET NX 后仍可能重复?

Redis 操作与 PostgreSQL 事务各自提交,可能出现以下情况:

请求 A:成功写入 Redis 幂等键
请求 A:提交 PostgreSQL 交易
请求 A:返回响应前连接断开
客户端:重试

重试时,如果 Redis 中的标记仍在,就可以阻止再次执行。如果标记因过期、驱逐、误删或故障而丢失,数据库又没有唯一约束,重试就可能再次创建交易。

反过来也有问题:Redis key 已写入,数据库事务却失败了,后续重试仍会被阻止,直到标记被清理或过期。

Redis 主从复制还存在故障切换窗口:主节点确认写入后,数据可能尚未复制到副本,主节点就发生了故障。是否遇到这种情况,需要结合部署和故障日志判断,不能仅凭约 50ms 的请求间隔认定。Redis 分布式锁文档

排查时应记录完整的幂等键、实例 ID、Redis 返回值和数据库提交结果。不要把 Redis 超时直接当成“key 不存在”,也不要在异常处理分支中无条件继续创建交易。

Lua 在 Redis Cluster 中是否跨节点原子?需要 Redlock 吗?

Lua 脚本在执行它的 Redis 节点上原子执行,执行期间不会插入其他客户端命令。Redis Lua 文档

通常在 Redis Cluster 中调用脚本时,涉及的 key 应位于同一个 hash slot。可以通过 hash tag 将相关 key 放到同一个 slot,例如:

idempotency:{tenant42:req789}:lock
idempotency:{tenant42:req789}:result

这不能形成覆盖所有分片的分布式事务,也无法将 PostgreSQL 写入纳入 Lua 的原子范围。Redis Cluster 规范

检查并写入单个幂等键,使用 SET NX 就够了,无需额外使用 Lua。Redlock 用于有特定假设和租约期限的分布式互斥,也不能让 Redis 操作与 PostgreSQL 提交成为同一个事务。要保证同一请求只创建一条交易记录,数据库唯一约束更直接。

PostgreSQL 为什么用了 SELECT FOR UPDATE 仍可能重复?

SELECT ... FOR UPDATE 锁住的是查询实际选中的行。如果两个事务都在查找尚不存在的交易:

SELECT id
FROM transactions
WHERE tenant_id = $1
  AND idempotency_key = $2
FOR UPDATE;

两边都可能得到空结果。此时没有交易行可供锁定,也没有约束阻止后续使用相同幂等键插入记录。PostgreSQL 行级锁文档

锁住一个已存在的租户行,可以让请求串行执行,但会扩大同一租户内的竞争范围,而且所有写入路径都必须遵守这套规则。防止重复交易,通常应直接建立唯一约束。

NestJS 中如何实现数据库幂等?

同步创建本地交易记录的接口,可以直接将幂等信息保存在交易表中:

CREATE TABLE transactions (
  id UUID PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  operation TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  request_hash TEXT NOT NULL,
  amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
  currency TEXT NOT NULL,

  CONSTRAINT transactions_idempotency_unique
    UNIQUE (tenant_id, operation, idempotency_key)
);

operation 用于区分创建交易、退款等操作。如果这张表只承载一种操作,也可以只约束 (tenant_id, idempotency_key)。这些字段应设为 NOT NULL,避免空值影响去重语义。

写入时使用 INSERT ... ON CONFLICT DO NOTHING RETURNING ...。只有插入成功,才执行后续业务写入;发生冲突则读取原记录。PostgreSQL INSERT 文档

以下 NestJS Service 使用 pg,假设已注册 PG_POOL provider,输入也已完成校验和规范化:amountMinor 是 PostgreSQL BIGINT 范围内的正整数字符串,currency 使用统一格式,tenantId 来自认证上下文。

import {
  ConflictException,
  Inject,
  Injectable,
  ServiceUnavailableException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { Pool } from 'pg';

type CreateInput = {
  amountMinor: string;
  currency: string;
};

@Injectable()
export class TransactionsService {
  constructor(
    @Inject('PG_POOL') private readonly pool: Pool,
  ) {}

  async create(
    tenantId: string,
    key: string,
    input: CreateInput,
  ) {
    const operation = 'create-transaction';
    const requestHash = createHash('sha256')
      .update(JSON.stringify([
        operation,
        input.amountMinor,
        input.currency,
      ]))
      .digest('hex');

    const client = await this.pool.connect();

    try {
      await client.query(
        'BEGIN ISOLATION LEVEL READ COMMITTED',
      );

      const inserted = await client.query(
        `INSERT INTO transactions (
           id, tenant_id, operation, idempotency_key,
           request_hash, amount_minor, currency
         )
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         ON CONFLICT (tenant_id, operation, idempotency_key)
         DO NOTHING
         RETURNING id, request_hash, amount_minor, currency`,
        [
          randomUUID(),
          tenantId,
          operation,
          key,
          requestHash,
          input.amountMinor,
          input.currency,
        ],
      );

      let row = inserted.rows[0];

      if (row) {
        // 需要随交易提交的其他本地业务 SQL,只在这里执行。
        // 所有语句都必须使用同一个 client。
      } else {
        const existing = await client.query(
          `SELECT id, request_hash, amount_minor, currency
           FROM transactions
           WHERE tenant_id = $1
             AND operation = $2
             AND idempotency_key = $3`,
          [tenantId, operation, key],
        );

        row = existing.rows[0];

        if (!row) {
          throw new ServiceUnavailableException(
            '暂时无法读取交易结果,请使用相同幂等键重试',
          );
        }

        if (row.request_hash !== requestHash) {
          throw new ConflictException(
            '同一幂等键不能用于不同的交易参数',
          );
        }
      }

      await client.query('COMMIT');

      return {
        id: row.id,
        amountMinor: row.amount_minor,
        currency: row.currency,
      };
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}

这段实现的行为和限制是:

  • 同一租户、同一操作、同一 key,只允许保留一条交易记录。
  • 两个请求竞争时,发生冲突的插入可能等待另一个事务结束。对方提交后读取原记录;对方回滚后,当前插入可以继续。
  • 相同 key 携带不同参数时返回冲突,避免将另一笔交易误当成成功重试。
  • 数据库已提交但响应丢失时,客户端使用原 key 重试,可以获得原交易结果。

冲突后的读取需要单独执行一条 SQL。在 READ COMMITTED 下,每条语句都会取得新的快照。将“插入或读取”合并成一个 CTE,并不能保证并发冲突时一定能返回原记录。PostgreSQL 事务隔离文档

事务中的所有 SQL 必须使用同一个数据库连接。使用 ORM 时也需要传递事务上下文;混用事务连接和普通 repository,可能导致部分写入提前提交。node-postgres 事务说明

实际使用时,请求指纹应由服务端根据规范化后的输入计算,包含订单号、收款方等所有影响业务结果的字段。示例中的业务参数只有金额和币种。

幂等检查是否应该放到 API gateway?

网关可以校验幂等键、限制重复请求,或缓存已完成的响应。最终的写入约束应放在持有业务数据的服务中。

网关通常无法与 PostgreSQL 共同提交事务,也覆盖不了 Kafka 消费者、定时任务、内部服务调用等其他写入入口。在 NestJS 中,可以用 Guard 或 Interceptor 读取请求头并校验格式,由 Service 处理数据库事务和结果重放。

如果需要原样重放 HTTP 状态码和响应体,或支持长时间运行的任务,可以使用独立幂等表,保存请求指纹、处理状态和响应快照。单次创建本地交易时,直接使用交易表通常更省事。

上线前还要检查什么?

  1. 客户端重试是否复用同一个 key。每次重试都生成新 key,任何按 key 去重的方案都会将其视为新请求。如果同一业务订单只能创建一次交易,还应按业务规则增加订单维度的唯一约束。
  2. 所有实例是否连接同一套 Redis 和数据库,是否使用完全相同的租户、操作和 key。
  3. 生产数据库中是否确实存在唯一约束。已有重复数据需要先处理,才能添加约束。
  4. 幂等保留期内是否会删除记录或修改幂等字段。记录删除后再次使用相同 key,可能重新创建交易;交易的去重期限不应直接由 Redis 的 24 小时 TTL 决定。
  5. 所有业务写入是否都在成功插入的分支内执行,并与交易记录一起提交。

如果交易流程还包含外部支付调用,数据库唯一约束只能保证本地记录不重复。外部调用需要传递稳定的下游幂等键,并处理超时后结果未知的情况。

需要发送 Kafka 消息时,可以在数据库事务中同时写入 outbox,消费者再在自己的数据库事务中去重。Kafka 的消息投递保证不会自动覆盖 PostgreSQL 写入。Kafka 消息投递语义

验证时,应让多个实例使用相同的租户和 key 同时提交请求,检查最终记录数、返回的交易 ID,以及不同参数复用 key 时是否返回冲突。还应测试数据库提交后响应中断、Redis 不可用,以及事务回滚后重试的情况。

备注:内容仅供参考。