3. Outbox 表结构设计:标准字段定义与索引策略

Outbox 表是分布式事务中“发件箱模式”的核心载体,其结构设计直接决定了数据写入、状态轮询、清理归档以及下游消费的性能。一个标准且经过压测验证的 Outbox 表,需要在字段定义上兼顾业务语义与存储效率,在索引策略上平衡写入吞吐与查询速度。

3.1 标准 Outbox 表字段定义

以下字段定义基于业界通用实践(如 Transactional Outbox 模式参考实现),并针对高并发压测场景进行了优化调整。每个字段的选择都对应特定的压测关注点。

字段名 类型(MySQL/PostgreSQL) 约束 压测关注点
id BIGINT UNSIGNED / BIGSERIAL PRIMARY KEY 自增主键保证写入顺序,避免页分裂;压测中关注自增锁竞争
aggregate_id VARCHAR(64) / UUID NOT NULL, INDEX 业务聚合根标识,用于去重与幂等;压测中关注 UUID 随机性对索引插入性能的影响
event_type VARCHAR(128) NOT NULL, INDEX 事件类型(如 OrderCreated);压测中关注枚举值分布对索引选择性的影响
payload JSON / JSONB / TEXT NOT NULL 事件体;压测中关注大 payload 对 binlog 复制延迟和磁盘 IO 的影响
created_at DATETIME(3) / TIMESTAMPTZ NOT NULL, INDEX 记录创建时间;压测中关注时间范围查询的索引下推效率
status TINYINT / SMALLINT NOT NULL, DEFAULT 0, INDEX 状态枚举(0=待发送, 1=已发送, 2=发送失败);压测中关注状态过滤的索引选择性
version INT UNSIGNED NOT NULL, DEFAULT 1 乐观锁版本号;压测中关注 CAS 更新时的冲突率与重试开销
trace_id VARCHAR(64) NULLABLE, INDEX 全链路追踪 ID;压测中关注可选字段对存储碎片的影响
压测建议:在压测准备阶段,建议将 payload 字段的测试数据大小设置为 1KB、4KB、16KB 三档,分别模拟轻量事件、中等事件和富事件场景,以观察不同负载下的磁盘吞吐与网络传输瓶颈。

3.2 索引策略:写入与查询的平衡艺术

Outbox 表的索引设计需要在“快速写入”与“高效轮询”之间找到平衡点。过多的索引会拖慢 INSERT 性能,过少的索引则会导致轮询查询变成全表扫描。以下索引策略经过压测验证,适用于大多数业务场景。

3.2.1 核心索引组合

  1. 主键索引(id):自增 BIGINT,保证写入顺序,减少页分裂。压测中建议关闭自增锁的 innodb_autoinc_lock_mode=2(交错模式)以提升并发写入吞吐。
  2. 状态+时间联合索引(status, created_at):这是轮询查询的核心索引。压测中,该索引用于快速定位“待发送”且“最早创建”的记录,避免扫描大量已发送数据。
  3. 聚合ID索引(aggregate_id):用于幂等性检查和去重查询。如果业务中大量使用幂等写入,该索引的命中率会很高。

3.2.2 索引策略对比表

索引类型 索引列 压测写入吞吐影响 压测轮询延迟影响 适用场景
单列索引 status 低(1个B+树维护) 中(需回表过滤时间) 低并发、小数据量
联合索引 (status, created_at) 中(2列B+树维护) 低(索引内完成排序过滤) 高并发、大数据量(推荐)
覆盖索引 (status, created_at, id) 中高(3列维护) 极低(无需回表) 极高并发轮询、对延迟极度敏感
函数索引 (DATE(created_at)) 低(函数计算开销) 低(按天分区查询) 按天清理归档场景
压测避坑:不要在 payload 字段上建立索引。JSON 或 TEXT 类型的索引不仅占用大量空间,还会严重拖慢写入性能。如果需要对 payload 内部字段进行过滤,请考虑使用 MySQL 8.0+ 的 JSON 多值索引或 PostgreSQL 的 GIN 索引,但务必在压测中验证其写入开销。

3.3 Outbox 表索引结构示意图

下图展示了 Outbox 表的核心索引结构及其在压测中的查询路径。联合索引 (status, created_at) 是轮询查询的关键,它允许数据库直接定位到最早待发送的记录,而无需扫描全表。

