什么是接口幂等性?
在分布式系统和微服务架构中,接口幂等性是一个非常重要的概念。所谓幂等性,是指同一个请求,无论执行多少次,对系统产生的影响都是相同的。换句话说,调用方重复发起同一个请求,服务端最终的业务结果应该保持一致,不会因为重复请求而产生额外的副作用。
在 HTTP 语义中,GET、HEAD、PUT、DELETE 天然是幂等的,而 POST 通常不是。但在实际业务中,我们经常需要保证 POST 接口的幂等性,例如支付、下单、退款等场景。
为什么需要幂等性?
面试中,面试官通常会追问“为什么需要幂等”。主要原因有以下几点:
- 网络超时重试:客户端发起请求后,由于网络抖动未收到响应,可能会自动重试,导致服务端收到多次相同请求。
- 用户重复操作:用户快速点击提交按钮,可能触发多次相同请求。
- 消息队列重复消费:MQ 通常保证 at-least-once 语义,消息可能被重复投递。
- 分布式系统重试机制:服务间调用失败后的重试策略,也会导致重复请求。
如果接口不具备幂等性,就可能出现重复扣款、重复下单、库存超卖等严重问题。
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 机制(防重令牌)
流程如下:
- 客户端在提交前,先向服务端申请一个全局唯一的 Token。
- 服务端将 Token 存入 Redis,并设置过期时间。
- 客户端携带 Token 发起业务请求。
- 服务端接收到请求后,从 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 + 去重表。
面试加分点
- 原子性:无论使用 Redis 还是数据库,操作必须保证原子性,推荐使用 Lua 脚本或事务。
- 过期时间:Redis key 一定要设置过期时间,防止内存泄漏。
- 幂等与并发:幂等解决的是重复请求问题,并发控制需要配合锁机制。
- 幂等与去重:幂等是业务语义,去重是实现手段,二者不能混淆。
- 分布式场景:单机锁无法满足分布式需求,必须使用分布式锁或集中式存储。
总结
接口幂等性是 PHP 后端面试中的高频考点,考察的是候选人对分布式系统、并发控制、缓存与数据库的综合理解。实际项目中,往往需要根据业务特点组合使用多种方案。例如:Token 机制 + 唯一索引,或 Redis 锁 + 状态机。理解每种方案的原理、优缺点和适用场景,才能在面试中给出令人满意的答案。
未经允许不得转载:任鹏个人博客 » PHP 面试题:PHP 如何实现接口幂等性

