每个事务系统都必须回答一个问题:
如果同一个请求到达两次,数据库中会包含一条记录还是两条?
重复请求可能因多种原因发生——双击只是最显而易见的原因:
- 保存按钮上的双击
- 浏览器在超时时重试
- 网络中断与重连
- 反向代理或负载均衡器重试
- 移动客户端在失去信号后重新发送
- 前端 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 将永久消失。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.