跳转至

经典案例:电商购物车与结算系统 (Shopping Cart & Checkout - 类似 淘宝 与 Amazon)

购物车与结算(Shopping Cart & Checkout)是电商业务中最核心、最直接连接用户购买欲望与交易落地的核心链路。

初学者往往以为购物车就是一个简单的数据库 INSERT 或 UPDATE 操作。但如果仔细推敲现实中的业务细节,会发现一系列极具挑战性的技术难点: - 未登录状态下往购物车塞了 3 件商品,一登录账号,这 3 件商品为什么能丝滑合并进账号原有的购物车中? - 商品价格是动态波动的(降价、大促),购物车里应该存商品价格吗? - 当用户在结账页连续手抖点击了 5 次“提交订单”,系统如何确保绝不会生成 5 笔重复扣款的订单?


1. 初学者视角:购物车的数据模型选型陷阱

很多初学者设计的第一版购物车数据库表结构往往是这样的:

-- 错误的设计示范:
CREATE TABLE cart_items (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    item_id BIGINT,
    quantity INT,
    unit_price DECIMAL(10, 2), -- 致命陷阱: 严禁在购物车表里存储单价!
    created_at TIMESTAMP
);
graph TD
    A["购物车存储了商品单价: 100 元"] --> B["商家在后台提价至 150 元, 或开启大促打折至 80 元"]
    B --> C["若购物车存死价格: 价格变动无法感知! 产生严重财务漏洞!"]
    D["购物车只存储: item_id 和 quantity"] --> E["每次打开购物车或点击结算: 实时根据 item_id 动态拉取最新价格与促销!"]

[!IMPORTANT] 铁律:购物车只存“买什么(item_id)”和“买多少(quantity)”,绝对不能存储商品单价! 商品价格必须在每次渲染和结算时,由底层的商品服务和促销计价引擎动态实时算得。


2. 需求分析与系统范围 (Requirements & Scope)

2.1 功能性需求

  1. 基础增删改查:添加商品进购物车、修改数量、删除商品、批量勾选;
  2. 未登录与已登录购物车合并 (Guest Cart Merge):未登录用户在浏览器添加的商品,在登录成功后无缝合并到用户账号中;
  3. 动态计价与促销引擎 (Pricing & Promotion Engine):实时计算商品单价、店铺优惠券、跨店满减(如满 300 减 50)与运费;
  4. 结算下单防重与幂等性 (Checkout Idempotency):防范重复提交订单。

2.2 非功能性需求

  1. 极速读写延迟 (Low Latency):购物车展示与加购接口响应必须在 \(20\text{ ms}\) 以内;
  2. 高可用性 (High Availability):购物车服务不能因单点故障导致用户无法加购;
  3. 结算时强一致性:结算扣减库存与订单金额必须做到绝对准确。

3. 存储选型:Redis Hash 极速加购

对于高并发的加购操作,若每次都读写 MySQL,数据库会被频繁更新打垮。工业界普遍采用 Redis Hash 作为购物车的一级高速存储:

graph LR
    subgraph "Redis Hash 结构 (Key: cart:user_1001)"
    F1["Field: item_2001 (商品 A)"] --> V1["Value: 2 (数量)"]
    F2["Field: item_2002 (商品 B)"] --> V2["Value: 1 (数量)"]
    F3["Field: item_2003 (商品 C)"] --> V3["Value: 5 (数量)"]
    end

常用 Redis 命令:

# 1. 用户 1001 将商品 2001 数量加 1 (若不存在则新增,存在则原子累加)
HINCRBY cart:1001 item_2001 1

# 2. 修改指定商品数量为 3
HSET cart:1001 item_2001 3

# 3. 删除指定商品
HDEL cart:1001 item_2001

# 4. 获取用户购物车的全部商品 ID 与数量
HGETALL cart:1001
- 极高吞吐:单次加购纯内存位操作,耗时小于 \(1\text{ ms}\); - 异步持久化兜底:通过消息队列将购物车变动异步同步给 MySQL 备份表,当 Redis 缓存失效时可从数据库重新预热。


4. 核心业务流程深度剖析 (Deep Dive)

