Skip to content

第22讲:CQRS如何设计与实现

本课时和紧接着的第 23 课时将介绍 CQRS 技术相关的内容,本课时侧重讲解 CQRS 技术的基本概念,下一课时将重点讲解 CQRS 技术在示例应用中的使用。

CQRS 是命令和查询的职责分离(Command Query Responsibility Segregation)对应的英文名称的首字母缩写。CQRS 中的命令指的是对数据的更新操作,而查询指的是对数据的读取操作,命令和查询的职责分离指的是用不同的模型来分别进行更新和读取操作。CQRS 与我们通常使用的更新和读取数据的方式并不相同。

我们通常对数据的操作方式是典型的 CRUD 操作,分别表示对记录的创建(Create)、读取(Read)、更新(Update)和删除(Delete)。在有些时候,还会加上一个列表(List)操作来读取满足条件的多个记录,组成 LCRUD 操作,CRUD 操作使用的是同一个模型。在面向对象的设计中,通常使用领域对象类来作为模型的描述,在进行持久化时,领域对象的实例被映射成关系型数据库中的表中的记录,或是 NoSQL 数据库中的文档等。这样的实现方式,相信很多开发人员都不陌生,也是开发中经常会用到的模式。很多开发框架都提供了对这种模式的支持,Spring Data 中的 CrudRepository 接口就提供了对 LCRUD 操作的基本抽象。

下图是单一模型的使用示意图,其中的模型在数据存储时使用,而展示模型则提供给客户端使用。更新和读取操作需要在这两个模型之间进行转换。

对更新和读取操作使用单一模型的好处是简单易懂,实现起来也容易,开发人员相关的经验比较丰富。但是单一模型在某些情况下也会遇到一些问题,这也是 CQRS 技术的用武之地。

单一模型的问题

单一模型要面对的问题是如何用一个模型来满足不同的更新和查询请求。当模型比较简单,或是模型的使用者比较少时,这并不是一个太大的问题;当模型变得复杂,或是需要满足很多使用者的不同需求时,维护这样的模型就变得很困难。

在一个应用中,总是有一些模型处于核心的地位,比如电子商务应用中的订单、客户和产品等模型,应用中的各种组件,都或多或少需要用到这些核心模型。如此多的依赖关系,导致核心模型的修改变得很困难,大部分代码在使用时,只需要用到核心模型的部分内容。在进行读取操作时,免不了要根据使用者的需要,对模型进行投影(Projection)和转换操作。投影指的是从模型中选择所需要的数据子集,而转换则是把模型转换成另外一种格式。在进行更新操作时,也需要先把客户端发送的模型转换成内部的单一模型,这样的模型转换会带来一定的性能开销。

模型转换的问题在使用关系型数据库时尤为明显。这是因为关系型数据库在设计时需要遵循不同的范式。规范化的结果是数据查询时可能需要进行多表的连接操作,影响性能。对于这个问题,通常的做法是创建一个单独的报表数据库来满足查询请求。报表数据库的表设计方便更好地满足查询需求,而数据则来源于业务数据库。两个数据库之间的数据同步和表模式转换,一般通过 ETL 工具来完成。这实际上是对更新和查询使用不同模型的做法的一种应用。

CQRS 的应用范围

与传统应用使用单一模型进行全部操作相比,CQRS 分别使用两个不同的模型来进行更新和查询操作。从一个模型到两个模型,所带来的复杂度的提升并不只是简单的翻倍,开发人员需要花费更多的时间来理解这两个模型的使用。只有当 CQRS 所带来的好处,超过它本身引入的复杂度时,使用 CQRS 技术才是有意义的。实际上,对于大部分应用来说,使用传统的单一模型的方式确实更好。适合于 CQRS 技术的应用主要有两类:第一类应用的更新模型和查询模型本身就存在很大差异,第二类应用在更新和查询操作时有不同的性能要求。

如果回顾第 17 课时介绍的事件源技术,可以发现使用事件源技术的应用在更新和查询时的模型是不相同的。事件源技术使用不同类型的事件来表示对状态的修改,而查询时则通过依次应用事件的修改,从而得到相关的结果对象。这使得事件源技术很适合与 CQRS 技术一块使用,实际上,这两者也经常被一块提及。

