1. 引言
本文将深入探讨命令查询职责分离(CQRS)和事件溯源(Event Sourcing)这两种设计模式的核心概念。
尽管它们常被并列提及、相辅相成,但我们会先分别理解各自的原理,再探讨它们如何协同工作。虽然有 Axon 等框架可以简化实现,但本文将从零开始构建一个简单的 Java 应用,帮助你真正掌握底层机制。
2. 基础概念
在动手编码前,我们先从理论层面理解这两个模式。它们各自独立成立,因此我们先分开讨论。当然,在企业级应用中,它们常常结合使用,并受益于其他架构模式的支持。
2.1. 事件溯源(Event Sourcing)
事件溯源提供了一种全新的方式来持久化应用状态:将状态变化记录为一系列有序的事件。通过选择性地查询这些事件,我们可以重建应用在任意时间点的状态。
关键在于:每一次状态变更都被视为一个不可变的“事实”事件。这意味着事件一旦发生就不能被修改。
重建应用状态,本质上就是“重放”所有事件的过程。这带来了巨大的灵活性:你可以选择性重放、反向重放,甚至进行“时间旅行”调试。最终,事件日志成为你系统的“唯一事实来源”(Single Source of Truth),而应用状态反而成了衍生品。
2.2. CQRS(命令查询职责分离)
简单来说,CQRS 就是将应用架构的“写”(命令)和“读”(查询)职责清晰地分离开。它基于 Bertrand Meyer 提出的“命令查询分离”(CQS)原则:
- ✅ 查询(Query):返回结果,但不改变系统的可观测状态。
- ✅ 命令(Command):改变系统状态,通常不返回值(或只返回状态码)。
CQRS 将这一原则贯彻到架构层面,不仅分离了业务逻辑,还可以进一步将数据存储的读写两边也拆开,当然,这需要一个同步机制来保证两边数据的一致性(通常是最终一致性)。
3. 一个简单的应用
我们将从一个典型的 CRUD 应用入手,逐步引入 CQRS 和事件溯源。
该应用提供对领域模型的增删改查操作,并包含数据持久化。我们也会借鉴一些领域驱动设计(DDD)的概念。
3.1. 应用概览
管理用户档案是一个常见需求。我们的领域模型如下:
如图所示,这是一个规范化的模型,暴露了多个 CRUD 接口。这里的持久化可以是内存存储,也可以是数据库。
3.2. 应用实现
首先,定义领域模型:
public class User {
private String userid;
private String firstName;
private String lastName;
private Set<Contact> contacts;
private Set<Address> addresses;
// getters and setters
}
public class Contact {
private String type;
private String detail;
// getters and setters
}
public class Address {
private String city;
private String state;
private String postcode;
// getters and setters
}
接着,一个简单的内存仓库:
public class UserRepository {
private Map<String, User> store = new HashMap<>();
}
最后,服务层暴露 CRUD 接口:
public class UserService {
private UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public void createUser(String userId, String firstName, String lastName) {
User user = new User(userId, firstName, lastName);
repository.addUser(userId, user);
}
public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
User user = repository.getUser(userId);
user.setContacts(contacts);
user.setAddresses(addresses);
repository.addUser(userId, user);
}
public Set<Contact> getContactByType(String userId, String contactType) {
User user = repository.getUser(userId);
Set<Contact> contacts = user.getContacts();
return contacts.stream()
.filter(c -> c.getType().equals(contactType))
.collect(Collectors.toSet());
}
public Set<Address> getAddressByRegion(String userId, String state) {
User user = repository.getUser(userId);
Set<Address> addresses = user.getAddresses();
return addresses.stream()
.filter(a -> a.getState().equals(state))
.collect(Collectors.toSet());
}
}
代码很简单,但正是这种“简单”暴露了问题。
3.3. 当前应用的痛点
这个 CRUD 模型看似正常,但在复杂场景下会踩坑:
- ⚠️ 领域模型僵化:读写操作共用同一套模型。简单场景没问题,但复杂模型下,读写需求差异巨大,很难同时优化。
- ⚠️ 持久化信息丢失:仓库只存最新状态。想做历史审计、状态回滚?没门!除非额外加日志,但这就成了补丁。
4. 引入 CQRS
我们先用 CQRS 解决第一个问题:分离读写模型。
4.1. 实现写端(Command Side)
写端负责处理状态变更。
定义命令(Command):代表一个状态变更意图。
public class CreateUserCommand {
private String userId;
private String firstName;
private String lastName;
}
public class UpdateUserCommand {
private String userId;
private Set<Address> addresses;
private Set<Contact> contacts;
}
定义聚合根(Aggregate Root):DDD 中的核心概念,是写模型的入口,负责业务规则校验和状态变更。
public class UserAggregate {
private UserWriteRepository writeRepository;
public UserAggregate(UserWriteRepository repository) {
this.writeRepository = repository;
}
public User handleCreateUserCommand(CreateUserCommand command) {
User user = new User(command.getUserId(), command.getFirstName(), command.getLastName());
writeRepository.addUser(user.getUserid(), user);
return user;
}
public User handleUpdateUserCommand(UpdateUserCommand command) {
User user = writeRepository.getUser(command.getUserId());
user.setAddresses(command.getAddresses());
user.setContacts(command.getContacts());
writeRepository.addUser(user.getUserid(), user);
return user;
}
}
写仓库(Write Repository):持久化写模型状态。
public class UserWriteRepository {
private Map<String, User> store = new HashMap<>();
// accessors and mutators
}
4.2. 实现读端(Query Side)
读端负责高效响应查询。
定义读模型:针对查询场景优化的数据结构。
public class UserAddress {
private Map<String, Set<Address>> addressByRegion = new HashMap<>();
}
public class UserContact {
private Map<String, Set<Contact>> contactByType = new HashMap<>();
}
读仓库(Read Repository):存储优化后的读模型。
public class UserReadRepository {
private Map<String, UserAddress> userAddress = new HashMap<>();
private Map<String, UserContact> userContact = new HashMap<>();
// accessors and mutators
}
定义查询(Query):
public class ContactByTypeQuery {
private String userId;
private String contactType;
}
public class AddressByRegionQuery {
private String userId;
private String state;
}
定义投影器(Projector):处理查询,返回结果。
public class UserProjection {
private UserReadRepository readRepository;
public UserProjection(UserReadRepository readRepository) {
this.readRepository = readRepository;
}
public Set<Contact> handle(ContactByTypeQuery query) {
UserContact userContact = readRepository.getUserContact(query.getUserId());
return userContact.getContactByType()
.get(query.getContactType());
}
public Set<Address> handle(AddressByRegionQuery query) {
UserAddress userAddress = readRepository.getUserAddress(query.getUserId());
return userAddress.getAddressByRegion()
.get(query.getState());
}
}
4.3. 同步读写数据
CQRS 的核心挑战:如何保持读写模型的数据同步?
我们引入一个 UserProjector
,它监听写模型的变更,并更新读模型:
public class UserProjector {
UserReadRepository readRepository = new UserReadRepository();
public UserProjector(UserReadRepository readRepository) {
this.readRepository = readRepository;
}
public void project(User user) {
// ... 将 User 的 contacts 映射到 UserContact ...
// ... 将 User 的 addresses 映射到 UserAddress ...
// 代码略,见原文
}
}
⚠️ 这种“状态投影”方式简单粗暴,但在复杂模型下会非常难维护。更好的方式是“事件投影”——这正是事件溯源的用武之地。
4.4. CQRS 的利与弊
✅ 优点:
- 读写模型分离,各自优化,架构更灵活。
- 可选用最适合的存储(如写用 Kafka,读用 Elasticsearch)。
- 天然适合事件驱动、微服务架构。
❌ 缺点:
- 复杂度飙升:对简单应用是过度设计。
- 代码重复:读写模型难免有重复逻辑。
- 一致性难题:只能做到最终一致性,强一致性成本极高。
5. 引入事件溯源
现在解决第二个痛点:持久化只存最新状态。
事件溯源将仓库改为存储事件流:
5.1. 实现事件与事件仓库
定义基础事件:
public abstract class Event {
public final UUID id = UUID.randomUUID();
public final Date created = new Date();
}
定义领域事件:
public class UserCreatedEvent extends Event {
private String userId;
private String firstName;
private String lastName;
}
public class UserContactAddedEvent extends Event {
private String contactType;
private String contactDetails;
}
// ... 其他事件:UserContactRemovedEvent, UserAddressAddedEvent 等
事件仓库(Event Store):
public class EventStore {
private Map<String, List<Event>> store = new HashMap<>();
}
5.2. 生成与消费事件
服务层不再直接修改状态,而是发布事件:
public class UserService {
private EventStore repository;
public void createUser(String userId, String firstName, String lastName) {
repository.addEvent(userId, new UserCreatedEvent(userId, firstName, lastName));
}
public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
User user = UserUtility.recreateUserState(repository, userId);
// 计算差异,发布增删事件
// ... 略 ...
}
public Set<Contact> getContactByType(String userId, String contactType) {
User user = UserUtility.recreateUserState(repository, userId);
return user.getContacts().stream()
.filter(c -> c.getType().equals(contactType))
.collect(Collectors.toSet());
}
}
关键点:recreateUserState()
通过重放所有事件来重建当前状态。
5.3. 事件溯源的利与弊
✅ 优点:
- 写操作极快(追加日志)。
- 天然审计日志,可追溯任何状态变更。
- 支持“时间旅行”查询历史状态。
- 消除 ORM 阻抗。
❌ 缺点:
- 学习成本高,思维转变大。
- 查询性能差(需重放事件),通常需配合快照(Snapshot)。
- 并非所有场景都适用。
6. CQRS 与事件溯源结合
当 CQRS 遇上事件溯源,威力倍增。事件流成为连接读写两边的“桥梁”。
6.1. 两者结合的实现
核心改动:
- 聚合根不再直接修改状态,而是发布事件:
public class UserAggregate {
private EventStore writeRepository;
public List<Event> handleCreateUserCommand(CreateUserCommand command) {
UserCreatedEvent event = new UserCreatedEvent(command.getUserId(),
command.getFirstName(), command.getLastName());
writeRepository.addEvent(command.getUserId(), event);
return Arrays.asList(event); // 返回事件列表
}
public List<Event> handleUpdateUserCommand(UpdateUserCommand command) {
// ... 重建状态,计算差异,发布多个事件 ...
return events; // 返回所有生成的事件
}
}
- 投影器监听事件,而非状态:
public class UserProjector {
public void project(String userId, List<Event> events) {
for (Event event : events) {
if (event instanceof UserAddressAddedEvent)
apply(userId, (UserAddressAddedEvent) event);
// ... 处理其他事件类型
}
}
public void apply(String userId, UserAddressAddedEvent event) {
// 直接将事件应用到读模型
UserAddress userAddress = readRepository.getUserAddress(userId) or new UserAddress();
userAddress.getAddressByRegion().computeIfAbsent(event.getState(), k -> new HashSet<>()).add(new Address(...));
readRepository.save(userAddress);
}
}
✅ 这才是正确的打开方式!事件驱动的投影(Event-based Projection)比状态驱动的投影(State-based Projection)更清晰、更可靠、更容易扩展。
7. 总结
本文从零构建了一个应用,逐步引入了 CQRS 和事件溯源。我们看到了:
- CQRS 解决了读写模型的分离与优化。
- 事件溯源解决了状态持久化的可追溯性与性能。
- 两者结合,通过事件流作为“粘合剂”,构建出高内聚、低耦合、可审计、易扩展的系统。
⚠️ 重要提醒:这些模式是“大杀器”,不是“万金油”。它们会显著增加系统复杂度。只有在领域模型足够复杂、读写需求差异巨大、且需要审计追溯时,才值得引入。对于简单的 CRUD 应用,老老实实写代码才是正道。
文中所有示例代码均可在 GitHub 仓库 找到。