16 KiB
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 影响范围: 定时补偿 JobDualDeductionReconcileJob的行为扩展;不涉及任何对外 HTTP 接口,无新增/修改/删除端点,无请求/响应结构变化,无网关路由变化
⚠️ 关键变化
- 前端/客服唯一需要知道的一条:改前,对同一
(order, day, roomType, hotel)槽位重新提交配房,若该槽位被一种"扣减已成功但配房未落库"的孤儿持有行占住,会恒抛808902("幂等键已使用"),没有任何自动或人工手段能解开,客人房订不进去。改后,这类孤儿行会被 Job 在宽限窗口(默认 60 分钟)后自动收敛(判为无主则释放),之后重新提交配房能正常走通并真实扣减库存。 - 本单不修根因——见下方独立小节,这句话不能被读成"孤儿行的产生原因已经修复"。
- 本单不改任何 HTTP 接口,纯粹是后台定时 Job(
DualDeductionReconcileJob)的行为扩展:新增第二段"无主持有行收敛"扫描 + 第三段"废弃扣减尝试"收口,与原有的第一段PENDING释放扫描并列,互不合并、互不干扰。原有第一段逻辑一字未改。 - 新增两条安全守卫(详见"六、边界行为"):指纹不符只报警不释放、按来源分支回查 owner(含团期持有行)——防止误判导致正在使用的库存被错误放掉(少卖变超卖)。
- 新增配置项
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列落地,本单据此做来源分支)
关联 / 联系人
链接
- Issue: #7390
- PR: 尚未创建(合并后回填 #N)
- Merge commit: 尚未产生(合并后回填 https://git.1814.love:8443/wx/HL/commit/sha)