如何在 service bus 架构中关联不同系统的实体 ID?
明确结论
实体 ID 可以映射,但不应由 Service Bus 在传输过程中把一个系统的 ID 翻译成另一个系统的 ID。更合适的设计是:
- 为跨系统业务实体分配稳定的集成 ID,例如
OrderId。 - 所有相关事件都携带这个集成 ID。
- 各系统保留自己的本地数据库主键,并在本地维护“集成 ID → 本地主键”的关联。
- 分别使用
MessageId、CorrelationId和业务实体 ID,不要混用。 - 对于无法改造的遗留系统,通过映射表或专门的防腐层处理旧 ID。
各数据库不必使用相同的主键,但所有系统都需要一个共同且稳定的标识,用来判断记录是否属于同一笔订单。
为什么不能只传本地 ID
假设 Web Portal 创建订单时使用本地主键 10482,Billing 创建对应记录后得到 B-7791,Inventory 中的主键则是 500031。
如果 Billing 发布以下消息:
{
"orderId": "B-7791",
"status": "Paid"
}
Web Portal 无法判断 B-7791 对应自己的 10482。随着系统数量增加,点对点映射也会迅速膨胀:
Portal ID ↔ Billing ID
Portal ID ↔ Inventory ID
Billing ID ↔ Inventory ID
...
这样会重新引入系统间的耦合。Service Bus 解除了通信层面的直接依赖,实体标识却仍然形成了网状依赖。
更合适的消息是:
{
"orderId": "01J7NQAXT6G6Y9DWQH2KPE2R8M",
"billingOrderId": "B-7791",
"status": "Paid"
}
其中:
orderId是跨系统稳定的业务实体标识。billingOrderId是 Billing 的本地标识,只在确实需要诊断、审计或后续调用时携带。- Web Portal 根据
orderId查找自己的本地订单,不需要依赖 Billing 的主键。
推荐的标识模型
至少需要区分以下几类 ID。
业务实体 ID
业务实体 ID 用于识别订单、客户、商品等业务实体,例如:
public Guid OrderId { get; init; }
它应在实体首次进入集成流程时生成,并在整个生命周期内保持不变。
本地数据库主键
本地主键只属于特定系统的持久化实现:
public long Id { get; set; }
public Guid IntegrationOrderId { get; set; }
Id 可以是自增整数,IntegrationOrderId 用于消费和发布集成事件。建议为 IntegrationOrderId 建立唯一索引。
MessageId
MessageId 标识一条具体消息。订单状态每变更一次,通常都会产生新的 MessageId。它适合用于检测重复消息,不能作为订单 ID。
CorrelationId
CorrelationId 标识一段业务流程或会话,例如“一次下单流程”或“一次订单取消流程”。同一笔订单可能参与多段流程,所以 CorrelationId 不一定等于 OrderId。
在简单场景中,两者可以暂时使用相同的值,但消息契约仍应区分它们的语义。否则以后引入退款、拆单或补货等流程时,很难继续扩展。
CausationId
CausationId 表示当前消息由哪条消息触发。它适合构建消息因果链和排查问题,同样不能代替实体 ID。
建议的实现步骤
1. 在创建入口生成集成 ID
最清楚“一个新订单已经产生”的系统,应在创建订单时生成 OrderId。通常由接收下单请求的 Web Portal 或订单服务负责。
var orderId = Guid.NewGuid();
var order = new PortalOrder
{
Id = localDatabaseId,
IntegrationOrderId = orderId,
Status = "Submitted"
};
如果有多个入口可以创建订单,就需要制定统一规则。可以使用 UUID、ULID,也可以由专门的订单服务生成 ID。具体采用哪种算法并不重要,重要的是这个 ID 必须唯一、不可变,而且不能由各系统重新生成。
2. 在集成事件中携带该 ID
public record OrderSubmitted(
Guid OrderId,
string CustomerNumber,
decimal Total,
DateTimeOffset SubmittedAt);
Billing 和 Inventory 消费事件后,各自保存本地记录:
public class BillingOrder
{
public long Id { get; set; }
public Guid IntegrationOrderId { get; set; }
public string Status { get; set; } = string.Empty;
}
数据库应为 IntegrationOrderId 建立唯一约束。它既能关联记录,也有助于处理重复投递。
3. 后续事件继续使用同一个实体 ID
public record OrderPaymentCompleted(
Guid OrderId,
string BillingReference,
DateTimeOffset CompletedAt);
Billing 发布状态事件时,不需要知道 Web Portal 的主键。Portal 收到事件后,可以直接通过集成 ID 查询订单:
public sealed class OrderPaymentCompletedConsumer
: IConsumer<OrderPaymentCompleted>
{
private readonly PortalDbContext _dbContext;
public OrderPaymentCompletedConsumer(PortalDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task Consume(
ConsumeContext<OrderPaymentCompleted> context)
{
var order = await _dbContext.Orders.SingleOrDefaultAsync(
x => x.IntegrationOrderId == context.Message.OrderId,
context.CancellationToken);
if (order is null)
{
throw new InvalidOperationException(
$"Order {context.Message.OrderId} was not found.");
}
order.PaymentStatus = "Completed";
order.BillingReference = context.Message.BillingReference;
await _dbContext.SaveChangesAsync(context.CancellationToken);
}
}
在实际项目中,“找不到订单”未必应该立即视为永久失败,因为消息可能乱序到达。例如,状态事件可能早于订单创建事件。可以根据业务要求选择重试、延迟处理,或者暂存暂时无法关联的消息。
必须兼容遗留 ID 时如何映射
如果内部系统无法保存外部 ID,或者接口只能返回本地 ID,映射机制仍然有必要。映射最好放在靠近遗留系统的适配器或防腐层中:
Integration OrderId → Legacy System OrderId
例如:
CREATE TABLE LegacyOrderMapping
(
IntegrationOrderId UNIQUEIDENTIFIER NOT NULL,
LegacyOrderId VARCHAR(64) NOT NULL,
CreatedAt DATETIME2 NOT NULL,
CONSTRAINT PK_LegacyOrderMapping
PRIMARY KEY (IntegrationOrderId),
CONSTRAINT UQ_LegacyOrderMapping_LegacyOrderId
UNIQUE (LegacyOrderId)
);
适配器接收标准事件,通过 IntegrationOrderId 查出遗留 ID,再调用遗留系统。遗留系统返回状态后,适配器进行反向查询,并重新发布带有标准 OrderId 的事件。
这种设计比在总线上建立全局“万能 ID 翻译服务”更容易维护,因为映射规则仍由最了解遗留系统的一侧管理。
如果确实需要中央映射库
中央映射库并非一定不合理。以下情况可能需要中央映射:
- 多个既有系统都无法保存外部 ID。
- 同一实体已经存在于不同系统中,需要匹配历史数据。
- 实体有合并、拆分、去重或主数据治理要求。
- 需要查询某个实体在所有系统中的标识。
- 企业已经在建设 MDM、客户主索引或 360° 视图。
此时不宜只设计一个包含两个字段的点对点映射表,更适合采用“规范实体 ID + 系统别名”的结构:
CREATE TABLE EntityIdentity
(
CanonicalId UNIQUEIDENTIFIER NOT NULL,
EntityType VARCHAR(50) NOT NULL,
SystemName VARCHAR(50) NOT NULL,
ExternalId VARCHAR(128) NOT NULL,
CONSTRAINT PK_EntityIdentity
PRIMARY KEY (CanonicalId, SystemName),
CONSTRAINT UQ_EntityIdentity_External
UNIQUE (EntityType, SystemName, ExternalId)
);
一组数据可能如下:
| CanonicalId | EntityType | SystemName | ExternalId |
|---|---|---|---|
8d1d... | Order | Portal | 10482 |
8d1d... | Order | Billing | B-7791 |
8d1d... | Order | Inventory | 500031 |
采用中央映射服务后,还必须明确以下问题:
- 哪个系统有权创建映射;
- 如何解决冲突;
- 如何处理实体合并或拆分;
- 是否允许删除映射;
- 查询服务不可用时,消息能否继续处理;
- 如何审计和修复映射数据;
- 它是否会成为所有消息处理共同依赖的单点瓶颈。
不要让 Service Bus 中间件隐式修改消息 ID。ID 转换应发生在明确的应用服务或适配器中,并且能够被测试、审计和重放。
MassTransit 中的处理方式
MassTransit 提供消息传输、消费、重试、Saga 和 Outbox 等集成能力,但它不会自动判断两个数据库记录是否代表同一个业务实体。实体关联仍需要明确写入消息契约和应用数据模型。
如果消息需要参与流程关联,可以实现 CorrelatedBy<Guid>:
using MassTransit;
public record OrderSubmitted(
Guid CorrelationId,
Guid OrderId,
decimal Total)
: CorrelatedBy<Guid>;
这两个字段需要分别处理:
var message = new OrderSubmitted(
CorrelationId: checkoutProcessId,
OrderId: orderId,
Total: total);
CorrelationId用于 MassTransit 的流程或 Saga 关联。OrderId用于定位业务实体。- 即使两者当前使用相同的值,业务代码也不应假定它们永远相同。
如果一个 Saga 的生命周期严格对应一笔订单,也可以使用 OrderId 关联 Saga。具体配置 API 可能因 MassTransit 版本和持久化组件而异,但原则相同:明确声明事件中的哪个字段用于关联,不要让消费者猜测本地数据库 ID。
一致性与可靠性问题
使用 Outbox 避免数据库和消息不一致
以下两个操作并非天然的原子事务:
- 保存订单;
- 向消息代理发布
OrderSubmitted。
如果数据库提交成功而消息发布失败,其他系统将无法收到订单。反过来,也可能出现消息已经发布,但本地事务最终回滚的情况。
应使用 Transactional Outbox,在同一个数据库事务中保存业务数据和待发布消息,再由后台过程将消息投递到总线。MassTransit 支持 Outbox,但具体配置取决于所用版本、数据库和传输组件,应参考对应版本的官方文档。
消费者必须幂等
消息系统通常采用“至少一次”投递语义,因此消费者可能收到重复消息。可以采用以下处理方式:
- 为
IntegrationOrderId建立唯一索引; - 保存已经处理过的
MessageId; - 对状态变更执行幂等更新;
- 使用 Inbox 或 MassTransit 的 Consumer Outbox;
- 不要把“重复插入异常”作为处理正常业务流程的唯一手段。
不要依赖消息顺序
即使同一端点通常按顺序消费,也不能假定消息经过不同队列、重试和并发处理后仍然保持全局顺序。事件最好携带版本号或发生时间:
public record OrderStatusChanged(
Guid OrderId,
long Version,
string Status,
DateTimeOffset OccurredAt);
消费者只接受版本高于当前记录的状态,可以降低旧消息覆盖新状态的风险。
消息契约的设计建议
事件应表达已经发生的业务事实:
public record OrderAccepted(
Guid OrderId,
string OrderNumber,
DateTimeOffset AcceptedAt);
不要发布与某个数据库结构直接绑定的对象:
// 不建议直接把 ORM entity 当消息契约
public class OrderEntity
{
public long Id { get; set; }
public byte[] RowVersion { get; set; }
public ICollection<OrderLineEntity> Lines { get; set; }
}
消息契约应包含消费者理解该事件所需的稳定信息,而不是照搬发布方的表结构。对于人类可读的订单号,也需要明确它与技术标识之间的区别:
OrderId:稳定且唯一的技术标识。OrderNumber:用于展示或业务查询,其生成规则可能变化。LocalId:只在某个系统内部使用,通常不应成为公共契约的主标识。
最终建议
新建集成应优先采用“全局稳定的业务实体 ID + 各系统本地主键”的方式。集成 ID 在实体首次创建时生成,此后通过所有相关事件持续传递。
对于无法保存该 ID 的遗留系统,可以在系统边界建立局部映射表和适配器。只有当身份映射已经成为跨企业的主数据能力时,才有必要考虑中央身份映射服务。
MassTransit 可以负责消息投递、流程关联、重试和 Outbox,实体 ID 的语义、所有权及映射规则则属于业务集成设计。消息应携带各系统都能理解的实体标识,而不是要求消费者解释另一个系统的数据库主键。
备注:内容仅供参考。
