每个事务系统都必须回答一个问题:

如果同一个请求到达两次,数据库中会包含一条记录还是两条?

重复请求可能因多种原因发生——双击只是最显而易见的原因:

  • 保存按钮上的双击
  • 浏览器在超时时重试
  • 网络中断与重连
  • 反向代理或负载均衡器重试
  • 移动客户端在失去信号后重新发送
  • 前端 bug 导致请求被触发两次
  • API 消费者重试失败的调用
  • Webhook 提供者重新发送事件

你无法阻止重复的请求。你只能阻止重复的记录。答案是数据库强制实现的幂等性

阅读本文时,请始终牢记一个区别:Django 负责验证,PostgreSQL 保证正确性。我们将以发票模块为例实现该模式,但它适用于任何创建端点。

问题

用户保存了一张发票。请求处理缓慢,请求被重试——可能是用户、浏览器或网络发起——现在存在两张完全相同的发票。报表、库存和会计记录都出错了。同样的问题也可能发生在订单、付款、注册或任何创建数据的模块中。

为什么仅靠客户端防护不够

在请求进行中禁用保存按钮是一种良好的用户体验,可以阻止大多数意外双击:

<button :disabled="saving" @click="save">Save</button>

Enter fullscreen mode Exit fullscreen mode

但它对网络重试、刷新、代理或其他 API 消费者无效。前端可以减少重复;但永远无法保证任何事情。

前端真正能做的是为每个操作赋予一个身份。当操作开始时(而不是在请求发送时)生成一个幂等性密钥,并在每次尝试中重复使用它:

data() {
  return {
    invoice: {},
    idempotencyKey: crypto.randomUUID(), // once per operation
  };
},
methods: {
  async save() {
    await axios.post('/api/invoices/', this.invoice, {
      headers: { 'X-Idempotency-Token': this.idempotencyKey },
    });
  },
  newInvoice() {
    this.invoice = {};
    this.idempotencyKey = crypto.randomUUID(); // reset only here
  },
},

Enter fullscreen mode Exit fullscreen mode

一个操作对应一个密钥。如果你按请求生成密钥(例如在全局 Axios 拦截器中),每次重试都会被视为新操作,服务器无法对其进行去重。

为什么先检查再插入的方法无效

直观的后台做法是在插入前先检查:

if not Invoice.objects.filter(idempotency_key=token).exists():
    Invoice.objects.create(idempotency_key=token, ...)

Enter fullscreen mode Exit fullscreen mode

在并发情况下,这会失败:

Request A arrives
    ↓
exists() → false
                        Request B arrives
                            ↓
                        exists() → false
A inserts row
                        B inserts row   ← duplicate

Enter fullscreen mode Exit fullscreen mode

两个请求都在插入之前通过了检查。这种“检查-再操作”的竞态条件同样适用于基于缓存的去重(先 cache.get()cache.set())。任何应用层检查都无法解决这个问题,因为检查和插入是两个独立步骤——另一个请求可能在两者之间执行。

关于 get_or_create() 的说明:许多 Django 开发者会在这里使用它,但它无法替代幂等性。其内部仍然是先查找再插入,并且仅在查找字段已经代表业务唯一性时才有效。幂等性密钥不同:它标识的是单个客户端操作,与记录内容无关。

让 PostgreSQL 保证唯一性

解决方法是将保证移到数据库中:

class Invoice(models.Model):
    idempotency_key = models.UUIDField(unique=True, db_index=True)
    # ... your fields

Enter fullscreen mode Exit fullscreen mode

为什么在应用层检查无效时,这个方法有效?因为 PostgreSQL 将唯一性检查和插入作为数据库引擎内部的单一原子操作执行。两个并发事务不可能同时插入相同的唯一值——一个成功,另一个失败并抛出 IntegrityError。检查和插入之间没有间隙,因此不可能出现竞态条件。

因此,不要将 IntegrityError 视为失败,而是将其视为工作已完成的信号:

from django.db import IntegrityError, transaction

def create(self, request):
    token = request.headers.get("X-Idempotency-Token")
    if not token:
        return Response({"detail": "Idempotency token required."}, status=400)serializer = InvoiceSerializer(data=request.data)serializer.is_valid(raise_exception=True)try:
        with transaction.atomic():
            invoice = serializer.save(idempotency_key=token)except IntegrityError:
        invoice = Invoice.objects.get(idempotency_key=token)return Response(InvoiceSerializer(invoice).data, status=200)return Response(InvoiceSerializer(invoice).data, status=201)

Enter fullscreen mode Exit fullscreen mode

首次请求:201 Created。任何重放:200 OK 并返回相同记录。Django 验证了请求;PostgreSQL 保证了正确性。

添加业务约束

一层防护很好;两层更好。大多数记录还有其自身的自然唯一性规则。将它作为第二层约束强制执行:

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["terminal_id", "number"],
            name="unique_invoice_per_terminal",
        )
    ]

Enter fullscreen mode Exit fullscreen mode

即使未来有 bug 绕过了幂等性密钥,PostgreSQL 仍然会拒绝重复记录。约束几乎没有成本,却能保护你免受尚未编写的 bug 的影响。

可复用的架构

完整的流程,可在任何模块中复用:

Client
    ↓  generate one idempotency key per operation
Django
    ↓  validate the request
PostgreSQL
    ↓  unique constraint enforces exactly-once insert
IntegrityError
    ↓  caught by Django
Return the existing resource (200 OK)

Enter fullscreen mode Exit fullscreen mode

系统中的每个创建端点都可以遵循这个确切的模式。

何时应该使用此方法

对以下场景使用幂等性,任何重复会导致损害的情况:

  • 发票、订单和采购订单
  • 付款和资金转账
  • 库存移动和发货
  • 票务创建和注册

可跳过自然可以安全重复的操作:

  • 搜索端点
  • 报表和分析
  • 任何只读 API

性能

唯一索引检查是一次极快的查找——仅需微秒级时间。约束对插入几乎不增加开销,PostgreSQL 可以在高写入量下处理此模式,无需队列、分布式锁或任何特殊基础设施。对于绝大多数应用,性能根本不是问题。

关键要点

事务系统绝不应依赖时序、客户端行为或网络条件来维护数据完整性——这些因素都是不可预测的。数据库是唯一能够在并发情况下保证唯一性的组件。为每个操作生成一个幂等性密钥,用 PostgreSQL 唯一约束强制执行,并捕获 IntegrityError 以返回已有记录。围绕这一保证构建你的 API,重复记录的 bug 将永久消失。