一、电商系统的性能痛点与解决思路

做过电商系统的人都知道,用户在使用时最烦的就是“卡”。比如大促的时候,点商品列表半天刷不出来,下订单时确认按钮点了没反应,改个收货地址还要等好几秒。这些问题的根源,其实大多和系统的“读写逻辑混在一起”有关。

电商系统里,读操作和写操作的场景完全不一样。读操作是什么?是用户看商品列表、搜商品、看订单详情、查物流,这类操作的特点是“次数多、要求快”,可能一个用户逛一次网站,会读几十次甚至上百次数据,但写操作很少。写操作是什么?是用户下订单、改收货地址、支付、确认收货,这类操作的特点是“次数少、要求准”,一个用户一天可能只写一两次数据,但绝对不能出错。

如果把读和写的逻辑放在一起,用同一个数据库、同一个接口去处理,就会出大问题。比如大促时,很多用户同时写订单,数据库要花时间处理这些写操作,这时候读商品列表的请求也过来,数据库得兼顾读写,两边的速度都会变慢。

那怎么解决这个问题?核心思路就是把读和写分开,也就是今天要讲的CQRS架构。

二、CQRS架构的核心概念

很多人第一次听到CQRS,会觉得这是个很复杂的概念,其实说白了就是“读写分离”的升级版本。传统的读写分离只是把数据库分成主库(用来写)和从库(用来读),但CQRS不只是数据库分开,连业务逻辑、代码实现都完全分开。

2.1 什么是CQRS

CQRS是Command Query Responsibility Segregation的缩写,翻译过来就是“命令查询职责分离”。这里的“命令”指的是写操作(比如下订单、改地址),“查询”指的是读操作(比如看商品、查订单)。

简单说,CQRS就是把系统分成两部分:

  • 命令端:专门处理写操作,只负责“做对”,不关心读的速度。
  • 查询端:专门处理读操作,只负责“做快”,不关心写的逻辑。

举个生活里的例子,就像小区的快递站。快递站有两个区域:一个是“入库区”,快递员把包裹送进来,工作人员扫码、登记、放好,这个过程要求准确,不能把包裹放错地方(对应命令端);另一个是“取件区”,用户来取快递,工作人员快速找到包裹给用户,这个过程要求快,不能让用户等太久(对应查询端)。如果把入库和取件混在一个区域,人多的时候就会乱,分开之后效率就高了。

2.2 CQRS和传统架构的区别

传统架构的核心是“一个模型干所有事”,比如一个商品类,既要用来保存商品的价格、库存(给写操作用),又要用来组装商品列表、搜索结果(给读操作用)。这个模型要同时满足读写的需求,经常会变得很复杂,改起来也麻烦。

CQRS架构的核心是“读写各用各的模型”,命令端有自己的模型(比如订单模型,只包含订单的核心信息,用来保证写操作的准确性),查询端有自己的模型(比如商品列表模型,只包含展示需要的信息,用来保证读操作的速度)。两个模型可以完全不一样,甚至可以用不同的技术来实现。

三、CQRS架构在电商系统的具体实现

接下来我们用一个具体的电商场景,一步步讲怎么实现CQRS架构。我们选的场景是“商品列表查询”和“商品库存修改”,这两个是电商系统里最常见的读写操作。

3.1 技术栈选择

本次实现的技术栈统一为:

  • 后端:Java + Spring Boot
  • 写数据库:MySQL(用来保存核心业务数据,保证数据一致性)
  • 读数据库:Redis(用来保存查询数据,保证读的速度)
  • 消息队列:RabbitMQ(用来同步写数据库和读数据库的数据)

3.2 命令端的实现

命令端的作用是处理写操作,比如用户修改商品的库存、价格。命令端的核心要求是“准确”,所以我们用MySQL来保存核心数据,因为MySQL的事务机制可以保证数据的一致性。

首先,我们先定义命令端的商品模型,这个模型只包含写操作需要的核心信息:

// 命令端商品模型,只包含写操作需要的核心字段
public class ProductCommandModel {
    private Long id; // 商品ID
    private String name; // 商品名称
    private Integer stock; // 商品库存
    private BigDecimal price; // 商品价格
    private Date updateTime; // 更新时间

    // 构造方法、getter、setter省略
}

然后,我们写一个修改商品库存的接口,这个接口会把修改后的商品数据保存到MySQL,然后发送一条消息到RabbitMQ,告诉查询端“商品数据更新了”:

@RestController
@RequestMapping("/product/command")
public class ProductCommandController {
    private final ProductCommandRepository productCommandRepository;
    private final RabbitTemplate rabbitTemplate;

