题目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