文件
hl-api-changelog/changelogs-v2/2026-09/10_7390_双写扣减孤儿持有行自动收敛-修复-管理后台.md
T
Mimingguang 4281aa8aca
changelog-filename-gate / validate (push) Failing after 1s
docs(changelog): #7390/#7347 frontmatter 回写 not_required
2026-09-10 15:09:21 +08:00

16 KiB
原始文件 Blame 文件历史

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 7390 双写扣减孤儿持有行自动收敛;重新提交配房不再被无主持有行恒锁 808902 admin wx(GIT) 修复 deployed not_required not_required mmg not_required 2026-09-10 mmg: 纯后端 DualDeductionReconcileJob 行为扩展(新增孤儿持有行收敛+废弃扣减收口两段,原 PENDING 扫描未改),不改任何对外 HTTP 接口/字段/VO/路由,无 Flyway;后端自答前端无需改代码。grep 实证前端零 808902/双写扣减补偿端点(dual-deduction/orphan/reconcile/持有行)命中,触点不存在;错误码由拦截器透后端 message。前端只需知:同槽位重复提交在宽限窗口(默认60min)内仍可能 808902 属预期勿误报。 2026-09-10 dev-v3

双写扣减孤儿持有行自动收敛(修复)

服务: hl-order-service-v3(house 域 DualDeductionReconcileJob / HouseDualDeductionService / HouseDualDeductionLogMapper) Issue: #7390 日期: 2026-09-10 影响范围: 定时补偿 Job DualDeductionReconcileJob 的行为扩展;不涉及任何对外 HTTP 接口,无新增/修改/删除端点,无请求/响应结构变化,无网关路由变化


⚠️ 关键变化

  1. 前端/客服唯一需要知道的一条:改前,对同一 (order, day, roomType, hotel) 槽位重新提交配房,若该槽位被一种"扣减已成功但配房未落库"的孤儿持有行占住,会恒抛 808902("幂等键已使用"),没有任何自动或人工手段能解开,客人房订不进去。改后,这类孤儿行会被 Job 在宽限窗口(默认 60 分钟)后自动收敛(判为无主则释放),之后重新提交配房能正常走通并真实扣减库存。
  2. 本单不修根因——见下方独立小节,这句话不能被读成"孤儿行的产生原因已经修复"。
  3. 本单不改任何 HTTP 接口,纯粹是后台定时 Job(DualDeductionReconcileJob)的行为扩展:新增第二段"无主持有行收敛"扫描 + 第三段"废弃扣减尝试"收口,与原有的第一段 PENDING 释放扫描并列,互不合并、互不干扰。原有第一段逻辑一字未改。
  4. 新增两条安全守卫(详见"六、边界行为"):指纹不符只报警不释放、按来源分支回查 owner(含团期持有行)——防止误判导致正在使用的库存被错误放掉(少卖变超卖)。
  5. 新增配置项 house.dual-deduction.orphan-grace-minutes(默认 60 分钟),需在 Nacos 确认已生效(沿用默认值即可,无需人工干预)。

⛔ 本单不修根因

HouseAssignmentService.submitPersist(配房提交链路的持久化阶段)仍然在 try 块之外(HouseAssignmentService.java:576-600 为扣减 try 块,persist 调用点在 :609 的 self.submitPersist(...),落在 try 之外),也就是说:扣减库存全部成功后,若持久化配房行这一步因为并发/DB约束/状态机等原因失败回滚,扣减日志行仍会停在 (resource_success=1, rollback_status=NONE) 且没有任何配房行与它对应——孤儿行会继续产生,产生速率与改前完全一样。

本单只是让这类已经产生的孤儿行,在经过宽限窗口后能被 Job 自动收敛(判为无主则释放槽位),从而解开重新提交的死锁。不得把本单理解为"孤儿行的产生原因已经修复"——根因修复(把 submitPersist 纳入事务边界)是独立后续单,本单范围之外。


一、背景