    // 构造注入
    public ProductCommandController(ProductCommandRepository productCommandRepository, RabbitTemplate rabbitTemplate) {
        this.productCommandRepository = productCommandRepository;
        this.rabbitTemplate = rabbitTemplate;
    }

    // 修改商品库存的接口
    @PostMapping("/updateStock")
    public String updateStock(@RequestBody ProductCommandModel product) {
        // 1. 把修改后的商品数据保存到MySQL,保证数据一致性
        productCommandRepository.save(product);

        // 2. 发送消息到RabbitMQ,通知查询端商品数据更新
        rabbitTemplate.convertAndSend("product.exchange", "product.update", product);

        return "库存修改成功";
    }
}

这里要注意,命令端的操作是“同步”的,也就是用户调用这个接口,必须等MySQL保存成功、消息发送成功,才会返回结果,这样才能保证数据的准确性。

3.3 查询端的实现

查询端的作用是处理读操作,比如用户查询商品列表、商品详情。查询端的核心要求是“快”,所以我们用Redis来保存查询数据,因为Redis的读写速度比MySQL快很多。

首先,我们定义查询端的商品模型,这个模型只包含读操作需要的信息,比如商品的名称、价格、库存、图片地址等,不需要包含写操作的复杂逻辑:

// 查询端商品模型,只包含读操作需要的字段
public class ProductQueryModel {
    private Long id; // 商品ID
    private String name; // 商品名称
    private BigDecimal price; // 商品价格
    private Integer stock; // 商品库存
    private String imageUrl; // 商品图片地址
    private Date updateTime; // 更新时间

    // 构造方法、getter、setter省略
}

然后,我们写一个查询商品列表的接口,这个接口会直接从Redis里读取数据,返回给用户:

@RestController
@RequestMapping("/product/query")
public class ProductQueryController {
    private final ProductQueryRepository productQueryRepository;

    // 构造注入
    public ProductQueryController(ProductQueryRepository productQueryRepository) {
        this.productQueryRepository = productQueryRepository;
    }

    // 查询商品列表的接口
    @GetMapping("/list")
    public List<ProductQueryModel> getProductList() {
        // 直接从Redis读取商品列表,速度很快
        return productQueryRepository.findAll();
    }
}

3.4 数据同步的实现

现在有个问题:命令端修改了商品数据(保存到MySQL),查询端的Redis里的数据怎么更新?这时候就需要用消息队列来同步数据。

我们写一个消费者,监听RabbitMQ的“商品更新”消息,当收到消息后,把命令端的商品数据转换成查询端的商品模型,然后更新到Redis里:

@Component
public class ProductDataSyncConsumer {
    private final ProductCommandRepository productCommandRepository;
    private final ProductQueryRepository productQueryRepository;

    // 构造注入
    public ProductDataSyncConsumer(ProductCommandRepository productCommandRepository, ProductQueryRepository productQueryRepository) {
        this.productCommandRepository = productCommandRepository;
        this.productQueryRepository = productQueryRepository;
    }

    // 监听商品更新的消息
    @RabbitListener(queues = "product.update.queue")
    public void syncProductData(ProductCommandModel commandModel) {
        // 1. 从MySQL读取最新的商品数据(因为命令端可能已经保存了)
        ProductCommandModel latestCommandModel = productCommandRepository.findById(commandModel.getId()).orElse(null);
        if (latestCommandModel == null) {
            return;
        }

        // 2. 把命令端的模型转换成查询端的模型
        ProductQueryModel queryModel = new ProductQueryModel();
        queryModel.setId(latestCommandModel.getId());
        queryModel.setName(latestCommandModel.getName());
        queryModel.setPrice(latestCommandModel.getPrice());
        queryModel.setStock(latestCommandModel.getStock());
        // 这里可以补充查询端需要的其他字段,比如图片地址(假设从其他服务获取)
        queryModel.setImageUrl("https://example.com/image/" + latestCommandModel.getId() + ".jpg");
        queryModel.setUpdateTime(latestCommandModel.getUpdateTime());

        // 3. 把查询端的模型保存到Redis
        productQueryRepository.save(queryModel);
    }
}

这里要注意,数据同步是“异步”的,也就是命令端修改完数据后,不需要等查询端更新完,就可以返回结果,这样不会影响命令端的速度。当然,这会带来一个问题:命令端修改了数据后,查询端可能要等几毫秒甚至几十毫秒才能更新,这段时间用户看到的是旧数据。这个问题我们后面会讲怎么解决。

四、CQRS架构的应用场景、优缺点与注意事项

4.1 应用场景

