PAYATHON 2026

如何在 service bus 架构中关联不同系统的实体 ID?

支付阿杰

明确结论

实体 ID 可以映射,但不应由 Service Bus 在传输过程中把一个系统的 ID 翻译成另一个系统的 ID。更合适的设计是:

  1. 为跨系统业务实体分配稳定的集成 ID,例如 OrderId。
  2. 所有相关事件都携带这个集成 ID。
  3. 各系统保留自己的本地数据库主键,并在本地维护“集成 ID → 本地主键”的关联。
  4. 分别使用 MessageId、CorrelationId 和业务实体 ID,不要混用。
  5. 对于无法改造的遗留系统,通过映射表或专门的防腐层处理旧 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)
);

一组数据可能如下:

CanonicalIdEntityTypeSystemNameExternalId
8d1d...OrderPortal10482
8d1d...OrderBillingB-7791
8d1d...OrderInventory500031

采用中央映射服务后,还必须明确以下问题:

  • 哪个系统有权创建映射;
  • 如何解决冲突;
  • 如何处理实体合并或拆分;
  • 是否允许删除映射;
  • 查询服务不可用时,消息能否继续处理;
  • 如何审计和修复映射数据;
  • 它是否会成为所有消息处理共同依赖的单点瓶颈。

不要让 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 避免数据库和消息不一致

以下两个操作并非天然的原子事务:

  1. 保存订单;
  2. 向消息代理发布 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 的语义、所有权及映射规则则属于业务集成设计。消息应携带各系统都能理解的实体标识,而不是要求消费者解释另一个系统的数据库主键。

备注:内容仅供参考。