记录一次PHP面试题

题目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. 旁路缓存模式:读流程:读缓存命中返回,未命中读库,写入缓存返回。写流程:方案1先更数据库再删缓存。应对更新库后和删除缓存之间读取的缓存旧数据问题,可以延迟一小时后双删。方案2先删缓存,再更新数据库。不一致问题更严重,也可以延迟双删解决。删除缓存可以交给消息队列异步操作。
  2. 写穿透模式:应用层将写操作交给一个抽象层处理。该层负责同步更新数据库、同步更新缓存。
  3. 写回模式:只更新缓存,缓存层异步更新数据到数据库。适用日志场景,容忍一部分数据丢失。
  4. 订阅数据库变更日志(Binlog/CDC),把变更事件发送到消息队列。

题目3 PHP有哪些运行模式

  1. cgi/fastcgi模式:fastcgi模式,核心思想进程常驻,fpm主进程管理一个进程池,请求交给空闲进程处理。
  2. 模块模式
  3. cli模式

题目4 中间件的架构模式或设计思想

  1. 责任链模式:一个请求经过管道的每个中间件 req next res
  2. 管道-过滤器模式:数据流经管道被每个过滤器处理
  3. 体现了解耦、可插拔性/开闭原则、可组合性、横切关注点集中处理、IOC/DI

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注