先说背景。项目上线两周后,运营在群里发了一张截图:同一个用户,同一个商品,一分钟内产生了三笔订单,状态都是「待支付」,金额完全一样。用户主动联系客服说误触了,能不能退掉两笔。
去查日志,三笔请求的时间戳分别是 10:23:41.802、10:23:41.915、10:23:42.103。前后差不到 300 毫秒。典型的前端按钮没锁住,用户手指点在屏幕上没松开,浏览器把 click 事件派发了三次。
这件事不复杂,但要彻底解决,需要在三个层面同时动手。这篇文章把整个改造过程完整拆开讲。
为什么「前端禁用按钮」不是答案
第一反应往往是加一句 button.disabled = true。这招能解决 90% 的场景,但剩下来的 10% 才是真正麻烦的。
想想这些情况怎么处理:
- 用户在提交过程中按了刷新,浏览器会重放上一次的 POST 请求;
- 网络抖动,客户端 SDK 自动重试,重试打到了另一台服务器;
- 用户在手机 App 里操作,客户端和服务端是两个团队,你不能保证对方一定加了防重逻辑;
- 有人拿抓包工具直接把请求重放十遍。
前端的限制都是「善意约束」,绕过它的成本几乎为零。要真解决问题,服务端必须自己扛住这件事。
三层防线,各管一段
做幂等不是加一道锁就完事。真实的系统里,我一般会布三层:
- 第一层:Token 校验。请求携带一个一次性 Token,用完即失效。它解决的是「同一个客户端连续重复提交」。
- 第二层:Redis 分布式锁。按业务维度(比如用户 ID + 商品 ID)加锁。它解决的是「并发请求同时打进来」。
- 第三层:数据库唯一索引。库存流水、订单号这类关键字段上建唯一索引。它解决的是「锁失效、服务重启、时钟漂移」这类极端情况下的兜底。
三层不是互相替代,而是各自管不同时间段的事情。只有第一层,跨服务的重试拦不住;只有第二层,锁超时释放了照样出问题;只有第三层,数据库压力会上去,而且报错体验差。
下面把三层依次写出来。
第一层:用中间件校验一次性 Token
先做一个生成 Token 的接口,前端在进入下单页时先调一次:
namespace appapicontroller;
use thinkfacadeCache;
use thinkResponse;
class Token
{
public function issue(): Response
{
$token = bin2hex(random_bytes(16));
// 60 秒内有效,一次使用
Cache::store('redis')->set('idem:token:' . $token, 1, 60);
return json(['token' => $token]);
}
}
然后是中间件本体:
namespace appmiddleware;
use Closure;
use thinkfacadeCache;
use thinkRequest;
use thinkResponse;
class Idempotent
{
public function handle(Request $request, Closure $next): Response
{
// 只对会修改数据的请求做校验
if (!$request->isPost() && !$request->isPut() && !$request->isDelete()) {
return $next($request);
}
$token = $request->header('X-Idempotent-Token');
if (empty($token)) {
return json(['code' => 40001, 'msg' => '缺少幂等 Token'], 400);
}
$key = 'idem:token:' . $token;
// Redis 的 DEL 返回被删除的键数量,是 1 说明本次是首次使用
$consumed = Cache::store('redis')->delete($key);
if (!$consumed) {
return json(['code' => 40002, 'msg' => '请求已处理,请勿重复提交'], 400);
}
try {
return $next($request);
} catch (Throwable $e) {
// 业务处理失败,把 Token 放回去让客户端可以重试
Cache::store('redis')->set($key, 1, 60);
throw $e;
}
}
}
这里有几个设计决定需要说明。
为什么用 DEL 而不是 GET + DEL?GET 再 DEL 是两步操作,两步之间有时间窗口,两个并发请求可能都能通过 GET 检查。用 DEL 的返回值判断是原子的,只有一个调用者会拿到 1。这是最容易写错的地方。
为什么失败要把 Token 放回去?如果不放回去,用户第一次提交因为某个业务校验没过(比如库存不足),第二次提交时 Token 已经消耗掉了,会收到「请勿重复提交」。用户一脸懵:我明明第一次就失败了,为什么不让再来一次。这里的取舍是:同一个 Token 只保证「成功处理」之前只能进一次,业务失败允许重试。
为什么 60 秒?太短,用户还没反应过来就过期了;太长,缓存占用上去了。60 秒是个常用值,具体还是看业务处理时长。
第二层:Redis 分布式锁
Token 解决的是同一个客户端重复发请求,但如果两个不同客户端(用户手上有 App 和网页同时在操作)恰好同时点提交,Token 挡不住。这时候需要在业务维度加锁:
namespace appservice;
use thinkfacadeCache;
class DistributedLock
{
/**
* @return string|null 拿到锁返回标识,拿不到返回 null
*/
public static function acquire(string $key, int $ttl = 10): ?string
{
$owner = bin2hex(random_bytes(8));
$redis = Cache::store('redis')->handler();
$ok = $redis->set($key, $owner, ['NX', 'EX' => $ttl]);
return $ok ? $owner : null;
}
public static function release(string $key, string $owner): void
{
$redis = Cache::store('redis')->handler();
// 用 Lua 脚本保证「比对 + 删除」的原子性
$script = <<<LUA
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
LUA;
$redis->eval($script, [$key, $owner], 1);
}
}
下单处的调用:
public function createOrder(Request $request)
{
$userId = $request->userId;
$goodsId = $request->post('goods_id');
$lockKey = sprintf('idem:order:%d:%d', $userId, $goodsId);
$owner = DistributedLock::acquire($lockKey, 10);
if ($owner === null) {
return json(['code' => 40003, 'msg' => '操作进行中,请稍候'], 429);
}
try {
// 先查一次,避免重复创建
$exists = Order::where('user_id', $userId)
->where('goods_id', $goodsId)
->where('status', '<>', 'cancelled')
->find();
if ($exists) {
return json(['code' => 0, 'data' => ['order_no' => $exists->order_no]]);
}
$order = $this->doCreateOrder($userId, $goodsId);
return json(['code' => 0, 'data' => ['order_no' => $order->order_no]]);
} finally {
DistributedLock::release($lockKey, $owner);
}
}
这块代码有两个和幂等直接相关的地方。
释放锁为什么用 Lua 脚本?如果不比对 owner 就直接 DEL,会出现这一幕:请求 A 拿到锁,处理时超时(超过 10 秒),锁自动过期;请求 B 拿到锁开始执行;此时 A 处理完了,直接 DEL 把 B 的锁删了;请求 C 又能拿到锁。整个锁的互斥就失效了。Lua 脚本在 Redis 端原子执行,只有 owner 对得上才会删。
为什么锁内还要再查一次订单?因为「锁保护的是当前进程」,不能依赖锁本身保证业务无重。锁内查询是应用层幂等,就算锁意外失效,这个查询依然能把重复请求拦下来。这就是所谓的「双保险」。
锁超时时间的设置是个平衡:设短了业务还没处理完锁就自动释放,会导致并发问题;设长了万一持有者崩溃,其他请求会一直等。10 秒对于下单这种操作是够的,如果里面有调三方接口,那得重新评估。
第三层:数据库唯一索引兜底
前面两层都是应用层的,如果服务重启、Redis 集群故障,或者你有个同学写了个脚本直接往数据库插数据,应用层的防线就都不起作用了。真正能在数据库这一层保证唯一性的,只有唯一索引。
ALTER TABLE `order`
ADD UNIQUE INDEX `uk_user_goods_active` (`user_id`, `goods_id`, `status`);
等等,这个索引设计有个问题:状态变成 cancelled 之后,用户应该能重新下单,但唯一索引不允许。所以这种「部分唯一」的需求,得换一种写法。
常见做法是给表加一个 active_flag 字段,用 1 表示有效、NULL 表示已失效:
ALTER TABLE `order`
ADD COLUMN `active_flag` TINYINT DEFAULT 1 COMMENT '1=有效 NULL=已失效',
ADD UNIQUE INDEX `uk_user_goods_active` (`user_id`, `goods_id`, `active_flag`);
MySQL 的唯一索引对 NULL 是容忍的——多行同一组 (user_id, goods_id) 只要 active_flag 都是 NULL,就不会冲突。取消订单时把 active_flag 置为 NULL,新订单又能创建了。
业务代码里捕获唯一索引冲突:
use thinkexceptionDbException;
try {
$order = Order::create([
'user_id' => $userId,
'goods_id' => $goodsId,
'active_flag' => 1,
// ...其他字段
]);
return $order;
} catch (DbException $e) {
// 1062 是 MySQL 唯一索引冲突的错误码
if (str_contains($e->getMessage(), '1062')) {
$existing = Order::where('user_id', $userId)
->where('goods_id', $goodsId)
->where('active_flag', 1)
->find();
return $existing;
}
throw $e;
}
捕获到 1062 之后不要直接抛「重复提交」,而是查出已有的订单返回给用户。用户拿到的是同一个订单号,这就是幂等——同样的请求执行多次,结果一致。
把三层都接上:完整流程
把中间件注册到路由上:
// route/app.php
Route::post('api/order', 'api/Order/create')
->middleware([
appmiddlewareIdempotent::class,
]);
客户端调用时序:
- 进入下单页,调
GET /api/token拿 Token; - 提交时携带 Token 到
POST /api/order; - Idempotent 中间件消费 Token,首次请求放行;
- Order 控制器获取分布式锁;
- 锁内查重,无重复则创建订单;
- 创建时若触发唯一索引冲突,捕获后返回已有订单;
- finally 释放锁;
- 返回订单信息。
用户重复点击引发的三个请求,会分别在第二层和第三层被拦下来:第一个正常处理,第二个拿不到锁返回 429,第三个消费 Token 失败返回 40002。用户看到的是同一个订单号加一句「操作进行中」,体验是连贯的。
五个容易写错的地方
一、Token 生成时没有设置过期时间。没用过的 Token 会一直留在 Redis 里,时间长了就是内存泄漏。一定要设 TTL。
二、锁的 TTL 和业务处理时间不匹配。调三方支付接口可能要十几秒,锁设 10 秒就自动释放了。要么加锁续期(看门狗),要么把锁的 TTL 设得比业务最长处理时间宽裕一些。
三、捕获数据库异常时把错误码搞混了。ThinkPHP 8 抛出的 DbException 里嵌套了 PDOException,取真正的 SQL 错误码要通过 $e->getPrevious()->getCode(),直接 getCode() 拿到的是框架的错误码。上面示例用字符串匹配只是一种简写,生产环境建议用更严谨的方式。
四、唯一索引冲突处理里又漏了条件。查已有订单时只按 user_id + goods_id 查,没带 active_flag = 1,可能翻出来一条已取消的历史订单返回给用户。这种 bug 排查起来很费劲,因为看起来数据没问题。
五、把幂等和限流搞混了。幂等是「同样的请求只执行一次」,限流是「单位时间内最多执行 N 次」。前者保护业务数据,后者保护系统稳定性。两者都需要,但放在不同的中间件里做。
什么时候这套方案太重了
三层防线不是所有接口都值得铺。如果是「发送短信验证码」这种 60 秒内只能调一次的场景,一层 Redis 限流就够了;如果是「修改用户昵称」这种天然可重入的操作,连幂等都不用做——把昵称改成「张三」,执行十遍结果都一样。
真正值得上三层的是这几类:涉及资金的操作、库存扣减、优惠券领取、积分兑换、以及那些「重复一次就会引发客诉」的动作。判断标准很朴素:如果我今晚忘了加防护,明天会不会被运营或者客服找上门。
最后补一句关于测试。这些幂等逻辑写好之后,光看代码很难判断对不对。我会写一小段并发压测脚本,用 Apache Bench 或者 k6 起 50 个并发往同一个接口发同样的请求,看数据库里到底有几个订单。跑通一次,后面睡得踏实一点。

