CQRS 实战:读写分离架构与事件投影落地

当单体应用的同一张表既要扛住高频写入、又要服务复杂报表查询时,索引争用与锁等待会迅速拖垮整体性能。CQRS(Command Query Responsibility Segregation,命令查询职责分离)把「写」和「读」拆成两套独立模型:写侧专注领域规则与数据一致性,读侧专注查询体验与吞吐。本文用一个订单场景,讲清如何用读写分离加事件投影(Event Projection)落地 CQRS,并给出可直接套用的代码骨架与踩坑清单。

一、为什么需要 CQRS:写读耦合的代价

在传统 CRUD 架构里,一张 orders 表同时承载下单、支付回调、状态流转等写操作,以及后台报表、用户中心列表等读操作。随着业务增长,三个矛盾会越来越突出:

  • 写被读拖慢:报表查询喜欢建一堆联合索引,而这些索引会拖慢每一次 INSERT/UPDATE。
  • 读被写锁住:长事务或热点行更新时,读请求排队等待,页面响应时间抖动剧烈。
  • 无法独立扩容:写和读的压力曲线完全不同,却被迫部署在同一套实例上,资源永远顾此失彼。

CQRS 的核心思想不是「分库分表」这么简单,而是承认一个事实:为写而优化的模型和为读而优化的模型,本就不该是同一个。举个真实场景:一张订单表为了支撑运营后台的「近 30 天分城市销售额」多维报表,被迫建了 5 个联合索引;结果大促期间,每秒上千笔下单因为要同步维护这些索引,写延迟从 8 毫秒飙到 400 毫秒,而报表查询又因为行锁排队,页面要 3 秒才出数。读写互相绑架,谁都跑不快。CQRS 的做法是把这张表拆成「写聚合表」和「读宽表」两套,写表只留保证一致性的最小索引,读表则按查询视图量身定制。

二、CQRS 的四种核心构件

落地 CQRS 前,先厘清四个角色的职责边界。它们组成一个闭环:命令触发写、写产生事件、事件驱动投影、投影刷新读模型。

构件职责是否修改状态
Command(命令)表达「想做什么」,如 PlaceOrder不直接改,交由 Handler
Query(查询)表达「要什么数据」,如 GetOrderSummary只读,绝不修改
Event(事件)记录「已经发生什么」,如 OrderPlaced写侧产出,供投影消费
Projection(投影)消费事件,维护读模型只写读库/缓存

三、写侧:命令与领域模型

写侧追求「尽量少的事」:只负责业务规则与一致性,不关心页面要怎么展示。下面用 Java 风格给出一个最小可运行的命令分发骨架。

3.1 命令与命令总线

// 命令:表达意图,携带聚合 ID 与必要参数
public record PlaceOrderCommand(
        String orderId,      // 由 分布式 ID 生成器 预先分配,见内链
        String userId,
        List<String> items,
        long timestamp
) {}

// 极简命令总线:按类型路由到对应 Handler
public class CommandBus {
    private final Map<Class<?>, CommandHandler<?>> handlers = new ConcurrentHashMap<>();

    public <C> void register(Class<C> type, CommandingHandler<C> h) {
        handlers.put(type, h);
    }

    @SuppressWarnings("unchecked")
    public void dispatch(Object command) {
        CommandHandler<?> h = handlers.get(command.getClass());
        ((CommandHandler<Object>) h).handle(command);
    }
}

3.2 聚合处理命令并发布领域事件

聚合(Aggregate)是写侧的唯一真相源。它校验业务规则后产出事件,而不是就地更新读模型。注意 orderId 在命令到达前就已由分布式 ID 生成方案分配好,避免写侧再来回查库。

public class Order {
    private final String orderId;
    private OrderStatus status = OrderStatus.CREATED;
    private final List<String> events = new ArrayList<>();

    public Order(String orderId, String userId, List<String> items) {
        this.orderId = orderId;
        // 业务规则:空订单直接拒绝,写侧只认规则
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("订单不能为空");
        }
        this.events.add(new OrderPlaced(orderId, userId, items, System.currentTimeMillis()));
    }

    public List<Object> drainEvents() {
        List<Object> drained = new ArrayList<>(events);
        events.clear();
        return drained;
    }
}

四、读侧:事件投影与查询模型

读侧完全不管业务规则,它只做一件事:把事件流转换成好查的表或缓存。这正是 CQRS 威力所在——你可以为不同查询场景维护多份投影。

