ThinkPHP8 常见并发场景解决方案文档

ThinkPHP8 常见并发场景解决方案文档

一、文档说明

本文档针对开发中高频出现的4类并发问题,提供基于 ThinkPHP8 的可直接运行解决方案,涵盖代码实现、核心原理、适用场景及关键注意事项,旨在帮助开发者快速解决并发场景下的数据一致性和系统稳定性问题。
适用范围:ThinkPHP8 开发者、需要处理并发场景(防超卖、防重复提交等)的后端开发人员
前置依赖:已配置 ThinkPHP8 环境,涉及 Redis 的场景需完成 Redis 扩展及配置

二、核心并发场景解决方案

场景1:库存扣减(防止超卖)

1.1 场景说明

高并发场景下(如秒杀、促销活动),多个用户同时抢购同一商品,若未做并发控制,会出现库存扣减为负数的"超卖"问题,导致业务逻辑异常。核心需求:保证库存数据一致性,不出现超卖。

1.2 方案选型:MySQL 悲观锁(行锁)

采用 MySQL InnoDB 存储引擎的行级排他锁,结合事务机制,确保"检查库存+扣减库存"的原子性,同一时间仅允许一个请求修改目标商品的库存记录。适用于库存一致性要求高、并发量中等的场景(如普通电商订单)。

1.3 实现代码

php 复制代码
<?php
namespace app\controller;

use think\facade\Db;
use think\response\Json;

class StockController
{
    /**
     * 商品库存扣减接口
     * @param int $productId 商品ID(URL参数)
     * @param int $num 扣减数量(默认1)
     * @return Json
     */
    public function reduceStock(int $productId, int $num = 1): Json
    {
        // 开启数据库事务,保证操作原子性
        Db::startTrans();
        try {
            // 1. 锁定目标商品行记录(排他锁,防止并发修改)
            // lock(true) 等价于 SQL 中的 SELECT ... FOR UPDATE
            $product = Db::name('product')
                ->where('id', $productId)
                ->lock(true)
                ->find();

            // 2. 基础校验
            if (empty($product)) {
                throw new \Exception("商品不存在");
            }
            if ($product['stock'] < $num) {
                throw new \Exception("库存不足,当前库存:{$product['stock']}");
            }

            // 3. 扣减库存(使用 Db::raw 确保SQL语法正确)
            $updateRes = Db::name('product')
                ->where('id', $productId)
                ->update(['stock' => Db::raw("stock - {$num}")]);

            if (!$updateRes) {
                throw new \Exception("库存扣减失败");
            }

            // 4. 提交事务
            Db::commit();
            return json([
                'code' => 0,
                'msg' => '库存扣减成功',
                'data' => ['product_id' => $productId, 'remain_stock' => $product['stock'] - $num]
            ]);
        } catch (\Exception $e) {
            // 5. 异常回滚事务
            Db::rollback();
            return json([
                'code' => 1,
                'msg' => $e->getMessage(),
                'data' => []
            ]);
        }
    }
}

1.4 关键解析

  • 行锁机制lock(true) 会对查询的商品记录加排他锁,同一时间只有一个事务能获取该锁,其他请求需等待锁释放,避免并发修改冲突。

  • 事务保障 :通过 Db::startTrans()Db::commit()Db::rollback() 确保"查库存+扣库存"是原子操作,要么全部成功,要么全部回滚。

  • 表结构要求:product 表需包含 id(主键)、stock(库存字段),主键索引确保行锁能精准锁定单条记录(无索引会退化为表锁,降低并发)。

  • 优化方向:高并发秒杀场景可改用"Redis 预扣减+MQ 异步落库"方案,进一步提升系统吞吐量。

场景2:订单创建(防止重复提交)

2.1 场景说明

用户因网络延迟、重复点击按钮、恶意重试等原因,可能导致同一订单请求被多次提交,出现重复创建订单、重复扣减库存的问题。核心需求:确保同一订单请求仅被处理一次。

2.2 方案选型:Token 令牌验证

基于"一次性 Token"机制,前端请求订单创建前先获取令牌,提交订单时携带令牌,后端验证令牌有效性(未使用过、未过期),验证通过后立即失效令牌,防止重复使用。适用于表单提交、API 接口防重放等场景。

2.3 实现代码

php 复制代码
<?php
namespace app\controller;

use think\facade\Cache;
use think\facade\Db;
use think\facade\Session;
use think\request\Request;
use think\response\Json;

