运维补偿接口的设计与实现:模式、形态与通用示例
本文聚焦"运维补偿接口"(用于修复线上异常数据的后台重试/补救入口)这一工程实践,不依赖任何具体业务,所有示例均为通用代码。
一、什么是运维补偿接口
运维补偿接口(也称运维工具接口 / 补偿重试接口 / 修复接口 )是一类只面向内部运维人员、不面向普通用户的后台入口,用于在以下场景修复数据:
- 外部系统调用超时/失败,导致本地"待通知/待推送"记录没发出去。
- MQ 消费失败,业务单据处于中间状态。
- 第三方回传丢失,需要人工触发重新处理。
- 历史脏数据需要按条件批量订正。
它的本质特征:读历史记录 → 重新执行某段业务逻辑 → 幂等、可重跑、有日志。
二、常见实现形态
形态 1:按 ID 列表重推(最常用)
入参是业务主键列表,逐条查出原记录/原参数,重新调用核心逻辑。
POST /ops/retry-push
body: { "ids": [101, 102, 103] }
适用:单条失败、可逐条定位的重推。
形态 2:按条件批量扫描重跑
入参是状态/时间范围等筛选条件,扫描失败记录批量重试。
POST /ops/retry-by-status
body: { "status": "FAILED", "startTime": "...", "endTime": "..." }
适用:某段时间大面积失败。
形态 3:按原参数重放(Replay)
从日志/历史表取出上次请求的完整入参,原样重放给下游。
适用:下游需要完全一致的参数,不能重新反解析。
形态 4:强制订正(Direct Fix)
不调核心逻辑,直接写 SQL/Repository 把状态、字段改对。
适用:简单字段错误,但风险高,需审计。
形态 5:dry-run 预览
补偿接口先返回"将要处理哪些、影响多少行",不真正执行,确认后再带 confirm=true 执行。
适用:批量高危操作。
三、核心设计原则
| 原则 | 说明 |
|---|---|
| 幂等 | 同一记录重跑 N 次结果一致,不重复发、不重复建单 |
| 可观测 | 每条处理结果记录日志 / 写补偿日志表(成功/失败/跳过原因) |
| 不破坏主流程 | 补偿逻辑复用核心方法,但隔离事务,失败不影响其他条 |
| 权限隔离 | 仅运维角色可访问,生产需审批/白名单 |
| 有总额统计 | 返回处理总数、成功数、失败数、跳过数 |
| 可重入 | 中断后可再次调用,已成功的跳过 |
四、通用完整示例(形态 1:按 ID 重推)
1. 入参 DTO
java
public class RetryPushRequest {
/** 业务主键列表 */
private List<Long> ids;
public List<Long> getIds() { return ids; }
public void setIds(List<Long> ids) { this.ids = ids; }
}
2. 返回结果(带逐条明细)
java
public class RetryResult {
private int total;
private int success;
private int failed;
private int skipped;
private List<String> errors = new ArrayList<>();
public void incSuccess() { success++; }
public void incFailed(String msg) { failed++; errors.add(msg); }
public void incSkipped() { skipped++; }
// getter / setter
}
3. 日志实体(补偿记录表)
java
@Entity
@Table(name = "ops_retry_log")
public class OpsRetryLog {
@Id @GeneratedValue
private Long id;
private String bizType; // 业务类型
private Long bizId; // 业务主键
private String status; // SUCCESS / FAILED / SKIPPED
private String errorMsg;
private Date createTime;
// getter / setter
}
4. 核心补偿 Service
java
@Service
@Slf4j
public class PushRetryService {
@Autowired
private BizRecordRepository bizRepo;
@Autowired
private NotifyLogRepository notifyRepo;
@Autowired
private OpsRetryLogRepository opsLogRepo;
@Autowired
private NotifyService notifyService; // 真正发通知的核心方法
/**
* 按 ID 重推,每条独立处理、独立事务,互不影响。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public RetryResult retryByIds(List<Long> ids) {
RetryResult result = new RetryResult();
result.total = ids.size();
for (Long id : ids) {
try {
Optional<BizRecord> opt = bizRepo.findById(id);
if (!opt.isPresent()) {
log.warn("补偿跳过-记录不存在, id={}", id);
result.incSkipped();
saveOpsLog("PUSH", id, "SKIPPED", "记录不存在");
continue;
}
BizRecord record = opt.get();
// 幂等:查上次通知日志,已成功则跳过
List<NotifyLog> logs = notifyRepo.findByBizId(id);
if (logs.stream().anyMatch(l -> "SUCCESS".equals(l.getStatus()))) {
log.info("补偿跳过-已成功, id={}", id);
result.incSkipped();
saveOpsLog("PUSH", id, "SKIPPED", "已成功");
continue;
}
// 取最近一次失败日志的参数重放;无日志则从主记录反解析
String param = logs.isEmpty()
? resolveParamFromRecord(record)
: logs.get(logs.size() - 1).getBizParam();
notifyService.push(record, param); // 复用核心逻辑
result.incSuccess();
saveOpsLog("PUSH", id, "SUCCESS", null);
} catch (Exception e) {
log.error("补偿失败, id={}, err={}", id, e.getMessage(), e);
result.incFailed("id=" + id + ":" + e.getMessage());
saveOpsLog("PUSH", id, "FAILED", e.getMessage());
}
}
return result;
}
private String resolveParamFromRecord(BizRecord record) {
// 从主记录反解析出下游所需参数(通用示例)
return "{\"bizId\":" + record.getId() + "}";
}
private void saveOpsLog(String type, Long bizId, String status, String err) {
OpsRetryLog log = new OpsRetryLog();
log.setBizType(type);
log.setBizId(bizId);
log.setStatus(status);
log.setErrorMsg(err);
log.setCreateTime(new Date());
opsLogRepo.saveAndFlush(log);
}
}
5. Controller(运维入口)
java
@RestController
@RequestMapping("/ops")
public class OpsController {
@Autowired
private PushRetryService retryService;
@PostMapping("/retry-push")
public RetryResult retryPush(@RequestBody RetryPushRequest req) {
if (req.getIds() == null || req.getIds().isEmpty()) {
throw new IllegalArgumentException("ids 不能为空");
}
return retryService.retryByIds(req.getIds());
}
}
五、形态 2 示例:按状态批量扫描
java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public RetryResult retryByStatus(String status, Date startTime, Date endTime) {
List<NotifyLog> failed = notifyRepo
.findByStatusAndCreateTimeBetween(status, startTime, endTime);
List<Long> ids = failed.stream()
.map(NotifyLog::getBizId)
.distinct()
.collect(Collectors.toList());
return retryByIds(ids); // 复用形态 1 的逻辑
}
六、形态 5 示例:dry-run 预览
java
public RetryResult previewRetry(List<Long> ids) {
RetryResult result = new RetryResult();
result.total = ids.size();
for (Long id : ids) {
boolean exists = bizRepo.findById(id).isPresent();
if (!exists) result.incSkipped();
else result.incSuccess(); // 仅预览,不真正执行
}
return result; // 调方确认后再带 confirm=true 调真正的 retryByIds
}
七、权限与安全防护(必做)
java
@PostMapping("/retry-push")
@PreAuthorize("hasRole('OPS')") // 仅运维角色
public RetryResult retryPush(@RequestBody RetryPushRequest req) {
// ...
}
补充建议:
- 生产环境加IP 白名单 / 审批流。
- 接口路径带
/ops/或/internal/前缀,网关层禁止对外暴露。 - 大批量限制单次 ID 数量(如 ≤ 1000),防止雪崩。
- 所有补偿操作留痕到
ops_retry_log,可追溯。
八、与事务边界的关联
- 补偿接口本身通常逐条
REQUIRES_NEW,保证一条失败不影响其他条。 - 补偿里写"补偿日志表"也处于新事务,需注意:若在事务提交后(afterCommit)写补偿日志,同样要手动填审计字段(见审计篇),或直接传参。
- 补偿调的核心方法若涉及外部 HTTP,要吞掉异常、记日志、落失败状态,绝不能让异常冒泡导致整批中断。
九、踩坑清单
| 坑 | 现象 | 正确做法 |
|---|---|---|
| 补偿逻辑直接复用主流程但没隔离事务 | 一条失败整批回滚 | 逐条 REQUIRES_NEW |
| 不幂等 | 重跑重复发通知/建单 | 先查已成功则跳过 |
| 无日志 | 跑完不知道谁成功谁失败 | 写补偿日志表 + 返回明细 |
| 暴露给公网 | 被滥用 | @PreAuthorize + 网关隔离 |
| 不限制批量大小 | 大批量拖垮 DB | 限流 / 分页 |
| 异常冒泡 | 接口 500,调用方不知部分成功 | 逐条 try-catch,汇总返回 |
| 没 dry-run | 误操作无法预览 | 高危操作先 preview |
十、一句话总结
运维补偿接口 = "查历史 → 复用核心逻辑重跑 → 逐条独立事务 + 幂等跳过 + 全量留痕" 的后台修复工具。它和"事务提交后写库"共享同一套事务边界认知(
REQUIRES_NEW、审计字段手动填、异常隔离),是生产系统不可缺少的"后悔药",但必须用权限、留痕、幂等三道闸门锁住风险。