PHP 下单时,先扣 MySQL 库存还是先改 Redis?一次小型系统的取舍
商品详情页上的“剩余 8 件”看起来只是一个数字。真正麻烦的是:两个人同时下单、用户重复点击、数据库已经提交但缓存删除失败,分别该怎么办?我的原则是把下单是否成功与页面显示什么分开:MySQL 决定订单和库存,Redis 只缓存供展示的库存。
从两个约束开始
假设每次请求只购买一种商品。request_key 是调用方为一次购买意图生成的唯一键;同一次请求重试必须沿用原键,新订单必须换键。数据库表可以简化为:
CREATE TABLE products (
id BIGINT PRIMARY KEY,
stock INT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
request_key VARCHAR(64) NOT NULL UNIQUE,
product_id BIGINT NOT NULL,
quantity INT NOT NULL
) ENGINE=InnoDB;
这里有两道约束:库存只能在 stock >= quantity 时扣减;同一个 request_key 只能有一条订单。实际系统还应增加用户归属、订单状态、价格快照及正数校验。
把订单与扣减放进同一个事务
下面省略连接配置,假设 redis 是已连接的 Redis 客户端,quantity 和 $requestKey 已通过业务校验。
function placeOrder(
PDO $pdo,
Redis $redis,
int $productId,
int $quantity,
string $requestKey
): int {
if ($quantity <= 0 || $requestKey === '') {
throw new InvalidArgumentException('Invalid order input');
}
try {
$pdo->beginTransaction();
$insert = $pdo->prepare(
'INSERT INTO orders (request_key, product_id, quantity) VALUES (?, ?, ?)'
);
$insert->execute([$requestKey, $productId, $quantity]);
$orderId = (int) $pdo->lastInsertId();
$update = $pdo->prepare(
'UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?'
);
$update->execute([$quantity, $productId, $quantity]);
if ($update->rowCount() !== 1) {
throw new RuntimeException('Product missing or insufficient stock');
}
$pdo->commit();
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if ($e->getCode() !== '23000') {
throw $e;
}
$find = $pdo->prepare(
'SELECT id, product_id, quantity FROM orders WHERE request_key = ?'
);
$find->execute([$requestKey]);
$existing = $find->fetch(PDO::FETCH_ASSOC);
if (!$existing || (int) $existing['product_id'] !== $productId
|| (int) $existing['quantity'] !== $quantity) {
throw $e;
}
return (int) $existing['id'];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
// 缓存维护发生在提交之后;失败时订单仍已成功。
try {
$redis->del('product:stock:' . $productId);
} catch (Throwable $e) {
// 生产环境应记录日志并投递重试任务。
}
return $orderId;
}
条件 UPDATE 让并发扣减在数据库中接受最终检查,避免“先读 8、两边都写回 7”的丢失更新。唯一键使同一次购买意图的重试返回原订单,而不是再扣一次库存。调用方若在提交后、收到响应前断线,也可以用同一键重试。
Redis 的职责:快读,但不裁决订单
详情页读取时,可以先取 product:stock:{id};未命中则查询 MySQL,并用短 TTL 写回缓存。下单成功后删除对应键,让后续读取回源。此方案仍不是强一致:提交与删键之间可能显示旧值;一个较早开始的读请求也可能在删键后把旧值写回。TTL 只能限制陈旧数据的持续时间,不能把展示库存变成下单承诺。
因此页面应写“库存以提交结果为准”。如果业务要求展示值也严格同步,就需要更严谨的版本号或读写协调方案;不要把 Redis 的 MULTI/EXEC 误当成跨 MySQL 与 Redis 的原子事务。
把失败场景当成验收用例
- 两个不同请求同时购买最后一件:只有一个扣减成功,另一笔事务回滚。
- 同一 request_key 重试:返回同一订单 ID,库存只扣一次。
- 库存不足:订单插入与扣减一起回滚。
- 提交后 Redis 暂时不可用:订单仍成功;监控、重试删键与短 TTL 负责收敛展示。
- 发生数据库死锁:回滚后有限次重试整个事务,同时记录次数并排查热点。
这个设计不追求让每个组件都“绝不出错”,而是先明确权威数据在哪里,再让重试与故障恢复有据可循。
参考资料
- PHP 手册:PDO 事务
- MySQL 8.4 手册:InnoDB 死锁
- Redis 文档:事务
- 点赞
- 收藏
- 关注作者
评论(0)