土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】CQRS:读写分离的架构哲学

文章导航

分类入口
architecture
标签入口
#CQRS#read-write-separation#event-sourcing#materialized-view

目录

一个电商平台的商品详情页,每秒承受 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 命令验证的位置

命令验证应分两层:

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 的信号:

  1. 读写数据模型差异大。读侧需要反标准化扁平结构,写侧需要标准化关系结构,两者硬塞在一起导致 SQL 查询越来越复杂。

  2. 读写性能需求差异大。典型的读多写少场景,读操作需要毫秒级响应,写操作可以接受较高延迟。

  3. 读侧需要特殊查询能力。如全文搜索(Elasticsearch)、图关系查询(Neo4j)、地理位置查询(PostGIS)。

  4. 读侧和写侧的扩展策略不同。写侧需要事务保证,读侧需要高并发水平扩展。

不应该引入 CQRS 的信号:

  1. 读写模型几乎相同——引入两套模型只增加维护成本。
  2. 数据量和并发量都不大——CQRS 解决规模问题,小规模系统不需要。
  3. 团队对分布式系统缺乏经验——完整 CQRS 涉及消息队列、最终一致性、投影重建等概念。
  4. 业务要求强一致性——完整 CQRS 的最终一致性会成为硬伤。

渐进式引入策略:

CRUD 单体 -> 简单 CQRS(分离读写模型) -> 中间形态(读表/物化视图) -> 完整 CQRS(独立读写库)

每一步的推进都应由具体痛点驱动。当简单 CQRS 能解决问题时,不要引入完整 CQRS 的复杂度。


十、总结

CQRS 的本质是一个朴素的观察:读操作和写操作服务于不同目的,对数据有不同需求,因此可以用不同的模型来处理。

这个观察的力量在于灵活性。从代码层面的不同 DTO,到完全独立的读写数据库,CQRS 可以在任何适当的层次实施。不必一步到位,可以从简单开始,遇到具体瓶颈时逐步深化。

几个核心要点:

第一,CQRS 不等于 Event Sourcing。CQRS 可以搭配传统 CRUD 写模型,通过 CDC、应用层事件或定时同步来构建读模型。不要因为 Event Sourcing 的复杂度而拒绝 CQRS。

第二,读模型是可重建的。读模型的 Schema 变更是低风险的——随时可以用写模型的数据重新生成。利用好这一点,大胆地为不同查询场景设计不同的读模型。

第三,最终一致性需要设计。引入独立读写库后,必须认真处理最终一致性带来的用户体验问题。写后读路由、乐观 UI、版本号轮询不是锦上添花,而是必需品。

第四,不是所有系统都需要 CQRS。CQRS 增加复杂度,只有在读写需求差异足够大时才值得。用同一个模型就能搞定的事情,不要为了”架构正确”而强行拆分。


参考资料

  1. Greg Young, “CQRS Documents” (2010). CQRS 模式的原始定义文档,详细阐述了命令查询职责分离的动机和实现方式。

  2. Martin Fowler, “CQRS” (bliki). 对 CQRS 模式的解读,强调 CQRS 不应作为顶层架构模式使用。 https://martinfowler.com/bliki/CQRS.html

  3. Bertrand Meyer, “Object-Oriented Software Construction”, Second Edition (1997). CQS 原则的原始出处,CQRS 的理论根基。

  4. Udi Dahan, “Clarified CQRS” (2009). 对 CQRS 模式的进一步澄清,讨论了 CQRS 与 SOA、DDD 的关系。 https://udidahan.com/2009/12/09/clarified-cqrs/

  5. Microsoft Azure Architecture Center, “CQRS pattern”. Azure 架构指南中的 CQRS 模式文档,包含实用的实施建议。 https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs

  6. Vaughn Vernon, “Implementing Domain-Driven Design” (2013). 在 DDD 上下文中实施 CQRS 的详细指导。

  7. Chris Richardson, “Microservices Patterns” (2018). 微服务架构中使用 CQRS 模式的实践指导,包含事件驱动的数据管理策略。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署

某金融交易平台在引入事件溯源(Event Sourcing)后,获得了完整的审计日志和时间旅行能力。但三个月后,团队发现一些事件流已经积累了超过 10 万条事件,聚合加载时间从毫秒级退化到秒级。更麻烦的是,业务迭代中修改了事件结构,旧版本事件无法反序列化。这些问题不是事件溯源本身的缺陷,而是工程实践上的坑——教科书通常…

2026-04-13 · architecture

【系统架构设计百科】事件驱动架构:从消息通知到事件溯源

事件通知、事件携带状态转移、事件溯源三种模式经常被混为一谈,但它们在耦合度、数据一致性、存储成本和调试难度上有本质差异。本文基于 Martin Fowler 的 EDA 分类,拆解三种模式的机制与取舍,分析 Kafka 在事件驱动架构中的角色与局限,讨论事件排序的工程挑战和 schema 演进策略。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。


By .