house_dual_deduction_log 表的一行记的是"一次扣减占了某个(订单/日期/房型/酒店)槽位"的库存持有关系。配房提交链路先逐条扣减库存(全部成功后),随后才调用 submitPersist 落配房行,而这个持久化调用不在 try 块内。因此当 submitPersist 事务回滚时,已扣减的日志行会留在 (resource_success=1, rollback_status=NONE) 这个状态,其 assignment_id 指向一个从未 insert 成功的预生成雪花 id——远端库存被真实占用,本地却没有任何配房行对应它。

改前唯一的补偿扫描 selectPendingForReconcile 只扫 rollback_status='PENDING',(1, NONE) 这个状态两类候选都不满足,Job 永远不会碰它。同时 (1, NONE) 也是正常持有行的合法状态,所以不能简单地"见到就释放"——必须先证明它无主。而这类孤儿行还会把对应槽位锁死:再次提交同一(order, day, roomType, hotel)会命中这条孤儿行,因 owner 不符(或数量不符)而抛 808902(幂等键已使用),槽位永久无法重新使用,方向是少卖 + 功能阻断(房卖不出去,客人也重提不了),不是超卖。

本单在 DualDeductionReconcileJob 里新增两段并列扫描(与原有 PENDING 扫描互不合并):

  • 第二段:扫 (resource_success=1, rollback_status=NONE) 且超过宽限窗口的行,按来源分支回查 owner,证明无主才释放。
  • 第三段:扫 (resource_success=0, rollback_status=NONE) 且租约过期的废弃扣减尝试,只放弃、不采用,避免与业务方同键重试产生竞态。

二、变更接口清单

不适用。本单不新增/修改/删除任何对外 HTTP 接口,没有严格意义上的"变更接口"。受影响的是既有定时 Job DualDeductionReconcileJob 的内部扫描逻辑,其触发入口(/v3/internal/jobs/dual-deduction-reconcile/scheduled、/v3/internal/jobs/dual-deduction-reconcile/daily)本身的方法/路径/请求响应结构均未改动。


三、接口详情

不适用:本单不涉及任何对外 HTTP 接口,故无"变更接口清单/逐接口 VO 契约"可填。以下按"行为变更点"分两小节说明 Job 内部逻辑变化。

本单不涉及对外 VO 契约,不适用模板逐接口 VO + 入参/出参字段表结构。按"行为变更点"分两小节说明。

1. 新增第二段扫描:无主持有行自动收敛

涉及方法:DualDeductionReconcileJob.reconcileOrphanHolding()(私有方法,被 doReconcile() 在原有 PENDING 扫描之后调用),无独立对外 HTTP 签名。

使用场景

Job 定频(每 5 分钟)与每日凌晨全量兜底两个既有触发点里,新增本段扫描。扫描 resource_success=1 AND rollback_status='NONE' AND update_time < now - orphan-grace-minutes 的候选行(游标分页,单轮最多翻 orphan-max-pages(默认 5)页,每页 200 条)。

处理逻辑

逐条按 source_type 分支回查 owner:

source_type 回查表 owner 不存在(含已软删) owner 存活且指纹一致 owner 存活但指纹不符 / 不再持有库存
空 或 HOUSE_ASSIGNMENT(逐单配房) house_hotel_assignment(经 HouseAssignmentService.findDeductionOwner,不直连 Mapper) 释放(调既有 restoreByLogId) 跳过,不改任何字段 只报警,不释放
GROUP_BATCH_PLAN(团期计划) 团期计划表(经 GroupBatchRoomPlanService.findDeductionOwner,不直连 Mapper) 释放 跳过,不改任何字段 只报警,不释放
未知取值,或 assignment_id 为空 —— 永不释放,只报警计数 —— ——

指纹 = (hotel_id, room_type_id, stay_date, room_count),不含价格与版本号。

释放动作复用既有的 restoreByLogId(id, round, "orphan-reconcile"),走既有 markReleasePending CAS + release-{logId}-{round} 账本键,没有新造释放路径。

空数据 / 降级响应

无候选(本轮没有超过宽限窗口的孤儿):本段直接跳过,只打一条聚合日志,不产生任何写入。