4.1 投影监听事件,刷新读模型

// 投影:监听 OrderPlaced,落一张为列表页优化的宽表
@Component
public class OrderSummaryProjection {

    @Autowired
    private JdbcTemplate readDb;

    @EventListener
    public void on(OrderPlaced event) {
        readDb.update(
            "INSERT INTO order_summary(order_id,user_id,item_count,status,placed_at) VALUES(?,?,?,?,?)",
            event.orderId(), event.userId(), event.items().size(), "CREATED", event.timestamp()
        );
    }
}

4.2 查询直击读模型,必要时加缓存

查询服务只面向读库或缓存,逻辑极简。高频列表可直接走Redis 缓存设计或Redis 核心数据结构做二级加速,把主库彻底隔离在查询链路之外。

public class OrderQueryService {
    @Autowired
    private JdbcTemplate readDb;
    @Autowired
    private RedisTemplate<String, String> redis;

    public OrderSummary getSummary(String orderId) {
        String cacheKey = "order:summary:" + orderId;
        String cached = redis.opsForValue().get(cacheKey);
        if (cached != null) return JSON.parseObject(cached, OrderSummary.class);
        // 只读读库,绝不碰写侧聚合
        OrderSummary s = readDb.queryForObject(
            "SELECT * FROM order_summary WHERE order_id=?", MAPPER, orderId);
        redis.opsForValue().set(cacheKey, JSON.toJSONString(s), 5, TimeUnit.MINUTES);
        return s;
    }
}

五、最终一致性与延迟权衡

CQRS 的读模型是异步更新的,这意味着用户刚下单,立刻查可能查不到——这是设计取舍,不是 bug。是否可接受取决于业务。

方案一致性写延迟适用场景
单库事务强一致高(锁竞争)金融核心账务
CQRS + 同步投影读库准实时中内部运营后台
CQRS + 异步投影最终一致低电商订单、Feed 流

六、落地清单与三个必踩的坑

把 CQRS 跑顺,关键是把「异步」带来的副作用提前堵死。下面三点是生产环境最容易翻车的地方。

6.1 命令必须幂等

消息中间件可能重复投递同一条命令。务必在写侧用「命令 ID + 去重表」做幂等设计,否则重试一次就多下一单。去重表主键就是 orderId,INSERT 失败即判定重复。

6.2 投影必须可重放

当投影逻辑改了,需要清空读模型后从头回放事件。所以事件必须落库(Event Store),而投影本身要幂等——同一条 OrderPlaced 重放两次,结果应一致。

6.3 读侧故障不能拖垮写侧

投影消费方挂了,写侧仍要能正常下单。用独立队列解耦,并为读侧查询加微服务熔断降级兜底:缓存查不到时降级返回「数据同步中」,而不是把异常抛给下单链路。

坑现象解法
命令重复重复下单/重复扣款命令 ID 去重表
投影漂移读模型与写模型对不上事件落库 + 可重放
级联雪崩读库慢拖垮写库队列解耦 + 熔断

七、什么时候不该用 CQRS

CQRS 不是银弹。如果你的系统读少写少、查询和写入的数据形态高度一致、团队人数有限,强行拆读写只会增加事件、投影、幂等三套复杂度。判断标准很简单:只有当「写模型」和「读模型」的优化方向明显分歧时,CQRS 的收益才大于成本。典型信号包括报表 SQL 已严重影响线上写入、读链路需要多种异构存储(ES/Redis/宽表),或不同业务方对「同一份数据」有截然不同的查询视图。

小结:CQRS 用「读写分离」换取吞吐与可维护性,用「事件投影」把写侧发生的变更同步到读侧。它把一致性从「强一致」降级为「最终一致」,换来的却是写读两套模型各自自由伸缩的空间。落地时守住命令幂等、投影可重放、读写解耦三条铁律,就能把这套架构稳稳跑在生产环境上。

如果你正在评估是否引入 CQRS,可以先做一个最小验证:把现有系统里「读多写少」且「查询形态和写入形态差异大」的那张表摘出来,单独建一份读模型并异步同步,跑两周看监控。一旦读写压力被拆开、主库写延迟回落,你就拿到了继续深入 CQRS 最硬的证据。反之,若拆分后复杂度上升却没换来可观测的性能收益,及时收手也是理性决策。

上一篇 Redis Stream 实战:消息队列与消费组落地
下一篇 Logrotate 实战:日志自动轮转与切割压缩
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]