学习

如何使用 Redis 和 CQRS 模式构建电商应用

Will Johnston
作者
Will Johnston, Redis 开发者增长经理
Prasan Kumar
作者
Prasan Kumar, Redis 技术解决方案开发者
GITHUB 代码

以下是克隆本教程中使用的应用源代码的命令

git clone --branch v4.2.0 https://github.com/redis-developer/redis-microservices-ecommerce-solutions

什么是命令和查询责任分离 (CQRS)?#

命令查询责任分离 (CQRS) 是微服务架构中的一个关键模式。它将读取(查询)和写入(命令)解耦,允许读写工作负载独立工作。

命令(写入)侧重于更高的数据可靠性和一致性,而查询(读取)侧重于性能。这使得微服务可以将数据写入较慢的基于磁盘的权威数据库系统,同时将数据预取并缓存到缓存中以进行实时读取。

这个想法很简单:你将“订购此产品”(写入操作)之类的命令与“显示我的订单历史记录”(读取操作)之类的查询分开。CQRS 应用通常基于消息传递,并依赖于 最终一致性

下面的示例数据架构演示了如何将 Redis 与 CQRS 结合使用

图中所示的架构使用了变更数据捕获模式(标记为“集成 CDC”)来跟踪命令数据库上的变更状态,并将其复制到查询数据库 (Redis)。这是与 CQRS 结合使用的常见模式。

实现 CDC 需要

  1. 1.从权威系统获取数据快照
  2. 2.执行 ETL 操作,最终将数据加载到目标缓存数据库
  3. 3.建立一种机制,将权威系统中的变更连续流式传输到缓存
提示

虽然你可以使用 Redis 的触发器和函数实现自己的 CDC 机制,但 Redis Cloud 带有其自身的集成 CDC 机制,可以为你解决这个问题。

你为什么可能使用 CQRS#

为了提高应用性能,请单独扩展你的读写操作。

考虑以下场景:你有一个电商应用,允许客户在购物车中添加产品。网站上有一个“立即购买”按钮,方便订购这些产品。刚开始时,你可能会设置并填充一个产品数据库(可能是 SQL 数据库)。然后你可能会编写一个后端 API 来一次性处理创建订单、创建发票、处理支付、处理发货以及更新客户订单历史记录等流程。

这种同步订单处理方法似乎是个好主意。但你很快就会发现,随着客户数量增加和销售量上升,数据库会变慢。实际上,大多数应用的读操作远多于写操作。你应该单独扩展这些操作。

你决定需要快速处理订单,这样客户就不必等待。然后,当你有时间时,再创建发票、处理支付、处理发货等。

因此你决定将这些步骤分开。使用微服务和 CQRS 方法可以让你独立扩展读写操作,并有助于解耦你的微服务。在 CQRS 模型中,单个服务负责处理从头到尾的整个命令。一个服务不应该依赖于另一个服务来完成一个命令。

电商应用的微服务 CQRS 架构#

让我们看一下演示应用的架构

  1. 1.products service: 负责从数据库查询产品并返回给前端
  2. 2.orders service: 负责验证和创建订单
  3. 3.order history service: 负责查询客户的订单历史记录
  4. 4.payments service: 负责处理订单支付
  5. 5.api gateway: 将所有服务统一到一个端点下
  6. 6.mongodb/ postgresql: 用作存储订单、订单历史记录、产品等的写入优化数据库
信息

在演示应用中,你不一定需要使用 MongoDB/PostgreSQL 作为写入优化数据库;你也可以使用其他 Prisma 支持的数据库。这只是一个示例。

在微服务架构中使用 CQRS#

请注意,在当前架构中,所有服务都使用相同的底层数据库。尽管你从技术上将读写分开了,但你无法独立扩展写入优化数据库。这就是 Redis 发挥作用的地方。如果你将 Redis 放在写入优化数据库前面,可以在向写入优化数据库写入时,使用 Redis 进行读取。Redis 的优点在于它对读写都很快,因此是缓存和 CQRS 的最佳选择。

信息

在本教程中,我们不着重介绍服务之间的通信协调方式,例如如何处理新订单的支付。该过程使用 Redis Streams,并在我们的 服务间通信指南 中进行了概述。

提示

当你的电商应用最终需要在全球范围内扩展时,Redis Cloud 提供 Active-Active 地域分布,实现本地延迟的读写,并提供 99.999% 的可用时间。

