幂等性解决重复下单问题:PHP 深度剖析与实战

高并发下,网络延迟时,前端用户快速点了2下,订单重复提交了
你会怎么处理?
加锁?
加队列?
前端增加防抖处理?
今天我们来运用幂等性来处理。

一、核心概念:什么是幂等性?

​幂等性​:​同一个请求执行 1 次和执行 N 次,最终结果完全一致,不会产生副作用​。

在下单场景中:

  • 正常:用户点击 1 次「提交订单」→ 生成 1 笔订单
  • 异常:网络卡顿 / 前端 BUG / 误触 → 重复发送 N 次下单请求
  • 幂等目标:无论请求重复多少次,只创建 1 笔订单,杜绝重复支付、超卖

二、重复下单的核心场景

  1. ​前端误触​:用户连续点击下单按钮
  2. ​网络重试​:请求超时,前端 / 网关自动重试
  3. ​接口重放:恶意用户抓取请求包重复提交
  4. ​后端延迟​:下单逻辑耗时,请求未完成就收到新请求

三、幂等性解决重复下单的标准方案

最优方案:全局唯一幂等号 + 分布式锁

流程:

  1. 进入下单页时,前端请求后端生成全局唯一幂等号(order_token)
  2. 前端提交订单时,必须携带这个 order_token
  3. 后端通过分布式锁锁定幂等号,保证请求串行执行
  4. 执行下单逻辑后,​持久化幂等号状态​,后续重复请求直接拦截

技术选型

  • 幂等号生成:UUID / 雪花算法
  • 分布式锁:Redis(高并发、高性能,最适合电商场景)
  • 存储:MySQL(记录幂等号与订单的绑定关系)

四、完整 PHP 实现代码

