很多团队在做交易系统升级时,第一反应是“数据库扛不住了,是不是该把 MySQL 换成 NoSQL?”这个问题本身就带着误区。真正的瓶颈往往不是 MySQL 不行,而是我们让同一套存储同时承担了事务写入、模糊检索、实时聚合、日志追加、风险扫描、运营报表等完全不同的负载。
对高并发系统来说,正确答案通常不是“替换”,而是“分工”。
本文不讲概念堆砌,而是围绕一个真实的高并发交易场景,系统讲清楚四件事:
- • 为什么 MySQL 仍然应该是交易核心的真理源
- • 为什么查询、搜索、日志、风控流水应当下沉到不同的 NoSQL 和分析引擎
- • 如何用 CQRS + CDC 把异构存储优雅地缝合起来
- • 如何把这套方案做成真正可上线、可扩展、可观测、可容灾的生产级架构
如果你正在建设电商、支付、履约、会员积分、优惠券、营销结算这类系统,这篇文章会比单纯讲“读写分离”更接近生产现实。
一、问题不是数据库不够强,而是职责没有分清
我们先看一个大促场景下的典型事故链路。业务要求看起来都很合理:
- • 用户下单时,订单、库存、优惠、支付状态必须强一致
- • 买家要实时看到订单详情、物流状态、退款进度
- • 商家后台要支持按商品名、收件人、手机号、时间区间、状态组合搜索
- • 风控系统要拉取最近 5 分钟的高频下单流水做规则判断
- • 运营系统要看分钟级成交额、爆品排行、区域分布
- • 客服系统要查订单快照、变更日志、履约轨迹
如果这些请求都直接压到 MySQL,会发生什么?
- • 核心订单表既承担事务写,又承担复杂读
- • 二级索引越来越多,写放大越来越严重
- •
like '%keyword%'、范围扫描、聚合统计不断侵蚀 buffer pool
- • 热点订单和大促商品产生明显的行锁竞争
- • 慢 SQL 堵塞连接池,反向拖垮核心写链路
- • 为了“实时”,越来越多同步逻辑被塞进下单事务
最后系统进入一种非常熟悉的状态:事务数据库明明是最重要的组件,却被最不适合它的工作拖垮。
所以架构升级的第一原则不是“换库”,而是先做一件更本质的事:把命令侧和查询侧拆开,把事务真相和查询视图拆开,把核心账本和派生数据拆开。 这正是 CQRS 的出发点。
二、先建立一个正确的架构判断:什么数据应该放在哪里
以订单交易系统为例,不同数据负载的本质完全不同。
| 数据类型 |
典型操作 |
一致性要求 |
访问模式 |
适合的存储 |
| 订单主记录、支付状态、库存扣减、优惠核销 |
事务写、状态迁移 |
强一致 |
点查、主键更新 |
MySQL / InnoDB |
| 订单详情页热点数据 |
高频读、短期热点 |
最终一致可接受 |
KV 点查 |
Redis |
| 商家后台订单搜索 |
多条件组合、全文检索、分页排序 |
秒级延迟可接受 |
检索 + 过滤 |
Elasticsearch / OpenSearch |
| 物流轨迹、操作日志、风控上下文快照 |
大量追加写、结构变化频繁 |
最终一致 |
文档查询、历史回放 |
MongoDB |
| 实时成交流水、风控流、指标流 |
顺序事件、高吞吐消费 |
至少一次 |
流式处理、聚合 |
Kafka |
| 离线报表、宽表分析 |
大范围扫描、聚合分析 |
T+0/T+1 |
列式扫描 |
ClickHouse / StarRocks / 湖仓 |
从职责上看,这是一种典型的 异构存储 协作:
- • MySQL 负责业务真相和事务边界
- • Kafka 负责事件分发和系统解耦
- • Redis 负责热点读取和低延迟访问
- • Elasticsearch 负责检索型读模型
- • MongoDB 负责高吞吐追加型文档数据
- • 分析引擎负责运营和风控报表
这里最容易犯的错误有两个:
- 把 MySQL 当作所有场景的统一出口
- 把 NoSQL 当作可以替代事务数据库的“银弹”
前者会把 OLTP 拖死,后者会把业务一致性做烂。正确姿势是:MySQL 做“账本”,NoSQL 做“视图”,Kafka 做“血管”,CQRS 做“分工规则”,CDC 做“同步机制”。
三、CQRS 不只是“读写分离”,而是模型分离
很多文章把 CQRS 讲成“写走主库、读走从库”,这太浅了。真正的 CQRS 是:
- • 命令模型只服务于状态变更
- • 查询模型只服务于读取体验
- • 两边的模型结构、存储方式、扩展策略可以完全不同
3.1 命令侧关注什么
命令侧解决的是:下单是否合法、库存是否足够、优惠券是否可用、支付状态如何流转、订单状态迁移是否满足约束。这要求明确的事务边界、强一致的状态变更、可审计的业务日志、幂等控制以及并发冲突处理。命令侧通常建模为较为规范的关系模型,比如 orders、order_items、payment_records、inventory_reservation、coupon_usage、outbox_event。
3.2 查询侧关注什么
查询侧解决的是:详情页是否足够快、搜索是否足够灵活、后台筛选是否丰富、排序/聚合/分页体验是否稳定、多端查询是否能按自己的维度组织数据。所以查询模型不需要忠实复刻 MySQL 表结构,它应该服务于页面和接口本身。例如:Redis 中存 order:detail:{orderId} 的精简 JSON,Elasticsearch 中存扁平化的 order_search_doc,MongoDB 中存完整履约轨迹 shipment_trace,ClickHouse 中存宽表 fact_order_paid。
3.3 CQRS 的真正价值
它带来的不是“理念高级”,而是几个非常现实的收益:写链路保持短小,事务成功率更高;查询侧按业务自由建模,不再受限于关系范式;读写两边可独立扩容;某个查询引擎出问题时,不直接拖垮核心交易;后续替换某个 NoSQL 组件时,不需要改动命令服务。
一句话总结: CQRS 的本质不是把数据库拆两份,而是承认“写模型”和“读模型”天然不是同一个东西。
四、CDC 才是把异构架构接起来的关键
如果 CQRS 解决的是“该怎么分工”,那么 CDC 解决的就是“分工之后怎么同步”。
4.1 为什么不能简单双写
很多团队一开始都会这么做:1. 下单事务写 MySQL;2. 事务结束后发 Kafka;3. 同时同步 Redis / ES / MongoDB。问题在于这是一种经典的“双写不一致”陷阱。可能发生的失败场景包括:MySQL 提交成功但 Kafka 发送失败、Kafka 发成功了但事务最终回滚、MySQL 和 ES 都写成功但 Redis 写失败、服务进程在“写库成功、发消息前”崩溃。这些场景一旦出现,你的查询侧就会出现脏数据、漏数据、重复数据,而且非常难补。
4.2 CDC 的本质
CDC 是 Change Data Capture,即“变更数据捕获”。对于 MySQL 来说,最可靠的 CDC 来源不是业务代码的回调、不是 ORM 事件,也不是应用日志,而是 binlog。原因很简单:它记录的是已经提交的事务变更,天然带有提交顺序,是数据库层面的事实来源,不依赖业务服务是否正常运行。
4.3 Debezium 做了什么
Debezium 会伪装成一个 MySQL replication slave,从 binlog 中持续读取变更,然后把变更转换为结构化事件发往 Kafka。这意味着应用服务无需感知消息发送细节,只要 MySQL 事务提交成功,变更最终就能被捕获,下游系统通过消费 Kafka 构建自己的视图。
4.4 为什么生产上更推荐 Outbox + CDC
虽然 Debezium 可以直接监听 orders 表变更,但生产上更推荐用 Outbox 模式。原因有三点:
- 领域语义更清晰。订单表更新未必天然等于一个明确业务事件。用 Outbox 可以显式记录
OrderPaid、OrderShipped 这类领域事件。
- 避免下游解析数据库细节。如果直接消费
orders 行级变更,下游不得不理解关系表结构、字段含义、历史兼容逻辑。Outbox 则提供面向业务的事件契约。
- 事务原子性有保障。订单表和 outbox 表在同一事务提交,业务状态和事件投递具备统一事实来源。
所以,生产推荐链路通常是:业务事务 -> 写 orders + 写 outbox -> MySQL commit -> Debezium 读 binlog -> Kafka -> 各读模型消费者
五、一套可落地的生产级总体架构
下面给出一个完整的交易系统异构架构参考图。
┌──────────────────────┐
│ API Gateway │
│ 鉴权 / 限流 / 路由 / 熔断 │
└──────────┬───────────┘
│
┌────────────────────┴────────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Order Command │ │ Query Gateway │
│ 下单/支付/取消/退款 │ │ 详情/搜索/列表/报表 │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ MySQL │ │ 路由不同查询存储视图 │
│ orders + outbox │ └─────┬──────┬───────┘
└──────────┬──────────┘ │ │
│ │ │
▼ │ │
┌─────────────────────┐ │ │
│ Debezium CDC │ │ │
│ 读取 binlog │ │ │
└──────────┬──────────┘ │ │
▼ │ │
┌─────────────────────┐ │ │
│ Kafka │◀────────────────────────┘ │
│ 领域事件总线 │ │
└─────┬────────┬──────┘ │
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────────┐
│ Redis Builder │ │ ES Builder │ │ Mongo Log Builder │
│ 热点详情视图 │ │ 搜索文档视图 │ │ 轨迹/操作日志视图 │
└────────────────┘ └────────────────┘ └────────────────────┘
│ │ │
└────────┬──────┴───────────────┬──────────────┘
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ Redis │ │ Elasticsearch │
└─────────────┘ └─────────────────┘
这个架构里有几个关键原则:
- • 核心事务只走 MySQL,不把 ES/Redis/Mongo 写入放进同步事务
- • 领域事件通过 Kafka 广播,消费侧各自构建自己的物化视图
- • 查询入口不直接“怼 MySQL”,而是根据场景路由到适配的数据源
- • 下游读模型天然允许最终一致,但必须设计补偿、重放和校验机制
六、业务场景落地:以下单链路为例完整拆解
我们以“创建订单”这个最典型的高并发交易场景来拆。
6.1 业务目标
假设系统要求如下:大促峰值下单请求 12 万 QPS;订单创建 P99 小于 200ms;订单详情查询 P99 小于 40ms;商家后台订单搜索 P99 小于 150ms;订单支付后 1 秒内在用户侧可见;搜索索引允许 1 到 3 秒最终一致延迟。
6.2 命令侧事务边界
一个简化后的订单创建事务可能包含:校验商品和价格快照、预占库存、校验优惠券、创建订单主记录、创建订单明细、写 outbox 事件、提交事务。注意:缓存刷新、ES 索引更新、物流视图写入、风控扩展信息推送,都不应该出现在这个事务里。
6.3 事件驱动后的读模型构建
事务提交后,各组件分别处理:Redis Builder 消费 OrderCreated,构造订单详情缓存;ES Builder 消费 OrderCreated,建立后台搜索文档;Mongo Builder 消费 OrderCreated,写入订单操作历史初始快照;风控服务消费 OrderCreated,拼接实时风险画像;分析服务消费 OrderPaid,更新成交指标流。
这时你会发现:命令侧在处理“业务正确性”,查询侧在处理“读取体验”,中间用事件流解耦。这就是成熟交易架构的分层方式。
七、MySQL 设计:真理源必须能扛住并发和演进
7.1 订单表与 Outbox 表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
buyer_id BIGINT NOT NULL,
seller_id BIGINT NOT NULL,
total_amount DECIMAL(18,2) NOT NULL,
pay_amount DECIMAL(18,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(32) NOT NULL,
source_channel VARCHAR(32) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
paid_at DATETIME(3) NULL,
KEY idx_buyer_created (buyer_id, created_at),
KEY idx_seller_created (seller_id, created_at),
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_title VARCHAR(256) NOT NULL,
unit_price DECIMAL(18,2) NOT NULL,
quantity INT NOT NULL,
total_amount DECIMAL(18,2) NOT NULL,
created_at DATETIME(3) NOT NULL,
KEY idx_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_key VARCHAR(128) NOT NULL,
event_version INT NOT NULL,
payload JSON NOT NULL,
headers JSON NULL,
created_at DATETIME(3) NOT NULL,
KEY idx_created_at (created_at),
KEY idx_aggregate (aggregate_type, aggregate_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里几个生产细节很重要:orders.version 用于乐观并发控制和事件版本传递;event_key 建议可路由到 Kafka key,保证同一订单事件有序;payload 不要盲目塞整张表所有字段,只保留领域事件所需信息;索引必须围绕事务 SQL 设计,不要为了运营查询把 MySQL 索引加成“报表库”。
7.2 分库分表策略
当订单量足够大时,单表写入和索引维护会成为瓶颈。常见策略是按 buyer_id 或 seller_id hash 分片,按时间+hash组合分片,使用雪花 ID / Leaf / Segment 生成全局唯一 ID。但要注意:分片键决定事务边界;跨分片聚合查询不应再依赖命令库完成;分片后更应坚持“查询走读模型”,否则业务复杂度会迅速上升。
7.3 高并发下单的 MySQL 优化原则
事务尽量短,只包含必要状态变更;避免在事务中做远程调用;严格控制索引数量,降低写放大;热点库存更新优先采用预占模型,避免长事务锁持有;明确隔离级别,通常以 READ COMMITTED 配合业务幂等控制更稳妥;连接池分池隔离,下单、支付、后台管理不要共用同一池。
八、生产级代码:命令侧不只是“save 一下”
下面给出一个更接近生产的 Java Spring Boot 示例。为了强调工程性,这里使用显式事务、幂等键、乐观版本和 outbox 事件构建。
8.1 创建订单应用服务
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final InventoryReservationService inventoryReservationService;
private final CouponDomainService couponDomainService;
private final OutboxEventRepository outboxEventRepository;
private final IdGenerator idGenerator;
private final Clock clock;
private final TransactionTemplate transactionTemplate;
public OrderApplicationService(OrderRepository orderRepository,
OrderItemRepository orderItemRepository,
InventoryReservationService inventoryReservationService,
CouponDomainService couponDomainService,
OutboxEventRepository outboxEventRepository,
IdGenerator idGenerator,
Clock clock,
TransactionTemplate transactionTemplate) {
this.orderRepository = orderRepository;
this.orderItemRepository = orderItemRepository;
this.inventoryReservationService = inventoryReservationService;
this.couponDomainService = couponDomainService;
this.outboxEventRepository = outboxEventRepository;
this.idGenerator = idGenerator;
this.clock = clock;
this.transactionTemplate = transactionTemplate;
}
public CreateOrderResult createOrder(CreateOrderCommand command) {
return transactionTemplate.execute(status -> {
Instant now = Instant.now(clock);
if (orderRepository.existsByRequestId(command.requestId())) {
Order existed = orderRepository.findByRequestId(command.requestId())
.orElseThrow();
return new CreateOrderResult(existed.getId(), existed.getOrderNo(), true);
}
inventoryReservationService.reserve(command.items());
couponDomainService.lockCoupon(command.couponId(), command.buyerId());
long orderId = idGenerator.nextId();
String orderNo = "O" + orderId;
Order order = Order.create(
orderId,
orderNo,
command.requestId(),
command.buyerId(),
command.sellerId(),
command.totalAmount(),
command.payAmount(),
command.sourceChannel(),
now
);
orderRepository.save(order);
List<OrderItem> items = command.items().stream()
.map(item -> OrderItem.create(
idGenerator.nextId(),
orderId,
item.skuId(),
item.productId(),
item.productTitle(),
item.unitPrice(),
item.quantity(),
now
))
.toList();
orderItemRepository.saveAll(items);
OrderCreatedEvent event = OrderCreatedEvent.from(order, items, now);
outboxEventRepository.save(OutboxEvent.of(
idGenerator.nextId(),
"Order",
String.valueOf(order.getId()),
"OrderCreated",
String.valueOf(order.getId()),
1,
event
));
return new CreateOrderResult(order.getId(), order.getOrderNo(), false);
});
}
}
这段代码里体现了几个生产关键点:requestId 用于接口幂等,避免重复下单;业务事件和订单写入同事务提交;outbox 保存的是明确的领域事件,而不是“数据库行镜像”;事件 key 使用 orderId,便于 Kafka 分区保持单订单有序。
8.2 支付回调处理必须幂等
支付回调是交易系统最容易出事故的入口之一。第三方支付平台会重试回调,网络抖动会产生重复通知,所以支付确认一定要做幂等。
@Service
public class PaymentCallbackService {
private final OrderRepository orderRepository;
private final PaymentRecordRepository paymentRecordRepository;
private final OutboxEventRepository outboxEventRepository;
private final IdGenerator idGenerator;
private final TransactionTemplate transactionTemplate;
public void handlePaid(PaymentCallbackCommand command) {
transactionTemplate.executeWithoutResult(status -> {
if (paymentRecordRepository.existsByChannelTradeNo(command.channelTradeNo())) {
return;
}
Order order = orderRepository.findByOrderNoForUpdate(command.orderNo())
.orElseThrow(() -> new IllegalArgumentException("order not found"));
if (order.isPaid()) {
return;
}
order.markPaid(command.paidAt());
orderRepository.updateStatus(order.getId(), order.getStatus(), order.getVersion());
paymentRecordRepository.save(PaymentRecord.success(
idGenerator.nextId(),
order.getId(),
command.channelTradeNo(),
command.paidAmount(),
command.paidAt()
));
outboxEventRepository.save(OutboxEvent.of(
idGenerator.nextId(),
"Order",
String.valueOf(order.getId()),
"OrderPaid",
String.valueOf(order.getId()),
order.getVersion().intValue(),
OrderPaidEvent.from(order, command)
));
});
}
}
这里推荐的组合策略是:外部幂等键 channelTradeNo;内部状态幂等,订单已支付则直接返回;数据库锁或乐观版本防止并发状态竞争;事件幂等,同一业务状态变更只产出一次有效事件。
九、Debezium 配置:把 binlog 变成可靠事件流
9.1 MySQL 开启 binlog
[mysqld]
server-id=1001
log-bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL
expire_logs_days=7
gtid_mode=ON
enforce_gtid_consistency=ON
几个关键点:ROW 模式最适合 CDC,避免语句级歧义;binlog 保留时间要覆盖消费端故障恢复窗口;GTID 有利于复制和恢复管理。
9.2 Debezium Connector 配置
{
"name": "order-mysql-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql",
"database.port": "3306",
"database.user": "debezium",
"database.password": "dbz-pass",
"database.server.id": "5401",
"database.server.name": "orderdb",
"topic.prefix": "orderdb",
"database.include.list": "trade",
"table.include.list": "trade.outbox_event",
"include.schema.changes": "false",
"snapshot.mode": "initial",
"decimal.handling.mode": "string",
"tombstones.on.delete": "false",
"transforms": "route",
"transforms.route.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.route.route.by.field": "event_type",
"transforms.route.route.topic.replacement": "trade.event.${routedByValue}",
"transforms.route.table.fields.additional.placement": "aggregate_id:header:aggregate_id,event_version:header:event_version,event_key:header:event_key",
"transforms.route.table.field.event.id": "id",
"transforms.route.table.field.event.key": "event_key",
"transforms.route.table.field.event.payload": "payload"
}
}
9.3 生产上要理解的 Debezium 细节
1. Snapshot 问题
Connector 首次启动时往往会先做快照。如果直接监听业务表,快照期可能产生海量初始化事件。Outbox 方案通常更容易控,因为只监听事件表。
2. Offset 持久化
Debezium 需要保存消费到哪个 binlog 位点。Kafka Connect 的 offsets topic 必须高可用,否则故障恢复会有重复消费风险。
3. 至少一次,而不是恰好一次
Debezium 到 Kafka 通常是至少一次语义。这意味着下游消费者必须具备幂等能力,而不是幻想“消息绝不重复”。
十、Redis 读模型:高并发详情页的第一承接层
订单详情页、支付结果页、最近订单列表,通常要求极低延迟。Redis 很适合做这类热点查询视图。
10.1 订单详情缓存构建
@Service
public class OrderDetailCacheProjector {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
@KafkaListener(
topics = {"trade.event.OrderCreated", "trade.event.OrderPaid", "trade.event.OrderCancelled"},
containerFactory = "kafkaListenerContainerFactory"
)
public void onEvent(ConsumerRecord<String, String> record) throws Exception {
JsonNode root = objectMapper.readTree(record.value());
String eventType = root.path("eventType").asText();
String orderId = root.path("orderId").asText();
long eventVersion = root.path("version").asLong();
String key = "order:detail:" + orderId;
String raw = redisTemplate.opsForValue().get(key);
OrderDetailView current = raw == null ? null : objectMapper.readValue(raw, OrderDetailView.class);
if (current != null && current.version() >= eventVersion) {
return;
}
OrderDetailView next = switch (eventType) {
case "OrderCreated" -> OrderDetailView.fromCreated(root);
case "OrderPaid" -> mergePaid(current, root);
case "OrderCancelled" -> mergeCancelled(current, root);
default -> null;
};
if (next != null) {
redisTemplate.opsForValue().set(
key,
objectMapper.writeValueAsString(next),
Duration.ofHours(24)
);
}
}
private OrderDetailView mergePaid(OrderDetailView current, JsonNode root) {
if (current == null) {
return OrderDetailView.placeholderPaid(root);
}
return current.withStatus("PAID", root.path("paidAt").asText(), root.path("version").asLong());
}
private OrderDetailView mergeCancelled(OrderDetailView current, JsonNode root) {
if (current == null) {
return OrderDetailView.placeholderCancelled(root);
}
return current.withStatus("CANCELLED", null, root.path("version").asLong());
}
}
这个投影器不是简单“收到消息就覆盖”,而是做了两个生产处理:基于 version 处理乱序和重复;允许“占位视图”兜底,避免极端情况下支付事件先于创建视图到达时直接丢数据。
10.2 Redis 层的高并发防护
要让 Redis 真正稳定,不只是“加缓存”这么简单。必须考虑缓存穿透(不存在订单被反复查询)、缓存击穿(某个热点 key 失效瞬间大量回源)、缓存雪崩(一批 key 同时过期)。常见处理手段有空值缓存、布隆过滤器、热点 key 逻辑过期 + 异步重建、多 TTL 打散过期时间、详情页热点预热。如果是大促场景,建议对爆款订单、支付结果页这类热点数据做主动预热,而不是完全依赖被动缓存。
十一、Elasticsearch 读模型:后台检索为什么必须独立出来
商家后台的订单查询几乎天然不适合 MySQL:商品名模糊搜索、收件人姓名搜索、手机号脱敏搜索、按地区/状态/金额/时间组合筛选、多字段排序、深分页。这些需求混在 OLTP 库里,迟早会出事。
11.1 ES 文档模型设计
{
"settings": {
"number_of_shards": 6,
"number_of_replicas": 1,
"refresh_interval": "1s"
},
"mappings": {
"properties": {
"orderId": { "type": "keyword" },
"orderNo": { "type": "keyword" },
"buyerId": { "type": "long" },
"sellerId": { "type": "long" },
"status": { "type": "keyword" },
"productTitles": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
},
"receiverName": {
"type": "text",
"analyzer": "ik_smart"
},
"receiverPhoneMasked": { "type": "keyword" },
"provinceCode": { "type": "keyword" },
"payAmount": { "type": "scaled_float", "scaling_factor": 100 },
"createdAt": { "type": "date" },
"paidAt": { "type": "date" }
}
}
}
这里有一个很重要的建模思想:ES 文档不需要和 MySQL 表一一对应,它应该是为查询接口量身拼装的搜索文档。 例如把订单和订单项打平到一个搜索文档里,存脱敏手机号用于查询,存商品标题数组用于全文检索,存冗余的区域/渠道/店铺信息,减少搜索时的 join 需求。
11.2 ES 投影服务
@Service
public class OrderSearchProjector {
private final ElasticsearchClient elasticsearchClient;
private final ObjectMapper objectMapper;
@KafkaListener(topics = {"trade.event.OrderCreated", "trade.event.OrderPaid", "trade.event.OrderClosed"})
public void consume(String message) throws Exception {
JsonNode root = objectMapper.readTree(message);
String eventType = root.path("eventType").asText();
OrderSearchDocument document = switch (eventType) {
case "OrderCreated" -> OrderSearchDocument.fromCreated(root);
case "OrderPaid" -> OrderSearchDocument.fromPaid(root);
case "OrderClosed" -> OrderSearchDocument.fromClosed(root);
default -> null;
};
if (document == null) {
return;
}
elasticsearchClient.update(u -> u
.index("order_search_v1")
.id(document.orderId())
.doc(document)
.docAsUpsert(true));
}
}
11.3 ES 生产上的三个现实问题
1. 近实时而非实时
ES 默认刷新不是实时可见。很多团队误以为“Kafka 已消费成功,为什么后台还搜不到”。答案很简单:索引已写入,但还没 refresh。对于必须强可见的页面,优先读 Redis 或 MySQL 真理源,而不是强行要求 ES 提供毫秒级可见性。
2. 深分页问题
后台运营经常提“跳到第 5000 页”。这不适合用普通 from + size。应使用 search_after,scroll 只用于离线导出,不用于实时用户查询。
3. 索引生命周期管理
订单搜索索引通常是长期索引,但日志类、审计类、轨迹类索引必须做 ILM,否则 ES 迟早被写爆。
十二、MongoDB 适合什么:灵活文档和追加日志
MongoDB 不是“为了用而用”,它适合的是关系模型处理起来成本很高的文档型数据。在交易领域,典型适用对象有:履约轨迹、操作审计日志、风控上下文快照、售后流转记录、第三方响应原文归档。MongoDB 的价值在于结构扩展灵活、对半结构化数据友好、写入吞吐高、历史快照回放方便。但要记住:MongoDB 很适合轨迹和日志,不适合替代 MySQL 作为交易主账本。
例如物流轨迹就非常适合按文档或按事件追加存储:
@Document("shipment_trace")
public class ShipmentTraceDocument {
@Id
private String id;
private Long orderId;
private String traceNo;
private List<TraceStep> steps;
private Instant updatedAt;
}
public record TraceStep(
String status,
String location,
String operator,
Instant occurredAt,
Map<String, Object> ext
) {}
如果轨迹更新非常频繁,也可以一条轨迹一步一个事件文档,避免单文档无限膨胀。
十三、高并发下真正难的,不是“写进去”,而是“一直写得对”
当系统进入大促或支付高峰,架构的难点不再是功能齐不齐,而是并发正确性。下面这些问题,几乎每个交易系统都会遇到。
13.1 幂等
幂等要分层设计:接口幂等,客户端重复提交使用 requestId;回调幂等,支付/履约回调使用外部流水号;事件幂等,消费端使用 eventId 或 (aggregateId, version) 去重;写入幂等,对 Redis、ES、Mongo 的写入要能重复执行不出错。
13.2 乱序
即使 Kafka 对同一 key 保证分区内有序,下游仍可能因为重试、并发、补偿而遇到乱序可见。解决思路通常是以 orderId 作为 Kafka key,在事件中携带 version,读模型更新时只接受更高版本。这件事极其重要,否则你会遇到已支付订单又被旧事件覆盖成待支付、已取消订单在详情页短暂变回已创建等问题。
13.3 重复消费
Kafka、Debezium、网络闪断、消费端重平衡,都可能产生重复投递。重复不是异常,而是常态。真正成熟的系统会在设计上默认它一定发生。
13.4 局部失败
比如 Redis 更新成功、ES 更新失败,ES 更新成功、Mongo 更新失败,消费成功但 offset 提交失败。解决这类问题的关键不是“祈祷全都成功”,而是:消费逻辑幂等、支持重试、支持死信队列、支持按事件重放、支持数据对账和回填。
十四、Kafka 工程化设计:不是把消息发出去就结束了
14.1 Topic 规划建议
按领域事件拆 topic,而不是一股脑全塞一个大 topic:trade.event.OrderCreated、trade.event.OrderPaid、trade.event.OrderCancelled、trade.event.OrderRefunded。这种方式的好处是下游按需订阅,权限和治理更清晰,每类事件的消费 SLA 可独立配置。
14.2 分区键设计
交易系统里通常优先保证“同一订单事件有序”,因此推荐 key = orderId。这样可以确保创建、支付、取消、退款事件落到同一分区,下游构建订单视图时顺序稳定。
14.3 生产端配置建议
spring:
kafka:
producer:
acks: all
retries: 10
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 1
delivery.timeout.ms: 30000
request.timeout.ms: 10000
虽然我们用的是 Debezium + Outbox,但业务侧仍然可能有其他事件生产者。这里的配置思想也值得保留:宁可略慢,不要轻易丢消息和打乱顺序。
14.4 消费端重试与死信
建议按事件重要性分层:可自动重试的瞬时失败,走有限次数重试;长期失败的脏数据,进入 DLT;DLT 事件支持后台修复后重放。
@Bean
public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate<Object, Object> kafkaTemplate) {
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate);
FixedBackOff backOff = new FixedBackOff(1000L, 3L);
DefaultErrorHandler errorHandler = new DefaultErrorHandler(recoverer, backOff);
errorHandler.addNotRetryableExceptions(IllegalArgumentException.class);
return errorHandler;
}
十五、查询路由设计:不是所有查询都应该先查 Redis
很多系统把“性能优化”简单理解成“先查 Redis,查不到再回源 MySQL”。这在复杂交易系统里是不够的。更合理的查询路由应该是:
| 查询场景 |
首选数据源 |
兜底策略 |
| 订单详情页 |
Redis |
回源 MySQL,并异步回填缓存 |
| 最近订单列表 |
Redis / ES 轻量列表视图 |
回源 MySQL 限流兜底 |
| 商家后台复杂搜索 |
Elasticsearch |
降级返回缩减条件或异步导出 |
| 物流轨迹 |
MongoDB |
回源轨迹事件存储 |
| 强一致订单状态确认 |
MySQL |
无 |
这个表里最重要的一点是:强一致校验必须允许直接读 MySQL。 例如支付状态最终确认、退款状态核对、核心对账接口,不应该为了“架构纯粹”强行只读缓存或搜索视图。CQRS 不是教条,它的关键是“分工”,不是“绝不回源”。
十六、可观测性建设:没有观测,最终一致就会变成“最终失联”
异构架构一旦上生产,最容易出的问题不是单点故障,而是“链路悄悄积压、局部慢慢腐坏、数据默默不一致”。所以一定要建立覆盖全链路的可观测性。
16.1 必须关注的核心指标
- MySQL: QPS / TPS, 行锁等待时间, 死锁次数, 慢查询数, 连接池活跃数。
- Debezium / Kafka Connect: Connector 运行状态, Source lag, Offset 提交延迟, 重启次数。
- Kafka: topic lag, consumer rebalance 次数, broker 磁盘使用率, ISR 缩减情况。
- Redis / ES / Mongo: 命中率, 平均延迟和 P99, 失败率, JVM/堆/GC, 集群分片健康。
16.2 最关键的两个业务观测指标
除了基础设施指标,更要有业务一致性指标:订单事件生成量 与 Redis/ES 投影成功量 的差值;支付成功订单数 与 支付可查询订单数 的差值。这两个指标往往比 CPU 和内存更能提前告诉你系统是不是开始偏离正确状态。
16.3 Trace 和日志
建议每个订单链路贯穿以下标识:traceId、orderId、requestId、eventId、eventVersion。这样在排查“为什么支付成功却搜不到”时,才能把 MySQL、Kafka、ES、Redis 的日志串起来。
十七、容灾与补偿:最终一致系统一定要能重放
很多团队在架构图上画了 CDC 和事件总线,但没有设计补偿体系,这会导致一旦某个读模型挂掉,恢复代价极高。
17.1 你必须具备的四种能力
- 事件重放:可以从 Kafka 指定 offset 或时间点重新消费,重建某个读模型。
- 全量回填:当 ES 索引误删、Redis 发生大面积失效时,能从 MySQL 或历史事件重建视图。
- 对账校验:定时比对 MySQL 真理源与 ES/Redis/Mongo 的数据一致性。
- 灰度重建:新版本读模型先写新索引或新 key 前缀,验证完成后再切流。
17.2 一个简单有效的对账思路
例如对 ES 搜索视图做每日巡检:从 MySQL 抽取最近 24 小时支付成功订单,按批次比对 ES 中文档是否存在、状态是否一致,差异写入补偿任务表,异步重建缺失文档。这比“出问题了人工查一下”靠谱得多。
十八、Kubernetes 与工程部署:服务要能扩,也要能稳
18.1 命令服务部署建议
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-command-service
spec:
replicas: 8
selector:
matchLabels:
app: order-command-service
template:
metadata:
labels:
app: order-command-service
spec:
containers:
- name: app
image: registry.example.com/trade/order-command-service:1.0.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
resources:
requests:
cpu: "1000m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "2Gi"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
18.2 不同服务应独立伸缩
一定不要把所有服务绑在一个扩缩容策略里。命令服务按 API QPS 扩容;Redis/ES 投影服务按 Kafka lag 扩容;搜索网关按查询流量扩容;对账补偿任务按离峰批处理资源执行。
18.3 背压比盲目扩容更重要
高峰期真正稳健的系统往往不是“无限扩”,而是“有节制地退化”:对搜索类接口限流;对非核心聚合接口做降级;对报表类接口切异步;对后台导出走离线任务。核心原则是:优先保住交易写链路,其次才是查询体验。
十九、真实案例:一次大促中这套架构是怎么扛住压力的
下面给一个更具体的业务场景。某零售平台大促期间:
- 峰值下单请求 11.6 万 QPS
- 支付回调峰值 2.8 万 QPS
- 商家后台搜索峰值 1.9 万 QPS
- 订单详情读取峰值 18 万 QPS
改造前:下单成功后同步刷缓存、同步写 ES、同步记轨迹,订单事务平均耗时 320ms,P99 达 880ms,MySQL CPU 峰值 92%,高峰时后台查询把核心连接池打满。
改造后:订单事务只保留订单写入、明细写入、库存预占、outbox 事件写入;Redis/ES/Mongo 全部下沉为异步读模型构建;搜索查询 100% 切到 ES;详情页 95% 命中 Redis。
结果:下单事务 P99 从 880ms 降到 135ms;MySQL CPU 从 92% 降到 38%;ES 集群单独水平扩容承接后台搜索流量;支付后用户侧 1 秒内可见率达到 99.95%;单个读模型服务故障时,交易主链路不再被直接拖死。
这才是“架构升级”的真实价值:不是某个组件更高级,而是系统的故障边界终于被隔离开了。
二十、最容易踩的坑,我建议你在上线前逐项自检
20.1 误把最终一致当成不一致无所谓
最终一致的前提是:有事件、有重试、有补偿、有观测、有重放。否则“最终一致”只是“没人知道什么时候会一致”。
20.2 没有版本控制,读模型被旧消息回滚
没有 version 的系统,在重复消费和乱序场景下极容易出错。
20.3 把所有查询都从 MySQL 回源
这会让你的读模型形同虚设,也会让主库在故障时再次被打爆。
20.4 Outbox 只写不清理
Outbox 表是高速增长表。必须设计定期归档、分区表或时间清理、已消费事件的生命周期管理。
20.5 只做 CDC,不做数据契约
下游不应该直接依赖底层表结构。领域事件 schema 必须稳定演进,否则改一个字段名就可能带崩所有消费者。
20.6 搜索和报表需求继续反向侵入命令库
架构刚改造完时最容易守住边界,时间久了最容易失守。要有清晰的团队规范:新的复杂查询优先设计读模型,而不是给 MySQL 再加一个索引凑合。
二十一、什么时候适合上这套架构,什么时候没必要
这套方案并不是所有系统都必须用。适合上 CQRS + CDC + 异构存储的典型特征:核心交易写和复杂查询读同时存在;查询流量明显高于写流量;有全文检索、组合筛选、轨迹日志、实时分析需求;核心链路对事务成功率和响应时间要求高;团队具备消息队列、缓存、搜索引擎的运维能力。
不适合一步上太重方案的场景:业务规模还很小,单库足够;查询需求简单,主键和少量索引已能覆盖;团队没有 Kafka / ES / Redis 的稳定运维经验。
如果当前阶段还不需要全套异构架构,也可以渐进式演进:先拆热点查询到 Redis,再拆复杂搜索到 ES,再引入 Outbox,最后引入 Debezium 和完整读模型体系。这比一上来“全量事件驱动化”更稳。
二十二、文章结论:没有万能数据库,只有清晰职责和正确协作
我们最后把全文压缩成一句架构结论:MySQL 负责真相,NoSQL 负责体验,Kafka 负责传递,CQRS 负责分工,CDC 负责同步。
对高并发交易系统来说,真正成熟的设计从来不是“All in MySQL”,也不是“All in NoSQL”,而是承认不同数据负载的物理特性不同,然后用工程化手段把它们组织起来。最值得你带走的不是某一段代码,而是这五个判断标准:
- 核心事务必须尽量短,只做最必要的强一致状态变更
- 查询视图必须按业务读取方式建模,而不是照抄关系表
- 领域事件必须通过可靠机制产生,Outbox + CDC 是成熟路径
- 下游所有读模型都必须按“重复、乱序、失败会发生”来设计
- 最终一致系统一定要具备观测、补偿、重放和对账能力
当你真正做到这五点,MySQL 和 NoSQL 就不再是“选谁”的争论,而会变成一套分工明确、各司其职、能够扛住生产流量的协作体系。这才是高并发交易架构里最重要的“共生术”。