PHP 面试题:PHP 如何实现接口幂等性

什么是接口幂等性?

在分布式系统和微服务架构中,接口幂等性是一个非常重要的概念。所谓幂等性,是指同一个请求,无论执行多少次,对系统产生的影响都是相同的。换句话说,调用方重复发起同一个请求,服务端最终的业务结果应该保持一致,不会因为重复请求而产生额外的副作用。

在 HTTP 语义中,GET、HEAD、PUT、DELETE 天然是幂等的,而 POST 通常不是。但在实际业务中,我们经常需要保证 POST 接口的幂等性,例如支付、下单、退款等场景。

为什么需要幂等性?

面试中,面试官通常会追问“为什么需要幂等”。主要原因有以下几点:

  1. 网络超时重试:客户端发起请求后,由于网络抖动未收到响应,可能会自动重试,导致服务端收到多次相同请求。
  2. 用户重复操作:用户快速点击提交按钮,可能触发多次相同请求。
  3. 消息队列重复消费:MQ 通常保证 at-least-once 语义,消息可能被重复投递。
  4. 分布式系统重试机制:服务间调用失败后的重试策略,也会导致重复请求。

如果接口不具备幂等性,就可能出现重复扣款、重复下单、库存超卖等严重问题。

PHP 中实现接口幂等性的常见方案

方案一:唯一索引 + 数据库约束

这是最基础也是最可靠的方案。在数据库层面为业务字段建立唯一索引,重复插入时会抛出异常,从而保证幂等。

try {
    $pdo->beginTransaction();
    $stmt = $pdo->prepare("INSERT INTO orders (order_no, user_id, amount) VALUES (?, ?, ?)");
    $stmt->execute([$orderNo, $userId, $amount]);
    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    if ($e->getCode() == 23000) {
        // 唯一索引冲突,说明是重复请求
        return ['code' => 0, 'msg' => '订单已存在'];
    }
    throw $e;
}

优点:实现简单,可靠性高。
缺点:只适用于插入场景,且依赖数据库,对分库分表场景不友好。

方案二:Token 机制(防重令牌)

流程如下:

  1. 客户端在提交前,先向服务端申请一个全局唯一的 Token。
  2. 服务端将 Token 存入 Redis,并设置过期时间。
  3. 客户端携带 Token 发起业务请求。
  4. 服务端接收到请求后,从 Redis 中删除 Token,删除成功则继续业务,删除失败说明是重复请求。
// 申请 Token
public function getToken(): string
{
    $token = md5(uniqid((string)mt_rand(), true));
    Redis::setex("idempotent:token:{$token}", 300, 1);
    return $token;
}

// 校验 Token
public function checkToken(string $token): bool
{
    // 使用 Lua 脚本保证原子性
    $lua = <<<LUA
    if redis.call('get', KEYS[1]) then
        redis.call('del', KEYS[1])
        return 1
    else
        return 0
    end
LUA;
    return (bool) Redis::eval($lua, 1, "idempotent:token:{$token}");
}

优点:适用于各种业务场景,通用性强。
缺点:需要额外交互,客户端需要改造。

方案三:Redis 分布式锁

利用 Redis 的 SET NX EX 命令实现分布式锁,以业务唯一标识(如订单号)作为锁的 key。

public function handle(array $params): array
{
    $lockKey = 'idempotent:lock:' . $params['order_no'];
    $lockValue = uniqid('', true);

    // 尝试获取锁,过期时间 10 秒
    $acquired = Redis::set($lockKey, $lockValue, 'NX', 'EX', 10);
    if (!$acquired) {
        return ['code' => 0, 'msg' => '请求处理中,请勿重复提交'];
    }

    try {
        // 执行业务逻辑
        return $this->doBusiness($params);
    } finally {
        // 使用 Lua 脚本释放锁,防止误删
        $lua = <<<LUA
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
LUA;
        Redis::eval($lua, 1, $lockKey, $lockValue);
    }
}

优点:实现简单,性能高。
缺点:锁过期时间需要合理设置,业务执行时间过长可能导致锁提前释放。

方案四:状态机 + 乐观锁

对于订单、支付等有状态流转的业务,可以通过状态机 + 乐观锁实现幂等。

$affected = $pdo->exec(
    "UPDATE orders SET status = 'paid', updated_at = NOW() 
     WHERE order_no = '{$orderNo}' AND status = 'unpaid'"
);

if ($affected === 0) {
    // 说明订单已经是 paid 状态,重复请求
    return ['code' => 0, 'msg' => '订单已支付'];
}

优点:无需额外存储,天然幂等。
缺点:只适用于状态流转明确的业务。

方案五:唯一请求 ID + 去重表

客户端为每个请求生成一个全局唯一的 request_id,服务端在处理前先查询去重表,若已存在则直接返回上次结果。

public function process(string $requestId, array $params): array
{
    $cacheKey = 'idempotent:result:' . $requestId;
    $cached = Redis::get($cacheKey);
    if ($cached) {
        return json_decode($cached, true);
    }

    $result = $this->doBusiness($params);
    Redis::setex($cacheKey, 86400, json_encode($result));
    return $result;
}

优点:可以缓存结果,直接返回,性能好。
缺点:需要客户端配合生成 request_id。

方案对比与选型建议

方案 适用场景 性能 实现复杂度
唯一索引 插入类业务
Token 机制 表单提交、通用场景
Redis 分布式锁 并发写场景
状态机乐观锁 状态流转业务
请求 ID 去重 通用场景

选型建议:

  • 插入类业务优先使用唯一索引。
  • 表单提交使用 Token 机制。
  • 高并发场景使用 Redis 分布式锁。
  • 状态流转业务使用状态机乐观锁。
  • 通用场景使用请求 ID + 去重表。

面试加分点

  1. 原子性:无论使用 Redis 还是数据库,操作必须保证原子性,推荐使用 Lua 脚本或事务。
  2. 过期时间:Redis key 一定要设置过期时间,防止内存泄漏。
  3. 幂等与并发:幂等解决的是重复请求问题,并发控制需要配合锁机制。
  4. 幂等与去重:幂等是业务语义,去重是实现手段,二者不能混淆。
  5. 分布式场景:单机锁无法满足分布式需求,必须使用分布式锁或集中式存储。

总结

接口幂等性是 PHP 后端面试中的高频考点,考察的是候选人对分布式系统、并发控制、缓存与数据库的综合理解。实际项目中,往往需要根据业务特点组合使用多种方案。例如:Token 机制 + 唯一索引,或 Redis 锁 + 状态机。理解每种方案的原理、优缺点和适用场景,才能在面试中给出令人满意的答案。

未经允许不得转载:任鹏个人博客 » PHP 面试题:PHP 如何实现接口幂等性

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