ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP支付接口重复提交怎么办?幂等性设计从问题定位到实战优化

PHP支付接口重复提交怎么办?幂等性设计从问题定位到实战优化 一、问题背景在 PHP 项目开发中有一类问题非常隐蔽。接口第一次测试的时候完全正常创建订单 ↓ 扣减库存 ↓ 生成支付订单 ↓ 返回成功但是正式上线以后偶尔会出现同一个订单被创建两次 同一笔支付记录出现两条 库存被重复扣减 用户连续点击两次生成两个订单这类问题通常不是 SQL 写错了也不是 PHP 语法问题。真正的原因往往是接口没有做好幂等性控制。二、什么是接口幂等性简单来说同一个业务请求执行一次和执行多次最终结果应该保持一致。例如请求 创建订单 order_tokenABC123第一次请求ABC123 ↓ 创建订单 ↓ 成功第二次请求ABC123 ↓ 发现已经处理 ↓ 直接返回第一次的结果而不是ABC123 ↓ 再次创建订单 ↓ 生成第二个订单这就是幂等。三、最常见的问题用户连续点击提交按钮假设前端有一个购买按钮button idsubmitOrder 立即购买 /button用户点击一次点击 ↓ 发送请求 ↓ 创建订单如果服务器响应比较慢用户可能会认为是不是没点成功于是再次点击。最终请求1 ─────→ 创建订单 请求2 ─────→ 创建订单如果后端没有任何限制用户点击一次 ↓ 实际创建两个订单这种问题在网络波动、移动端网络切换、接口超时重试等情况下更加容易出现。四、先看一个有问题的PHP代码例如订单接口public function createOrder() { $userId request()-get(user_id); $productId request()-get(product_id); $quantity request()-get(quantity); $product DB::table(products) -where(id, $productId) -first(); if (!$product) { return response()-json([ code 404, message 商品不存在 ]); } $orderId DB::table(orders)-insertGetId([ user_id $userId, product_id $productId, quantity $quantity, amount $product-price * $quantity, created_at now() ]); return response()-json([ code 200, order_id $orderId ]); }代码本身看起来没有明显问题。但是连续请求两次POST /api/order/create就可能执行两次请求1 → insert → order_id10001 请求2 → insert → order_id10002最终用户只想买一次却得到了两个订单。五、第一步先确认问题在哪里遇到重复订单以后不要马上给数据库加各种锁。首先查看请求日志。例如记录Log::info(create order, [ user_id $userId, product_id $productId, quantity $quantity, request_id request()-header(X-Request-ID) ]);如果日志发现12:00:01 request_idA001 12:00:01 request_idA002两个请求几乎同时进入接口那么问题就比较明确。接下来需要解决的是如何让同一个业务请求只产生一个结果。六、不要只依赖前端防重复点击很多项目第一反应是button.disabled true;例如$(#submitOrder).click(function () { $(this).prop(disabled, true); $.post(/api/order/create, { product_id: 10001, quantity: 1 }); });这个方案可以减少用户连续点击。但是它不能真正解决幂等问题。因为还有很多情况用户刷新页面 网络重试 APP自动重试 请求超时后重新发送 代理重试 多个客户端同时请求所以前端防重复点击只能作为体验优化不能作为后端幂等方案。七、第一种方案业务唯一编号比较常见的方案是给每次业务请求生成一个唯一 Token。例如order_token客户端创建订单之前生成202609191200001ABC然后提交{ product_id: 10001, quantity: 1, order_token: 202609191200001ABC }后端首先检查这个 order_token 是否已经处理如果没有执行创建订单如果已经处理直接返回之前的订单八、数据库唯一索引是非常重要的一层保护假设订单表增加ALTER TABLE orders ADD UNIQUE KEY uk_order_token (order_token);表结构CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, amount DECIMAL(10,2) NOT NULL, order_token VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_order_token (order_token) );这时候即使两个 PHP 请求同时执行请求A → order_tokenABC123 请求B → order_tokenABC123数据库最终也只能允许一个记录成功。因为ABC123必须唯一。九、PHP代码增加幂等处理例如public function createOrder() { $userId request()-get(user_id); $productId request()-get(product_id); $quantity request()-get(quantity); $orderToken request()-get(order_token); if (!$orderToken) { return response()-json([ code 400, message 缺少请求标识 ]); } $oldOrder DB::table(orders) -where(order_token, $orderToken) -first(); if ($oldOrder) { return response()-json([ code 200, order_id $oldOrder-id, message 订单已创建 ]); } $product DB::table(products) -where(id, $productId) -first(); if (!$product) { return response()-json([ code 404, message 商品不存在 ]); } try { $orderId DB::table(orders)-insertGetId([ user_id $userId, product_id $productId, quantity $quantity, amount $product-price * $quantity, order_token $orderToken, created_at now() ]); } catch (\Exception $e) { $oldOrder DB::table(orders) -where(order_token, $orderToken) -first(); if ($oldOrder) { return response()-json([ code 200, order_id $oldOrder-id, message 订单已创建 ]); } throw $e; } return response()-json([ code 200, order_id $orderId, message 创建成功 ]); }这里有一个非常重要的地方$oldOrder DB::table(orders) -where(order_token, $orderToken) -first();第一次检查是为了已经处理过 → 直接返回但是仅仅这样还不够。十、为什么“先查询再插入”仍然存在问题假设两个请求同时进来。请求 A查询 ABC123 ↓ 不存在请求 B查询 ABC123 ↓ 不存在然后A → INSERT B → INSERT如果没有数据库唯一索引最终还是可能产生两条数据。所以查询判断和唯一约束是两个不同层面的保护。正确方案应该是应用层检查 数据库唯一索引数据库唯一索引是最终防线。十一、为什么不能只使用Redis有些项目会这样做$key order:create: . $orderToken; if (Redis::exists($key)) { return 订单已经创建; } Redis::setex($key, 300, 1); // 创建订单看起来也可以。但是这里仍然存在并发问题请求A → exists → false 请求B → exists → false 请求A → set 请求B → set如果exists和set不是原子操作就仍然可能出现竞争。可以使用Redis::set( $key, 1, NX, EX, 300 );但是即便使用 Redis数据库唯一约束仍然建议保留。因为 Redis 属于缓存/协调层而订单最终数据仍然应该由数据库负责保证唯一性。十二、Redis MySQL双重保护比较完整的方案可以设计成客户端 ↓ order_token ↓ Redis幂等检查 ↓ MySQL唯一索引 ↓ 创建订单例如$key idempotent:order: . $orderToken; $locked Redis::set( $key, 1, NX, EX, 300 ); if (!$locked) { $order DB::table(orders) -where(order_token, $orderToken) -first(); if ($order) { return response()-json([ code 200, order_id $order-id ]); } return response()-json([ code 409, message 请求正在处理中 ]); }然后执行订单创建。十三、订单接口更适合使用数据库事务如果创建订单不只是 INSERT 一条数据而是创建订单 ↓ 创建订单商品 ↓ 扣减库存 ↓ 写入支付记录那么就不能只考虑订单是否重复。必须考虑整个业务流程的一致性。例如DB::transaction(function () use ( $userId, $productId, $quantity, $orderToken ) { $product DB::table(products) -where(id, $productId) -lockForUpdate() -first(); if (!$product) { throw new Exception(商品不存在); } if ($product-stock $quantity) { throw new Exception(库存不足); } $orderId DB::table(orders)-insertGetId([ user_id $userId, product_id $productId, quantity $quantity, order_token $orderToken, amount $product-price * $quantity, created_at now() ]); DB::table(products) -where(id, $productId) -decrement(stock, $quantity); });这样订单创建和库存扣减可以放在同一个事务中。如果中途发生异常创建订单 ↓ 扣库存 ↓ 出现异常 ↓ 事务回滚可以避免只完成一半业务。十四、支付接口的幂等性更加重要支付类接口尤其需要考虑重复请求。例如用户点击支付 ↓ 支付请求发送 ↓ 服务器已经处理 ↓ 网络超时客户端没有收到成功响应。于是客户端认为支付失败然后再次请求。如果后端没有幂等控制就可能重复创建支付记录。因此支付接口通常需要一个明确的业务编号例如payment_no后端根据payment_no判断这笔业务是否已经处理。逻辑payment_no ↓ 查询支付记录 ↓ 已经成功 ├── 是 → 返回成功结果 │ └── 否 → 继续处理这样即使客户端因为网络原因重复发送请求也不会简单地重复创建业务数据。十五、幂等并不等于禁止重复请求这是一个很容易理解错的地方。幂等不是第二次请求直接报错而应该是第一次请求 创建订单 返回订单ID10001 第二次请求 发现已经处理 返回订单ID10001也就是说重复请求可以发生但不能重复产生业务结果。这才是幂等真正解决的问题。十六、完整的业务流程一个比较完整的订单接口可以设计成用户点击购买 ↓ 生成 order_token ↓ 提交订单 ↓ Redis幂等检查 ↓ MySQL查询 order_token ↓ 是否已经存在 ┌────┴────┐ │ │ 是 否 │ │ 返回旧订单 开启事务 ↓ 查询商品 ↓ 检查库存 ↓ 创建订单 ↓ 扣减库存 ↓ 提交事务 ↓ 返回订单其中Redis主要负责快速拦截重复请求。MySQL唯一索引负责最终的数据一致性保护。MySQL事务负责多个数据库操作的一致性。十七、测试重复请求开发完成以后不要只测试正常点击一次应该主动模拟重复请求。例如连续发送请求1order_tokenABC123 请求2order_tokenABC123 请求3order_tokenABC123 请求4order_tokenABC123最终数据库应该只有order_token ABC123这一条订单。同时四个请求可以根据业务设计返回请求1 → 创建成功订单10001 请求2 → 已处理订单10001 请求3 → 已处理订单10001 请求4 → 已处理订单10001而不能出现10001 10002 10003 10004四个订单。十八、常见错误总结错误一只做前端按钮禁用button.disabled true;只能减少用户误操作。不能防止网络重试和恶意重复请求。错误二只查询不加唯一索引if (!$order) { insert(); }高并发情况下仍然存在竞争窗口。错误三只依赖RedisRedis可以做快速幂等控制但最终业务数据仍然应该有数据库层面的约束。错误四没有事务如果业务包含订单 库存 支付 优惠券多个步骤就应该根据业务情况考虑事务和一致性设计。十九、最终方案对于普通 PHP 订单接口可以采用① 客户端生成唯一请求Token ② Redis进行快速重复请求拦截 ③ MySQL增加业务唯一索引 ④ 创建订单使用数据库事务 ⑤ 重复请求返回第一次处理结果 ⑥ 对异常情况进行日志记录核心数据库设计ALTER TABLE orders ADD UNIQUE KEY uk_order_token (order_token);核心 Redis 操作$locked Redis::set( $key, 1, NX, EX, 300 );核心思想应用层减少重复处理 Redis控制并发 数据库保证唯一 事务保证一致性二十、总结PHP 接口出现重复订单、重复支付、重复扣库存等问题时真正需要解决的不是“用户为什么点了两次”。因为在真实互联网环境中重复请求 网络重试 请求超时 客户端重试 并发请求都是正常情况。后端真正需要做到的是即使同一个请求到达服务器多次也只能产生一次有效业务结果。因此一个比较完整的幂等设计可以总结成唯一业务Token ↓ Redis快速拦截 ↓ 数据库唯一索引 ↓ 事务处理业务 ↓ 重复请求返回已有结果对于 PHP 电商、支付、订单、优惠券、库存等系统来说幂等性并不是一个可有可无的高级功能而是业务接口设计中非常重要的一环。尤其是在高并发环境下不要假设一个请求只会到达服务器一次。真正可靠的系统设计应该从一开始就考虑如果这个请求来了两次会发生什么 如果同时来了100次又会发生什么 如果第一次已经成功但是客户端没有收到响应又会发生什么把这些情况提前考虑清楚才能真正避免线上出现重复订单、重复扣款以及库存异常等问题。
返回列表