经典案例:电商购物车与结算系统 (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 功能性需求¶
- 基础增删改查:添加商品进购物车、修改数量、删除商品、批量勾选;
- 未登录与已登录购物车合并 (Guest Cart Merge):未登录用户在浏览器添加的商品,在登录成功后无缝合并到用户账号中;
- 动态计价与促销引擎 (Pricing & Promotion Engine):实时计算商品单价、店铺优惠券、跨店满减(如满 300 减 50)与运费;
- 结算下单防重与幂等性 (Checkout Idempotency):防范重复提交订单。
2.2 非功能性需求¶
- 极速读写延迟 (Low Latency):购物车展示与加购接口响应必须在 \(20\text{ ms}\) 以内;
- 高可用性 (High Availability):购物车服务不能因单点故障导致用户无法加购;
- 结算时强一致性:结算扣减库存与订单金额必须做到绝对准确。
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
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; - 重复点击的请求直接被拦截拒绝,彻底避免重复下单!