环境依赖

  1. PHP 7.4+
  2. Redis 扩展(phpredis
  3. MySQL 5.7+

步骤 1:数据库表设计

1. 订单表(核心业务表)

CREATE TABLE `orders` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '订单ID',
  `order_sn` varchar(32) NOT NULL COMMENT '订单编号',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `total_amount` decimal(10,2) NOT NULL COMMENT '订单金额',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '订单状态 0-待支付 1-已支付',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_order_sn` (`order_sn`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

2. 幂等记录表(保证幂等性)

CREATE TABLE `idempotent_order` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `order_token` varchar(64) NOT NULL COMMENT '全局幂等号',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `order_sn` varchar(32) DEFAULT NULL COMMENT '关联订单号',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-处理中 1-处理成功 2-处理失败',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_order_token` (`order_token`) COMMENT **【关键】唯一索引,强制幂等**
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单幂等记录表';

✅ ​核心关键点​:order_token 加​唯一索引​,数据库层面兜底防重复。


步骤 2:Redis 分布式锁工具类

<?php
/**
 * Redis分布式锁:保证下单请求串行执行,解决并发重复下单
 */
class RedisLock
{
    private $redis;
    private $lockKey;
    private $lockTimeout = 5; // 锁超时时间(秒),防止死锁

    public function __construct($host = '127.0.0.1', $port = 6379, $password = null)
    {
        $this->redis = new Redis();
        $this->redis->connect($host, $port);
        if ($password) {
            $this->redis->auth($password);
        }
    }

    /**
     * 获取锁
     * @param string $key 锁键
     * @param string $requestId 唯一请求ID(防止误删他人锁)
     * @return bool
     */
    public function lock($key, $requestId)
    {
        $this->lockKey = $key;
        // SET命令原子性:不存在则设置,同时设置过期时间
        return $this->redis->set($key, $requestId, ['nx', 'ex' => $this->lockTimeout]);
    }

    /**
     * 释放锁(Lua脚本保证原子性,避免误解锁)
     * @param string $requestId
     * @return bool
     */
    public function unlock($requestId)
    {
        $lua = "
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        ";
        return $this->redis->eval($lua, [$this->lockKey, $requestId], 1);
    }
}

步骤 3:生成全局幂等号接口

前端进入下单页时调用,获取唯一的 order_token

<?php
/**
 * 生成订单幂等号(前端进入下单页调用)
 */
function generateOrderToken()
{
    header('Content-Type: application/json');
    $userId = $_POST['user_id'] ?? 0;
    if (!$userId) {
        echo json_encode(['code' => 400, 'msg' => '用户ID不能为空']);
        exit;
    }

    // 生成全局唯一幂等号:时间戳+用户ID+随机字符串
    $orderToken = md5(uniqid(mt_rand(), true) . $userId . time());

    // 返回给前端,必须携带此token提交订单
    echo json_encode([
        'code' => 200,
        'data' => ['order_token' => $orderToken],
        'msg' => '获取成功'
    ]);
}

// 执行接口
generateOrderToken();

步骤 4:核心:幂等性下单接口(PHP 完整实现)

<?php
/**
 * 幂等性下单接口(核心代码)
 */
class OrderController
{
    private $pdo;
    private $redisLock;

    public function __construct()
    {
        // 初始化MySQL连接
        $this->pdo = new PDO(
            "mysql:host=127.0.0.1;dbname=test;charset=utf8mb4",
            'root',
            'your_password'
        );
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        // 初始化Redis锁
        $this->redisLock = new RedisLock();
    }

    /**
     * 下单主方法
     */
    public function createOrder()
    {
        header('Content-Type: application/json');
        // 1. 获取请求参数
        $userId = $_POST['user_id'] ?? 0;
        $orderToken = $_POST['order_token'] ?? ''; // 幂等号(必传)
        $totalAmount = $_POST['total_amount'] ?? 0;

        // 2. 基础参数校验
        if (!$userId || !$orderToken || $totalAmount <= 0) {
            $this->returnJson(400, '参数错误');
        }

        // 3. 定义锁标识:每个用户+幂等号一把锁
        $lockKey = "order:lock:{$orderToken}";
        $requestId = uniqid(); // 唯一请求ID,防止误解锁

        try {
            // ====================== 幂等核心1:获取分布式锁 ======================
            if (!$this->redisLock->lock($lockKey, $requestId)) {
                $this->returnJson(429, '请求过于频繁,请稍后再试');
            }

            // ====================== 幂等核心2:查询幂等记录 ======================
            $idempotent = $this->getIdempotentRecord($orderToken);
            if ($idempotent) {
                // 已处理:直接返回结果
                if ($idempotent['status'] == 1) {
                    $this->returnJson(200, '订单创建成功', ['order_sn' => $idempotent['order_sn']]);
                }
                // 处理中/失败:拦截重复请求
                $this->returnJson(400, '订单正在处理中,请勿重复提交');
            }

            // ====================== 幂等核心3:插入幂等记录(预处理) ======================
            $this->insertIdempotentRecord($orderToken, $userId);

            // ====================== 4. 执行业务:创建订单 ======================
            $orderSn = $this->generateOrderSn(); // 生成订单号
            $this->insertOrder($orderSn, $userId, $totalAmount);

            // ====================== 幂等核心4:更新幂等记录为成功 ======================
            $this->updateIdempotentStatus($orderToken, 1, $orderSn);

            // 5. 返回成功
            $this->returnJson(200, '订单创建成功', ['order_sn' => $orderSn]);

        } catch (PDOException $e) {
            // 异常:更新幂等记录为失败
            $this->updateIdempotentStatus($orderToken, 2);
            $this->returnJson(500, '订单创建失败:' . $e->getMessage());
        } finally {
            // 无论成功失败,释放锁
            $this->redisLock->unlock($requestId);
        }
    }

    // -------------------------- 工具方法 --------------------------
    // 查询幂等记录
    private function getIdempotentRecord($orderToken)
    {
        $stmt = $this->pdo->prepare("SELECT * FROM idempotent_order WHERE order_token = ?");
        $stmt->execute([$orderToken]);
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }

    // 插入幂等记录(处理中)
    private function insertIdempotentRecord($orderToken, $userId)
    {
        $stmt = $this->pdo->prepare("INSERT INTO idempotent_order (order_token, user_id, status) VALUES (?, ?, 0)");
        $stmt->execute([$orderToken, $userId]);
    }

    // 更新幂等状态
    private function updateIdempotentStatus($orderToken, $status, $orderSn = null)
    {
        if ($orderSn) {
            $stmt = $this->pdo->prepare("UPDATE idempotent_order SET status = ?, order_sn = ? WHERE order_token = ?");
            $stmt->execute([$status, $orderSn, $orderToken]);
        } else {
            $stmt = $this->pdo->prepare("UPDATE idempotent_order SET status = ? WHERE order_token = ?");
            $stmt->execute([$status, $orderToken]);
        }
    }

    // 生成订单号
    private function generateOrderSn()
    {
        return date('YmdHis') . mt_rand(100000, 999999);
    }

    // 插入订单
    private function insertOrder($orderSn, $userId, $totalAmount)
    {
        $stmt = $this->pdo->prepare("INSERT INTO orders (order_sn, user_id, total_amount) VALUES (?, ?, ?)");
        $stmt->execute([$orderSn, $userId, $totalAmount]);
    }

    // JSON输出
    private function returnJson($code, $msg, $data = [])
    {
        echo json_encode(compact('code', 'msg', 'data'));
        exit;
    }
}

// 执行下单接口
(new OrderController())->createOrder();

五、深度剖析:为什么这套方案能 100% 解决重复下单?

1. 三重防重复机制(层层兜底)

表格

层级 实现方式 作用
前端层 按钮点击后禁用、防抖 拦截用户误触
应用层 Redis 分布式锁 拦截并发重复请求,保证串行执行
数据库层 幂等号唯一索引 终极兜底,即使锁失效,也无法插入重复记录

评论 (0)

暂无评论