--- schema: "hl-changelog/v2" ticket: "7390" title: "双写扣减孤儿持有行自动收敛;重新提交配房不再被无主持有行恒锁 808902" consumer: "admin" author: "wx(GIT)" change_type: "修复" backend_status: "deployed" gateway_status: "not_required" frontend_status: "not_required" frontend_owner: "mmg" frontend_ref: "" target_release: "" verified_at: "" status_note: "not_required 2026-09-10 mmg: 纯后端 DualDeductionReconcileJob 行为扩展(新增孤儿持有行收敛+废弃扣减收口两段,原 PENDING 扫描未改),不改任何对外 HTTP 接口/字段/VO/路由,无 Flyway;后端自答前端无需改代码。grep 实证前端零 808902/双写扣减补偿端点(dual-deduction/orphan/reconcile/持有行)命中,触点不存在;错误码由拦截器透后端 message。前端只需知:同槽位重复提交在宽限窗口(默认60min)内仍可能 808902 属预期勿误报。" updated_at: "2026-09-10" base: "dev-v3" --- # 双写扣减孤儿持有行自动收敛(修复) > **服务**: hl-order-service-v3(house 域 `DualDeductionReconcileJob` / `HouseDualDeductionService` / `HouseDualDeductionLogMapper`) > **Issue**: [#7390](https://git.1814.love:8443/wx/HL/issues/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](https://git.1814.love:8443/wx/HL/issues/7390) - 前置依赖:`#7339`(酒店库存操作幂等键,已合入,建立了原有 `PENDING` 补偿扫描)、`#7323`(`source_type` 列落地,本单据此做来源分支) ## 关联 / 联系人 ### 链接 - **Issue**: [#7390](https://git.1814.love:8443/wx/HL/issues/7390) - **PR**: 尚未创建(合并后回填 [#N](https://git.1814.love:8443/wx/HL/pulls/N)) - **Merge commit**: 尚未产生(合并后回填 [https://git.1814.love:8443/wx/HL/commit/sha](https://git.1814.love:8443/wx/HL/commit/sha)) ### 联系人 - **后端负责人**: @wx - **前端负责人**: @mmg