分布式系统中接口时序不确定性处理
一、核心问题
在分布式系统中,多个独立接口由外部系统分别调用,无法保证到达顺序。当接口 B 的处理依赖接口 A 的结果时,如果 B 先到、A 后到,B 的处理必然失败。
期望时序: 实际可能:
A(创建订单) → B(补充条码) B(补充条码) → A(创建订单)
↓ ↓ ↓ ↓
订单存在 → 条码关联成功 订单不存在 → 条码关联失败 ❌
产生原因
- 外部系统多个模块独立回调,彼此不感知时序
- 网络延迟不均,先发的请求可能后到
- 外部系统内部有队列/异步处理,不同类型消息的处理速度不同
- 多个微服务分别推送,无全局编排
不处理的后果
- 依赖方处理失败,需等定时任务补偿(延迟从秒级变为分钟级甚至小时级)
- 失败日志堆积,告警噪音
- 定时任务负载增大
- 用户体验下降(数据延迟可见)
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、解决思路
核心原则:谁后到,谁负责串联
方案一:后到方主动触发先到方
┌─────────────────────────────────────────────┐
│ B 先到 → 标记待处理(等待 A) │
│ A 后到 → 完成自身逻辑 → 检查 B 是否在等 → 触发 B │
└─────────────────────────────────────────────┘
方案二:先到方轮询等待
┌─────────────────────────────────────────────┐
│ B 先到 → 检查 A 是否完成 → 未完成 → 进入重试 │
│ A 后到 → 完成自身逻辑 │
│ 定时重试 → B 再次检查 → A 已完成 → B 处理成功 │
└─────────────────────────────────────────────┘
方案三:聚合后统一处理
┌─────────────────────────────────────────────┐
│ A 到达 → 写入聚合表 │
│ B 到达 → 写入聚合表 → 检查聚合完整性 → 完整则处理 │
└─────────────────────────────────────────────┘
三、模式分类
模式 1:后到方主动触发(推荐)
特点:时延最短(秒级),不依赖定时任务,响应及时。
A 后到时:完成自身 → 反查 B 的状态 → B 待处理则投递 MQ 触发 B
B 后到时:正常执行(A 已完成,依赖条件满足)
适用场景:两个接口有明确的依赖关系,且后到方能够通过数据关联找到先到方的记录。
模式 2:失败重试 + 定时补偿
特点:实现简单,但延迟较高。
B 先到 → 尝试执行 → 依赖不满足 → 标记失败
定时任务 → 扫描失败记录 → 重新投递 → 此时 A 已完成 → B 成功
适用场景:时效性要求不高,或改造成本较高时的兜底方案。
模式 3:聚合等待模式
特点:所有条件到齐后才处理,不存在失败重试。
A 到达 → 写片段到聚合表
B 到达 → 写片段到聚合表 → 检查是否全部到齐 → 到齐则执行
适用场景:多方数据需要全部到齐才能处理(如多物流节点聚合、分片数据汇总)。
模式 4:延迟消费
特点:通过延迟队列给依赖方留出到达时间。
B 先到 → 放入延迟队列(等待 N 秒)→ N 秒后消费 → 此时 A 大概率已到
适用场景:两个接口的时间差通常很小(几秒内),用固定延迟覆盖绝大多数情况。
四、通用示例代码
4.1 状态表设计
sql
CREATE TABLE event_dependency_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(32) NOT NULL COMMENT '事件类型',
biz_key VARCHAR(128) NOT NULL COMMENT '业务唯一键',
payload TEXT NOT NULL COMMENT '原始数据(JSON)',
status CHAR(1) NOT NULL DEFAULT 'O' COMMENT 'O-待处理 P-失败 Y-成功',
depend_on VARCHAR(32) COMMENT '依赖的事件类型',
error_msg VARCHAR(512),
retry_count INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX uk_event_biz (event_type, biz_key),
INDEX idx_status (status, event_type)
) COMMENT '事件依赖日志表';
4.2 模式 1 完整实现:后到方主动触发
接口 A:被依赖方(如"创建订单")
java
@Service
@Slf4j
public class EventAService {
@Resource
private EventDependencyLogRepository logRepository;
@Resource
private EventAProcessor processor;
@Resource
private DependencyTriggerService triggerService;
/**
* 接收事件 A(被依赖方).
* 完成自身逻辑后,主动检查并触发依赖自己的事件 B。
*/
@Transactional(rollbackFor = Exception.class)
public void handleEventA(String bizKey, Object payload) {
// 1. 落日志
Long logId = saveLog("EVENT_A", bizKey, payload);
if (logId == null) return; // 已成功,幂等返回
// 2. 执行核心业务逻辑(如创建订单、生成出库单)
ProcessResult result = processor.process(payload);
// 3. 标记自身成功
markSuccess(logId);
// 4. 【关键】主动触发依赖自己的事件 B
triggerService.triggerDependentEvent(bizKey, "EVENT_B");
}
}
接口 B:依赖方(如"补充条码")
java
@Service
@Slf4j
public class EventBService {
@Resource
private EventDependencyLogRepository logRepository;
@Resource
private EventBMqSender mqSender;
/**
* 接收事件 B(依赖方).
* 只落日志并投递 MQ,不关心依赖是否满足(由消费者判断)。
*/
@Transactional(rollbackFor = Exception.class)
public void handleEventB(String bizKey, Object payload) {
Long logId = saveLog("EVENT_B", bizKey, payload);
if (logId == null) return;
// 事务提交后投递 MQ
final Long finalLogId = logId;
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
mqSender.send(finalLogId);
}
});
}
}
事件 B 的消费者
java
@Component
@Slf4j
public class EventBConsumer {
@Resource
private EventDependencyLogRepository logRepository;
@Resource
private EventBProcessor processor;
@RabbitListener(queues = "${mq.queue.event-b}")
public void consume(Long logId) {
EventDependencyLog eventLog = logRepository.findById(logId).orElse(null);
if (eventLog == null || "Y".equals(eventLog.getStatus())) {
return;
}
try {
// 执行业务逻辑(内部会查询事件A的结果)
// 如果事件 A 尚未完成,这里会抛异常
processor.process(eventLog.getPayload());
eventLog.setStatus("Y");
logRepository.saveAndFlush(eventLog);
} catch (DependencyNotReadyException e) {
// 依赖未就绪,标记 P,等待事件 A 完成后主动触发
log.info("事件B依赖未就绪, bizKey={}, 等待事件A触发", eventLog.getBizKey());
eventLog.setStatus("P");
eventLog.setErrorMsg(e.getMessage());
logRepository.saveAndFlush(eventLog);
} catch (Exception e) {
log.warn("事件B处理失败, logId={}", logId, e);
eventLog.setStatus("P");
eventLog.setRetryCount(eventLog.getRetryCount() + 1);
eventLog.setErrorMsg(e.getMessage());
logRepository.saveAndFlush(eventLog);
throw e;
}
}
}
主动触发服务(核心组件)
java
@Service
@Slf4j
public class DependencyTriggerService {
@Resource
private EventDependencyLogRepository logRepository;
@Resource
private EventBMqSender eventBMqSender;
/**
* 当事件 A 完成时,检查是否有依赖它的事件 B 在等待.
* 如果存在且未完成,事务提交后投递 MQ 触发重新消费。
*
* @param bizKey 关联的业务键(两个事件通过此键关联)
* @param dependentEventType 要检查的依赖事件类型
*/
public void triggerDependentEvent(String bizKey, String dependentEventType) {
// 通过业务键关联查找依赖事件
String dependentBizKey = resolveDependentBizKey(bizKey);
if (dependentBizKey == null) {
return; // 无关联关系(如非该业务场景)
}
EventDependencyLog dependentLog = logRepository
.findByBizKeyAndEventType(dependentBizKey, dependentEventType);
// 不存在(尚未到达)或已成功,无需触发
if (dependentLog == null || "Y".equals(dependentLog.getStatus())) {
return;
}
Long targetLogId = dependentLog.getId();
log.info("主动触发依赖事件, eventType={}, bizKey={}, logId={}",
dependentEventType, dependentBizKey, targetLogId);
// 事务提交后投递 MQ(保证当前事务数据对消费者可见)
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
eventBMqSender.send(targetLogId);
}
});
}
/**
* 从事件 A 的业务键推导出事件 B 的业务键.
* 两个事件可能用不同的标识,需要通过数据关联。
*/
private String resolveDependentBizKey(String eventABizKey) {
// 示例:事件A用订单号,事件B用子单号+后缀
// 通过关联表查出子单号
SubOrder subOrder = subOrderRepository.findByOrderNo(eventABizKey);
if (subOrder == null || subOrder.getSubOrderNo() == null) {
return null;
}
return subOrder.getSubOrderNo() + "D";
}
}
4.3 模式 3 实现:聚合等待
java
@Service
@Slf4j
public class AggregationService {
@Resource
private EventFragmentRepository fragmentRepository;
@Resource
private AggregatedProcessor aggregatedProcessor;
/**
* 接收事件片段.
* 每个片段到达时检查是否所有片段都已到齐,到齐则触发处理。
*/
@Transactional(rollbackFor = Exception.class)
public void receiveFragment(String aggregateKey, String fragmentType, Object payload) {
// 1. 存储片段
EventFragment fragment = new EventFragment();
fragment.setAggregateKey(aggregateKey);
fragment.setFragmentType(fragmentType);
fragment.setPayload(JsonUtil.toJson(payload));
fragment.setArrivedTime(new Date());
fragmentRepository.saveAndFlush(fragment);
// 2. 检查是否所有必需片段都已到齐
Set<String> requiredTypes = getRequiredFragmentTypes(aggregateKey);
Set<String> arrivedTypes = fragmentRepository.findArrivedTypes(aggregateKey);
if (arrivedTypes.containsAll(requiredTypes)) {
log.info("所有片段已到齐, aggregateKey={}", aggregateKey);
// 3. 聚合处理
List<EventFragment> allFragments = fragmentRepository
.findByAggregateKey(aggregateKey);
aggregatedProcessor.processAll(aggregateKey, allFragments);
} else {
Set<String> missing = new HashSet<>(requiredTypes);
missing.removeAll(arrivedTypes);
log.info("等待片段到达, aggregateKey={}, missing={}", aggregateKey, missing);
}
}
private Set<String> getRequiredFragmentTypes(String aggregateKey) {
// 根据业务规则确定需要哪些片段
return Sets.newHashSet("ORDER_INFO", "BARCODE_INFO", "LOGISTICS_INFO");
}
}
4.4 模式 4 实现:延迟消费
java
@Service
@Slf4j
public class DelayedEventService {
@Resource
private DelayedMqSender delayedMqSender;
/**
* 接收依赖方事件,延迟 N 秒后再消费.
* 给被依赖方留出到达时间。
*/
@Transactional(rollbackFor = Exception.class)
public void handleWithDelay(String bizKey, Object payload, int delaySeconds) {
Long logId = saveLog("DELAYED_EVENT", bizKey, payload);
if (logId == null) return;
final Long finalLogId = logId;
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 投递到延迟队列
delayedMqSender.sendWithDelay(finalLogId, delaySeconds);
}
});
}
}
@Component
@Slf4j
public class DelayedMqSender {
@Resource
private RabbitTemplate rabbitTemplate;
/**
* 投递延迟消息.
* RabbitMQ 通过 x-delayed-message 插件或 TTL + 死信队列实现。
*/
public void sendWithDelay(Long logId, int delaySeconds) {
rabbitTemplate.convertAndSend("delayed-exchange", "event.delayed", logId,
message -> {
message.getMessageProperties().setDelay(delaySeconds * 1000);
return message;
});
}
}
五、业务键关联策略
两个独立接口通常使用不同的业务标识,需要通过某种方式建立关联:
5.1 直接关联
两个接口入参中有共同字段:
java
// 接口 A 入参:orderId = "ORDER_001"
// 接口 B 入参:orderId = "ORDER_001"
// 直接用 orderId 关联
5.2 间接关联(通过中间表)
java
// 接口 A 入参:deliveryCode = "SO.001"
// 接口 B 入参:subOrderNo = "8900001D"
// 关联方式:
// delivery_master.id → sub_table.delivery_id (取 sub_order_no)
// sub_order_no + "D" = 接口 B 的 biz_key
private String resolveBizKey(String deliveryCode) {
SubTable sub = subTableRepo.findByDeliveryCode(deliveryCode);
if (sub == null || sub.getSubOrderNo() == null) {
return null; // 非该业务场景
}
return sub.getSubOrderNo() + "D";
}
5.3 规则推导
java
// 接口 A 入参:orderNo = "8900001"
// 接口 B 入参:bizKey = "8900001D"(固定后缀 D)
// 关联方式:字符串拼接规则
private String deriveBizKey(String orderNo) {
return orderNo + "D";
}
六、并发安全设计
时序不确定还带来并发问题:两个接口可能几乎同时到达。
6.1 分布式锁隔离
java
@Component
@Slf4j
public class ConcurrencySafeConsumer {
@Resource
private DistributedLockProvider lockProvider;
public void consumeWithLock(String bizKey, Long logId) {
// 同一业务键的两个事件用同一把锁
String lockKey = "event:process:" + bizKey;
DistributedLock lock = lockProvider.getLock(lockKey, 60, TimeUnit.SECONDS);
if (!lock.tryLock(30, TimeUnit.SECONDS)) {
log.warn("获取锁失败, bizKey={}", bizKey);
return;
}
try {
doProcess(logId);
} finally {
lock.unlock();
}
}
}
6.2 不同锁粒度避免互斥
如果两个事件用不同的锁,需要确保不会死锁:
java
// 事件 A 的锁:按操作维度
String lockKeyA = "order:create:" + memberId;
// 事件 B 的锁:按单据维度
String lockKeyB = "barcode:process:" + subOrderNo;
// 两把锁互不影响,A 完成后触发 B 时不会被 B 的锁阻塞
// 因为触发动作是"投递 MQ"而非"直接调用 B 的逻辑"
6.3 事务提交后才发消息
java
// A 的事务:创建订单 → 提交 → 释放锁 → 发送触发 B 的 MQ
// B 的消费:收到 MQ → 获取 B 的锁 → 查到 A 的数据(已提交可见)→ 处理
// 时序保证:
// 1. A 的数据已提交 → B 能查到
// 2. A 的锁已释放 → B 获取自己的锁不会被阻塞
// 3. MQ 在提交后才发 → 不存在数据未提交就消费的问题
七、多级保障组合
生产环境通常组合使用多种策略:
java
@Service
@Slf4j
public class RobustEventHandler {
/**
* 完整的时序不确定性处理流程.
*
* 第一级:B先到,尝试处理
* → 依赖不满足,标记P
*
* 第二级:A后到,主动触发B(秒级)
* → 立即投递MQ,B重新消费
*
* 第三级:定时任务兜底(分钟级)
* → 扫描P状态记录重新投递
*
* 幂等保证:无论哪一级触发,多次执行结果一致
*/
// 事件 A 处理完成
@Transactional(rollbackFor = Exception.class)
public void onEventACompleted(String bizKey) {
// 核心逻辑...
doBusinessLogic(bizKey);
// 【第二级】主动触发
triggerDependentEvent(bizKey);
}
// 事件 B MQ 消费
public void onEventBConsumed(Long logId) {
try {
process(logId); // 可能因 A 未到而失败
} catch (DependencyNotReadyException e) {
markFailed(logId, e); // 【第一级】标记失败,等待触发
}
}
// 【第三级】定时补偿
@Scheduled(cron = "0 */5 * * * ?")
public void compensate() {
findFailedLogs().forEach(log -> mqSender.send(log.getId()));
}
}
八、适用场景与选型建议
| 场景特征 | 推荐模式 | 理由 |
|---|---|---|
| 时效性要求高(秒级) | 后到方主动触发 | 最快响应,不等调度周期 |
| 时效性一般(分钟级可接受) | 失败重试 + 定时补偿 | 实现简单,改造量小 |
| 两接口时间差极小(<5秒) | 延迟消费 | 固定延迟覆盖大多数情况 |
| 多方数据缺一不可 | 聚合等待 | 数据完整性优先 |
| 外部系统不可控(可能长时间不回调) | 主动触发 + 定时 + 人工 | 多级保障 |
九、注意事项
- 关联查询要轻量:主动触发时的反查操作应走索引,避免全表扫描
- 触发失败不能影响主流程 :MQ 发送放在
afterCommit中,且 sender 内部 try-catch,不回滚主事务 - 避免循环触发:A 触发 B,B 不应反过来触发 A(通过事件类型区分)
- 状态过滤 :只触发
O/P状态的记录,Y状态不重复触发 - 监控延迟分布:统计从"B 先到标记 P"到"A 触发 B 成功变 Y"的时间差,验证优化效果
- 降级方案:主动触发机制异常时,定时任务仍能兜底,不会造成数据永久不一致