Inner Reg Sync Redesign
architect review — current vs. proposed — 2026-03-09
1 — 问题诊断: 为什么能停27天无人知
根因不是某行代码 — 而是架构缺失
同步停滞27天却没人发现,暴露的不是"哪个接口挂了",而是整套同步机制在可观测性、容错性、状态管理三个维度上的系统性缺失。任何单点异常都会让整条链路静默死亡,且无任何告警机制触发人工干预。
0
告警通知
0
健康检查
0
重试机制
27d
发现延迟
2 — 现有架构七大缺陷
| # | 缺陷 | 现状描述 | 风险等级 |
|---|---|---|---|
| F1 | 静默失败 错误黑洞 |
catch 块仅 log.error,不抛异常、不告警、不重试。Stage 1 整体被 try-catch 包裹,外部接口返回任何异常都被吞掉。QueryZdRegsBiz:214-218 / SyncRegsDataJob:28-29 |
致命 |
| F2 | sync_status 死锁陷阱 |
Stage 2 处理前先将 sync_status 设为 1(同步中),如果服务重启/OOM/异常中断,状态永远停在1。查询条件 IN(0,3) 不包含1,这批数据永远不会被重新处理。UpdateZdRegsBiz:142 / 86 |
致命 |
| F3 | 零可观测性 零监控 |
没有 Metrics、没有 HealthCheck、没有告警。唯一的可观测手段是翻日志。停了27天靠人肉发现。 | 致命 |
| F4 | 递归调用 无保护 |
queryRegsFromZd() 使用递归拉取下一批数据,如果制度系统返回大量数据,递归深度不可控,可能 StackOverflow。虽然有 zd.limit 但默认可能未配。QueryZdRegsBiz:139 |
高 |
| F5 | 三阶段 无编排 |
三个 Stage 是完全独立的 Quartz Job,各跑各的 Cron。Stage 1 00:00 拉数据 → Stage 2 每2h转换 → Stage 3 18:00 同步。没有事件驱动、没有依赖关系,完全靠时间差来"碰运气"。 | 高 |
| F6 | 筛选条件 过于严格 |
Stage 2 要求 5 个条件同时满足才处理。任何一个字段异常(如制度系统返回了 status=2 而非 3),记录就被永久跳过,且无任何提示。这不是"过滤",是"静默丢数据"。 |
高 |
| F7 | 无幂等性 保证 |
Stage 2 的 lawRegulationProcess 先删后插,没有事务保护覆盖全流程。中途失败可能导致部分删除+部分插入的脏数据状态。 |
高 |
3 — 改进方案总览
设计原则: 不大改架构,聚焦补齐三个关键能力
考虑到这是银行生产系统,不建议推倒重来(引入消息队列、重写为事件驱动等)。风险高、周期长、审批困难。改进方案的原则是:在现有 Spring Boot + Quartz 技术栈内,用最小改动补齐三个关键能力。
A. 状态机 + 容错
让每条同步记录的生命周期可管理、可恢复、不会卡死
B. 可观测 + 告警
主动发现问题,而不是等用户反馈"数据怎么没更新"
C. 可恢复 + 幂等
任何中断都能自动恢复或手动补偿,不丢数据
4 — 核心改进 A: 状态机 + 容错
Current: 脆弱的二值状态
Proposed: 完整状态机
sync_status只有 0/1/2/3 四种值- 处理前直接改为1,中断即卡死
- 查询条件
IN(0,3)永久跳过状态1 - 没有超时检测机制
- 没有最大重试次数
- 增加
retry_count和last_attempt_time字段 - 状态1(同步中) 超过30分钟自动回退到0
- 重试次数超过3次 → 状态4(人工处理)
- 每次处理记录
last_attempt_time - 定时扫描"卡死"记录并自动修复
实现代码示例: 超时自动回退
// 在 UpdateZdRegsBiz.updateLawRegulation() 开头增加: // 1. 自动修复卡在"同步中"超过30分钟的记录 LambdaUpdateWrapper<LawRegulationSyncRecord> fixStuck = Wrappers .lambdaUpdate(LawRegulationSyncRecord.class) .set(LawRegulationSyncRecord::getSyncStatus, "0") .eq(LawRegulationSyncRecord::getSyncStatus, "1") .lt(LawRegulationSyncRecord::getUpdateTime, DateUtil.offsetMinute(new Date(), -30)); lawRegulationSyncRecordService.update(fixStuck); // 2. 超过3次重试的移入人工队列 LambdaUpdateWrapper<LawRegulationSyncRecord> toDead = Wrappers .lambdaUpdate(LawRegulationSyncRecord.class) .set(LawRegulationSyncRecord::getSyncStatus, "4") .in(LawRegulationSyncRecord::getSyncStatus, "0", "3") .ge(LawRegulationSyncRecord::getRetryCount, 3); lawRegulationSyncRecordService.update(toDead);
改动量评估: 表加2个字段 (
retry_count INT DEFAULT 0, last_attempt_time DATETIME),UpdateZdRegsBiz 开头加10行修复逻辑,updateSyncStatus 方法中增加 retry_count 自增。总改动 < 30行代码。
5 — 核心改进 B: 可观测 + 告警
新增: 同步健康检查定时任务
新建一个轻量级 Quartz Job,每天10:00检查同步健康状态,发现异常时通过现有的消息服务推送告警。
检查项 1: 同步新鲜度
sync_record 表最新记录距今超过 48小时 → 告警说明 Stage 1 拉取已停止
检查项 2: 积压检测
sync_status IN (0,3) 的记录超过 50条 → 告警说明 Stage 2 转换能力不足或已停止
检查项 3: 卡死检测
sync_status = 1 且 update_time 超过 1小时 的记录 → 告警说明有数据处于僵死状态
检查项 4: 错误率检测
最近24h内
说明外部接口大面积异常
result_code = E 的比率超过 50% → 告警说明外部接口大面积异常
Current: 纯日志, 需人工翻查
Proposed: 主动发现 + 推送
- 所有异常只写
log.error - 日志量大,淹没在正常日志中
- 没有结构化 Metrics
- 依赖人工发现问题
- 发现时已滞后数周
- 新增
SyncHealthCheckJob(每天10:00) - 复用现有
MessageService发站内消息/邮件 - 后台管理页增加同步状态看板 (复用现有前端组件)
- 关键操作增加结构化日志 (JSON格式便于检索)
- 问题发现时间: 数周 → 24小时内
改动量评估: 新增
SyncHealthCheckJob + SyncHealthCheckJobScheduler 两个类 (约100行),复用现有 MessageService 推送告警。前端可选增加一个简单的同步状态页面。
6 — 核心改进 C: 可恢复 + 幂等
三项关键改造
C1. 递归改循环
queryRegsFromZd() 从递归改为 while 循环 + 计数器。避免 StackOverflow,同时方便记录进度和中断恢复。改动: ~15行
C2. Stage 1 增加超时
callZd() 的 HTTP 请求没有设置超时。增加 connectTimeout=10s + readTimeout=60s。防止请求永久挂起阻塞 Quartz 调度。改动: ~5行
C3. 手动补偿接口
在
改动: ~40行
RegMaintainController 增加管理端接口:指定 traceId 重新拉取、批量重置 sync_status、手动触发 Stage 2。提供运维自救能力。改动: ~40行
C1 示例: 递归改循环
// Before: 递归 (有 StackOverflow 风险) public QueryZdRegsXml queryRegsFromZd(req, counter) { QueryZdRegsXml result = regsProcess(req, params, counter); String nextTraceId = result.getNextTraceId(); if (isNotEmpty(nextTraceId)) { req.setTraceId(nextTraceId); result = queryRegsFromZd(req, counter); // 递归! } return result; } // After: 循环 (安全、可控、可记录进度) public void syncRegsFromZd(String traceId) { int count = 0; int limit = zdConfig.getLimit() != null ? zdConfig.getLimit() : 100; String currentTraceId = resolveTraceId(traceId); while (currentTraceId != null && count < limit) { try { QueryZdRegsXml result = regsProcess(currentTraceId); currentTraceId = result.getNextTraceId(); count++; } catch (Exception e) { log.error("[SYNC] 第{}批处理失败, traceId={}", count, currentTraceId, e); break; // 失败时中断, 下次从断点继续 } } log.info("[SYNC] 本次共处理{}批", count); }
C2 示例: HTTP 超时设置
// Before: 无超时 (可能永久阻塞) String body = HttpRequest.post(url) .addHeaders(headers).form(params) .execute().body(); // After: 明确超时 String body = HttpRequest.post(url) .addHeaders(headers).form(params) .timeout(60000) // 总超时 60 秒 .execute().body();
7 — 逐项对比: 现有 vs. 改进
| 维度 | 现有方案 | 改进方案 | 改动量 |
|---|---|---|---|
| 异常感知 | catch 后仅 log.error,异常被吞掉 | 健康检查 Job 每日巡检 + 消息告警推送 | 新增1个Job |
| 问题发现 时间 |
27天(人工偶然发现) | < 24小时(定时巡检主动发现) | -- |
| 状态卡死 | sync_status=1 后服务重启 → 永久卡死 | 超时自动回退 + 重试次数上限 + 死信队列 | 加2字段+10行 |
| 递归风险 | queryRegsFromZd 递归调用,理论可 StackOverflow | 改为 while 循环,异常时 break 保存进度 | 改15行 |
| HTTP 超时 | callZd() 无超时,可能永久挂起阻塞 Quartz | 设置 60s 超时 | 改1行 |
| 数据静默 丢失 |
5个条件全不满足的记录被永久跳过,无提示 | 不满足条件的记录标记为"待人工审核(status=5)"并告警 | 加5行判断 |
| 运维手段 | 无补偿接口,出问题只能直接改数据库 | 管理端补偿接口: 重拉/重置/重试 | 加3个接口 |
| 阶段协调 | 三阶段靠 Cron 时间差碰运气 | Stage 1 完成后主动触发 Stage 2(可选增强) | 可选改进 |
8 — 分阶段实施路径
三个阶段, 按优先级递减
P0 紧急 止血修复
目标: 防止再次静默死亡
总改动: ~30行
耗时: 0.5天
- HTTP 超时设置 (1行)
- sync_status 超时自动回退 (10行)
- 递归改循环 (15行)
- Stage 1 catch 块增加告警 (5行)
总改动: ~30行
耗时: 0.5天
P1 重要 可观测性
目标: 主动发现问题
总改动: ~150行
耗时: 1-2天
- 新增 SyncHealthCheckJob (100行)
- 复用 MessageService 推送告警
- 管理端补偿接口 (40行)
- DB 增加 retry_count 等字段
总改动: ~150行
耗时: 1-2天
P2 增强 长期演进
目标: 架构韧性
总改动: 按需
耗时: 按需迭代
- 前端同步状态看板
- Stage 1 完成后事件驱动 Stage 2
- 异常记录人工审核流程
- 同步数据一致性校验
总改动: 按需
耗时: 按需迭代
核心理念: 好的同步系统不是"永不出错"的系统,而是"出错时能快速发现、自动恢复、不丢数据"的系统。P0 阶段 30 行代码就能解决最致命的三个问题(卡死、超时、静默失败),投入产出比极高,建议立即实施。