找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3383

积分

0

好友

445

主题
发表于 1 小时前 | 查看: 3| 回复: 0

很多团队在做交易系统升级时,第一反应是“数据库扛不住了,是不是该把 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 负责高吞吐追加型文档数据
  • • 分析引擎负责运营和风控报表

这里最容易犯的错误有两个:

  1. 把 MySQL 当作所有场景的统一出口
  2. 把 NoSQL 当作可以替代事务数据库的“银弹”

前者会把 OLTP 拖死,后者会把业务一致性做烂。正确姿势是:MySQL 做“账本”,NoSQL 做“视图”,Kafka 做“血管”,CQRS 做“分工规则”,CDC 做“同步机制”。

三、CQRS 不只是“读写分离”,而是模型分离

很多文章把 CQRS 讲成“写走主库、读走从库”,这太浅了。真正的 CQRS 是:

  • • 命令模型只服务于状态变更
  • • 查询模型只服务于读取体验
  • • 两边的模型结构、存储方式、扩展策略可以完全不同

3.1 命令侧关注什么

命令侧解决的是:下单是否合法、库存是否足够、优惠券是否可用、支付状态如何流转、订单状态迁移是否满足约束。这要求明确的事务边界、强一致的状态变更、可审计的业务日志、幂等控制以及并发冲突处理。命令侧通常建模为较为规范的关系模型,比如 ordersorder_itemspayment_recordsinventory_reservationcoupon_usageoutbox_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 模式。原因有三点:

  1. 领域语义更清晰。订单表更新未必天然等于一个明确业务事件。用 Outbox 可以显式记录 OrderPaidOrderShipped 这类领域事件。
  2. 避免下游解析数据库细节。如果直接消费 orders 行级变更,下游不得不理解关系表结构、字段含义、历史兼容逻辑。Outbox 则提供面向业务的事件契约。
  3. 事务原子性有保障。订单表和 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_idseller_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.OrderCreatedtrade.event.OrderPaidtrade.event.OrderCancelledtrade.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 和日志

建议每个订单链路贯穿以下标识:traceIdorderIdrequestIdeventIdeventVersion。这样在排查“为什么支付成功却搜不到”时,才能把 MySQL、Kafka、ES、Redis 的日志串起来。

十七、容灾与补偿:最终一致系统一定要能重放

很多团队在架构图上画了 CDC 和事件总线,但没有设计补偿体系,这会导致一旦某个读模型挂掉,恢复代价极高。

17.1 你必须具备的四种能力

  1. 事件重放:可以从 Kafka 指定 offset 或时间点重新消费,重建某个读模型。
  2. 全量回填:当 ES 索引误删、Redis 发生大面积失效时,能从 MySQL 或历史事件重建视图。
  3. 对账校验:定时比对 MySQL 真理源与 ES/Redis/Mongo 的数据一致性。
  4. 灰度重建:新版本读模型先写新索引或新 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”,而是承认不同数据负载的物理特性不同,然后用工程化手段把它们组织起来。最值得你带走的不是某一段代码,而是这五个判断标准:

  1. 核心事务必须尽量短,只做最必要的强一致状态变更
  2. 查询视图必须按业务读取方式建模,而不是照抄关系表
  3. 领域事件必须通过可靠机制产生,Outbox + CDC 是成熟路径
  4. 下游所有读模型都必须按“重复、乱序、失败会发生”来设计
  5. 最终一致系统一定要具备观测、补偿、重放和对账能力

当你真正做到这五点,MySQL 和 NoSQL 就不再是“选谁”的争论,而会变成一套分工明确、各司其职、能够扛住生产流量的协作体系。这才是高并发交易架构里最重要的“共生术”。




上一篇:适配Claude Code的AI原生PPT工具:单文件HTML,电子杂志风一键生成
下一篇:GPU闲置率达95%:你真的需要H200吗?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-5 21:28 , Processed in 0.706064 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表