一个电商平台的商品详情页,每秒承受 5 万次读请求;而商品信息的更新操作,每秒不超过 50 次。读写比达到 1000:1。用同一套模型、同一张表、同一组索引来同时服务读和写,意味着要么为了写性能牺牲读体验,要么为了读优化拖慢写操作。这就是命令查询职责分离(CQRS, Command Query Responsibility Segregation)要解决的核心矛盾:读操作和写操作有截然不同的性能特征、数据结构需求和扩展策略,把它们硬塞进同一个模型是一种妥协,而非最优解。
CQRS 的思想并不复杂,但在实践中常被误解。最常见的误解是把 CQRS 和事件溯源(Event Sourcing)画等号,认为引入 CQRS 就必须引入事件存储、事件重放等一系列重型基础设施。事实并非如此。CQRS 是一个独立的架构模式,它的价值在很多不使用 Event Sourcing 的场景中同样成立。
本文将从 CQRS 的理论根源出发,逐层拆解它的不同实现形态,讨论读模型的物化视图策略、最终一致性的用户体验设计,并通过一个在线教育平台的案例展示 CQRS 的落地过程。
系列导航:上一篇:事件驱动架构 | 下一篇:六边形架构
一、从 CQS 到 CQRS:概念溯源
1.1 Bertrand Meyer 的 CQS 原则
命令查询分离(CQS, Command Query Separation)最早由 Bertrand Meyer 在《面向对象软件构造》(Object-Oriented Software Construction)中提出。CQS 是一个方法级别的设计原则:
一个方法要么是一个命令(Command),执行动作并改变状态,但不返回值;要么是一个查询(Query),返回数据但不改变状态。两者不应混合。
// 符合 CQS 的设计
public class Stack<T> {
public void push(T item) { items.add(item); } // Command
public void pop() { items.remove(items.size() - 1); } // Command
public T top() { return items.get(items.size() - 1); } // Query
public int size() { return items.size(); } // Query
}CQS 的价值在于可预测性:调用查询方法可以放心地知道它不会产生副作用;调用命令方法可以明确知道它的目的是改变状态。
1.2 从方法级别到架构级别
2010 年前后,Greg Young 将 CQS 的思想从方法级别提升到了架构级别,提出了 CQRS。关键洞察是:既然读和写在方法级别就应该分离,那么在更大的架构尺度上,读操作和写操作使用完全不同的模型(甚至不同的数据存储)也是合理的。
传统分层架构中,读和写共享同一个领域模型、同一个数据访问层、同一个数据库 Schema。CQRS 的核心主张是:读操作和写操作使用不同的模型。写模型(Write Model)为数据修改而优化,承载业务规则和完整性约束;读模型(Read Model)为数据展示而优化,承载查询逻辑和视图组装。
1.3 CQRS 不等于 Event Sourcing
这一点必须明确:CQRS 和事件溯源(Event Sourcing)是两个独立的模式。
| 对比维度 | CQRS | Event Sourcing |
|---|---|---|
| 核心思想 | 读写使用不同模型 | 用事件序列作为数据的唯一真实来源 |
| 可独立使用 | 是 | 是 |
| 写模型要求 | 任意(CRUD、ES 等) | 必须是 append-only 的事件存储 |
| 读模型要求 | 从写模型派生的优化视图 | 与 CQRS 无关 |
Greg Young 本人也多次强调,CQRS 不要求 Event Sourcing。在他 2010 年的文档中,Event Sourcing 被描述为一个可选的增强,而非必要条件。
二、简单 CQRS vs 完整 CQRS
CQRS 不是全有或全无的决策。根据读写分离的程度,可以分为三个层次。
2.1 简单 CQRS:同库分模型
最轻量的 CQRS——在应用代码层面分离读写模型,底层使用同一个数据库。
// 写模型:包含业务规则
type Course struct {
ID string
Title string
Price int64
Status CourseStatus
Chapters []Chapter
}
func (c *Course) Publish() error {
if len(c.Chapters) == 0 {
return errors.New("课程至少包含一个章节才能发布")
}
c.Status = CourseStatusPublished
return nil
}
// 读模型:为列表展示优化
type CourseListItem struct {
ID string
Title string
Price int64
ChapterCount int
StudentCount int
Rating float64
}优势:改动小、风险低,数据强一致。劣势:读写性能仍互相影响。
2.2 中间形态:同库分表
在同一数据库中为读侧维护专门的物化视图或读表:
-- 写侧主表:标准化设计
CREATE TABLE courses (
id BIGINT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT,
price BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
instructor_id BIGINT NOT NULL REFERENCES instructors(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 读侧物化视图:反标准化设计
CREATE MATERIALIZED VIEW course_list_view AS
SELECT c.id, c.title, c.price, c.status,
i.name AS instructor_name,
COUNT(DISTINCT ch.id) AS chapter_count,
COUNT(DISTINCT e.id) AS enroll_count,
COALESCE(AVG(r.score), 0) AS avg_rating
FROM courses c
JOIN instructors i ON c.instructor_id = i.id
LEFT JOIN chapters ch ON c.id = ch.course_id
LEFT JOIN enrollments e ON c.id = e.course_id
LEFT JOIN reviews r ON c.id = r.course_id
WHERE c.status = 'published'
GROUP BY c.id, c.title, c.price, c.status, i.name;解决了复杂查询拖慢写操作的问题,但仍受限于单库的容量上限。
2.3 完整 CQRS:独立存储
读侧和写侧使用完全独立的数据存储。写库可以是关系型数据库,读库可以是 Elasticsearch、Redis 或另一个关系型数据库。数据通过事件或消息从写库同步到读库,存在最终一致性。
2.4 三种形态的架构图
graph TB
subgraph "简单 CQRS"
C1[客户端] --> API1[API 层]
API1 --> CM1[Command Handler]
API1 --> QM1[Query Handler]
CM1 --> DB1[(同一数据库)]
QM1 --> DB1
end
subgraph "中间形态"
C2[客户端] --> API2[API 层]
API2 --> CM2[Command Handler]
API2 --> QM2[Query Handler]
CM2 --> WT2[(写表)]
WT2 -->|触发器/定时同步| RT2[(读表/物化视图)]
QM2 --> RT2
end
subgraph "完整 CQRS"
C3[客户端] --> API3[API 层]
API3 --> CM3[Command Handler]
API3 --> QM3[Query Handler]
CM3 --> WDB3[(写数据库)]
WDB3 -->|事件/CDC| MQ3[消息队列]
MQ3 -->|投影| RDB3[(读数据库)]
QM3 --> RDB3
end
2.5 三种形态对比
| 维度 | 简单 CQRS | 中间形态 | 完整 CQRS |
|---|---|---|---|
| 数据一致性 | 强一致 | 近实时 | 最终一致 |
| 实现复杂度 | 低 | 中 | 高 |
| 读写性能隔离 | 无 | 部分 | 完全 |
| 独立扩展能力 | 无 | 有限 | 完全 |
| 适用读写比 | < 10:1 | 10:1 ~ 100:1 | > 100:1 |
大多数系统从简单 CQRS 开始就够了,只有遇到明确的瓶颈时才有必要向更完整的形态演进。
三、不绑定 Event Sourcing 的 CQRS
3.1 误解的来源
CQRS 和 Event Sourcing 的绑定误解有几个来源:Greg Young 在推广 CQRS 时大量使用 Event Sourcing 作为示例;早期的 CQRS 框架(如 Axon Framework)默认集成了 Event Sourcing;社区博客中两者几乎总是成对出现。
但 CQRS 的定义——读写使用不同模型——完全不需要 Event Sourcing。
3.2 CRUD 写模型 + 独立读模型
最常见的”无 Event Sourcing 的 CQRS”:写侧用传统 CRUD,读侧通过同步机制获取数据。
// 写侧:标准 CRUD
func (s *CourseCommandService) UpdateCourse(ctx context.Context, cmd UpdateCourseCommand) error {
course, err := s.repo.FindByID(ctx, cmd.CourseID)
if err != nil {
return fmt.Errorf("查询课程失败: %w", err)
}
course.Title = cmd.Title
course.Price = cmd.Price
course.UpdatedAt = time.Now()
if err := s.repo.Save(ctx, course); err != nil {
return fmt.Errorf("保存课程失败: %w", err)
}
s.sync.OnCourseUpdated(ctx, course)
return nil
}
// 读侧:专门的查询服务
func (s *CourseQueryService) ListCourses(ctx context.Context, q CourseListQuery) ([]CourseListItem, error) {
return s.readRepo.Search(ctx, SearchParams{
Keyword: q.Keyword, Category: q.Category,
SortBy: q.SortBy, Offset: q.Offset, Limit: q.Limit,
})
}3.3 读模型同步机制
不依赖 Event Sourcing 时,有多种同步方式:
应用层事件同步——写操作完成后发布事件,由消费者更新读模型:
func (s *CourseCommandService) UpdateCourse(ctx context.Context, cmd UpdateCourseCommand) error {
// ... 写操作 ...
return s.eventBus.Publish(ctx, "course.updated", CourseUpdatedEvent{
CourseID: course.ID, Title: course.Title, Price: course.Price,
})
}变更数据捕获(CDC, Change Data Capture)——使用数据库变更日志(如 PostgreSQL 的 WAL)捕获写侧变更,对写侧代码完全无侵入:
# Debezium CDC 连接器配置
name: course-connector
config:
connector.class: io.debezium.connector.postgresql.PostgresConnector
database.hostname: write-db
database.port: 5432
database.dbname: courses
table.include.list: public.courses,public.chapters
topic.prefix: coursedb
plugin.name: pgoutput定时同步——定期将写库数据增量同步到读库,适合对实时性要求不高的场景(报表、排行榜)。
3.4 实际场景:电商商品列表
典型”CQRS 但不需要 Event Sourcing”的场景:电商商品系统。
写侧用 PostgreSQL 存储商品主数据,保证事务完整性。读侧用 Elasticsearch 存储商品索引,支持全文搜索、拼音匹配和多维筛选。通过 CDC 将 PostgreSQL 的变更实时同步到 Elasticsearch。整个架构中没有 Event Sourcing 的影子,但 CQRS 的价值完全体现了出来——写操作每秒几十次,读操作每秒数万次,读写性能完全隔离。
四、读模型的物化视图策略
4.1 同步投影
同步投影(Synchronous Projection)指在写操作的同一个事务中更新读模型:
func (s *CourseService) PublishCourse(ctx context.Context, courseID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 更新写模型
_, err = tx.ExecContext(ctx,
"UPDATE courses SET status = 'published', updated_at = NOW() WHERE id = $1", courseID)
if err != nil {
return fmt.Errorf("更新课程状态失败: %w", err)
}
// 同一事务中更新读模型
_, err = tx.ExecContext(ctx, `
INSERT INTO course_search_index (id, title, price, status)
SELECT c.id, c.title, c.price, 'published' FROM courses c WHERE c.id = $1
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, updated_at = NOW()
`, courseID)
if err != nil {
return fmt.Errorf("更新搜索索引失败: %w", err)
}
return tx.Commit()
}优势是数据强一致;劣势是增加了写延迟,读模型更新失败会导致写操作回滚。
4.2 异步投影
异步投影(Asynchronous Projection)将读模型更新放到写操作之后,通过消息队列完成:
// 读侧消费者
func (h *CourseProjectionHandler) HandleCoursePublished(ctx context.Context, evt CoursePublishedEvent) error {
course, err := h.writeRepo.FindByID(ctx, evt.CourseID)
if err != nil {
return fmt.Errorf("读取课程数据失败: %w", err)
}
return h.readStore.Upsert(ctx, buildCourseReadModel(course))
}引入最终一致性,但写性能更好,读模型更新失败不影响写操作。
4.3 混合策略
实际系统中最实用的是混合策略:关键字段同步,非关键字段异步。例如订单系统中,订单状态同步更新到读模型(用户提交后需立即看到状态变化),而统计信息(总消费金额、日交易量)可以异步更新。
4.4 读模型的存储选择
| 查询场景 | 推荐存储 | 理由 |
|---|---|---|
| 全文搜索、多维筛选 | Elasticsearch | 倒排索引,复杂聚合 |
| 低延迟键值查询 | Redis | 内存存储,微秒级响应 |
| 复杂关联查询 | PostgreSQL 只读副本 | SQL 表达能力,复杂 JOIN |
| 图关系查询 | Neo4j | 图遍历天然适配 |
| 时序数据 | TimescaleDB | 时序优化存储和查询 |
一个系统可以有多个读模型。例如课程系统同时拥有 Elasticsearch 读模型(搜索筛选)、Redis 读模型(热点详情页)、PostgreSQL 读模型(后台报表)。
4.5 物化视图的重建策略
读模型的一个重要特性:它是可重建的。因为读模型数据源自写模型,任何时候都可以从写模型重新构建。重建场景包括 Schema 变更、数据损坏、存储引擎更换、同步 Bug 修复。
通过索引别名(Alias)实现蓝绿部署,可以零停机完成重建:
func (r *Rebuilder) Rebuild(ctx context.Context) error {
newIndex := fmt.Sprintf("courses_%d", time.Now().Unix())
if err := r.readStore.CreateIndex(ctx, newIndex); err != nil {
return err
}
var lastID int64
for {
rows, _ := r.writeDB.QueryContext(ctx,
"SELECT * FROM courses WHERE id > $1 ORDER BY id LIMIT $2", lastID, 500)
batch := scanAll(rows)
if len(batch) == 0 {
break
}
r.readStore.BulkInsert(ctx, newIndex, batch)
lastID = batch[len(batch)-1].ID
}
return r.readStore.SwitchAlias(ctx, "courses", newIndex)
}五、最终一致性的用户体验设计
完整 CQRS 架构中最棘手的问题不是技术实现,而是最终一致性(Eventual Consistency)带来的用户体验问题。
5.1 问题的本质
写操作更新写库后,需要时间同步到读库。在此窗口内,用户从读库查询到的仍是旧数据:
t0: 用户提交修改 -> 写入写库(成功)
t1: 事件发布到消息队列
t2: 消费者接收事件,更新读库
t3: 用户刷新页面,从读库查询
如果用户在 t0 和 t2 之间刷新页面,看到的是旧数据。
5.2 Read-Your-Writes 一致性
读己所写(Read-Your-Writes Consistency)是核心思路:确保用户总是能读到自己刚写入的数据。
func (s *CourseQueryService) GetCourse(ctx context.Context, courseID string, opts QueryOptions) (*CourseDetail, error) {
if opts.AfterVersion > 0 {
readModel, _ := s.readRepo.FindByID(ctx, courseID)
if readModel != nil && readModel.Version >= opts.AfterVersion {
return readModel, nil
}
return s.writeRepo.FindByIDAsReadModel(ctx, courseID) // 回退到写库
}
return s.readRepo.FindByID(ctx, courseID)
}5.3 乐观 UI
乐观 UI(Optimistic UI):前端在写请求发出后立即用预期结果更新界面,不等待服务端响应。失败时回退。
function useCourseUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateCourseData) => api.updateCourse(data),
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['course', newData.id] });
const previous = queryClient.getQueryData(['course', newData.id]);
queryClient.setQueryData(['course', newData.id], (old: Course) => ({
...old, ...newData, updatedAt: new Date().toISOString(),
}));
return { previous };
},
onError: (_err, newData, context) => {
if (context?.previous) {
queryClient.setQueryData(['course', newData.id], context.previous);
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
},
});
}5.4 版本号 + 轮询策略
写操作返回版本号,前端用该版本号轮询读侧,直到读侧追上:
async function updateAndWaitForSync(courseId: string, data: UpdateCourseData): Promise<Course> {
const result = await api.updateCourse(courseId, data);
const targetVersion = result.version;
for (let i = 0; i < 10; i++) {
const course = await api.getCourse(courseId);
if (course.version >= targetVersion) return course;
await sleep(300);
}
return api.getCourseFromWriteSide(courseId);
}5.5 写后读路由
在写操作完成后的短时间内(如 5 秒),将该用户的读请求路由到写库而非读库。对前端完全透明。
func (r *ReadRouter) GetCourse(ctx context.Context, userID, courseID string) (*CourseDetail, error) {
key := fmt.Sprintf("%s:%s", userID, courseID)
if writeTime, ok := r.writeCache.Get(key); ok {
if time.Since(writeTime) < r.ttl {
return r.getFromWriteDB(ctx, courseID) // 写后短窗口内走写库
}
}
return r.readService.GetCourse(ctx, courseID) // 正常走读库
}TTL 应略大于写库到读库的同步延迟。同步延迟通常在 1 秒以内时,TTL 设为 3-5 秒即可。
六、CQRS 的实现模式
6.1 Go 语言实现
命令定义与处理:
package command
type CreateCourseCommand struct {
Title string
Description string
Price int64
InstructorID string
}
type CreateCourseHandler struct {
repo CourseRepository
publisher EventPublisher
}
func (h *CreateCourseHandler) Handle(ctx context.Context, cmd CreateCourseCommand) error {
if cmd.Title == "" {
return fmt.Errorf("课程标题不能为空")
}
course := &Course{
ID: generateID(), Title: cmd.Title, Description: cmd.Description,
Price: cmd.Price, InstructorID: cmd.InstructorID,
Status: StatusDraft, CreatedAt: time.Now(), Version: 1,
}
if err := h.repo.Save(ctx, course); err != nil {
return fmt.Errorf("保存课程失败: %w", err)
}
return h.publisher.Publish(ctx, CourseCreatedEvent{
CourseID: course.ID, Title: course.Title, CreatedAt: course.CreatedAt,
})
}查询定义与处理:
package query
type ListCoursesQuery struct {
Keyword string
SortBy string
Page int
PageSize int
}
type ListCoursesHandler struct {
readStore CourseReadStore
}
func (h *ListCoursesHandler) Handle(ctx context.Context, q ListCoursesQuery) (*CourseListResult, error) {
if q.PageSize <= 0 || q.PageSize > 100 {
q.PageSize = 20
}
return h.readStore.SearchCourses(ctx, q)
}API 层路由:
func (a *CourseAPI) HandleCreateCourse(w http.ResponseWriter, r *http.Request) {
var cmd command.CreateCourseCommand
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
http.Error(w, "请求格式错误", http.StatusBadRequest)
return
}
if err := a.createHandler.Handle(r.Context(), cmd); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func (a *CourseAPI) HandleListCourses(w http.ResponseWriter, r *http.Request) {
q := query.ListCoursesQuery{
Keyword: r.URL.Query().Get("keyword"),
Page: parseIntOrDefault(r.URL.Query().Get("page"), 1),
PageSize: parseIntOrDefault(r.URL.Query().Get("page_size"), 20),
}
result, err := a.listHandler.Handle(r.Context(), q)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}6.2 Java/Spring 实现
// 命令与处理
public record CreateCourseCommand(String title, String description, long price, String instructorId) {}
@Service
public class CourseCommandHandler {
private final CourseRepository courseRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public String handle(CreateCourseCommand cmd) {
if (cmd.price() < 0) throw new IllegalArgumentException("课程价格不能为负数");
Course course = Course.create(cmd.title(), cmd.description(), cmd.price(), cmd.instructorId());
courseRepository.save(course);
eventPublisher.publishEvent(new CourseCreatedEvent(course.getId(), course.getTitle(), Instant.now()));
return course.getId();
}
}
// 查询服务(直接 SQL,不经过领域模型)
@Service
public class CourseQueryService {
private final JdbcTemplate jdbcTemplate;
public List<CourseListItemDTO> listPublished(String keyword, int page, int size) {
String sql = """
SELECT c.id, c.title, c.price, i.name AS instructor_name,
COUNT(DISTINCT ch.id) AS chapter_count,
COALESCE(AVG(r.score), 0) AS avg_rating
FROM courses c
JOIN instructors i ON c.instructor_id = i.id
LEFT JOIN chapters ch ON c.id = ch.course_id
LEFT JOIN reviews r ON c.id = r.course_id
WHERE c.status = 'published' AND c.title ILIKE ?
GROUP BY c.id, c.title, c.price, i.name
ORDER BY chapter_count DESC LIMIT ? OFFSET ?
""";
return jdbcTemplate.query(sql,
new Object[]{"%" + keyword + "%", size, (page - 1) * size},
(rs, n) -> new CourseListItemDTO(rs.getString("id"), rs.getString("title"),
rs.getLong("price"), rs.getString("instructor_name"),
rs.getInt("chapter_count"), rs.getDouble("avg_rating")));
}
}
// 异步投影
@Component
public class CourseReadModelProjection {
@EventListener
@Async
public void on(CourseCreatedEvent event) {
// 从写库读取完整数据,构建读模型写入读库
}
}6.3 命令验证的位置
命令验证应分两层:
- 格式验证:在 API 层或命令对象自身完成——字段是否为空、格式是否正确。不需要访问数据库。
- 业务规则验证:在命令处理器或领域对象中完成——“课程至少包含一个章节才能发布”。需要读取当前状态。
6.4 查询端的缓存策略
读模型本身就是一种缓存。在此基础上还可以引入 Redis 等缓存层,但要注意在读模型更新时主动失效缓存,而非等待 TTL 过期,否则缓存数据会比读模型更旧。
七、案例分析:在线教育平台的课程管理系统
7.1 业务背景
某在线教育平台”知学堂”(化名),2019 年开始运营,主要业务是录播课程销售。到 2023 年,平台积累了 50000 门课程、300 万注册用户、日活约 20 万。
课程管理系统的核心功能:
- 写端:讲师创建和编辑课程、章节管理、定价、审核流程、学员报名
- 读端:课程列表展示、搜索、推荐、详情页、后台报表
7.2 第一阶段:单体架构(2019-2020)
标准三层 Spring Boot 单体,读写共享 Service 和 Repository,数据一致性完美。
7.3 遇到的问题
2021 年用户量增长到 100 万,问题出现:
问题 1:课程列表查询需要 5 表 JOIN,P99 延迟超过 800 毫秒。
问题 2:讲师编辑课程时的 UPDATE 操作与首页读操作争抢数据库资源,晚间编辑高峰期首页加载变慢。
问题 3:MySQL LIKE 查询不支持分词、拼音搜索。用户搜”Python 入门”找不到标题为”python 基础教程”的课程。
7.4 第二阶段:简单 CQRS(2021)
分离 CommandService 和
QueryService,在数据库中创建反标准化的
course_search
表,预先计算章节数、学员数、评分。课程列表查询从多表 JOIN
变成单表查询,P99 延迟从 800ms 降到 50ms。
7.5 第三阶段:完整 CQRS(2022)
课程数量增长到 30000 门,引入 Elasticsearch 作为独立读存储:
[Web/App] -> [API Gateway]
|
+-- [课程命令服务] -> [MySQL 写库]
| |
| +-- [Debezium CDC] -> [Kafka]
| |
+-- [课程查询服务] -> [Elasticsearch] <-----------+
-> [Redis 缓存]
通过 IK 分词器和拼音分析器,搜索体验显著提升。
7.6 遇到的问题与解决方案
数据同步延迟:讲师修改课程后在列表中看到旧数据。解决方案:写后读路由,修改后 5 秒内直接从 MySQL 查询。
投影服务积压:大促期间大量报名事件导致 Elasticsearch 更新延迟从 200ms 飙升到 30 秒。解决方案:消费者从 1 个增加到 4 个(按 course_id 分区);引入批量写入(Bulk API);报名人数改为每分钟定时统计。
索引重建耗时:50000 门课程全量重建需要 2 小时。解决方案:蓝绿索引策略零停机切换;增量重建追补期间变更;4 个 Worker 并行读取。
7.7 最终效果
| 指标 | 单体架构 | 简单 CQRS | 完整 CQRS |
|---|---|---|---|
| 课程列表 P99 | 800ms | 50ms | 15ms |
| 搜索能力 | LIKE 模糊匹配 | MySQL 全文索引 | ES 中文分词+拼音 |
| 读写隔离 | 无 | 轻微 | 完全 |
| 数据一致性 | 强一致 | 强一致 | 最终一致(~200ms) |
| 运维复杂度 | 低 | 低 | 高 |
八、反模式与常见陷阱
8.1 反模式1:所有服务都用 CQRS
CQRS 增加复杂度。内部管理后台、简单 CRUD 服务、配置管理服务——这些读写模型几乎相同、读写比均衡的场景不需要 CQRS。判断标准:如果用同一个模型同时服务读写没有感受到明显痛苦,就不需要 CQRS。
8.2 反模式2:读模型和写模型共享领域对象
CQRS 的核心在于分离。如果列表查询返回完整的写模型
Course
对象,就失去了核心价值。读侧应使用专门的扁平结构(CourseListItem),只包含展示需要的字段。
8.3 反模式3:忽略最终一致性对业务的影响
引入完整 CQRS 前,必须评估哪些场景能容忍最终一致性。不能容忍的典型场景:
- 库存扣减后立即显示最新库存(超卖风险)
- 支付完成后立即显示订单状态(用户焦虑)
- 权限变更后立即生效(安全风险)
这些场景要么不用完整 CQRS,要么必须实施写后读路由等补偿机制。
8.4 反模式4:过度拆分导致数据同步链路过长
当读模型需要聚合多个微服务的数据时,避免层层级联投影。让聚合投影服务直接订阅所有相关事件源,减少中间环节:
改进前(链式):
[课程服务] -> [课程投影] -+
[讲师服务] -> [讲师投影] -+-> [聚合投影] -> [读库]
改进后(扁平):
[课程服务] --事件--> [消息队列] --+
[讲师服务] --事件--> [消息队列] --+--> [课程搜索投影] -> [读库]
[评价服务] --事件--> [消息队列] --+
九、CQRS 的适用性判断
9.1 Trade-off 对比
| 维度 | 不使用 CQRS | 简单 CQRS | 完整 CQRS |
|---|---|---|---|
| 开发复杂度 | 低:一套模型 | 中:两套模型 | 高:两套模型 + 同步机制 |
| 数据一致性 | 强一致 | 强一致 | 最终一致 |
| 读性能优化空间 | 有限 | 中等:读模型可反标准化 | 极大:读库自由选型 |
| 写性能影响 | 读操作直接影响写 | 部分隔离 | 完全隔离 |
| 独立扩展性 | 不支持 | 有限 | 完全支持 |
| 查询灵活性 | 受限于写 Schema | 可建专用读表 | 可用最佳查询引擎 |
| 运维成本 | 低 | 低~中 | 高 |
| 团队要求 | 初级团队 | 理解读写分离 | 分布式系统经验 |
| 适用读写比 | 均衡或 < 10:1 | 10:1 ~ 100:1 | > 100:1 |
9.2 决策框架
考虑引入 CQRS 的信号:
读写数据模型差异大。读侧需要反标准化扁平结构,写侧需要标准化关系结构,两者硬塞在一起导致 SQL 查询越来越复杂。
读写性能需求差异大。典型的读多写少场景,读操作需要毫秒级响应,写操作可以接受较高延迟。
读侧需要特殊查询能力。如全文搜索(Elasticsearch)、图关系查询(Neo4j)、地理位置查询(PostGIS)。
读侧和写侧的扩展策略不同。写侧需要事务保证,读侧需要高并发水平扩展。
不应该引入 CQRS 的信号:
- 读写模型几乎相同——引入两套模型只增加维护成本。
- 数据量和并发量都不大——CQRS 解决规模问题,小规模系统不需要。
- 团队对分布式系统缺乏经验——完整 CQRS 涉及消息队列、最终一致性、投影重建等概念。
- 业务要求强一致性——完整 CQRS 的最终一致性会成为硬伤。
渐进式引入策略:
CRUD 单体 -> 简单 CQRS(分离读写模型) -> 中间形态(读表/物化视图) -> 完整 CQRS(独立读写库)
每一步的推进都应由具体痛点驱动。当简单 CQRS 能解决问题时,不要引入完整 CQRS 的复杂度。
十、总结
CQRS 的本质是一个朴素的观察:读操作和写操作服务于不同目的,对数据有不同需求,因此可以用不同的模型来处理。
这个观察的力量在于灵活性。从代码层面的不同 DTO,到完全独立的读写数据库,CQRS 可以在任何适当的层次实施。不必一步到位,可以从简单开始,遇到具体瓶颈时逐步深化。
几个核心要点:
第一,CQRS 不等于 Event Sourcing。CQRS 可以搭配传统 CRUD 写模型,通过 CDC、应用层事件或定时同步来构建读模型。不要因为 Event Sourcing 的复杂度而拒绝 CQRS。
第二,读模型是可重建的。读模型的 Schema 变更是低风险的——随时可以用写模型的数据重新生成。利用好这一点,大胆地为不同查询场景设计不同的读模型。
第三,最终一致性需要设计。引入独立读写库后,必须认真处理最终一致性带来的用户体验问题。写后读路由、乐观 UI、版本号轮询不是锦上添花,而是必需品。
第四,不是所有系统都需要 CQRS。CQRS 增加复杂度,只有在读写需求差异足够大时才值得。用同一个模型就能搞定的事情,不要为了”架构正确”而强行拆分。
参考资料
Greg Young, “CQRS Documents” (2010). CQRS 模式的原始定义文档,详细阐述了命令查询职责分离的动机和实现方式。
Martin Fowler, “CQRS” (bliki). 对 CQRS 模式的解读,强调 CQRS 不应作为顶层架构模式使用。 https://martinfowler.com/bliki/CQRS.html
Bertrand Meyer, “Object-Oriented Software Construction”, Second Edition (1997). CQS 原则的原始出处,CQRS 的理论根基。
Udi Dahan, “Clarified CQRS” (2009). 对 CQRS 模式的进一步澄清,讨论了 CQRS 与 SOA、DDD 的关系。 https://udidahan.com/2009/12/09/clarified-cqrs/
Microsoft Azure Architecture Center, “CQRS pattern”. Azure 架构指南中的 CQRS 模式文档,包含实用的实施建议。 https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs
Vaughn Vernon, “Implementing Domain-Driven Design” (2013). 在 DDD 上下文中实施 CQRS 的详细指导。
Chris Richardson, “Microservices Patterns” (2018). 微服务架构中使用 CQRS 模式的实践指导,包含事件驱动的数据管理策略。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署
某金融交易平台在引入事件溯源(Event Sourcing)后,获得了完整的审计日志和时间旅行能力。但三个月后,团队发现一些事件流已经积累了超过 10 万条事件,聚合加载时间从毫秒级退化到秒级。更麻烦的是,业务迭代中修改了事件结构,旧版本事件无法反序列化。这些问题不是事件溯源本身的缺陷,而是工程实践上的坑——教科书通常…
【系统架构设计百科】事件驱动架构:从消息通知到事件溯源
事件通知、事件携带状态转移、事件溯源三种模式经常被混为一谈,但它们在耦合度、数据一致性、存储成本和调试难度上有本质差异。本文基于 Martin Fowler 的 EDA 分类,拆解三种模式的机制与取舍,分析 Kafka 在事件驱动架构中的角色与局限,讨论事件排序的工程挑战和 schema 演进策略。
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。