有些应用在更新和查询时有不同的性能需求,使用单一模型没办法满足这一性能需求,这一点与在算法设计时选择数据结构的思路是相似的。在修改和访问这两种操作中,不同数据结构的时间复杂度是不同的,有些应用的查询操作的数量远多于更新操作,因此需要对查询操作进行优化。使用 CQRS 技术把更新和查询两种操作进行分离之后,就可以对它们分别进行针对性的优化,在运行时可以采用不同的扩展策略。比如,可以为查询操作添加数量很多的运行实例。

在微服务架构的应用中,由于每个微服务各自独立,我们可以只把 CQRS 技术应用在其中的某个微服务上。这样可以充分利用 CQRS 技术的优势,同时避免对整个应用进行较大的改动。

CQRS 的设计

CQRS 的设计要点是为查询和命令创建不同的模型,命令模型 用来对数据进行修改,用来描述对数据修改的意愿,而查询模型则用来读取数据,这里需要把命令和事件进行区分。命令描述的是改变状态的期望,而事件则是状态改变的结果。在电子商务网站中,用户可以通过点击相关的按钮来表示希望取消订单的意愿,这会以命令的形式发送到服务器。服务器的命令处理逻辑负责更新数据,并根据处理结果发布不同的事件。发布事件并不是必需的动作。

下图是 CQRS 设计的示意图,需要注意的是其中的箭头表示数据流向。数据查询操作从数据存储开始,转换成查询模型之后,提供给客户端使用;数据更新操作从客户端开始,转换成命令模型之后,保存到数据存储中。

命令模型中只包含必需的信息,一个应用中可能包含很多适用于不同用户场景的命令。以取消订单为例,相应的命令只需要包含订单的标识符即可。

查询模型的设计专门为满足查询请求进行了优化,查询模型在设计时,考虑更多的是使用者的需求。查询模型的使用者通常是用户界面,根据用户界面展示时的要求,来设计查询模型。比如,在电子商务应用中,用户界面需要展示所有订单分类之后的统计信息。如果使用单一模型,需要在读取数据之后,再进行额外的计算来得到统计信息,这种做法的性能相对较差。如果专门为了统计信息设计相应的查询模型,那么只需要直接读取即可,并不需要额外的计算。

CQRS 的实现

实现 CQRS 的重点是更新和查询模型的实现,结合前面课时中对事务性消息的介绍,命令本质上是一种消息。后端实现中通常会使用消息队列或消息中间件来接收命令,接收到的命令都需要进行验证来保证合法性。命令的验证包括两部分:一部分与业务无关,只是检查命令是否满足结构上的要求,比如是否缺失必需的字段等;另一部分则与业务相关,需要根据命令执行时的上下文来确定,比如订单支付命令所处理的订单对象,是否处于合法的状态。通过验证的命令会按照接收的顺序来执行。执行顺序的错误可能造成数据不一致,命令在执行时会更新数据存储。

查询模型的设计需求来自使用者,查询模型通常不包含复杂的业务逻辑,只是作为数据的容器。这使得查询模型使用起来很简单。

下面通过一个具体的实例来说明 CQRS 技术的实现,该实例以银行账户的存款和取款操作作为应用场景。

下面代码中的 AccountCommand 类是账户相关的命令的模型,Command 是所有命令的标记接口。AccountCommand 的子类 CreditCommand 和 DebitCommand 分别表示存款和取款操作。命令只是简单的 POJO 对象。

java
@Getter
public abstract class AccountCommand implements Command {
  private final String accountId;
  private final AccountAction action;
  private final MonetaryAmount amount;
  private final long timestamp = System.currentTimeMillis();
  protected AccountCommand(final String accountId,
      final AccountAction action,
      final MonetaryAmount amount) {
    this.accountId = accountId;
    this.action = action;
    this.amount = amount;
  }
}

查询模型的设计取决于对数据的使用需求。实例的两个使用需求分别是获取当前的账户余额和账户的历史操作。下面代码中的 AccountBalance 类负责维护所有账户的余额信息,其中的 updateBalance 和 getBalance 方法分别用来更新和获取账户的余额。这里使用一个内存中的哈希表作为数据存储。

