重构与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 返回结果。这个区别很重要。

我的小技巧:命令方法名用动词短语,比如 updateEmailplaceOrder。查询方法名用名词短语,比如 getUserProfilefindOrdersByUserId。光看名字就知道是读还是写。

重构第二步:把原来的混合方法拆成两个

updateAndGetProfile 拆成 UpdateEmailCommandGetUserProfileQuery

// 命令:更新邮箱
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 的基本架构。命令和查询走不同的路径,互不干扰。

客户端 命令处理器 写模型 写数据库 查询处理器 读模型 读数据库 命令 查询 数据同步(事件/ETL) 命令路径(写) 查询路径(读)

你看,命令和查询从入口就分开了。写模型更新后,通过事件或定时任务同步到读模型。读模型可以针对查询场景做优化,比如建索引、缓存、甚至用不同的数据库。

什么时候该用 CQRS?什么时候不该用?

我整理了一个表格,帮你快速判断:

场景 适合 CQRS 不适合 CQRS
读写比例 读远多于写,或写远多于读 读写比例接近 1:1
模型复杂度 读模型和写模型差异很大 读模型和写模型几乎一样
性能要求 查询需要独立优化(缓存、索引、反范式) 简单 CRUD,性能不是瓶颈
团队规模 团队有足够人力维护两套模型 小团队,人力紧张
一致性要求 可以接受最终一致性 要求强一致性,读写必须实时同步

避坑指南:我曾经在一个小项目里强行上了 CQRS,结果团队只有三个人,维护两套模型累得半死。后来我学乖了——先做接口分离,等确实需要独立模型时再上。CQRS 是工具,不是银弹。

重构后的收益

把读写混合的代码重构为 CQRS 之后,你会看到这些变化:

  • 可读性提升:每个方法只做一件事,名字就能说明意图
  • 可测试性提升:命令和查询可以独立测试,不用再 mock 一堆无关依赖
  • 性能优化空间:查询可以加缓存、建索引,不用担心影响写操作
  • 扩展性增强:想加新查询?直接加一个 Query 类,不影响现有命令
  • 并发安全性:写操作不再被查询阻塞,锁竞争大大减少

嗯,说到底,CQRS 不是什么高深莫测的模式。它就是把「读写分离」这个朴素思想做到极致。你从今天开始,看到代码里有一个方法既读又写,就可以考虑拆一拆。不用一步到位,先拆接口,再拆模型,慢慢来。

我个人习惯是:每次重构只改一个点,改完跑测试,确认没问题再改下一个。这样风险最小,收益最大。


公众号:蓝海资料掘金营,微信deep3321