错误响应

不适用:本方法内部 try/catch 单条隔离,单行处理异常只记 releaseFailed 计数与 error 日志,不向上抛出,不影响其余候选行与其它两段扫描。

业务边界

  • 游标分页(Redis key house:dual-deduct:orphan-cursor,值 {updateTime}|{id},TTL 24 小时):因为正常持有行被跳过时不改任何字段(含 update_time),若不用游标,每轮都会卡在同一批最老的正常持有行上,后方真正的孤儿永远轮不到。取回不足一页视为扫到表尾,游标清除,下一轮从头重扫。
  • 全程不修改正常持有行的任何字段,owner 存活 分支是纯读,零副作用。
  • 逐行"owner 存活,跳过"日志走 DEBUG 级别(候选集是全部超过 60 分钟的正常持有行,量级可能很大,打 INFO 会刷屏),聚合统计走 INFO:[house-orphan] 完成 candidates= released= releasePending= releaseFailed= ownerAlive= fingerprintMismatch= unresolved= pages= cursor=X→Y。

2. 新增第三段扫描:废弃扣减尝试收口

涉及方法:DualDeductionReconcileJob.replayAbandonedAttempts() + HouseDualDeductionService.replayAbandonedAttempt(logId, expectedRound)。

使用场景

扫 (resource_success=0, rollback_status='NONE') 且租约(house.dual-deduction.deduct-attempt-lease-minutes,默认 10 分钟,与扣减侧认领租约复用同一配置)过期的行——这类行代表一次扣减尝试因超时/kill等原因悬而未决,且没有被同键业务重试接管。

处理逻辑

只放弃、不采用:重新领取租约后按结果分支收口成 DONE(成功则转入第二段同款释放路径的准备状态,交第一段收口)或 FAILED(失败重试计数)。Job 的任何分支都不产出 (1, NONE),也不把行交给第二段——这类行唯一合法的"被采用"路径是同键业务重试,Job 与业务重试抢的是同一把租约,Job 只能放弃,不能抢先采用,否则会与业务方产生竞态导致资源误判。

业务边界

  • 若 Job 领到租约后、写入放弃意图之前,同键业务重试抢先接管,Job 的合并 CAS 会返回 0,此时立即停止、零后续写(不改 assignment_id、不标 DONE、不调释放),只记 replayLostLease 计数与警告日志,避免释放掉业务刚刚接管的持有。

四、契约约束与正确调用方式

本单不涉及任何入参校验规则变化,也没有新的正确/错误 payload 需要记录(本单无 HTTP 契约)。唯一需要前端/客服知道的行为规则是:

同一 (order, day, roomType, hotel) 槽位如果重新提交仍然收到 808902,先检查提交时间与上一次失败尝试的时间差是否已超过宽限窗口(默认 60 分钟)——若未超过,属预期内的正常等待,无需上报为 bug;若已远超过窗口仍持续 808902,才需要按异常上报排查。

切换状态时的必要动作

无。本单不涉及任何需要调用方显式置空/切换的字段,全部由 Job 后台异步处理。