让我们来看一些示例代码,这些代码有助于将 Redis 和主数据库 (MongoDB/PostgreSQL) 与 CQRS 模式结合使用。

使用 Next.js 和 Tailwind 构建的电商应用前端#

电商微服务应用包括一个前端,使用 Next.js 和 TailwindCSS 构建。应用后端使用 Node.js。数据使用 Prisma 存储在 Redis 和 MongoDB/PostgreSQL 中。下面是你将看到的电商应用前端截图

  • 仪表盘: 显示产品列表,并提供搜索功能

购物车: 将产品添加到购物车,然后点击“立即购买”按钮结账

订单历史记录: 下单后,顶部导航栏中的“订单”链接会显示订单状态和历史记录

注意

以下是克隆本教程中使用的应用源代码的命令

git clone --branch v4.2.0 https://github.com/redis-developer/redis-microservices-ecommerce-solutions

使用 Redis 和主数据库 (MongoDB/PostgreSQL) 构建 CQRS 微服务应用#

让我们来看一下 order service 的示例代码,并查看 CreateOrder 命令(写入操作)。然后我们再看看 order history service 来查看 ViewOrderHistory 命令(读取操作)。

创建订单命令 API#

以下代码显示了创建订单的 API 请求和响应示例。

创建订单请求#

docs/api/create-order.md
// POST http://api-gateway/orders/createOrder
{
  "products": [
    {
      "productId": "11002",
      "qty": 1,
      "productPrice": 4950
    },
    {
      "productId": "11012",
      "qty": 2,
      "productPrice": 1195
    }
  ]
}

创建订单响应#

{
  "data": "d4075f43-c262-4027-ad25-7b1bc8c490b6", //orderId
  "error": null
}

当你发出请求时,它会通过 API 网关到达 orders service。最终,它会调用一个 createOrder 函数,该函数如下所示

server/src/services/orders/src/service-impl.ts
const createOrder = async (
  order: IOrder,
  //...
) => {
  if (!order) {
    throw 'Order data is mandatory!';
  }

  const userId = order.userId || USERS.DEFAULT; // Used as a shortcut, in a real app you would use customer session data to fetch user details
  const orderId = uuidv4();

  order.orderId = orderId;
  order.orderStatusCode = ORDER_STATUS.CREATED;
  order.userId = userId;
  order.createdBy = userId;
  order.statusCode = DB_ROW_STATUS.ACTIVE;
  order.potentialFraud = false;

  order = await validateOrder(order);

  const products = await getProductDetails(order);
  addProductDataToOrders(order, products);

  await addOrderToRedis(order);

  await addOrderToPrismaDB(order);

  //...

  return orderId;
};
信息

为了教程的简洁性,我们在同一个服务中将数据同时写入主数据库和 Redis(双写)。如前所述,一种常见的模式是让服务写入一个数据库,然后单独使用 CDC 机制来更新另一个数据库。例如,你可以直接写入 Redis,然后使用 RedisGears 在后台处理 Redis 和主数据库的同步。在本教程中,我们不详细介绍如何处理同步,而是侧重于数据如何在 Redis 中存储和访问。

提示

如果你使用的是 Redis Cloud,你可以利用 集成 CDC 机制,而无需自己实现。

请注意,在前面的代码块中,我们调用了 addOrderToRedis 函数将订单存储到 Redis 中。我们使用 用于 Node.js 的 Redis OM 将订单实体存储在 Redis 中。该函数如下所示

server/src/services/orders/src/service-impl.ts
import { Schema, Repository } from 'redis-om';
import { getNodeRedisClient } from '../utils/redis/redis-wrapper';

//Redis Om schema for Order
const schema = new Schema('Order', {
  orderId: { type: 'string', indexed: true },

  orderStatusCode: { type: 'number', indexed: true },
  potentialFraud: { type: 'boolean', indexed: false },
  userId: { type: 'string', indexed: true },

  createdOn: { type: 'date', indexed: false },
  createdBy: { type: 'string', indexed: true },
  lastUpdatedOn: { type: 'date', indexed: false },
  lastUpdatedBy: { type: 'string', indexed: false },
  statusCode: { type: 'number', indexed: true },
});

//Redis OM repository for Order (to read, write and remove orders)
const getOrderRepository = () => {
  const redisClient = getNodeRedisClient();
  const repository = new Repository(schema, redisClient);
  return repository;
};

//Redis indexes data for search
const createRedisIndex = async () => {
  const repository = getRepository();
  await repository.createIndex();
};

