重构与CQRS:重构读写混合的代码为命令查询职责分离
说实话,我见过太多系统最后死在「读写不分家」上。
你想想看,一个方法里既查数据又改数据,刚开始觉得挺方便。等业务复杂了,查询逻辑和命令逻辑搅在一起,改一个地方崩一片。我在项目中遇到过好几次这种场景——线上突然慢得像蜗牛,一查发现是某个查询把写操作的锁给堵了。
嗯,今天咱们就聊聊怎么用 CQRS 把这团乱麻理清楚。
什么是 CQRS?说白了就是「读写分离」的升级版
CQRS 全称 Command Query Responsibility Segregation,命令查询职责分离。核心思想很简单:读操作和写操作走不同的模型。
命令(Command)负责改变状态,查询(Query)负责获取数据。两者互不干扰。
我个人的习惯是:先看代码里有没有一个方法既返回数据又修改数据。如果有,那就是重构的信号。
核心原则:
- 命令:有副作用,改变系统状态,不返回业务数据(只返回成功/失败)
- 查询:无副作用,只读数据,不修改任何状态
重构前的「读写混合」代码长什么样?
咱们先看一个典型的反面教材。这是一个用户服务,里面有个方法叫 updateAndGetProfile——名字就暴露了问题。
public class UserService {
private UserRepository userRepository;
private EmailService emailService;
// 既更新又查询,还发了邮件
public UserProfile updateAndGetProfile(Long userId, String newEmail) {
User user = userRepository.findById(userId);
if (user == null) {
throw new UserNotFoundException("用户不存在");
}
// 命令:更新邮箱
user.setEmail(newEmail);
user.setUpdatedAt(LocalDateTime.now());
userRepository.save(user);
// 命令:发送通知
emailService.sendEmailChangeNotification(user.getOldEmail(), newEmail);
// 查询:返回用户资料
UserProfile profile = new UserProfile();
profile.setUserId(user.getId());
profile.setName(user.getName());
profile.setEmail(user.getEmail());
profile.setLastLogin(user.getLastLogin());
return profile;
}
}
这段代码有什么问题?我列几个:
- 职责混乱:一个方法干了三件事——更新、发通知、查资料
- 难以测试:想测查询逻辑,还得先跑一遍更新和发邮件
- 性能隐患:如果查询很频繁,每次都要连带执行写操作,锁竞争严重
- 扩展困难:想给查询加缓存?得先拆开再说
我曾经在一个电商项目里见过类似代码,一个订单查询接口里居然包含了库存扣减逻辑。结果压测时查询量一大,库存扣减的锁把整个服务拖垮了。嗯,那次事故之后,团队痛下决心重构。
重构第一步:分离命令和查询的接口
重构不是一蹴而就的。我建议分三步走,每一步都能独立上线验证。
第一步:定义清晰的命令接口和查询接口
// 命令接口:只执行操作,不返回业务数据
public interface Command<T> {
void execute(T commandData);
}
// 查询接口:只返回数据,不修改状态
public interface Query<T, R> {
R execute(T queryData);
}
你看,命令的 execute 返回 void,查询的 execute 返回结果。这个区别很重要。
我的小技巧:命令方法名用动词短语,比如 updateEmail、placeOrder。查询方法名用名词短语,比如 getUserProfile、findOrdersByUserId。光看名字就知道是读还是写。
重构第二步:把原来的混合方法拆成两个
把 updateAndGetProfile 拆成 UpdateEmailCommand 和 GetUserProfileQuery。
// 命令:更新邮箱
public class UpdateEmailCommand implements Command<UpdateEmailData> {
private UserRepository userRepository;
private EmailService emailService;
@Override
public void execute(UpdateEmailData data) {
User user = userRepository.findById(data.getUserId());
if (user == null) {
throw new UserNotFoundException("用户不存在");
}
user.setEmail(data.getNewEmail());
user.setUpdatedAt(LocalDateTime.now());
userRepository.save(user);
emailService.sendEmailChangeNotification(user.getOldEmail(), data.getNewEmail());
}
}
// 查询:获取用户资料
public class GetUserProfileQuery implements Query<Long, UserProfile> {
private UserRepository userRepository;
@Override
public UserProfile execute(Long userId) {
User user = userRepository.findById(userId);
if (user == null) {
throw new UserNotFoundException("用户不存在");
}
UserProfile profile = new UserProfile();
profile.setUserId(user.getId());
profile.setName(user.getName());
profile.setEmail(user.getEmail());
profile.setLastLogin(user.getLastLogin());
return profile;
}
}
现在调用方可以按需使用:
// 只更新
updateEmailCommand.execute(new UpdateEmailData(userId, "new@example.com"));
// 只查询
UserProfile profile = getUserProfileQuery.execute(userId);
注意:拆开之后,如果调用方需要「更新完立刻查最新数据」,不要图省事又写回一个方法。正确的做法是:先执行命令,再执行查询。如果担心数据一致性,可以用事务或事件驱动来保证。
重构第三步:引入独立的读模型和写模型
这是 CQRS 的进阶玩法。当查询和命令的模型差异越来越大时,可以考虑让它们各自拥有独立的数据模型。
举个例子:写模型可能是规范化的关系表,读模型可能是反范式化的视图或者缓存。
// 写模型:规范化的实体
@Entity
public class User {
@Id
private Long id;
private String name;
private String email;
private String oldEmail;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime lastLogin;
// ... 其他字段
}
// 读模型:轻量化的 DTO,可能来自缓存或物化视图
public class UserProfile {
private Long userId;
private String name;
private String email;
private LocalDateTime lastLogin;
// 只包含查询需要的字段
}
我建议在项目初期不要急着上独立的读模型。先做好接口分离,等确实遇到性能瓶颈或模型差异过大时,再引入独立的读存储。过早优化是万恶之源嘛。
CQRS 的核心流程图
下面这张图展示了 CQRS 的基本架构。命令和查询走不同的路径,互不干扰。
你看,命令和查询从入口就分开了。写模型更新后,通过事件或定时任务同步到读模型。读模型可以针对查询场景做优化,比如建索引、缓存、甚至用不同的数据库。
什么时候该用 CQRS?什么时候不该用?
我整理了一个表格,帮你快速判断:
| 场景 | 适合 CQRS | 不适合 CQRS |
|---|---|---|
| 读写比例 | 读远多于写,或写远多于读 | 读写比例接近 1:1 |
| 模型复杂度 | 读模型和写模型差异很大 | 读模型和写模型几乎一样 |
| 性能要求 | 查询需要独立优化(缓存、索引、反范式) | 简单 CRUD,性能不是瓶颈 |
| 团队规模 | 团队有足够人力维护两套模型 | 小团队,人力紧张 |
| 一致性要求 | 可以接受最终一致性 | 要求强一致性,读写必须实时同步 |
避坑指南:我曾经在一个小项目里强行上了 CQRS,结果团队只有三个人,维护两套模型累得半死。后来我学乖了——先做接口分离,等确实需要独立模型时再上。CQRS 是工具,不是银弹。
重构后的收益
把读写混合的代码重构为 CQRS 之后,你会看到这些变化:
- 可读性提升:每个方法只做一件事,名字就能说明意图
- 可测试性提升:命令和查询可以独立测试,不用再 mock 一堆无关依赖
- 性能优化空间:查询可以加缓存、建索引,不用担心影响写操作
- 扩展性增强:想加新查询?直接加一个 Query 类,不影响现有命令
- 并发安全性:写操作不再被查询阻塞,锁竞争大大减少
嗯,说到底,CQRS 不是什么高深莫测的模式。它就是把「读写分离」这个朴素思想做到极致。你从今天开始,看到代码里有一个方法既读又写,就可以考虑拆一拆。不用一步到位,先拆接口,再拆模型,慢慢来。
我个人习惯是:每次重构只改一个点,改完跑测试,确认没问题再改下一个。这样风险最小,收益最大。
公众号:蓝海资料掘金营,微信deep3321