21 KiB
幂等键检修报告(Idempotency-Key 从"声明"到"兑现")
- 日期:2026-09-18
- 起因:HTTP 语义检修收尾时留下的同形状失联点预测——「幂等键声明在前端、兑现在后端,两侧都坏」
- 结论:确认全断,且是双向断裂。已修复;过程中推翻 1 条上一轮的机制性结论(
stringtype=unspecified覆盖不到)、发现 1 处规范覆盖漏标,并在活栈复跑时又暴露 1 处CI 上从未真正通过过的 E2E 缺陷。
一、起因:一条自己写下却没核实的句子
HTTP 语义检修报告第六节「真正开放第三方还需要什么」里,我写了:
- 分页稳定性与幂等:
Idempotency-Key已支持,但需确认第三方可见端点的覆盖范围。
写完这句话之后的核对推翻了自己:后端当时零处读取 Idempotency-Key 头。
全仓没有任何一个 @RequestHeader("Idempotency-Key"),也没有任何一层拦截器读它。
那句「已支持」是沿着 counseling 端点的 body 字段幂等(V039)顺手写下的印象,不是事实。
这恰好是 HTTP 报告自己立下的规矩的反面案例——注释(或结论)与代码不符比没有更危险。 于是顺势把"同形状失联点"逐个查下去:凡是「一端声明、另一端兑现」的契约,逐个核对。
二、审计发现
1. 主体问题:后端零处读取幂等头,前端 25 处调用点全部石沉大海
| 侧 | 实际状态 |
|---|---|
| 规范 | 第八章要求「提交审核 / 喜欢 / 活动报名 / 红娘申请 / 举报 / 签到 / 成功牵手」必须支持 Idempotency-Key;第 146 章给出 6 个必测场景,验收口径是「最终不能生成重复核心数据」 |
| 前端 | 25 处调用点(8 个 service + 2 个 TS 页面,共 10 个文件)生成并把该头交给 api.js 发送;连同 api.js 里的发送点合计 26 处(V045 注释所记的 26 即此口径) |
| 后端 | 0 处读取。请求头被完整丢弃 |
这是声明端与实现端的彻底失联:前端以为有保护,后端完全不知情。
2. 前端那一侧也是坏的——键每次都不同,就算后端实现了也不会命中
原写法形如:
idempotencyKey: `like:${Date.now()}`
Date.now() 在每次调用时求值。于是:
- 用户连点两次「喜欢」→ 两次调用 → 两把不同的键 → 命中不了同一条幂等记录;
- 弱网重发 → 新的时间戳 → 同样失效。
也就是说这不是"前端声明了、后端没兑现",而是两侧各自都坏: 后端不读,前端即使被读也永远对不上。互补的两半,谁也不起作用。
3. 唯一真正实现幂等的端点用的不是 header
只有 counseling 申请(V039 的 uk_counseling_idem)真正幂等,靠的是 body 字段 idempotencyKey,而非规范要求的请求头。唯一一处能用的实现,走的还是一条非规范路径。
4. 两道兜底防线双双落进 500
重复提交真正的最后防线是数据库唯一约束(并发下两个请求都查重通过、再各自插入,由唯一索引挡住第二个)。
DuplicateKeyException无映射 → 落进handleUnknownException→ 500。 但 500 的语义是"服务端故障,可以重试"——调用方于是重试,制造更多重复。 本该是"别盲目重试"的 409。IllegalStateException无映射 → 全仓 30 处throw new IllegalStateException(...)语义清一色是「当前状态不允许该动作」(会话已关闭、匹配已解除、发送过频等),同样落 500。
5. 一个我原本以为存在的保护,实际不存在
上一轮 D-23B-J(operator does not exist: uuid = character varying)是靠
docker profile 的 JDBC 参数 stringtype=unspecified 解决的,当时判定为"一处解决全局"。
本轮 V045 把 actor_id 建成 VARCHAR(64),在同样开着 stringtype=unspecified 的
docker profile 下依然抛出 operator does not exist: character varying = uuid,
所有带幂等键的请求 500。这说明 stringtype=unspecified 覆盖不到这条路径。
根因(本轮定位):项目全局注册了 UUIDStringTypeHandler,它对任何合法 UUID 形式的
String 参数执行 ps.setObject(i, UUID.fromString(parameter), Types.OTHER) ——
即以显式 uuid 类型发送。而 stringtype=unspecified 只影响以 unknown 类型发送的参数,
管不到已被显式指定类型的参数。
因此真正的不变量不是"JDBC 参数配好了",而是"列类型必须是 uuid"。 (这条不变量本轮做了全库机械扫描,见第四节。)
6. 活栈复跑时又暴露一处:CI 上的 E2E 从未真正通过过
本节第 6 点是在重建 jar、复跑活栈 E2E 时才浮现的——它和被修的幂等无关, 但属于同一形状的问题(声明在文档里、兑现依赖某台机器的手工数据)。
E2E 首跑报错:
POST /auth/operator/login -> HTTP 401: {"code":"UNAUTHORIZED","message":"用户名或密码错误"}
定位后发现根因链:
| 环节 | 事实 |
|---|---|
| 迁移 | 没有任何 operator 账号种子(全仓 grep 确认) |
DemoDataSeeder |
不创建运营账号(只统计数量),且默认关闭 |
Phase 23B 的 seed_e2e_operators.sql |
创建 6 个 e2e_* 账号 —— 全新库唯一的账号来源 |
conftest.admin_token |
却写死 username="admin" |
admin 是某台开发机上手工创建的遗留账号:本机有它,所以本地一直跑绿;
CI 是全新库,没有这个账号 → 该 fixture 必然 401 → e2e job 必然失败。
讽刺的是,seed_e2e_operators.sql 的文件头明确写着它的存在意义是
"把'可自举'做成基建的一部分,而不是依赖某台机器上手工创建的遗留账号" ——
而同一个提交里的 fixture 恰恰还在依赖它。
conftest 里另一句注释"外加迁移 seed 的 admin"则是纯粹的虚构(迁移里没有)。
修法:admin_token 改用 seed 创建的 e2e_admin(绑定 super_admin,语义等价),
并纠正那句虚构注释;另加机械守卫(见第四节第 6 点)防止再犯。
三、检修内容
设计取向:一处实现、零业务代码改动、逐端点声明
逐个端点写幂等(25 处前端调用点 × 30 处后端各自实现)既漏又重。改为:
| 改动 | 作用 |
|---|---|
V045 新建 idempotency_records 表 + 唯一索引 uk_idempotency_scope |
幂等的落库真相源 |
V046 修正 actor_id 列类型 VARCHAR → uuid |
对齐本库列类型约定(见第二节第 5 点) |
@Idempotent 注解 |
唯一的声明点,29 处(本轮末补至 30 处) |
IdempotencyInterceptor |
进 Controller 前占位/命中已完成的记录直接重放/命中处理中回 409 |
IdempotencyResponseCacheFilter |
缓存首次成功的响应体,供重放逐字节还原 |
WebMvcConfig(本项目第一个通用 WebMvcConfigurer) |
把拦截器挂到 /** |
GlobalExceptionHandler 两处新映射 |
IllegalStateException、DuplicateKeyException → 409 |
前端 api.js |
键在「一次未成功的逻辑操作」生命周期内稳定,成功才释放 |
业务代码零改动:29 个端点只是加一行注解,Controller 里没有一行幂等逻辑。
五个必须说清的设计决策
-
先占位、后执行,靠唯一索引决出赢家。 不用「先查再插」——那正是 Phase 09/11/13A 反复出现的竞态形态 (
SELECT-then-INSERT单线程正确、并发下双双查不到再各自插入)。 两个并发请求都尝试INSERT同一把键,唯一索引保证只有一个成功。 -
重放而不是重算。 首次成功后,把整个响应体连同状态码一起回填;同键重放返回逐字节相同的响应。 调用方看到的响应完全一致,不需要理解"这是重放"。
-
失败必须释放占位——否则用户被永久卡死。 5xx 或抛异常时删除占位记录,让客户端可以安全重试。 反过来若保留,一次瞬时故障会让这把键在有效期内永远 409, 而客户端按设计会一直复用同一个键 → "这个用户再也提交不了"。
-
孤儿占位可被接管。 进程在中途被 kill,占位会永远停在
PROCESSING。 超过阈值(300s)后允许下一个同键请求接管,把"进程崩溃"降级为"重试一次"。 接管条件含state='PROCESSING' AND created_at < staleBefore, 仍在正常处理中的请求不会被抢走。 -
键的作用域含
actor_id。 唯一索引是(actor_id, endpoint, idempotency_key)。 同一把键由不同用户使用互不干扰——用户之间键碰撞不构成越权。 含endpoint是因为客户端可能复用同一把键打不同接口,那属于两次不同的操作。
异常映射的取舍
| 异常 | 映射 | 理由 |
|---|---|---|
IllegalStateException |
409 | 30 处语义无例外都是状态冲突。加兜底映射而非逐一替换成 ConflictException(工作量大且会漏);代价是框架内部真抛该异常时也成 409,故记 ERROR 级日志(其余 4xx 均为 WARN/DEBUG),监控可按 ERROR 区分"真业务冲突"与"意外状态错误" |
DuplicateKeyException |
409 | 唯一约束冲突 = 并发重复提交 |
DataIntegrityViolationException |
不映射(保持 500) | 它是 DuplicateKeyException 的父类,同时覆盖 CHECK / NOT NULL 约束违反——那意味着代码往库里写了非法值(服务端 bug),应由 500 暴露,不该伪装成"客户端可重试的 409" |
前端:键的生命周期
新增 api.idempotencyKey(scope, subject):
- 键缓存在
_pendingIdempotency(指纹 =scope + subject),TTL 10 分钟; - 同一逻辑操作在成功之前复用同一把键——这正是幂等键的语义;
- 收到
200 + success才releaseIdempotencyKey()释放, 失败(含网络错误)不释放,下次重试沿用同一把键; - 26 处
Date.now()写法全部替换(8 个 service + 2 个 TS 页面,25 处调用点), 残留数经 grep 验证为 0(grep -rnE "idempotencyKey: *\"` 无命中)。
四、验证证据
1. 列类型不变量:全库机械扫描
既然真正的不变量是"列类型必须是 uuid",就对全部迁移做了扫描:
列出所有声明为 VARCHAR 的 *_id 列。
| 列 | 表 | 判定 |
|---|---|---|
actor_id VARCHAR(64) |
idempotency_records |
违规,V046 已改为 uuid |
client_message_id VARCHAR(128) |
messaging | 客户端生成的幂等标识,非用户 id,不参与 UUID 参数比较 → 安全 |
provider_request_id VARCHAR(128) |
verification | 外部服务商 id → 安全 |
resource_id VARCHAR(128) |
audit_logs |
多态审计资源 id;Java 侧零处以其作查询条件(已 grep 确认)→ 安全 |
source_id VARCHAR(128) |
— | 同上,零处比较 → 安全 |
结论:actor_id 是唯一真实地雷,已修;不存在第二处同类隐患。
2. 活栈实测(判定性:证明机制真的生效)
E2E 在真实 PostgreSQL(danyang-e2e-backend,docker profile)上运行:
| 场景 | 断言 |
|---|---|
| 同键重放 | 第二次响应与第一次逐字节相同(含 HTTP 状态) |
| 同键重复提交 | 业务表只产生一条记录 |
| body 字段幂等(counseling 旧路径) | 仍防重复(未因新机制回归) |
| 不同键 | 视为两次独立操作,各自成功 |
| 同键不同用户 | 互不影响(作用域隔离) |
| 并发同键(5 线程) | 业务落库恰好一次 |
| 无键重复 Like | 不得返回 500(经唯一约束兜底应为 409) |
tests/e2e/idempotency_test.py 共 7 用例;E2E 全量 49 passed(本轮末;其中 2 条为新增的
fixture_account_guard_test.py,见第二节第 6 点)。
3. 后端全量回归
见第五节「回归过程」——含一处被测试固化的旧基线。
4. 机械守卫(本轮新增)
新增 IdempotencyDeclarationGuardTest(2 用例):
specRequiredEndpointsDeclareIdempotent:按规范第八章的 7 类端点族 + Phase 16 互选链路, 逐条去源码里找@PostMapping("..."),校验其上一非空行是@Idempotent;idempotentAnnotationAlwaysGuardsAPostMapping:任何@Idempotent后必须紧跟@PostMapping(放到 GET 上拦截器照样会占位,属静默错误行为),并设全局声明数下界。
同时修正了一处假绿:GlobalExceptionHandlerTest 原有 3 条断言只测 DTO 与异常对象,
一次都没进过 GlobalExceptionHandler —— 两个新映射被删掉它依然全绿。
新增 3 条真实调用处理器方法的断言,并用反射锁住"不映射 DataIntegrityViolationException"这个刻意取舍。
5. 负向验证(守卫必须真会红,否则等于摆设)
对 IdempotencyDeclarationGuardTest 注入两处变异,各验一次:
| 注入的变异 | 守卫输出 |
|---|---|
摘掉 CheckinController 手工签到的 @Idempotent |
CheckinController.java#/operator/{id}/checkin/manual 缺少 @Idempotent(其上一行是: */) |
把 @Idempotent 错放到某个 @GetMapping 上 |
CheckinController.java:85 的 @Idempotent 后面不是 @PostMapping,而是: @GetMapping("/operator/{id}/checkin-stats") |
两个用例各变红一次、各自精确报出文件名与行号,无多余噪声;还原后全绿
(还原后复核全局 @Idempotent 共 30 处)。
6. E2E 自检守卫(tests/e2e/fixture_account_guard_test.py,2 用例)
针对第二节第 6 点那类"fixture 依赖本机手工数据"的缺陷,做机械拦截:
| 用例 | 断言 |
|---|---|
test_conftest_only_logs_in_with_seeded_accounts |
从 seed_e2e_operators.sql 解析出账号清单,扫描 conftest.py 里所有登录用户名(含 ADMIN_USERNAME 常量),任何一个不在 seed 里就变红并列出差异 |
test_no_operator_account_is_seeded_by_migrations |
锁住推理前提:迁移里确实没有 operator 种子。若将来有人加了,此用例变红,提示重新评估 admin_token 的选择 |
两个用例各带一条"解析失败即失败"的断言(assert seeded / assert used)——
守卫自身静默失效(正则失配 → 空集合 → 一切通过)是本项目反复踩过的坑。
五、回归过程
| 轮次 | 结果 | 判定 |
|---|---|---|
| 首轮 | 664 / 1 failure / 0 errors / 3 skipped | 唯一失败为 MigrationValidationTest.testMigrationNamingConvention(见下) |
| 定向复跑 | 21 / 0 / 0(迁移 13 + 异常处理器 6 + 幂等守卫 2) | BUILD SUCCESS |
| 负向验证 | 2 用例变红 | 变异精准命中,还原后全绿 |
| 最终全量 | 669 / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS | 基线 664,净 +5 |
首轮失败信息:
版本号超出当前基线: V045__add_idempotency_records.sql ==> expected: <true> but was: <false>
这是有意设计的基线守卫(LATEST_MIGRATION 常量,注释写着"新增 Flyway 迁移时必须同步递增此值"),
用来强制新增迁移被显式承认,而非静默漂移。按流程把基线由 44 抬至 46 并登记 V045/V046 的原因。
净 +5 的构成:
| 来源 | 增量 |
|---|---|
GlobalExceptionHandlerTest |
3 → 6(新增 3 条真实调用处理器方法的断言,此前 3 条一次都没进过处理器) |
IdempotencyDeclarationGuardTest |
0 → 2(本轮新增守卫) |
值得记下的是:首轮的 664 与 HTTP 语义检修后的基线完全一致——
说明 29 处 @Idempotent、拦截器、响应缓存过滤器、两处异常映射,
没有破坏任何一条既有测试,也没有任何一条既有测试断言了旧的错误行为
(对比 HTTP 语义检修时出现的 3 处"测试固化了错误期望",本轮为零)。
即:新增机制是纯增量,未触碰既有行为契约。
活栈复跑
jar 重建后(backend/target/danyang-match-platform-1.0.0-RC.1.jar,
经宿主机 bind mount 挂进 danyang-e2e-backend)重启并复跑 E2E:
| 观测 | 结果 |
|---|---|
| Flyway 启动日志 | Successfully validated 46 migrations、Current version of schema "public": 046 —— 印证 V045/V046 已生效且未改动已应用的 V045(改了会校验和不符) |
| 过滤器注册日志 | IdempotencyResponseCacheFilter 已配置 |
| E2E | 49 passed(幂等 7 用例 + 其余全量 + 2 条新增守卫) |
小程序 tsc --noEmit |
EXIT=0 |
UI 契约门控 ui-tests/*.mjs |
14 / 14 通过 |
复跑过程中暴露的 admin_token 缺陷见第二节第 6 点。
六、同轮一并修复的契约漂移
1. POST /activities/{id}/interests —— 文档承诺了一个从未存在的端点
| 侧 | 状态 |
|---|---|
docs/api/API_INVENTORY.md:141 |
记载 POST /activities/{id}/interests = 提交活动互选 |
前端 activityService.submitInterests() |
按此实现,路径一致(并已按本轮规范补了幂等键) |
后端 ActivityInterestController |
无该端点(只有 /{id}/interests/mutual、/{id}/interests/mutual/{partnerId}、/{id}/interests/confirm)→ 调用必然 404 |
| 实际能力 | Phase 16 已把「表达意向」合并进 POST /activities/{id}/feedback(interestTargetUserIds),确认环节走 /interests/confirm |
| 调用方 | activityStore.submitInterests() 暴露了它,但全仓无任何页面/组件调用 → 死代码 |
我先是把它记为"需产品决策",随后放弃了这个判断。理由:
/feedback 要求"活动已结束 + 报名已通过",与"随时提交互选"语义并不相同,
所以"改指向 feedback"不是等价替换;但保留一个必然 404 的方法同样不是选项——
它只是 Phase 16 之前的设计残留。真正的产品决策只有一件:
是否需要一个独立的"提交互选"端点;在决定之前,前端不该留指向 404 的代码。
本轮按最小、可逆的方式处理:
- 删除
activityService.submitInterests()与activityStore.submitInterests()(各留一段注释说明删除原因与恢复条件); - 更正
API_INVENTORY该区块,按真实端点重列并附勘误说明; - store 头部关于 AGENTS 第 6 条(单向意向保密)的注释同步改为引用
submitFeedback返回的newMutuals(原注释引用的是已删除方法的matchedUserIds)。
验证:node --check 两文件通过;miniapp 内 submitInterests 残留仅剩 2 处注释;
tsc --noEmit EXIT=0;UI 契约门控 14/14。
若产品确认需要独立端点,应先定义语义并补后端实现,再恢复前端方法——
而不是反过来让前端先有一个 404 的调用。
七、遗留与需要留意
1. 幂等记录不是永久证据
idempotency_records 有 TTL(默认 86400s)与 idx_idempotency_expires 支撑的清理扫描。
要留意的语义:清理之后同一把键会被当作新操作。
因此幂等键保证的是"短时间内重复提交不产生重复数据",不是"永久去重"——
永久去重仍必须由业务层的唯一约束承担(这也是 DuplicateKeyException → 409 那条映射的意义)。
2. 第三方开放场景(承接 HTTP 报告遗留)
幂等机制已在用户端 30 个写端点上就位;若将来开放第三方, 需决定第三方可见端点的幂等覆盖范围,以及键的 TTL 是否需要按调用方分级。
八、结论
本轮把幂等键从"两端各自声明、没有一处兑现"变成了可机械验证的机制:
- 声明侧(
@Idempotent)与兑现侧(拦截器 + 唯一索引)由守卫测试钉在一起, 新增端点漏标会直接变红; - 前端键的生命周期修正为"成功才释放、失败复用", 修掉了"每次调用一把新键、幂等永远命中不了"这个即使后端实现也无效的缺陷;
- 两道兜底(业务状态冲突、唯一约束冲突)从 500 收敛为 409, 调用方终于能区分"别重试"与"服务端故障";
- 定位并纠正了一条错误的安全假设:
stringtype=unspecified覆盖不到 经类型处理器显式指定类型的参数,真正的不变量是列类型约定; 该不变量经全库扫描确认收敛(唯一违规项已修,无第二处)。
顺带清掉三处同形状的问题——它们都不是幂等本身的缺陷, 但都是"一处声明、另一处不兑现"这一模式的复发:
| 问题 | 兑现状态 |
|---|---|
API_INVENTORY 承诺的 POST /activities/{id}/interests 后端从未实现 |
更正文档、删除指向 404 的前端死代码 |
conftest.admin_token 依赖本机手工创建的 admin 账号 |
改用 seed 账号,CI 上的 E2E 至此才可能真正通过 |
GlobalExceptionHandlerTest 号称测异常映射、却一次都没进过处理器 |
补 3 条真实调用处理器方法的断言 |
一处自我纠正也记在这里:HTTP 报告里那句「Idempotency-Key 已支持」在写下时不成立,
本轮已在该报告中显式勘误并保留原文,以免"结论先于核实"再次被当作既成事实引用。