五、数据库行为

  • 本单无 Flyway、无表结构变更、无字段增删(house_dual_deduction_log.source_type 列由前置单 #7323 已经带上,本单只是消费它,不新增列)。
  • 判为无主的行会被 Job 更新为 DONE(走既有 restoreByLogId → markReleasePending/markRolledBackCas 链路),对应的资源侧库存账本会多出一条 release-{logId}-{round} 记录(幂等,不会重复释放)。
  • 判为"owner 存活但指纹不符"的行零写入,只记日志与内存计数。

六、边界行为

  • 指纹不符只报警不释放:owner 存活但 (hotel_id, room_type_id, stay_date, room_count) 与日志行不一致(改期/调整链路的残留形态),Job 只 log.warn 计数,不动库存、不改状态——自动释放会把业务正在使用的库存放掉,方向从少卖变成超卖,比原问题更糟。
  • 团期持有行按来源分支回查,而不是与逐单配房共用同一张 owner 表:source_type='GROUP_BATCH_PLAN' 的行经 GroupBatchRoomPlanService.findDeductionOwner 回查团期计划表,而不是走 HouseAssignmentService(后者查不到团期计划,若误用会把所有存活的团期持有行都误判成"无主"进而错误释放)。判定逻辑与逐单配房一致:owner 存活且指纹一致则跳过,owner 已软删/不存在才释放。
  • 宽限窗口内的候选(update_time 未超过 orphan-grace-minutes)本轮不处理,等下一轮或超过窗口后再处理。
  • 三段扫描互不干扰:某一段抛异常只影响该段本轮结果,另外两段照常执行(runSegment 逐段 try/catch 隔离)。
  • 第一段(原有 PENDING 扫描)逻辑一字未改。

六.5、枚举 / 数据字典

新增配置项

配置 key 默认值 说明
house.dual-deduction.orphan-grace-minutes 60(分钟) 第二段孤儿判据的宽限窗口:扣减成功到配房持久化在途的最长容忍时长,超过才纳入无主候选扫描
house.dual-deduction.orphan-max-pages 5 第二段单轮最多翻几页(每页 200 条),保证整轮在锁 TTL(5 分钟)内结束,翻不完的下一轮凭 Redis 游标接力
house.dual-deduction.deduct-attempt-lease-minutes 10 第三段候选租约窗口,与扣减侧认领租约复用同一配置项(非新增,本单新用途)

以上三项均已在代码里给了默认值兜底(@Value("${...:默认值}")),未在 Nacos 显式配置时按默认值生效。

Job 统计口径(用于巡检/排障,前端不感知但客服排障可能用到)

计数字段 含义
candidates 第二段本轮扫到的候选总数
released 判为无主并成功释放的数量
releasePending 判为无主但释放未最终确认(转 PENDING 交第一段兜底)的数量
releaseFailed 释放动作本身异常的数量
ownerAlive owner 存活且指纹一致,跳过的数量(正常持有行)
fingerprintMismatch owner 存活但指纹不符,只报警未释放的数量
unresolved 来源判不出/assignment_id 为空,永不释放的数量

七、不影响范围

  • 仅影响:DualDeductionReconcileJob 的内部扫描行为(新增两段,原有第一段不变)。
  • 零影响:
    • 无新增/修改/删除任何对外 HTTP 接口,网关路由零改动。
    • 无 Flyway、无表结构变更。
    • 正常持有行(owner 存活且指纹一致)的任何字段(含 update_time)不会被本单的扫描修改。
    • 原有 PENDING 释放扫描(第一段)逻辑一字未改。
    • 不修复 submitPersist 在 try 块外的根因,孤儿行仍会以改前同样的速率继续产生(见上方"本单不修根因"节)。

八、测试环境已验证

  • 部署状态:测试服已部署 HEAD 18c1df126,该 HEAD 已包含本单实现提交 f6e5c9356(PR #7426,git merge-base --is-ancestor f6e5c9356 18c1df126 为真)。
  • 前端可感知的核心行为:同一 (order, day, roomType, hotel) 槽位被孤儿行占住后,改前重新提交恒抛 808902 且无出口;改后经宽限窗口(默认 60 分钟)后 Job 自动收敛,重新提交能正常走通并真实扣减库存(走既有 claimDoneForRededuct 路径,rollback_round 递增真扣)。
  • 网关验证:不适用,本单不涉及任何网关路由的接口。
  • 兼容性结论:纯后台 Job 行为扩展,对前端零接口契约影响;前端不需要做任何代码改动,只需理解上述宽限窗口行为,避免把窗口期内的 808902 误报为新 bug。

十、相关文档

  • 关联 Issue: wx/HL#7390
  • 前置依赖:#7339(酒店库存操作幂等键,已合入,建立了原有 PENDING 补偿扫描)、#7323(source_type 列落地,本单据此做来源分支)

关联 / 联系人

链接

联系人

  • 后端负责人: @wx
  • 前端负责人: @mmg