题目1 如何设计接口可以防止重复点击,扣减库存如何保证幂等性?
简单实现
<?php namespace app\controller; use app\BaseController; use think\facade\Cache; use think\facade\Db; use Ramsey\Uuid\Uuid; class Idempotent extends BaseController { /** * 幂等性:一次和多次请求某一个资源应该具有同样的副作用。 * 幂等号:UUID、业务唯一标识组合 * 方案1:客户端生成幂等号 * 客户端发起扣费请求时,必须生成并携带一个全局唯一的幂等号,服务端收到某个幂等号请求时执行扣库存,将幂等号扣减结果Redis或数据库持久化。后续请求先判断幂等号是否存在,如果存在返回之前的结果。如果不存在按新请求处理。 * 幂等号必须由客户端生成并确保唯一,对幂等号的检查、扣库存、结果存储在同一个事务,确保原子性。 */ public function index() { try { $data = $this->request->param(); $uniqueId = $data['uniqueId']; $productId = $data['productId']; $key = 'idempotent:'.$uniqueId; // 检查幂等号是否存在 if (Cache::has($key)) { return json(Cache::get($key)); } Cache::set($key, 0, 86400);//状态0为未处理 // 检查是否有扣减记录 $deductRecord = Db::table('deduct_records') ->where('unique_id', $uniqueId) ->find(); if ($deductRecord) { if ($deductRecord['status'] == 'success') { // 已经成功扣减过库存,直接返回结果 return json([ 'status' => 'success' ]); }else if ($deductRecord['status'] == 'error'){ // 已经失败扣减过库存,直接返回结果 return json([ 'status' =>'error', 'error_message' => $deductRecord['error_message'] ]); } } // 开启数据库事务 Db::startTrans(); // 扣减库存 $inventory = Db::table('product_inventory') ->where('product_id', $productId) ->lock(true) ->find(); if ($inventory['stock'] <= 0) { throw new \Exception('库存不足'); } // 扣减库存操作 Db::table('product_inventory') ->where('product_id', $productId) ->dec('stock') ->update(); // 持久化结果 $result = [ 'status' => 'success', 'unique_id' => $uniqueId, 'product_id' => $productId, 'deducted' => 1, 'timestamp' => time() ]; // 记录扣减记录 方便对库存 建立定期对库存的监控兜底 Db::table('deduct_records')->insert($result); // 事务提交 Db::commit(); // 存储到Redis(设置24小时过期) Cache::set($key, $result, 86400); return json($result); } catch (\Exception $e) { Db::rollback(); Cache::delete($key); // 添加失败记录 Db::table('deduct_records')->insert([ 'status' => 'error', 'unique_id' => $uniqueId, 'product_id' => $productId, 'error_message' => $e->getMessage(), 'timestamp' => time() ]); return json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } /** * 方案2:服务端生成 token * 客户端会先发送一个请求去获取 token,服务端会生成一个全局唯一的 ID 作为 token 保存在 redis 中,同时把这个 ID 返回给客户端 * 客户端第二次调用业务请求的时候必须携带这个 token * 服务端会校验这个 token,如果校验成功,则执行业务,并删除 redis 中的 token * 如果校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,直接返回指定的结果给客户端 * 对 redis 中是否存在 token 以及删除的代码逻辑建议用 Lua 脚本实现,Lua脚本在Redis中执行是原子的,可以避免竞态条件 */ public function getToken() { try { $token = Uuid::uuid4()->toString(); $key = 'idempotent_token:'. $token; $redis = Cache::store('redis')->handler(); $redis->set($key, 1, 86400); return json([ 'code' => 200, 'msg' => '操作成功', 'data' => [ 'token' => $token ] ]); } catch (\Exception $e) { return json([ 'code' => 500, 'msg' => '操作失败: '.$e->getMessage() ]); } } public function checkToken() { try { $data = $this->request->param(); $token = $data['token'] ?? ''; if (empty($token)) { throw new \Exception('缺少必要参数: token'); } $key = 'idempotent_token:' . $token; $lua = <<<LUA if redis.call("GET", KEYS[1]) then redis.call("DEL", KEYS[1]) return 1 else return 0 end LUA; // 执行原子性校验 $redis = Cache::store('redis')->handler(); $result = $redis->eval($lua, [$key], 1); if ($result !== 1) { return json([ 'code' => 400, 'msg' => '重复请求或无效Token' ]); } // 执行业务逻辑 Db::name('product_inventory') ->where('product_id', $data['product_id']) ->dec('stock') ->update(); return json([ 'code' => 200, 'msg' => '操作成功' ]); } catch (\Exception $e) { return json([ 'code' => 500, 'msg' => '操作失败: '.$e->getMessage() ]); } } /** * 方案3:根据带唯一索引的MySQL+状态机实现 * 插入失败就拒绝请求 * 插入成功就继续执行 **/ public function insertIndex() { try { $data = $this->request->param(); $uniqueId = $data['uniqueId']; $productId = $data['productId']; // 开启数据库事务 Db::startTrans(); // 先尝试插入记录 $record = [ 'unique_id' => $uniqueId, 'product_id' => $productId, 'status' => 'processing', 'timestamp' => time() ]; Db::table('deduct_records') ->strict(false) ->insert($record); // 扣减库存 $affected = Db::table('product_inventory') ->where('product_id', $productId) ->where('stock', '>', 0) ->dec('stock') ->update(); if ($affected === 0) { throw new \Exception('库存不足'); } // 更新记录状态 Db::table('deduct_records') ->where('unique_id', $uniqueId) ->update(['status' => 'success']); Db::commit(); return json([ 'status' => 'success', 'unique_id' => $uniqueId ]); } catch (\Exception $e) { Db::rollback(); // 处理唯一键冲突(错误码23000) if ($e->getCode() == '23000') { return json([ 'status' => 'error', 'message' => '重复请求' ]); } return json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } /** * 方案4:基于Redis 分布式锁实现 * 使用SETNX原子操作实现分布式锁,确保只有一个请求可以执行扣减操作 **/ public function setNx() { try { $data = $this->request->param(); $uniqueId = $data['uniqueId']; $productId = $data['productId']; $key = 'idempotent:'.$uniqueId; $redis = Cache::store('redis')->handler(); // 使用SETNX原子操作 $lockAcquired = $redis->setnx($key, 1); if (!$lockAcquired) { return json([ 'code' => 400, 'msg' => '重复请求,请勿重复提交' ]); } $redis->expire($key, 86400); // 开启数据库事务 Db::startTrans(); // 扣减库存 $inventory = Db::table('product_inventory') ->where('product_id', $productId) ->lock(true) ->find(); if (!$inventory || $inventory['stock'] <= 0) { throw new \Exception('库存不足'); } Db::table('product_inventory') ->where('product_id', $productId) ->dec('stock') ->update(); // 保存记录 $record = [ 'unique_id' => $uniqueId, 'product_id' => $productId, 'status' => 'success', 'timestamp' => time() ]; Db::table('deduct_records')->insert($record); Db::commit(); return json([ 'code' => 200, 'msg' => '操作成功', 'data' => $record ]); } catch (\Exception $e) { Db::rollback(); // 删除Redis锁 Cache::delete($key); return json([ 'code' => 500, 'msg' => '操作失败: '.$e->getMessage() ]); } } /** * 方案5:基于MySQL的乐观锁加版本号实现 **/ public function optimisticLock() { try { $data = $this->request->param(); $uniqueId = $data['uniqueId']; $productId = $data['productId']; // 开启事务 Db::startTrans(); // 获取当前版本号 $inventory = Db::table('product_inventory') ->where('product_id', $productId) ->lock('lock in share mode') ->field('stock,version') ->find(); if (!$inventory || $inventory['stock'] <= 0) { throw new \Exception('库存不足'); } // 带版本号的更新操作 $affected = Db::table('product_inventory') ->where('product_id', $productId) ->where('version', $inventory['version']) ->dec('stock') ->inc('version') ->update(); if ($affected === 0) { throw new \Exception('操作冲突,请重试'); } // 记录扣减操作 $record = [ 'unique_id' => $uniqueId, 'product_id' => $productId, 'status' => 'success', 'timestamp' => time() ]; Db::table('deduct_records')->insert($record); Db::commit(); return json([ 'code' => 200, 'msg' => '操作成功', 'data' => $record ]); } catch (\Exception $e) { Db::rollback(); return json([ 'code' => 500, 'msg' => '操作失败: '.$e->getMessage() ]); } } }
-- 商品库存表 CREATE TABLE product_inventory ( product_id INT PRIMARY KEY COMMENT '商品ID', stock INT NOT NULL DEFAULT 0 COMMENT '库存数量' version INT NOT NULL DEFAULT 0 COMMENT '版本号' ); -- 扣库存表 CREATE TABLE deduct_records ( id INT AUTO_INCREMENT PRIMARY KEY, unique_id VARCHAR(64) NOT NULL UNIQUE, product_id INT NOT NULL, status VARCHAR(20) NOT NULL, deducted INT NOT NULL, timestamp INT NOT NULL, error_message VARCHAR(255) NOT NULL DEFAULT '' );
ab -n 10 -c 10 'http://www.thinkphp8.test/idempotent/index?uniqueId=test136&productId=1' #ab模拟10次并发 #客户端uuid 包nanoid https://github.com/ai/nanoid,包uuid https://github.com/uuidjs/uuid
概念参考 https://xie.infoq.cn/article/e2752bf22f3a9e4f9ce2c404e
题目2 Redis如何保证和数据库数据一致性
- 旁路缓存模式:读流程:读缓存命中返回,未命中读库,写入缓存返回。写流程:方案1先更数据库再删缓存。应对更新库后和删除缓存之间读取的缓存旧数据问题,可以延迟一小时后双删。方案2先删缓存,再更新数据库。不一致问题更严重,也可以延迟双删解决。删除缓存可以交给消息队列异步操作。
- 写穿透模式:应用层将写操作交给一个抽象层处理。该层负责同步更新数据库、同步更新缓存。
- 写回模式:只更新缓存,缓存层异步更新数据到数据库。适用日志场景,容忍一部分数据丢失。
- 订阅数据库变更日志(Binlog/CDC),把变更事件发送到消息队列。
题目3 PHP有哪些运行模式
- cgi/fastcgi模式:fastcgi模式,核心思想进程常驻,fpm主进程管理一个进程池,请求交给空闲进程处理。
- 模块模式
- cli模式
题目4 中间件的架构模式或设计思想
- 责任链模式:一个请求经过管道的每个中间件 req next res
- 管道-过滤器模式:数据流经管道被每个过滤器处理
- 体现了解耦、可插拔性/开闭原则、可组合性、横切关注点集中处理、IOC/DI