const addOrderToRedis = async (order: OrderWithIncludes) => {
  if (order) {
    const repository = getOrderRepository();
    //insert Order in to Redis
    await repository.save(order.orderId, order);
  }
};

使用 RedisInsight 查看示例 订单

提示

下载 RedisInsight 来查看你的 Redis 数据或在工作台中使用原始 Redis 命令。

订单历史记录 API#

以下代码显示了获取客户订单历史记录的 API 请求和响应示例。

订单历史记录请求#

docs/api/view-order-history.md
// GET http://api-gateway/orderHistory/viewOrderHistory

订单历史记录响应#

{
  "data": [
    {
      "orderId": "d4075f43-c262-4027-ad25-7b1bc8c490b6",
      "userId": "USR_22fcf2ee-465f-4341-89c2-c9d16b1f711b",
      "orderStatusCode": 4,
      "products": [
        {
          "productId": "11002",
          "qty": 1,
          "productPrice": 4950,
          "productData": {
            "productId": "11002",
            "price": 4950,
            "productDisplayName": "Puma Men Race Black Watch",
            "variantName": "Race 85",
            "brandName": "Puma",
            "ageGroup": "Adults-Men",
            "gender": "Men",
            "displayCategories": "Accessories",
            "masterCategory_typeName": "Accessories",
            "subCategory_typeName": "Watches",
            "styleImages_default_imageURL": "http://host.docker.internal:8080/images/11002.jpg",
            "productDescriptors_description_value": "<p>This watch from puma comes in a heavy duty design. The assymentric dial and chunky..."
          }
        },
        {
          "productId": "11012",
          "qty": 2,
          "productPrice": 1195,
          "productData": {
            "productId": "11012",
            "price": 1195,
            "productDisplayName": "Wrangler Women Frill Check Multi Tops",
            "variantName": "FRILL CHECK",
            "brandName": "Wrangler",
            "ageGroup": "Adults-Women",
            "gender": "Women",
            "displayCategories": "Sale and Clearance,Casual Wear",
            "masterCategory_typeName": "Apparel",
            "subCategory_typeName": "Topwear",
            "styleImages_default_imageURL": "http://host.docker.internal:8080/images/11012.jpg",
            "productDescriptors_description_value": "<p><strong>Composition</strong><br /> Navy blue, red, yellow and white checked top made of 100% cotton, with a jabot collar, buttoned ..."
          }
        }
      ],
      "createdBy": "USR_22fcf2ee-465f-4341-89c2-c9d16b1f711b",
      "lastUpdatedOn": "2023-07-13T14:11:49.997Z",
      "lastUpdatedBy": "USR_22fcf2ee-465f-4341-89c2-c9d16b1f711b"
    }
  ],
  "error": null
}

当你发出请求时,它会通过 API 网关到达 order history service。最终,它会调用一个 viewOrderHistory 函数,该函数如下所示

server/src/services/order-history/src/service-impl.ts
const viewOrderHistory = async (userId: string) => {
  const repository = OrderRepo.getRepository();
  let orders: Partial<IOrder>[] = [];
  const queryBuilder = repository
    .search()
    .where('createdBy')
    .eq(userId)
    .and('orderStatusCode')
    .gte(ORDER_STATUS.CREATED) //returns CREATED and PAYMENT_SUCCESS
    .and('statusCode')
    .eq(DB_ROW_STATUS.ACTIVE);

  console.log(queryBuilder.query);
  orders = <Partial<IOrder>[]>await queryBuilder.return.all();
};
信息

请注意,order history service 只需要访问 Redis 来获取所有订单。这是因为我们在 orders service 中处理了 Redis 和主数据库之间的存储和同步。

你可能习惯于将 Redis 用作缓存,同时存储和检索字符串化的 JSON 值或哈希值。但是,请仔细看上面的代码。在代码中,我们将订单存储为 JSON 文档,然后使用 Redis OM 搜索属于特定用户的订单。在这里,Redis 的工作方式就像一个搜索引擎,能够加快查询速度并独立于主数据库(在本例中是 MongoDB/PostgreSQL)进行扩展。

准备好将 Redis 与 CQRS 模式结合使用了吗?#

希望本教程能帮助你理解如何将 Redis 与 CQRS 模式结合使用。它可以帮助减轻主数据库的负载,同时仍然允许你存储和搜索 JSON 文档。有关此主题的更多资源,请查看下面的链接

更多资源#

使用 Redis 构建微服务

通用