如何设计安全高效且支持 guest 的 Shopping cart?
结论
建议把购物车保存在服务端,客户端只保存随机生成的购物车标识。
- guest:Cookie 中只保存不可预测的
cart_token,商品明细放在数据库或 Redis。 - member:购物车与
user_id绑定。用户登录时,合并 guest cart 和 member cart。 - 购物车中的价格只作为展示快照。结算时必须根据商品、库存和促销系统的数据重新计算。
- 超时可通过
expires_at、访问时惰性过期和定时批量清理来处理,无需为每次操作单独创建数据库清理任务。 - 所有增删改操作都必须经过服务端校验,并配合 CSRF 防护、限流和数据库约束。
Option 1 的思路基本正确,但不应直接把 PHPSESSID 当作购物车的业务标识,也不能信任购物车表中的 unit_price。Option 2 如果把完整购物车放进普通 Cookie,虽然能减少数据库访问,却会带来数据篡改、容量限制、信息暴露和同步困难等问题,通常得不偿失。
推荐的数据模型
购物车和商品行应分成两张表:
CREATE TABLE cart (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
token CHAR(64) NOT NULL,
user_id BIGINT UNSIGNED NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cart_token (token),
KEY idx_cart_user_status (user_id, status),
KEY idx_cart_expires (expires_at)
) ENGINE=InnoDB;
CREATE TABLE cart_item (
cart_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity INT UNSIGNED NOT NULL,
unit_price_snapshot DECIMAL(12, 2) NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (cart_id, product_id),
KEY idx_cart_item_product (product_id),
CONSTRAINT fk_cart_item_cart
FOREIGN KEY (cart_id) REFERENCES cart(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
各字段和约束的用途如下:
token是购物车标识,不是 PHP Session ID。user_id为NULL时,表示这是 guest cart。unit_price_snapshot只用于显示用户添加商品时看到的价格。status可使用active、converted、expired等有限状态。expires_at用于判断购物车是否已经失效。(cart_id, product_id)唯一约束可以防止同一商品出现多行记录。
如果商品还有 SKU、规格或销售单位,应保存 sku_id,不能只保存 product_id。
guest cart 的标识方式
用户第一次使用购物车时,由服务端生成高强度随机 token:
function generateCartToken()
{
if (function_exists('random_bytes')) {
return bin2hex(random_bytes(32));
}
if (function_exists('openssl_random_pseudo_bytes')) {
$strong = false;
$bytes = openssl_random_pseudo_bytes(32, $strong);
if ($bytes !== false && $strong) {
return bin2hex($bytes);
}
}
throw new RuntimeException('No cryptographically secure random source available');
}
这里需要注意 PHP 5 的具体小版本,因为 random_bytes() 只在较新的 PHP 版本中可用。旧环境可以使用 openssl_random_pseudo_bytes(),但不能退回到 rand()、时间戳或递增 ID。
Cookie 中只保存 token:
$cookie = new CHttpCookie('cart_token', $token);
$cookie->httpOnly = true;
$cookie->secure = Yii::app()->request->isSecureConnection;
$cookie->path = '/';
$cookie->expire = time() + 30 * 60;
Yii::app()->request->cookies['cart_token'] = $cookie;
生产环境应全站使用 HTTPS,并启用 Secure、HttpOnly 和适合业务场景的 SameSite 策略。较老的 PHP 或 Yii 版本可能没有直接设置 SameSite 的 API,此时需要在 Web 服务器、响应头或框架扩展层处理。
以下数据不能放进 Cookie 后直接采用:
- 商品价格
- 折扣金额
- 库存状态
- 用户身份
- 订单总额
- 是否已付款
用户看到随机 token 本身没有关系。重点是 token 必须难以猜测,而且不能出现在 URL、日志或第三方跳转参数中。
添加和更新商品
客户端只提交商品或 SKU 标识以及期望数量,服务端负责检查:
- 商品是否存在并且允许销售。
- 数量是否为整数。
- 数量是否在允许范围内。
- 购物车是否属于当前 guest 或 member。
- 购物车是否已经过期或完成结算。
下面是 Yii 1 风格的示例:
public function addItem($cartId, $productId, $quantity)
{
$cartId = (int) $cartId;
$productId = (int) $productId;
$quantity = (int) $quantity;
if ($cartId <= 0 || $productId <= 0) {
throw new InvalidArgumentException('Invalid cart or product');
}
if ($quantity < 1 || $quantity > 100) {
throw new InvalidArgumentException('Invalid quantity');
}
$product = Product::model()->findByPk($productId);
if ($product === null || !$product->is_available) {
throw new DomainException('Product is not available');
}
$sql = "
INSERT INTO cart_item
(cart_id, product_id, quantity, unit_price_snapshot, updated_at)
VALUES
(:cart_id, :product_id, :quantity, :price, NOW())
ON DUPLICATE KEY UPDATE
quantity = LEAST(quantity + VALUES(quantity), 100),
unit_price_snapshot = VALUES(unit_price_snapshot),
updated_at = NOW()
";
Yii::app()->db->createCommand($sql)->execute(array(
':cart_id' => $cartId,
':product_id' => $productId,
':quantity' => $quantity,
':price' => $product->price,
));
Yii::app()->db->createCommand(
"UPDATE cart
SET updated_at = NOW(),
expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE)
WHERE id = :cart_id
AND status = 'active'"
)->execute(array(':cart_id' => $cartId));
}
调用 addItem() 前仍要验证购物车所有权。客户端提交了 cart_id,不代表它有权修改对应的购物车。
更安全的方式是始终根据当前用户或 Cookie token 查找购物车,不允许客户端直接指定 cart_id。
价格和库存必须在结算时重新验证
购物车不是订单,也不能作为价格凭证。即使攻击者无法修改数据库,购物车也可能长期保留旧价格。因此,进入结算流程后必须重新读取权威数据:
$transaction = Yii::app()->db->beginTransaction();
try {
$cart = $cartRepository->findActiveCartForUpdate($cartId);
if ($cart === null || strtotime($cart->expires_at) <= time()) {
throw new DomainException('Cart has expired');
}
$items = $cartRepository->findItems($cart->id);
$validatedItems = array();
$subtotal = '0.00';
foreach ($items as $item) {
$product = $productRepository->findForCheckout($item->product_id);
if ($product === null || !$product->is_available) {
throw new DomainException('A product is no longer available');
}
if ($item->quantity > $product->available_quantity) {
throw new DomainException('Insufficient inventory');
}
$currentPrice = $product->price;
$lineTotal = bcmul($currentPrice, (string) $item->quantity, 2);
$subtotal = bcadd($subtotal, $lineTotal, 2);
$validatedItems[] = array(
'product_id' => $product->id,
'quantity' => $item->quantity,
'unit_price' => $currentPrice,
'line_total' => $lineTotal,
);
}
// 在独立的结算服务中应用 coupon、shipping、tax 等规则。
$order = $checkoutService->createOrder(
$cart->user_id,
$validatedItems,
$subtotal
);
$cartRepository->markConverted($cart->id, $order->id);
$transaction->commit();
} catch (Exception $e) {
$transaction->rollback();
throw $e;
}
金额计算不能使用二进制浮点数。可以使用数据库的 DECIMAL、PHP BCMath,或者先把金额转换成最小货币单位,再用整数计算。币种和舍入方式也要按照业务规则处理。
购物车页面可以提示价格发生变化,但订单价格必须以结算时的服务端计算结果为准。
guest 登录后的购物车合并
用户登录成功后,需要同时处理 guest cart 中的商品和该用户此前保存的 active cart。
常见的合并规则包括:
- 相同 SKU 的数量相加,但不能超过单次购买上限和库存限制。
- 不同 SKU 继续保留。
- 已下架商品应移除或标记为不可购买。
- 不沿用原价格,重新读取当前价格。
- 合并完成后,把 guest cart 标记为
converted或merged。 - 生成新的 Session ID,防止 session fixation。
- 清除或轮换原来的 guest
cart_token。
整个合并过程应放在同一个数据库事务中,并锁定相关购物车,避免用户同时打开多个页面时发生重复合并。
超时与数据清理
“30 分钟没有活动就清空”可以分三层处理。
第一层是在每次读取或修改购物车时检查:
status = 'active' AND expires_at > NOW()
过期购物车应立即视为空购物车。即使清理任务还没有执行,系统也不能继续使用其中的数据。
第二层是只在购物车确实发生修改时延长 expires_at。如果每次刷新页面都延期,机器人或后台请求可能让购物车一直不过期。
第三层是通过 cron、队列任务或 MySQL Event 定时分批删除:
DELETE FROM cart
WHERE status = 'active'
AND expires_at < NOW()
LIMIT 1000;
清理时应循环执行小批量删除,避免一次处理大量数据。大事务可能引发锁等待、复制延迟和磁盘 I/O 峰值。
MySQL Event 足以完成简单清理,但应用层定时任务通常更便于监控、记录失败和控制批次。无论采用哪种方式,判断购物车是否有效都不能依赖清理任务成功执行。
在实际业务中,30 分钟可能太短。如果希望用户几天后还能继续购物,可以分别设置 guest 和 member 的有效期。这属于产品策略,并不是安全方面的固定标准。
如何防止大量 session 或 cart 攻击
数据库是否容易被填满,很大程度上取决于系统会不会无条件创建购物车。可以采用以下措施:
- 不要在用户访问首页时创建购物车,只在第一次添加商品时创建。
- 限制单个购物车的商品种类和每种商品的数量。
- 按 IP、账号和设备信号限制创建购物车、添加商品等接口的请求频率。
- 对异常来源启用验证码或网关级防护。
- 空购物车不写入数据库,或者设置更短的有效期。
- 使用批量清理和容量监控。
- 避免每次修改都插入一条新的购物车记录。
- 限制每个 member 可拥有的 active cart 数量。
- 为数据库账号配置最小权限。
仅仅改用 Cookie 并不能解决资源攻击。攻击者仍可持续发送伪造 Cookie 或大量请求,因此服务端必须做好限流和容量控制。
还需要注意的安全边界
购物车接口至少要做到:
- 使用参数化查询或 ORM 参数绑定,防止 SQL injection。
- 使用 CSRF token,保护添加、删除和修改数量等写操作。
- 对输出内容进行编码,防止商品名称等数据造成 XSS。
- 严格检查购物车所有权,防止 IDOR。
- 登录后轮换 Session ID。
- 全站使用 HTTPS。
- 不在日志中记录 session、cart token、支付信息和个人信息。
- 对关键更新使用事务。
- 对重复提交使用幂等键,尤其是创建订单和支付环节。
- 在服务端校验数量、价格、库存、商品状态和币种。
- 结算成功后冻结购物车,禁止再次生成订单。
Redis 是否更合适
如果购物车访问量很大、生命周期较短,而且 guest cart 可以在缓存故障后丢失,可以把它放在 Redis 中并直接设置 TTL:
cart:{token}:items
cart:{token}:meta
每次有效修改后刷新 TTL。Redis 可以减少关系数据库中的短期数据和清理压力,但仍要处理以下问题:
- Redis 的内存上限和淘汰策略。
- 是否开启持久化。
- 单个购物车的大小限制。
- 限流和恶意 key 创建。
- 登录后的合并与一致性。
- Redis 故障时的降级方式。
如果 member cart 需要跨设备长期保存,通常更适合存入 SQL 数据库。常见做法是用 Redis 保存活跃的 guest cart,用数据库保存 member cart 和订单。规模不大时,也可以全部使用数据库,让系统保持简单。
如果坚持把购物车放在 Cookie
至少要用服务端密钥进行 HMAC 签名,同时验证签名、版本和过期时间。加密只能隐藏内容,签名才能证明内容没有被修改。如果既要保密又要防篡改,应使用经过验证的 authenticated encryption 实现。
即便如此,Cookie 方案仍会受到浏览器容量、每次请求携带数据、密钥轮换、跨设备同步和撤销困难等限制。结算时也仍然必须重新验证价格和库存。
因此,完整 Cookie cart 只适合商品很少、不含敏感信息且允许数据丢失的简单场景。对于常规电商系统,服务端购物车配合不透明的随机 token,通常是更稳妥的默认方案。
备注:内容仅供参考。