class OrderController
{
    /**
     * 1. 获取防重复提交 Token(前端调用)
     * @return Json
     */
    public function getOrderToken(): Json
    {
        // 生成随机唯一 Token(md5+uniqid 保证唯一性)
        $token = md5(uniqid(mt_rand(), true));
        // 存储 Token:单机用 Session,分布式用 Redis(此处兼容两种场景)
        if (config('cache.default') === 'redis') {
            // Redis 存储,设置 10 分钟过期(避免令牌堆积)
            Cache::store('redis')->set("order_token:".Session::getId(), $token, 600);
        } else {
            // Session 存储
            Session::set('order_token', $token);
        }
        return json([
            'code' => 0,
            'msg' => 'Token 获取成功',
            'data' => ['token' => $token]
        ]);
    }

    /**
     * 2. 创建订单接口(前端携带 Token 提交)
     * @param Request $request
     * @return Json
     */
    public function createOrder(Request $request): Json
    {
        $postData = $request->post();
        // 必要参数校验
        $validateRes = $this->validate($postData, [
            'user_id' => 'require|integer',
            'product_id' => 'require|integer',
            'amount' => 'require|float|gt:0',
            'token' => 'require'
        ]);
        if ($validateRes !== true) {
            return json(['code' => 1, 'msg' => $validateRes, 'data' => []]);
        }

        $token = $postData['token'];
        $tokenKey = config('cache.default') === 'redis' 
            ? "order_token:".Session::getId() 
            : 'order_token';

        // 3. 验证 Token 有效性
        $storedToken = config('cache.default') === 'redis' 
            ? Cache::store('redis')->get($tokenKey) 
            : Session::get('order_token');

        if (empty($storedToken) || $storedToken !== $token) {
            return json(['code' => 1, 'msg' => '重复提交或 Token 已失效', 'data' => []]);
        }

        try {
            // 4. 验证通过,立即销毁 Token(核心:确保一次性使用)
            if (config('cache.default') === 'redis') {
                Cache::store('redis')->delete($tokenKey);
            } else {
                Session::delete('order_token');
            }

            // 5. 执行创建订单逻辑(此处可调用库存扣减接口)
            $orderId = Db::name('order')->insertGetId([
                'user_id' => $postData['user_id'],
                'product_id' => $postData['product_id'],
                'amount' => $postData['amount'],
                'order_sn' => $this->generateOrderSn(), // 生成订单号
                'status' => 1, // 1-待支付
                'create_time' => time()
            ]);

            return json([
                'code' => 0,
                'msg' => '订单创建成功',
                'data' => ['order_id' => $orderId, 'order_sn' => $this->generateOrderSn()]
            ]);
        } catch (\Exception $e) {
            return json(['code' => 1, 'msg' => $e->getMessage(), 'data' => []]);
        }
    }

    /**
     * 辅助方法:生成唯一订单号
     * @return string
     */
    private function generateOrderSn(): string
    {
        return date('YmdHis') . mt_rand(1000, 9999);
    }
}

2.4 关键解析

  • Token 唯一性 :通过 uniqid(mt_rand(), true) 生成随机字符串,结合 md5 加密,确保 Token 唯一且不可预测。

  • 存储适配:兼容单机(Session)和分布式(Redis)部署,Redis 存储时通过 SessionID 区分用户,避免 Token 混淆。

  • 一次性有效性:订单创建成功前立即销毁 Token,即使同一 Token 被重复提交,也会因 Token 不存在而被拒绝。

  • 过期机制:Redis 存储的 Token 设置 10 分钟过期,避免因用户未提交订单导致 Token 长期堆积。

场景3:分布式任务调度(防止重复执行)

3.1 场景说明

分布式部署环境下(多台服务器),定时任务(如生成日报表、数据同步)若未做控制,会出现多台服务器同时执行同一任务的情况,导致数据重复处理、资源浪费。核心需求:同一任务同一时间仅被一台服务器执行。

3.2 方案选型:Redis 分布式锁

基于 Redis 的 SET NX(Set if Not Exists)原子命令实现分布式锁,任务执行前尝试获取锁,获取成功则执行任务,失败则说明其他节点正在执行,直接返回。适用于分布式定时任务、跨服务数据同步等场景。

3.3 实现代码

php 复制代码
<?php
namespace app\controller;

use think\facade\Cache;
use think\response\Json;