4.1 未登录与已登录购物车的合并机制 (Guest Cart Merge)

用户经常在未登录状态下浏览商品并点击“加入购物车”。此时系统还没有用户的 user_id,如何保存?

sequenceDiagram
    participant Browser as 浏览器
    participant Gateway as API 网关
    participant CartSvc as 购物车服务
    participant Redis as Redis 集群

    Note over Browser: 1. 未登录状态下加购
    Browser->>Gateway: POST /cart/add (Cookie 带上 guest_cart_id: "uuid_9988")
    Gateway->>CartSvc: 透传 guest_cart_id
    CartSvc->>Redis: HSET cart:guest:uuid_9988 item_101 2

    Note over Browser: 2. 用户点击登录账号 (User ID: 10086)
    Browser->>Gateway: POST /login (带上 guest_cart_id: "uuid_9988")
    Gateway->>CartSvc: 触发合并购物车事件 (user_10086, guest_uuid_9988)
    CartSvc->>Redis: 1. 读取 cart:guest:uuid_9988 的所有商品
    CartSvc->>Redis: 2. 将数量逐一合并累加至 cart:user_10086
    CartSvc->>Redis: 3. DEL cart:guest:uuid_9988 (删除临时游客购物车)
    CartSvc-->>Browser: 合并完成! 界面无缝展示最新合并结果

4.2 动态计价与促销计算引擎 (Pricing Engine)

当用户勾选了若干件商品点击“去结算”时,计价引擎必须在毫秒级内完成复杂的优惠计算:

graph TD
    Items["已勾选的商品列表 [ (item_1, 2件), (item_2, 1件)... ]"] --> PriceSvc["1. 商品中心: 获取最新实时单价"]
    PriceSvc --> GroupBy["2. 按店铺/商家拆单 (Group by Merchant)"]
    GroupBy --> ShopCoupon["3. 店铺级优惠: 满足单店满 100 减 10 券"]
    ShopCoupon --> CrossShop["4. 平台级优惠: 跨店满 300 减 50 (按商品金额比例分摊)"]
    CrossShop --> Shipping["5. 运费模板计算 (重量/包邮规则)"]
    Shipping --> Total["最终实付金额 = 原价总和 - 店铺优惠 - 跨店满减 + 运费"]
  • 优惠分摊(Discount Allocation):跨店满减优惠必须按比例精确分摊到每一件具体商品上(否则后续若用户发生部分单品退货,系统将无法计算该单品应退多少钱)。

4.3 结算下单的终极防重与幂等性 (Checkout Idempotency)

在移动网络抖动环境下,用户点击“提交订单”后按钮可能没有立即变灰,用户急躁地连点 3 次:

sequenceDiagram
    participant User as 手机端用户
    participant App as 客户端 App
    participant Svc as 交易订单服务
    participant Redis as Redis 分布式锁

    Note over User,App: 1. 进入结算确认页
    App->>Svc: GET /checkout/init (初始化结算单)
    Svc-->>App: 返回结算数据 + 全局唯一结算令牌 (checkout_token: "tok_8899")

    Note over User,App: 2. 用户连击 3 次"提交订单" (均携带 tok_8899)
    par 第一次提交
        App->>Svc: POST /order/create (token: "tok_8899")
        Svc->>Redis: SET order:token:tok_8899 1 NX EX 300
        Redis-->>Svc: 成功获取锁! 执行生成订单、锁库存
        Svc-->>App: 订单创建成功! 跳转支付
    and 第二次并发提交
        App->>Svc: POST /order/create (token: "tok_8899")
        Svc->>Redis: SET order:token:tok_8899 1 NX EX 300
        Redis-->>Svc: 返回获取锁失败 (Key 已存在!)
        Svc-->>App: 拒绝重复请求, 直接返回第一次已创建成功的订单号
    end
  • 防重令牌机制(Token Pattern):
  • 进入结算页时下发唯一的 checkout_token;
  • 提交订单时通过 Redis 的原子 SET NX 占领该 Token;
  • 重复点击的请求直接被拦截拒绝,彻底避免重复下单!