Outbox 表索引结构与查询路径 Outbox 表索引结构与压测查询路径 Outbox 表 id (PK, BIGINT) aggregate_id (VARCHAR) event_type (VARCHAR) payload (JSON/TEXT) created_at (DATETIME) status (TINYINT) version (INT) trace_id (VARCHAR) 主键索引 (PRIMARY) 索引列: id 结构: B+Tree (聚簇) 压测关注: 自增锁竞争 联合索引 (idx_status_time) 索引列: status, created_at 结构: B+Tree (二级) 压测关注: 索引下推效率 轮询查询核心路径 聚合ID索引 (idx_aggregate) 压测关注: 幂等写入冲突 聚簇 二级索引 二级索引 压测轮询查询 SELECT * FROM outbox WHERE status=0 ORDER BY created_at ASC LIMIT 100

3.4 压测场景下的索引优化建议

  • 避免冗余索引:不要单独为 status 和 created_at 分别建立单列索引,联合索引已经覆盖了这两种查询模式。冗余索引会增加写入时的索引维护开销。
  • 监控索引选择性:在压测中,通过 SHOW INDEX FROM outbox 观察 Cardinality 值。如果 status 字段的区分度很低(例如 90% 的数据都是“待发送”),联合索引的效果会下降,此时可以考虑引入 shard_id 或 partition_key 作为索引前缀。
  • 考虑索引覆盖:如果轮询查询只需要返回 id、aggregate_id 和 payload,可以将联合索引扩展为覆盖索引 (status, created_at, id, aggregate_id, payload),避免回表操作。但需在压测中验证索引体积膨胀对写入性能的影响。
  • 分区表与索引协同:对于日增量超过千万级的压测场景,建议使用 RANGE 分区(按 created_at 按月或按周分区),配合局部索引。压测中需关注分区裁剪是否生效,以及跨分区查询的性能退化。
压测关键指标:在 10 万 TPS 写入并发下,联合索引 (status, created_at) 的 B+Tree 深度应控制在 3 层以内(约 2000 万条数据以内)。如果深度超过 4 层,轮询查询的延迟会显著上升,此时应考虑分区或分表策略。

3.5 字段与索引的 DDL 示例

以下 DDL 语句展示了标准 Outbox 表的完整定义,包含推荐的索引策略。该 DDL 已在压测环境中验证,适用于 MySQL 8.0+ 和 PostgreSQL 15+。

-- MySQL 8.0+ 标准 Outbox 表 DDL
CREATE TABLE outbox (
    id          BIGINT UNSIGNED AUTO_INCREMENT,
    aggregate_id VARCHAR(64)     NOT NULL COMMENT '聚合根ID',
    event_type  VARCHAR(128)     NOT NULL COMMENT '事件类型',
    payload     JSON             NOT NULL COMMENT '事件体',
    created_at  DATETIME(3)      NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
    status      TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '状态:0=待发送,1=已发送,2=失败',
    version     INT UNSIGNED     NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
    trace_id    VARCHAR(64)      DEFAULT NULL COMMENT '追踪ID',
    PRIMARY KEY (id),
    INDEX idx_status_time (status, created_at),
    INDEX idx_aggregate_id (aggregate_id),
    INDEX idx_trace_id (trace_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  COMMENT='分布式事务Outbox表';

-- PostgreSQL 15+ 标准 Outbox 表 DDL
CREATE TABLE outbox (
    id          BIGSERIAL,
    aggregate_id VARCHAR(64)     NOT NULL,
    event_type  VARCHAR(128)     NOT NULL,
    payload     JSONB            NOT NULL,
    created_at  TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    status      SMALLINT         NOT NULL DEFAULT 0,
    version     INT              NOT NULL DEFAULT 1,
    trace_id    VARCHAR(64)      DEFAULT NULL,
    PRIMARY KEY (id)
);

CREATE INDEX idx_outbox_status_time ON outbox (status, created_at);
CREATE INDEX idx_outbox_aggregate_id ON outbox (aggregate_id);
CREATE INDEX idx_outbox_trace_id ON outbox (trace_id);
压测准备:在压测开始前,建议使用 ALTER TABLE outbox ENGINE=InnoDB; 重建表以消除碎片,并使用 ANALYZE TABLE outbox; 更新统计信息,确保优化器能选择正确的索引。