class TaskController
{
    /**
     * 分布式定时任务执行入口
     * 示例:每日凌晨2点执行日报表生成任务
     * @param string $taskName 任务名称(如:daily_report)
     * @return Json
     */
    public function runDistributedTask(string $taskName): Json
    {
        // 1. 锁相关配置
        $lockKey = "distributed_lock:task:{$taskName}"; // 锁 Key(按任务名称区分)
        $lockExpire = 300; // 锁过期时间(5分钟),防止节点挂掉导致锁永久不释放
        $lockValue = uniqid(); // 锁值(用于释放锁时校验,避免误删其他锁)

        // 2. 尝试获取 Redis 分布式锁(SET NX + EX 原子操作)
        // NX:仅当 Key 不存在时设置成功;EX:设置过期时间(秒)
        $isLocked = Cache::store('redis')->set($lockKey, $lockValue, $lockExpire, ['NX']);

        if (!$isLocked) {
            // 未获取到锁,说明其他节点正在执行任务
            return json([
                'code' => 1,
                'msg' => "任务【{$taskName}】已在其他节点执行中",
                'data' => []
            ]);
        }

        try {
            // 3. 获取锁成功,执行任务逻辑
            switch ($taskName) {
                case 'daily_report':
                    $this->generateDailyReport(); // 生成日报表
                    break;
                case 'data_sync':
                    $this->syncData(); // 数据同步(示例方法)
                    break;
                default:
                    throw new \Exception("未知任务名称:{$taskName}");
            }

            return json([
                'code' => 0,
                'msg' => "任务【{$taskName}】执行完成",
                'data' => []
            ]);
        } catch (\Exception $e) {
            return json([
                'code' => 1,
                'msg' => "任务【{$taskName}】执行失败:" . $e->getMessage(),
                'data' => []
            ]);
        } finally {
            // 4. 任务执行完成/失败,释放锁(Lua脚本保证原子性)
            // 避免因任务执行时间超过锁过期时间,导致误删其他节点的锁
            $luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Cache::store('redis')->eval($luaScript, [$lockKey, $lockValue], 1);
        }
    }

    /**
     * 任务1:生成日报表(示例实现)
     */
    private function generateDailyReport(): void
    {
        // 模拟耗时任务(实际场景:查询昨日数据、生成Excel、推送邮件等)
        sleep(3);
        // 此处可添加报表生成逻辑(如写入 report 表、存储文件等)
    }

    /**
     * 任务2:数据同步(示例实现)
     */
    private function syncData(): void
    {
        // 模拟数据同步逻辑(如同步第三方数据到本地库)
        sleep(2);
    }
}

3.4 关键解析

  • 原子锁获取set($lockKey, $lockValue, $lockExpire, ['NX']) 是原子操作,避免"检查锁是否存在"和"设置锁"两步操作之间出现并发问题。

  • 锁过期保护:设置 5 分钟过期时间,即使执行任务的节点意外挂掉,锁也会自动过期释放,避免死锁。

  • 安全释放锁:通过 Lua 脚本校验锁值后再删除,确保仅释放当前节点持有的锁,避免因任务执行超时导致锁被其他节点误删。

  • 任务调度配置:可结合 ThinkPHP 定时任务(think-cron),在多台服务器部署相同定时任务,通过分布式锁实现"单点执行"。

场景4:缓存更新(防止缓存击穿/雪崩)

4.1 场景说明

  • 缓存击穿:一个热点 Key 过期时,大量请求同时穿透到数据库,导致数据库瞬间压力剧增。

  • 缓存雪崩:大量 Key 同时过期,或缓存服务宕机,导致所有请求直接打到数据库,可能引发数据库崩溃。

核心需求:保护数据库,避免缓存失效时的流量冲击。

4.2 方案选型:Redis 互斥锁(防击穿)+ 缓存空值(防穿透)+ 过期时间随机化(防雪崩)

通过互斥锁确保只有一个请求去数据库查询热点数据并更新缓存,其他请求等待或重试;对不存在的 Key 缓存空值,防止缓存穿透;给 Key 设置随机过期时间,避免大量 Key 同时过期。

4.3 实现代码

php 复制代码
<?php
namespace app\controller;

use think\facade\Cache;
use think\facade\Db;
use think\response\Json;

