题干与适用场景
请设计 POST /orders 的幂等契约。移动端可能在弱网中超时并自动重试,网关也可能在连接中断后重放请求;无论相同逻辑操作到达一次还是多次,服务端都只能创建一个订单。相同请求成功后应能重放第一次的结果,同一个幂等键若对应不同订单参数则必须拒绝。
本题假设调用方通过 Idempotency-Key 传入一次逻辑操作的随机标识,服务端使用 PostgreSQL。订单创建的数据库写入和待发送事件可以放进同一个本地事务;支付、库存等外部系统不能参加这个事务。API 是多租户的,响应中可能有需要授权才能查看的订单信息。
范围包括请求去重、并发竞争、事务边界、结果重放、过期策略和下游副作用。跨地域数据库复制、支付业务本身的状态机和消息系统选型不展开。它适合后端岗位,因为核心能力是把 API 语义落实为数据库约束、失败恢复和可观察的请求流程,而不是画一张跨组件架构图。
面试官考察点
第一个信号是能否区分“重试身份”和“业务身份”。幂等键表示调用方眼中的一次操作,不能代替登录态、租户隔离或订单的业务唯一键。服务端至少按 (tenantid, endpoint, idempotencykey) 建立作用域;若只按裸键查询,两个租户碰巧使用同一字符串就可能互相读到结果。
第二个信号是把并发正确性交给唯一约束,而不是应用层时序。SELECT 未命中后再 INSERT 会让两个请求同时认为自己是第一次。唯一索引与 INSERT ... ON CONFLICT 才能让数据库仲裁唯一所有者。请求指纹还要参与判断,否则调用方误用旧键时,服务端会把旧订单响应当成新订单结果。
第三个信号是能解释提交边界。订单、幂等结果和 outbox 事件必须一起提交:提交前失败就全部回滚;提交成功但 HTTP 响应丢失,重试读取已完成记录并重放。支付和库存调用放到事务外,由 outbox 消费者发送,并把派生的幂等标识继续传给下游。
强回答还会说明键保留多久、并发中的第二个请求怎样返回、哪些错误应保存、如何恢复长任务,以及怎样监控键冲突和卡住的操作。只说“Redis 加锁”没有回答锁过期、进程崩溃、结果重放和数据库提交后的竞态。
回答前需要澄清的问题
- 重复请求的定义是什么? 本题把同一租户、同一端点和同一幂等键视为同一次操作;如果业务已经有不可重复的
checkout_id,还应给订单表增加独立唯一约束。 - 调用方能否提供资源 ID? 若调用方拥有稳定的订单 ID,可以改用
PUT /orders/{clientOrderId};HTTP 语义本身把 PUT 定义为幂等。服务端生成订单 ID 时,POST 加幂等键更合适。 - 创建订单是否只包含数据库工作? 若可以在一个短事务内完成,推荐单事务方案。若必须运行数十秒的工作流,需改为可见的
IN_PROGRESS记录、租约和接管令牌,不能长期占用数据库事务。 - 相同结果要精确到什么程度? 本题保存第一次的 HTTP 状态码和可安全重放的响应体。若响应包含短期签名或实时字段,应只保存资源 ID,再按明确契约重建响应。
- 幂等键保留多久? 至少覆盖客户端可能重试或离线补发的最长窗口。若业务要求窗口外也绝不重复,还需永久业务唯一键;仅延长缓存 TTL 不能代替业务约束。
- 哪些失败应被记住? 参数校验失败发生在取得幂等所有权前,不保存结果;事务内确定性的业务拒绝可以保存并重放;基础设施错误导致事务回滚时不留下完成记录,允许重试。
30 秒回答框架
“我会把幂等键限定在租户和 POST /orders 端点内,对规范化后的订单参数计算请求指纹。数据库用 (tenantid, endpoint, idempotencykey) 唯一约束仲裁并发请求。第一个请求在同一事务里写幂等记录、订单和 outbox,最后保存状态码与响应体;提交后才返回。重复请求若指纹相同就重放结果,指纹不同返回冲突。事务提交后响应丢失也不会再建订单。外部支付和库存由 outbox 异步触发,并继续使用派生幂等键。键保留时间覆盖最大重试窗口,永久防重再靠订单的业务唯一约束。”
分步骤深入解答
先定义协议。要求认证后的调用方为每次“创建一个订单”的意图生成高熵键,并在所有重试中复用;新订单必须换新键。键不含邮箱、手机号等敏感数据。服务端限定长度和字符集,但不能把“键看起来随机”当作授权依据。HTTP POST 默认不具备 PUT 那样的幂等语义,安全重试来自这份应用层契约。
请求指纹从服务端校验后的业务字段生成,而不是直接哈希原始 JSON 字节。字段顺序、无意义空白和默认值不应让同一请求得到不同指纹;追踪 ID、时间戳等非业务字段也应排除。可以把规范化后的 DTO、端点版本和影响订单结果的字段按稳定顺序编码,再计算摘要。摘要用于发现键误用,不是签名或鉴权机制。
最小数据模型如下,具体 SQL 仅表达约束:
CREATE TABLE idempotency_requests (
tenant_id text NOT NULL,
endpoint text NOT NULL,
idempotency_key text NOT NULL,
request_fingerprint text NOT NULL,
response_status integer,
response_body jsonb,
resource_id uuid,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, endpoint, idempotency_key)
);短事务方案的正常流程是:
- 在事务外完成身份、键格式和请求参数校验,生成指纹。
- 开启事务,执行
INSERT ... ON CONFLICT DO NOTHING RETURNING ...尝试取得键。 - 成功插入的请求是所有者;它创建订单、写入 outbox,并把状态码、响应体和订单 ID 更新到幂等记录,然后一次提交。
- 未插入的请求读取已有记录。指纹不同返回
409 idempotencykeyreused;指纹相同则返回第一次保存的状态码和响应体,可附加自定义的重放标记。 - HTTP 响应只在提交成功后发送。若连接在提交后断开,下一次请求会走第 4 步,不会再创建订单。
PostgreSQL 的唯一索引会让相同键的并发插入发生等待,随后得到唯一的插入或冲突结果。实现应给等待设置有界超时:短时间内等到第一个事务提交,就直接重放;等待超时则返回明确的 idempotencyinprogress 和重试建议。不能先在应用层 SELECT 再决定是否插入,也不能在冲突时覆盖已有指纹或结果。
这个方案的关键不变量是:订单行可见时,对应的幂等结果和 outbox 事件也已可见;事务回滚时三者都不可见。因此故障可以逐项推导:创建订单前进程崩溃会回滚,重试重新竞争;订单已经提交但响应丢失会重放;同键不同参数不会执行;两个同时到达的相同请求只有一个能越过唯一约束完成写入。对确定性的业务拒绝,例如优惠券已失效,可以在事务中保存稳定的错误响应,避免重试得到不同含义;数据库连接中断等未提交故障则不保存。
若创建流程不能保持短事务,改用两阶段状态机。第一笔短事务提交 INPROGRESS、leaseexpiresat 和递增的 attempttoken,接口返回 202 或让同键请求查询状态。租约到期后,新执行者用比较并交换取得更大的令牌;所有完成更新都必须校验令牌,防止旧执行者恢复后覆盖新结果。订单仍要有业务操作 ID 的唯一约束,副作用仍通过 outbox 或下游幂等键执行。没有接管令牌的“状态加 TTL”会让旧、新执行者同时运行,只是把重复概率推迟了。
外部支付、库存和通知不能夹在本地数据库事务中。所有者与订单一起写出 OrderCreated outbox 记录,提交后由消费者投递。消费者按事件 ID 去重;调用支付或库存 API 时使用由订单 ID 和操作类型派生的稳定键。这样消费者至少一次投递不会转化成至少一次扣款。若下游不支持幂等,就需要本地状态机、查询对账或人工补偿,不能宣称 outbox 自动消除了外部重复。
过期策略必须来自重试契约。Stripe 的公开实现会在一定保留期后允许清理键,这只说明键可以有生命周期,并不构成所有系统通用的时长。服务端应让 expiresat 覆盖移动端离线队列、网关重试和人工补发的最长窗口;清理后复用旧键会被当成新操作。若 checkoutid 在业务上永远只能生成一张订单,应在 orders 表上建立永久唯一约束,即使幂等记录已经删除也能挡住重复。
Redis 可以缓存已完成结果,不能单独承担正确性边界。SET NX 成功后进程可能在订单提交前或提交后崩溃,锁 TTL 也可能在慢请求完成前到期;缓存还无法原子提交订单和 outbox。推荐让数据库唯一约束决定“只能创建一次”,缓存只减少热门重放的读取压力,并以数据库记录为真源。
验证不能只发两次串行请求。至少覆盖:50 个并发相同键只得到一个订单和一个 outbox 事件;相同键不同参数返回冲突;在提交后、发响应前注入断连,重试仍返回原订单;事务回滚后相同键能成功重试;键过期后的行为符合契约;两个租户使用相同裸键互不影响;消费者重复收到事件不会重复扣款。监控记录新建、重放、参数冲突、并发等待超时、最老未完成状态、事务回滚、outbox 积压和过期清理量,但日志不能包含幂等键对应的敏感请求体。
高质量示范回答
“我先约定一个幂等键只代表某个租户在 POST /orders 上的一次创建意图。客户端第一次请求生成键,超时重试继续用同一个;服务端对校验后的订单字段和 API 版本生成稳定指纹。同键同指纹可以重放,同键不同指纹直接报冲突。
数据库里我会建 (tenantid, endpoint, idempotencykey) 主键。请求先完成参数校验,再用 INSERT ... ON CONFLICT DO NOTHING 竞争所有权。赢的请求在一个短事务里创建订单、写 outbox、保存首次状态码和响应体,然后提交并返回。输的请求不能再建订单:等到前一个事务结束后读取记录,指纹相同就返回原结果,等待超过内部上限就告诉调用方该操作仍在处理。这样即使事务已经提交而响应丢了,下一次重试看到的仍是同一个订单。
支付和库存不会在这个事务里同步调用。我会让 outbox 消费者投递,并给每个下游操作使用由订单 ID 派生的幂等键。幂等记录只保留到最大重试窗口,所以若结账会话在任何时间都只能创建一张订单,我还会给订单的 checkout_id 加永久唯一约束。
最后我会用并发、断连和重复消息做故障注入,验证订单、幂等结果和 outbox 的数量,而不只是检查 HTTP 200。线上重点看重放率、同键参数冲突、并发等待超时、事务回滚和 outbox 积压。”
常见错误
- 先查询是否存在,再插入订单 → 两个请求能同时查询为空并各自写入 → 用数据库唯一约束和原子冲突处理仲裁所有者。
- 只按幂等键查记录 → 租户之间可能碰撞,甚至泄露别人的响应 → 把认证租户和端点纳入作用域,并在重放时重新授权。
- 同键直接返回旧结果,不比较参数 → 调用方误用键时会把旧订单当成新订单 → 保存规范化请求指纹,不一致就返回明确冲突。
- 订单先提交,幂等结果稍后写 → 两次写入之间崩溃会留下订单却没有去重记录 → 把订单、结果和 outbox 放进同一数据库事务。
- 把支付调用放进数据库事务 → 网络超时会拉长锁持有时间,而且支付成功与本地回滚无法原子化 → 事务内写 outbox,事务外用下游幂等键执行。
- 只用带 TTL 的 Redis 锁 → 进程崩溃和锁过期后无法判断订单是否已提交,也不能重放结果 → 数据库约束保正确,缓存只做加速。
- 所有错误都缓存 → 参数修正后仍会重放旧的校验错误,瞬时故障也失去重试机会 → 取得所有权前的校验失败不保存,区分稳定业务结果与未提交故障。
- 清理幂等记录后仍承诺永久防重 → 过期键会被视为新操作 → 公开保留窗口,并用业务唯一键满足永久不变量。
追问及应对
追问一:第一个请求还没提交,第二个请求该返回什么?
短事务方案中,唯一索引冲突会让第二个插入等待。给数据库等待和整个接口设置小于客户端超时的边界;若第一笔事务及时提交,第二个请求读取并重放。若等待达到边界,返回可识别的 idempotencyinprogress 和重试时间,而不是另起一张订单。长任务则使用已经提交的 IN_PROGRESS 状态和查询接口,不让 HTTP 请求一直占连接。
追问二:服务在提交成功后、写 HTTP 响应前崩溃怎么办?
这正是保存结果的原因。订单、outbox 和响应快照已经在同一事务中提交;客户端重试时唯一键冲突,服务端验证指纹后返回保存的状态码和响应体。若只保存“处理过”布尔值,服务端仍不知道该返回哪个订单,调用方也无法区分成功和未知。
追问三:同一幂等键携带了不同金额怎么办?
返回 409 idempotencykeyreused,同时记录冲突指标,不执行订单创建。不能用新参数覆盖旧记录,也不能静默返回旧订单。指纹必须覆盖金额、币种、商品、收货信息引用等会改变业务结果的字段,并包含 API 或规范化规则版本。
追问四:长任务的执行者拿到租约后暂停,租约被别人接管,旧执行者又恢复怎么办?
每次接管都递增 attempt_token。订单状态变更、完成结果写入和可控下游任务都携带该令牌,并以“只有当前令牌能更新”为条件。旧执行者恢复后写入会失败,再读取新状态退出。下游无法校验令牌时,仍要依赖稳定业务操作 ID 的幂等接口或对账补偿;租约本身不能阻止已经发出的外部副作用。
追问五:为什么不能只给 orders.checkout_id 加唯一约束?
业务唯一约束能阻止第二张订单,适合作为永久兜底,但它没有定义同键不同参数是否冲突、第一次返回了什么状态码、并发处理中怎样答复,也未必覆盖没有 checkout_id 的调用方。幂等记录提供请求级协议和结果重放,业务唯一约束保护领域不变量;两者解决的层次不同。