异步链路中的数据透传方案
问题场景
在异步处理链路中,某些原始信息在中间环节会被"有损转换"(如字符串转数字丢失前导零、精度截断、格式归一化等),但下游节点需要使用原始值。如何在不修改中间存储结构的前提下,将原始数据完整传递到下游?
典型链路:
HTTP请求(原始数据) → 写入DB(有损) → 发MQ → 异步消费 → 下游需要原始数据
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
方案一:MQ 消息体附带额外字段
思路:在发送 MQ 消息时,将原始数据作为额外字段塞进消息体。
适用场景:发消息的节点就是持有原始数据的节点,且消费端直接从消息体取值。
局限:如果消费端不是从消息体取值,而是从 DB 重新查询对象,附带字段就丢了。
java
// === 消息体定义 ===
@Data
public class OrderMessage implements Serializable {
private Long orderId;
private Integer userId; // DB存的是int,有损
private String userIdRaw; // 附带原始字符串,无损
}
// === 生产者 ===
@Service
public class OrderProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendOrderMessage(Order order, String rawUserId) {
OrderMessage msg = new OrderMessage();
msg.setOrderId(order.getId());
msg.setUserId(order.getUserId());
msg.setUserIdRaw(rawUserId); // 携带原始值
rocketMQTemplate.convertAndSend("order-topic", msg);
}
}
// === 消费者 ===
@Component
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-consumer-group")
public class OrderConsumer implements RocketMQListener<OrderMessage> {
@Override
public void onMessage(OrderMessage msg) {
// 优先使用原始值
String userId = msg.getUserIdRaw() != null
? msg.getUserIdRaw()
: String.valueOf(msg.getUserId());
// 使用 userId 进行后续处理...
}
}
缺点:如果链路中间有 DB 回查重建对象的环节(如 confirm 时从 DB 查出再发 MQ),附带字段在 DB 中不存在,重建后为 null。
方案二:Redis 旁路缓存
思路:在持有原始数据时写入 Redis(key 关联业务主键),下游需要时从 Redis 读取。
适用场景:链路中有 DB 回查重建对象的环节,MQ 消息体无法透传;数据有明确的生命周期。
java
// === 写入端(创建任务时) ===
@Service
public class TaskService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String RAW_DATA_PREFIX = "task:raw_data:";
private static final long EXPIRE_HOURS = 48;
@Transactional
public TaskDTO createTask(String rawCode, String name) {
Task task = new Task();
task.setCode(Integer.valueOf(rawCode)); // 有损:007 → 7
task.setName(name);
taskMapper.insert(task);
// 旁路缓存原始值
redisTemplate.opsForValue().set(
RAW_DATA_PREFIX + task.getId(),
rawCode,
EXPIRE_HOURS, TimeUnit.HOURS
);
return toDTO(task);
}
}
// === 读取端(异步消费时) ===
@Component
public class TaskProcessor {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private TaskMapper taskMapper;
private static final String RAW_DATA_PREFIX = "task:raw_data:";
public void process(Long taskId) {
// 优先从Redis取原始值
String rawCode = redisTemplate.opsForValue().get(RAW_DATA_PREFIX + taskId);
if (rawCode == null || rawCode.isEmpty()) {
// 兜底:从DB取(有损值)
Task task = taskMapper.selectById(taskId);
rawCode = String.valueOf(task.getCode());
}
// rawCode 在Redis命中时保留了前导零
downstream.save(rawCode);
}
}
注意事项:
- TTL 必须覆盖业务完整生命周期(从创建到消费完成)
- Redis 宕机时有兜底策略(降级为 DB 有损值)
- Key 命名需要有环境前缀避免多环境冲突
方案三:数据库扩展字段
思路:在现有表中新增一个 varchar 字段专门存储原始值,或使用 JSON 扩展字段。
适用场景:需要持久化、可查询、无过期问题。
3.1 新增独立字段
sql
-- 不改原字段类型,新增一列存原始值
ALTER TABLE task ADD COLUMN code_raw VARCHAR(32) DEFAULT NULL COMMENT '原始编码(保留格式)';
@Data
@TableName("task")
public class Task {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("code")
private Integer code; // 有损存储
@TableField("code_raw")
private String codeRaw; // 无损存储
}
// 写入
task.setCode(Integer.valueOf(rawCode));
task.setCodeRaw(rawCode);
taskMapper.insert(task);
// 读取(任何环节都能取到原始值)
String originalCode = task.getCodeRaw();
3.2 JSON 扩展字段
sql
ALTER TABLE task ADD COLUMN ext_data JSON DEFAULT NULL COMMENT '扩展数据';
@Data
@TableName("task")
public class Task {
@TableId(type = IdType.AUTO)
private Long id;
private Integer code;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> extData;
}
// 写入
Map<String, Object> ext = new HashMap<>();
ext.put("codeRaw", rawCode);
ext.put("source", "API");
task.setExtData(ext);
// 读取
String originalCode = (String) task.getExtData().get("codeRaw");
优点 :持久可靠,不依赖 Redis,任何环节回查都能拿到。
缺点:需要 DDL 变更;JSON 字段查询性能差。
方案四:链路上下文传递(Context Propagation)
思路:通过线程上下文或调用链上下文传递,类似 MDC、TraceId 的方式。
适用场景:同步调用链内的多层透传;结合线程池需要额外处理。
java
// === 上下文持有者 ===
public class ImportContext {
private static final ThreadLocal<Map<String, String>> CONTEXT =
ThreadLocal.withInitial(HashMap::new);
public static void set(String key, String value) {
CONTEXT.get().put(key, value);
}
public static String get(String key) {
return CONTEXT.get().get(key);
}
public static void clear() {
CONTEXT.remove();
}
}
// === 入口设置 ===
@RestController
public class TaskController {
@PostMapping("/create")
public Result create(@RequestParam String userId, @RequestParam MultipartFile file) {
try {
ImportContext.set("rawUserId", userId);
return taskService.createAndProcess(userId, file);
} finally {
ImportContext.clear(); // 必须清理
}
}
}
// === 下游读取 ===
@Component
public class BatchCreator {
public void createBatch(Long taskId) {
String rawUserId = ImportContext.get("rawUserId");
batch.setUserId(rawUserId != null ? rawUserId : fallbackFromDB(taskId));
batchMapper.insert(batch);
}
}
致命局限:ThreadLocal 无法跨线程。如果中间经过 MQ(异步线程消费),上下文就断了。
跨线程改进:结合 TransmittableThreadLocal(阿里 TTL 库)或手动序列化到 MQ Header:
java
// === 通过 MQ Message Properties 透传 ===
public void send(Task task, String rawUserId) {
Message message = new Message("topic", "tag", JsonUtil.toBytes(task));
message.putUserProperty("rawUserId", rawUserId); // 塞到消息属性
producer.send(message);
}
// === 消费端从 Properties 取 ===
public void consume(MessageExt messageExt) {
String rawUserId = messageExt.getUserProperty("rawUserId");
Task task = JsonUtil.fromBytes(messageExt.getBody(), Task.class);
// rawUserId 完整保留
}
方案对比
| 维度 | MQ消息体附带 | Redis旁路缓存 | DB扩展字段 | 链路上下文 |
|---|---|---|---|---|
| 是否需要改表 | ✗ | ✗ | ✓ | ✗ |
| 持久可靠性 | 中(消费即丢) | 中(有TTL) | 高 | 低(线程级) |
| 跨MQ透传 | ✓(消息体自带) | ✓(按key读) | ✓(回查可得) | 需借助Header |
| 回查场景适用 | ✗(回查拿不到) | ✓ | ✓ | ✗ |
| 额外依赖 | 无 | Redis | 无 | 无 |
| 实现复杂度 | 低 | 低 | 低 | 中 |
选型决策树
原始数据需要在异步链路中透传?
│
├─ 消费端直接从MQ消息体取值?
│ └─ YES → 方案一(MQ消息体附带)
│
├─ 消费端会从DB回查重建对象?
│ ├─ 能改表结构?
│ │ └─ YES → 方案三(DB扩展字段,最可靠)
│ └─ 不能改表?
│ └─ 方案二(Redis旁路缓存)
│
└─ 全程同步调用链(无MQ)?
└─ 方案四(ThreadLocal上下文)
总结
数据透传的本质是在有损节点旁边开辟一条无损通道。选择哪种方案取决于:
- 中间是否有 DB 回查重建(决定 MQ 消息体方案是否可行)
- 是否能修改数据库结构(决定是否用 DB 扩展字段)
- 数据的生命周期和可靠性要求(决定 Redis TTL 是否够用)
- 链路是同步还是异步(决定 ThreadLocal 是否可行)