class CacheController
{
    /**
     * 获取商品详情(带缓存防击穿/雪崩/穿透)
     * @param int $productId 商品ID
     * @return Json
     */
    public function getProductDetail(int $productId): Json
    {
        // 1. 缓存 Key 定义(按业务类型+ID 命名)
        $cacheKey = "product:detail:{$productId}";
        // 锁 Key(按缓存 Key 衍生,确保一一对应)
        $lockKey = "lock:cache:{$productId}";
        $lockExpire = 10; // 锁过期时间(10秒)
        $cacheExpire = 3600 + mt_rand(0, 600); // 缓存过期时间(1小时+随机0-10分钟,防雪崩)

        // 2. 优先从缓存获取数据
        $productDetail = Cache::get($cacheKey);
        if ($productDetail !== false) {
            // 缓存命中:若为缓存的空值,返回"商品不存在"
            if (empty($productDetail)) {
                return json(['code' => 1, 'msg' => '商品不存在', 'data' => []]);
            }
            return json(['code' => 0, 'msg' => 'success', 'data' => $productDetail]);
        }

        // 3. 缓存未命中,尝试获取互斥锁
        $isLocked = Cache::store('redis')->set($lockKey, 1, $lockExpire, ['NX']);
        if (!$isLocked) {
            // 未获取到锁:返回"系统繁忙",前端可重试
            return json(['code' => 2, 'msg' => '系统繁忙,请稍后再试', 'data' => []]);
        }

        try {
            // 4. 获取锁成功,从数据库查询数据
            $productDetail = Db::name('product')
                ->where('id', $productId)
                ->find();

            // 5. 处理查询结果:缓存真实数据或空值(防穿透)
            if (empty($productDetail)) {
                // 商品不存在,缓存空值(1分钟过期,避免长期占用缓存)
                Cache::set($cacheKey, '', 60);
                return json(['code' => 1, 'msg' => '商品不存在', 'data' => []]);
            }

            // 商品存在,缓存真实数据(带随机过期时间,防雪崩)
            Cache::set($cacheKey, $productDetail, $cacheExpire);

            return json(['code' => 0, 'msg' => 'success', 'data' => $productDetail]);
        } catch (\Exception $e) {
            return json(['code' => 1, 'msg' => $e->getMessage(), 'data' => []]);
        } finally {
            // 6. 释放锁(无论成功失败,都要释放)
            Cache::store('redis')->delete($lockKey);
        }
    }
}

4.4 关键解析

  • 防击穿:互斥锁确保只有一个请求去数据库查询热点数据,其他请求因获取不到锁而返回重试提示,避免大量请求同时穿透到数据库。

  • 防穿透:对不存在的商品(数据库无记录),缓存空值(1分钟过期),避免恶意请求(如遍历商品ID)反复穿透到数据库。

  • 防雪崩:缓存过期时间设置为"1小时+随机0-10分钟",使大量热点 Key 的过期时间分散,避免同时过期导致缓存雪崩。

  • 降级策略:未获取到锁时返回"系统繁忙",引导用户重试,避免系统过载。

三、总结:场景-方案对应表

并发场景 推荐方案 核心技术 适用场景
库存扣减(防超卖) MySQL 悲观锁+事务 行级排他锁、数据库事务 库存一致性要求高、并发量中等
订单创建(防重复提交) Token 令牌验证 一次性 Token、Session/Redis 存储 表单提交、API 接口防重放
分布式任务调度(防重复执行) Redis 分布式锁 SET NX 原子命令、Lua 脚本释放锁 多服务器部署定时任务、跨服务数据同步
缓存更新(防击穿/雪崩) Redis 互斥锁+缓存空值+随机过期 分布式锁、缓存空值、随机过期时间 热点数据查询、高并发缓存访问场景

四、扩展说明

  1. 所有示例代码均基于 ThinkPHP8 开发,需确保项目已正确配置数据库、Redis(涉及 Redis 的场景)。

  2. 高并发场景下(如秒杀),建议结合消息队列(如 RabbitMQ、RocketMQ)进一步削峰填谷,提升系统稳定性。

  3. 分布式锁除 Redis 外,还可使用 ZooKeeper 实现(可靠性更高,但性能略低),根据业务需求选择。

  4. 实际开发中需结合日志记录、监控告警(如锁竞争情况、缓存命中率),便于问题排查和系统优化。

🍵 写在最后

我是 网络乞丐,热爱代码,目前专注于 Web 全栈领域。

欢迎关注我的微信公众号「乞丐的项目」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!

相关推荐
用户3521802454752 小时前
🥯2025 年终极避坑指南:Spring Boot 2.7 + 3.2 混合集群的 Redis + OAuth2 序列化血泪史
java·后端·spring cloud
superman超哥2 小时前
Rust 闭包的定义与捕获:所有权系统下的函数式编程
开发语言·后端·rust·函数式编程·rust闭包·闭包的定义与捕获
落枫592 小时前
如何快速搭建一个JAVA持续交付环境
后端·github
用户8356290780512 小时前
如何将 Python 列表高效导出为 Excel 文件
后端·python
止水编程 water_proof2 小时前
SpringBoot快速上手
java·spring boot·后端
li.wz2 小时前
ShardingSphere 与 PolarDB-X 选型对比
java·后端·微服务
得物技术3 小时前
RocketMQ高性能揭秘:承载万亿级流量的架构奥秘|得物技术
后端·rocketmq
普通码农3 小时前
PowerShell 神操作:输入「p」直接当「pnpm」用,敲命令速度翻倍!
前端·后端·程序员
绝无仅有4 小时前
Git 操作偏门指南:常用和隐藏命令与问题解决
后端·面试·github