Files
danyang-match/docs/幂等键检修报告-2026-09-18.md

21 KiB
Raw Permalink Blame History

幂等键检修报告(Idempotency-Key 从"声明"到"兑现")

  • 日期:2026-09-18
  • 起因:HTTP 语义检修收尾时留下的同形状失联点预测——「幂等键声明在前端、兑现在后端,两侧都坏」
  • 结论:确认全断,且是双向断裂。已修复;过程中推翻 1 条上一轮的机制性结论(stringtype=unspecified 覆盖不到)、发现 1 处规范覆盖漏标,并在活栈复跑时又暴露 1 处CI 上从未真正通过过的 E2E 缺陷。

一、起因:一条自己写下却没核实的句子

HTTP 语义检修报告第六节「真正开放第三方还需要什么」里,我写了:

  1. 分页稳定性与幂等: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 里没有一行幂等逻辑。

五个必须说清的设计决策

  1. 先占位、后执行,靠唯一索引决出赢家。 不用「先查再插」——那正是 Phase 09/11/13A 反复出现的竞态形态 (SELECT-then-INSERT 单线程正确、并发下双双查不到再各自插入)。 两个并发请求都尝试 INSERT 同一把键,唯一索引保证只有一个成功。

  2. 重放而不是重算。 首次成功后,把整个响应体连同状态码一起回填;同键重放返回逐字节相同的响应。 调用方看到的响应完全一致,不需要理解"这是重放"。

  3. 失败必须释放占位——否则用户被永久卡死。 5xx 或抛异常时删除占位记录,让客户端可以安全重试。 反过来若保留,一次瞬时故障会让这把键在有效期内永远 409, 而客户端按设计会一直复用同一个键 → "这个用户再也提交不了"。

  4. 孤儿占位可被接管。 进程在中途被 kill,占位会永远停在 PROCESSING。 超过阈值(300s)后允许下一个同键请求接管,把"进程崩溃"降级为"重试一次"。 接管条件含 state='PROCESSING' AND created_at < staleBefore, 仍在正常处理中的请求不会被抢走。

  5. 键的作用域含 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 的代码。

本轮按最小、可逆的方式处理:

  1. 删除 activityService.submitInterests() 与 activityStore.submitInterests() (各留一段注释说明删除原因与恢复条件);
  2. 更正 API_INVENTORY 该区块,按真实端点重列并附勘误说明;
  3. 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 已支持」在写下时不成立, 本轮已在该报告中显式勘误并保留原文,以免"结论先于核实"再次被当作既成事实引用。