PHP 下单时,先扣 MySQL 库存还是先改 Redis?一次小型系统的取舍

举报
HasaH 发表于 2026/10/29 02:50:49 2026/10/29
【摘要】 用 PHP、MySQL 和 Redis 实现一个小型下单流程:MySQL 条件更新防超卖,唯一请求键保证幂等,提交后删除展示缓存;同时分析缓存竞态、故障恢复与验收用例。

商品详情页上的“剩余 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 只能有一条订单。实际系统还应增加用户归属、订单状态、价格快照及正数校验。

把订单与扣减放进同一个事务

下面省略连接配置,假设 pdo已启用异常模式,pdo 已启用异常模式,redis 是已连接的 Redis 客户端,productId、productId、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 的原子事务。

把失败场景当成验收用例

  1. 两个不同请求同时购买最后一件:只有一个扣减成功,另一笔事务回滚。
  2. 同一 request_key 重试:返回同一订单 ID,库存只扣一次。
  3. 库存不足:订单插入与扣减一起回滚。
  4. 提交后 Redis 暂时不可用:订单仍成功;监控、重试删键与短 TTL 负责收敛展示。
  5. 发生数据库死锁:回滚后有限次重试整个事务,同时记录次数并排查热点。

这个设计不追求让每个组件都“绝不出错”,而是先明确权威数据在哪里,再让重试与故障恢复有据可循。

参考资料

  • PHP 手册:PDO 事务
  • MySQL 8.4 手册:InnoDB 死锁
  • Redis 文档:事务
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。