15 KiB
架构决策记录:QA 交叉验证发现的架构级遗漏
日期:2026-05-11 决策人:TechLead 代码基线:ee3a31e 关联文档:docs/REMEDIATION_TASK_BOARD_2026-05-06.md
0. 决策总览
| 编号 | 问题 | 选择方案 | 优先级 |
|---|---|---|---|
| D-01 | callback_target 契约漂移 | ① 删除 CallbackTarget 字段及其使用 | P1 |
| D-02 | outbox 并发 claim 缺失 | ① 增加行级锁(UPDATE RETURNING 实现) | P1 |
| D-03 | platform webhook 非严格事务外盒 | ② 在文档/代码中明确标注"最终一致性"边界 | P1 |
| D-04 | E2E 稳定性根因 | QA 分析 → TechLead 确认方向 → Engineer 修复 | P1 |
1. D-01:callback_target 契约漂移
1.1 现状
internal/service/platformevents/builder.go:31-46从meta.CallbackTarget读取并写入event.CallbackTargetinternal/domain/platformevent/event.go:41定义CallbackTarget string字段,Validate()强制非空internal/platformadapter/types.go:25PlatformInboundMeta定义CallbackTarget stringinternal/service/platformdelivery/worker.go:111投递时直接使用固定的w.CallbackURL,完全不消费event.CallbackTargetinternal/app/app.go:169-171worker 初始化按平台只配一个CallbackBaseURL,无多目标路由模型
1.2 方案选择:① 删除 CallbackTarget 字段及其使用
理由
当前平台配置模型(config.PlatformAdapterProfileConfig)是每平台一个固定回调地址,且没有多目标回调的产品需求证据。保留一个"模型字段存在、校验强制非空、但运行时零消费"的伪能力,会持续造成:
- 数据模型与运行时行为不一致(契约漂移)
- 新成员阅读代码时的认知负担
- 未来重构时误以为已支持多目标路由
删除该字段是低成本、可回退的清理动作。若未来确有同一平台多目标回调需求,届时必然伴随配置模型重构(从单 URL 到路由表),同步恢复字段即可。
风险
- 回滚成本:需从 git 历史恢复字段,改动面可控。
- 数据兼容性:DB 中已有
callback_target列,本次只清理 Go 层代码,DB 列暂不删除(避免对已部署环境做破坏性 schema 变更),后续通过独立 migration 清理。
回退策略
若产品侧在本周内提出多目标回调需求,立即中止删除,改为方案②(worker 按 CallbackTarget 路由到配置表 cs_platform_callbacks)。
1.3 文件级落点
| 文件 | 位置 | 动作 |
|---|---|---|
internal/domain/platformevent/event.go |
L41 | 删除 CallbackTarget string 字段 |
internal/domain/platformevent/event.go |
L63-65 | 删除 Validate() 中 CallbackTarget 非空校验 |
internal/platformadapter/types.go |
L25 | 删除 PlatformInboundMeta.CallbackTarget |
internal/service/platformevents/builder.go |
L15 | 删除 defaultCallbackTarget 常量 |
internal/service/platformevents/builder.go |
L31-34 | 删除 callbackTarget 读取与兜底逻辑 |
internal/service/platformevents/builder.go |
L46 | 删除 CallbackTarget 赋值 |
internal/store/postgres/platform_event_store.go |
L52,58 | 删除 INSERT 中 callback_target 列与参数 |
internal/store/postgres/platform_event_store.go |
L80,106 | 删除 SELECT 中 callback_target 列与扫描 |
internal/store/postgres/platform_event_store.go |
L185-186 | 删除 dead_letter 插入中 callback_target |
internal/service/platformevents/builder_test.go |
各 CallbackTarget: "default" |
删除相关断言与字段 |
internal/store/postgres/platform_event_store_test.go |
各 CallbackTarget: "default" |
删除相关字段 |
internal/service/platformdelivery/worker_test.go |
各 CallbackTarget: "default" |
删除相关字段 |
internal/domain/platformevent/event_test.go |
各 CallbackTarget: "default" |
删除相关字段 |
注意:
db/migration/0002_platform_event_outbox.up.sql本次不修改;DB 列保留留空运行,待后续专门做 schema 清理 migration。
1.4 QA 可验证项
grep -r "CallbackTarget" --include="*.go" internal/返回空(除可能存在的 vendor 外)go test ./internal/... -count=1全部通过go build ./...通过
2. D-02:outbox 并发 claim 缺失
2.1 现状
internal/store/postgres/platform_event_store.go:78-86ListDue使用普通SELECT ... ORDER BY ... LIMIT- 无
FOR UPDATE SKIP LOCKED,多实例部署时多个 worker 会读到同一批事件,导致重复投递 MarkDelivered/MarkRetry/MarkDeadLetter是独立 SQL,存在竞态窗口
2.2 方案选择:① 增加行级锁(UPDATE RETURNING 实现)
理由
显式声明"单实例约束"(方案②)会限制架构扩展性,与生产级目标不符。纯 SELECT ... FOR UPDATE SKIP LOCKED 若不在事务内保持锁则无意义(ListDue 返回后事务结束,锁释放)。
最简生产级实现是把 ListDue 改造为事务内 claim 模式:
UPDATE cs_platform_event_outbox
SET status = 'claiming', updated_at = NOW()
WHERE id IN (
SELECT id FROM cs_platform_event_outbox
WHERE platform = $1 AND status IN ('pending','retrying') AND next_attempt_at <= $2
ORDER BY next_attempt_at ASC, occurred_at ASC, created_at ASC, id ASC
LIMIT $3
FOR UPDATE SKIP LOCKED
)
RETURNING *
- 一行 SQL 完成"筛选 + 加锁 + 状态变更 + 返回"
- 其他实例并发时因
SKIP LOCKED自动跳过已锁行 - worker 投递完成后,再调用
MarkDelivered/MarkRetry将状态从claiming迁移到终态
风险
- claim 悬挂:worker 在
claiming后 crash,事件会永远卡在claiming。需要新增claim 超时回收机制(如每 30 秒把超过 5 分钟的claiming重置为retrying)。 - 新增
claiming状态需要修改 DB CHECK 约束,需新增 migration。
回退策略
若实现周期超过 2 人日,可先合并 claiming 状态与超时回收逻辑,但保留单实例部署注释作为兜底声明。
2.3 文件级落点
| 文件 | 位置 | 动作 |
|---|---|---|
db/migration/0003_outbox_claiming_status.up.sql |
新增 | 新增 migration:将 'claiming' 加入 chk_cs_platform_event_outbox_status;建议同时加 idx_cs_platform_event_outbox_claiming_timeout 索引 (status, updated_at) |
internal/domain/platformevent/event.go |
L12-16 | 新增 StatusClaiming Status = "claiming";Validate() 中允许该状态 |
internal/store/postgres/platform_event_store.go |
L78-86 | ListDue 改为事务内执行上述 UPDATE ... RETURNING 模式 |
internal/store/postgres/platform_event_store.go |
新增方法 | 新增 ReleaseStaleClaims(ctx, timeout time.Duration) (int, error):将超时的 claiming 重置为 retrying |
internal/service/platformdelivery/worker.go |
L60-80 Start |
在 Start 中新增一个额外 ticker(如每 30 秒),调用 ReleaseStaleClaims |
internal/service/platformdelivery/worker.go |
L93-102 RunOnce |
保持现有 ListDue 调用方式(接口名不变,内部实现已改),投递成功后正常 MarkDelivered/MarkRetry |
2.4 QA 可验证项
- 并发无重复投递:在测试中启动两个 worker(两个 goroutine 或两个进程),注入 100 条事件,验证 callback server 收到的 event_id 无重复。
- claim 回收:模拟 worker crash(在
ListDue后、投递前 panic),等待 claim 超时后,验证事件被重新置为retrying并最终被成功投递。 go test ./internal/service/platformdelivery/... -count=1通过。
3. D-03:platform webhook 非严格事务外盒
3.1 现状
internal/http/handlers/platform_webhook_handler.go:86调用h.dialog.Processinternal/http/handlers/platform_webhook_handler.go:100调用h.eventWriter.InsertPendingBatch- 两者不在同一事务
dialog.Process成功但 outbox 写入失败时,业务状态(Session/Ticket/Audit)已持久化,但下游收不到事件
3.2 方案选择:② 在文档/代码中明确标注"最终一致性"边界
理由
当前 dialog.Process 内部本身也没有事务外盒:
SessionRepository.GetOrCreate(独立 SQL)DedupRepository.TryRecord(独立 SQL)TicketRepository.Create(独立 SQL)SessionRepository.Save(独立 SQL)AuditRepository.Add(独立 SQL)
各 repository 调用均为独立连接操作。单独把 outbox 写入拉进 dialog 事务是局部优化,不能解决 dialog 内部一致性问题。要真正做到严格事务外盒,需要重构整个 repository 层,引入 UnitOfWork 或 TxProvider,改动面大、风险高、与当前稳定性优先目标不符。
当前阶段更务实的选择是:
- 明确承认最终一致性边界
- 利用现有机制降低风险:outbox 写入失败会返回 HTTP 500,平台方可重试;
Dedup.TryRecord可防止重复处理导致的业务状态重复变更
风险
- 小概率场景:dialog 成功(Session/Ticket 已变)+ outbox 失败 → 下游无事件。但该场景会返回 500,平台 webhook 调用方可重试;重试时 dedup 保证业务状态不再变更,outbox 会重新写入。
- 若平台方不重试,则出现状态与事件不一致。需在 API 文档中明确告知平台方必须处理 500 重试。
回退策略
未来当 repository 层统一引入 UnitOfWork / sql.Tx 支持后,可将 dialog 全链路 + outbox 纳入同一事务。届时从文档和代码注释中移除"最终一致性"标注。
3.3 文件级落点
| 文件 | 位置 | 动作 |
|---|---|---|
internal/http/handlers/platform_webhook_handler.go |
L86-103 之间 | 增加注释块,说明 dialog.Process 与 outbox.InsertPendingBatch 不在同一事务,架构定位为最终一致性;outbox 失败返回 500,由调用方重试 |
internal/service/dialog/service.go |
文件头部或 Process 上方 |
增加注释,说明当前 repository 层无统一事务外盒,各存储操作为最终一致性 |
| 本文档 | - | 作为正式架构决策记录留存 |
3.4 QA 可验证项
- 阅读
platform_webhook_handler.go与dialog/service.go注释,确认存在"最终一致性"说明。 - 无需要运行的专属测试(本决策为架构声明,非代码功能变更)。
4. D-04:E2E 稳定性根因分析与修复路径
4.1 现状
test/e2e/sub2api_callback_flow_test.go反复出现:- 事件顺序异常:
reply.generated先于message.received sql: database is closed
- 事件顺序异常:
4.2 根因分析(TechLead 预评估,需 QA 确认)
根因 A:sql: database is closed
- 测试函数内
defer db.Close()(L150)在t.Cleanup(application.Shutdown)(L102-104)之前执行 - Go 执行顺序:函数体 defer(LIFO)→
t.Cleanup(LIFO) - 结果:DB 先关闭 → application worker 仍在运行 → worker 访问已关闭的 DB →
database is closed
根因 B:事件顺序异常
platformdelivery.Worker.RunOnce是串行for循环逐个投递(worker.go:98-102)- 但 callback server 是
httptest.Server,对每个 HTTP 请求启动独立 goroutine - 测试中的 callback handler 用
mu.Lock保护received切片,但锁的获取顺序 ≠ HTTP 请求发起顺序 - 结果:
message.processing的请求 handler 可能先拿到锁,先于message.received被 append
4.3 修复方向(TechLead 确认)
| 根因 | 修复方向 |
|---|---|
database is closed |
调整测试生命周期:将 db.Close 从 defer 移入 t.Cleanup,并确保其在 application.Shutdown 之后执行;或让 application 复用测试创建的 sql.DB 并由 Shutdown 统一关闭 |
| 事件顺序异常 | 方案一(推荐):callback server 改为同步队列模式(channel + 单 goroutine 消费),保证收到的顺序与 worker 发出顺序一致;方案二:断言逻辑改为按语义排序后比较(不依赖 received 原始顺序) |
4.4 执行路径
- QA 负责:复跑测试并抓取完整日志,确认上述两个根因与预评估一致;输出根因分析报告(含时间线、goroutine 竞争证据)。
- TechLead 负责:审核 QA 根因报告,确认修复方向(本 ADR 已预先确认方向如上)。
- Engineer 负责:按确认后的方向修改测试代码;若 QA 发现新的根因,TechLead 重新评估方向。
4.5 文件级落点
| 文件 | 位置 | 动作 |
|---|---|---|
test/e2e/sub2api_callback_flow_test.go |
L149-150 | 移除 defer db.Close();改为在 t.Cleanup 中关闭,且注册顺序晚于 application.Shutdown 的 Cleanup |
test/e2e/sub2api_callback_flow_test.go |
L157-166 | callback server handler 改为同步队列:用带缓冲 channel 收集事件,断言侧从 channel 按顺序读取 |
test/e2e/sub2api_callback_flow_test.go |
L209-230 | waitForSessionEvents / 断言逻辑适配同步队列模式 |
4.6 QA 可验证项
go test ./test/e2e/... -run TestSub2APICallbackFlow -count=10连续 10 次全部通过- 无
database is closed报错 - 无事件顺序失败
5. 与旧 Remediation Board 的兼容性评估
| 本 ADR 编号 | Remediation Board 对应项 | 兼容性 |
|---|---|---|
| D-01 | D-03 / I-03(callback_target 契约漂移) | 兼容并细化。Board 要求"删除伪能力或落地多目标路由",本 ADR 明确选择删除,给出完整文件级落点。 |
| D-02 | D-04 / I-04(outbox 并发 claim) | 兼容并升级。Board 要求"至少明确单实例约束,或设计 claim/skip locked 方案",本 ADR 选择生产级 claim 方案(UPDATE RETURNING),比最低要求更进取,但落点可控。 |
| D-03 | I-05(transactional outbox) | 兼容。Board 允许"评估并落地更严格方案,或明确记录当前非强一致边界",本 ADR 选择后者并给出理由(dialog 内部本身无事务)。 |
| D-04 | V-02(E2E 稳定性复跑) | 兼容并细化。Board 要求"形成重复执行证据",本 ADR 给出明确根因假设、修复方向和 QA/Engineer 分工。 |
结论:本 ADR 的所有决策均可在不推翻 Remediation Board 的前提下执行,是对 Board 中设计段/实现段任务的具体技术收口。
6. 阶段门控结论
| 检查项 | 结论 | 说明 |
|---|---|---|
| D-01 设计是否可进入实现 | 通过 | 删除方向明确,文件级落点已枚举,回退策略清晰 |
| D-02 设计是否可进入实现 | 通过 | UPDATE RETURNING 方案已确定,需 Engineer 评估 claim 超时回收实现复杂度(预计不超过 1 人日) |
| D-03 设计是否可进入实现 | 通过 | 仅代码注释与文档标注,无功能代码变更,风险最低 |
| D-04 是否可进入 Engineer 修复 | 条件通过 | 需 QA 先输出根因分析报告(预计 0.5 人日),TechLead 二次确认后 Engineer 执行 |
| 整体是否可进入实现段 | 通过 | D-01/D-02/D-03 可并行启动;D-04 按"QA 分析 → TechLead 确认 → Engineer 修复"串行执行 |
7. 最短执行顺序建议
- 并行启动:
- Engineer A:D-01(删除 CallbackTarget)
- Engineer B:D-03(最终一致性注释)+ D-02(claim 机制)
- QA 同步启动:D-04 根因分析(跑测试、抓日志、确认根因)
- QA 产出根因报告后:TechLead 10 分钟内确认方向
- Engineer B 接着做:D-04 测试修复
- 全部完成后:QA 跑 V-02 稳定性复跑(
go test ./... -count=5)作为阶段门禁