java
public class AccountBalance {
  private final ConcurrentHashMap<String, MonetaryAmount> data = new ConcurrentHashMap<>();
  public void updateBalance(final String accountId,
      final MonetaryAmount delta) {
    this.data.compute(accountId, (id, current) -> {
      if (current == null) {
        return delta;
      }
      return current.add(delta);
    });
  }
  public MonetaryAmount getBalance(final String accountId) {
    return this.data.getOrDefault(accountId, Money.of(0, "CNY"));
  }
}

下面代码中的 AccountHistory 类负责维护用户账户的历史操作记录,其中的 addHistoryItem 方法用来添加表示历史记录的 AccountHistoryItem 对象,而 getHistoryItems 方法则返回一个账户的全部历史记录。这里同样使用内存中的哈希表作为数据存储。

java
public class AccountHistory {
  private final ConcurrentHashMap<String, List<AccountHistoryItem>> data = new ConcurrentHashMap<>();
  public void addHistoryItem(final AccountHistoryItem item) {
    this.data.compute(item.getAccountId(), (id, items) -> {
      if (items == null) {
        items = new ArrayList<>();
      }
      items.add(item);
      return items;
    });
  }
  public List<AccountHistoryItem> getHistoryItems(final String accountId) {
    return this.data.getOrDefault(accountId, Collections.emptyList());
  }
}

AccountBalance 和 AccountHistory 两个类作为命令模型和查询模型共用的数据存储。下面代码中的 CommandProcessor 类用来处理不同类型的命令。对于 AccountCommand 对象,根据命令的类型,对 AccountBalance 和 AccountHistory 中包含的数据进行不同的修改。

java
public class CommandProcessor {
  private final AccountHistory accountHistory;
  private final AccountBalance accountBalance;
  public CommandProcessor(
      final AccountHistory accountHistory,
      final AccountBalance accountBalance) {
    this.accountHistory = accountHistory;
    this.accountBalance = accountBalance;
  }
  public void process(final Command command) {
    if (command instanceof AccountCommand) {
      final AccountCommand cmd = (AccountCommand) command;
      final AccountHistoryItem item = new AccountHistoryItem(
          cmd.getAccountId(),
          cmd.getAction(),
          cmd.getAmount(),
          cmd.getTimestamp()
      );
      this.accountHistory.addHistoryItem(item);
      if (cmd instanceof CreditCommand) {
        this.accountBalance.updateBalance(cmd.getAccountId(), cmd.getAmount());
      } else if (cmd instanceof DebitCommand) {
        this.accountBalance
            .updateBalance(cmd.getAccountId(), cmd.getAmount().negate());
      }
    }
  }
}

下面代码中的 AccountQueryService 类用来执行对账户对象的查询操作,所使用的数据存储同样是 AccountBalance 和 AccountHistory 这两个对象。getAccountSummary 方法的返回值 AccountSummary 对象是查询模型,包含了账户余额和历史记录。

java
public class AccountQueryService {
  private final AccountHistory accountHistory;
  private final AccountBalance accountBalance;
  public AccountQueryService(
      final AccountHistory accountHistory,
      final AccountBalance accountBalance) {
    this.accountHistory = accountHistory;
    this.accountBalance = accountBalance;
  }
  public AccountSummary getAccountSummary(final String accountId) {
    return new AccountSummary(accountId,
        this.accountBalance.getBalance(accountId),
        this.accountHistory.getHistoryItems(accountId).stream()
            .sorted(Comparator.comparingLong(AccountHistoryItem::getTimestamp)
                .reversed())
            .map(
                item -> new HistoryItem(item.getAction(), item.getAmount(),
                    this.formatDateTime(item.getTimestamp()))
            ).collect(Collectors.toList())
    );
  }
  private String formatDateTime(final long timestamp) {
    return LocalDateTime
        .ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault())
        .format(DateTimeFormatter.ISO_DATE_TIME);
  }
}

总结

与传统应用使用单一模型相比,CQRS 技术使用两个不同的模型来进行更新和查询操作。对于某些应用来说,CQRS 技术可以更好地对应用建模,以及提高性能。本课时对 CQRS 技术的设计和实现进行了基本的介绍,通过本课时的学习,你可以对 CQRS 技术有一定的了解,知道在什么情况下使用 CQRS 技术是一个好的选择。