不是所有的电商系统都适合用CQRS架构,只有当系统的读写比例差距很大,或者读写操作的要求完全不同时,才适合用CQRS。比如:

  • 大促期间的电商系统:读操作是写操作的几十倍甚至上百倍,用CQRS可以把读操作的压力分担到查询端,避免影响写操作。
  • 商品搜索、商品列表:这类读操作要求速度快,写操作(比如修改商品信息)的频率很低,用CQRS可以让读操作的速度提升很多。
  • 订单系统:写操作(下订单)要求准确,读操作(查订单)要求快,用CQRS可以保证订单的准确性,同时提升查订单的速度。

4.2 优缺点

优点

  • 性能提升明显:读写分开后,读操作可以用专门的优化技术(比如Redis、ES),速度会比传统架构快很多;写操作也不会被读操作影响,稳定性更好。
  • 代码结构更清晰:读写的逻辑分开,每个部分的职责更明确,开发和维护起来更方便。比如修改商品列表的展示逻辑,只需要改查询端的代码,不会影响写操作的逻辑。
  • 扩展性更好:读写可以分别扩展,比如读操作压力大的时候,可以增加Redis的节点;写操作压力大的时候,可以增加MySQL的节点,不用像传统架构那样,要扩展整个系统。

缺点

  • 架构更复杂:需要同时维护命令端、查询端、数据同步三个部分,比传统架构多了很多环节,开发和部署的难度都更大。
  • 数据一致性问题:因为数据同步是异步的,所以会出现“最终一致性”的问题,也就是命令端修改了数据后,查询端的旧数据会存在一段时间。如果用户对数据一致性的要求很高(比如库存显示),这个问题会影响用户体验。
  • 开发成本更高:需要同时开发命令端和查询端,还要处理数据同步的问题,开发时间会比传统架构长很多。

4.3 注意事项

  • 不要盲目使用:如果你的电商系统规模很小,读写比例差距不大,就没必要用CQRS,传统架构足够用了,还能节省开发成本。
  • 处理好最终一致性的问题:如果用户对数据一致性的要求很高(比如库存显示),可以用一些技术来解决,比如在命令端修改数据后,先给查询端发送一条即时消息,让查询端优先更新这条数据;或者在用户查询的时候,如果发现数据是旧的,就从命令端的MySQL里读取最新数据返回给用户。
  • 做好监控和日志:因为CQRS架构的环节很多,一旦出问题,很难排查。所以要做好每个环节的监控(比如命令端的响应时间、查询端的响应时间、数据同步的延迟时间),还要做好日志记录,方便排查问题。

五、CQRS架构的优化技巧

5.1 读端的优化

读端的核心是快,所以可以用一些优化技术来提升读的速度:

  • 用Redis缓存热点数据:比如大促期间的热门商品,把它们的信息缓存到Redis里,甚至可以把Redis部署在离用户更近的地方(比如CDN节点),提升访问速度。
  • 用ES做搜索:如果商品列表需要搜索功能,用ES来做读端的数据库,因为ES的搜索速度比MySQL快很多,还支持复杂的搜索条件。
  • 预计算数据:比如商品的销量、评分,这些数据不需要实时更新,可以每天预计算一次,保存到读端的数据库里,这样读的时候直接拿预计算好的数据,速度更快。

5.2 写端的优化

写端的核心是准,所以可以用一些优化技术来保证写的稳定性:

  • 用分布式事务保证数据一致性:如果写操作涉及到多个数据库(比如订单和库存),可以用分布式事务来保证所有数据库的操作要么都成功,要么都失败。
  • 用事件溯源技术:把写操作的每一步都记录下来,比如“用户修改库存从10到5”“用户修改价格从100到90”,这样如果数据出问题,可以回溯每一步的操作,找到问题的根源。

5.3 数据同步的优化

数据同步的核心是快,所以可以用一些优化技术来缩短同步的时间:

  • 用高性能的消息队列:比如用RabbitMQ的集群模式,或者用Kafka来做消息队列,提升消息的发送和接收速度。
  • 批量同步数据:如果有大量的商品数据需要同步,可以批量处理,减少同步的次数。
  • 增量同步:只同步修改过的数据,而不是每次都同步所有数据,减少同步的数据量。

六、文章总结

CQRS架构是一种专门为了解决读写比例差距大的系统的性能问题而设计的架构,它的核心是把读写逻辑完全分开,让读操作更快,写操作更准。在电商系统里,CQRS架构可以有效解决大促期间的性能问题,提升用户体验。

当然,CQRS架构也不是万能的,它有自己的缺点和适用场景。在使用之前,一定要先评估自己的系统需求,看看是否适合用CQRS。如果适合,再根据系统的具体情况,选择合适的技术栈和优化方案,才能发挥CQRS架构的最大优势。