学习笔记20260105

网络接口限流设计

分布式锁实现限流

通过Redis的SETNX命令实现分布式锁机制,确保同一用户在特定时间窗口内只能执行一次请求,有效控制请求频率。

工作流程图

flowchart TD A[请求进入] --> B{检查缓存类型} B -->|File类型| C[直接放行] B -->|Redis类型| D[生成唯一Key] D --> E{尝试获取锁} E -->|成功| F[处理业务逻辑] E -->|失败| G[返回限流错误] F --> H[释放锁] H --> I[返回响应] G --> J[请求太过频繁]

核心组件实现

  • 缓存服务:提供统一的缓存操作接口,支持标签管理、分布式锁等功能
  • 限流中间件:基于Redis实现的API请求频率控制机制

缓存服务

<?php

namespace app\services;

use think\facade\Cache;

class CacheService
{
    /**
     * 尝试获取分布式锁
     *
     * 使用Redis的setnx命令实现分布式锁,防止重复请求或并发冲突
     *
     * @param string $key 锁的标识符
     * @param int $timeout 锁的超时时间(秒),默认10秒
     * @return bool 获取锁成功返回true,失败返回false
     */
    public static function setMutex(string $key, int $timeout = 1): bool
    {
        // 获取当前Unix时间戳,用于后续比较和设置过期时间
        $curTime = time();

        // 构造Redis中锁的键名,添加前缀避免与其他业务键名冲突
        // 格式:redis:mutex:{业务标识}
        $readMutexKey = "redis:mutex:{$key}";

        /**
         * 第一步:尝试获取锁
         *
         * 使用Redis的setnx命令(SET if Not eXists)原子性地设置锁
         * - 如果键不存在:设置成功,返回1(true)
         * - 如果键已存在:设置失败,返回0(false)
         *
         * 锁的值为当前时间戳 + 超时时间,表示锁的过期时间
         * 这样即使进程意外退出,锁也会在超时后自动释放,防止死锁
         */
        $mutexRes = Cache::store('redis')->handler()->setnx($readMutexKey, $curTime + $timeout);

        // 如果setnx返回true(非0),表示成功获取到锁
        if ($mutexRes) {
            // 获取锁成功,返回true允许继续执行
            return true;
        }

        /**
         * 第二步:锁已存在,检查是否过期
         *
         * 获取锁失败说明已经有其他进程持有锁
         * 现在需要检查这个锁是否已经过期(可能由于持有锁的进程异常退出而未释放)
         */
        $time = Cache::store('redis')->handler()->get($readMutexKey);

        /**
         * 第三步:处理可能存在的死锁
         *
         * 检查当前时间是否超过了锁的过期时间
         * 如果 $curTime > $time,说明:
         * 1. 锁已经过期(超过了设置的超时时间)
         * 2. 可能是因为持有锁的进程异常退出,没有正常释放锁
         */
        if ($curTime > $time) {
            /**
             * 第四步:清理过期的锁并重新尝试获取
             *
             * 注意:这里存在一个潜在的竞态条件问题
             * 在多个进程同时检测到锁过期并执行删除操作时,
             * 可能多个进程都会认为自己获得了锁
             *
             * 但在限流场景中,这种短暂的竞态条件是可接受的
             * 因为:1)限流时间窗口较短;2)用户请求间隔通常大于竞态时间窗口
             */

            // 删除已过期的锁(如果此时有其他进程刚刚设置了新值,del操作会失败)
            Cache::store('redis')->handler()->del($readMutexKey);

            // 再次尝试获取锁(使用setnx,确保原子性)
            return Cache::store('redis')->handler()->setnx($readMutexKey, $curTime + $timeout);
        }

        /**
         * 第五步:锁有效且被其他进程持有
         *
         * 锁未过期且被其他进程正常持有,当前请求需要等待
         * 返回false表示获取锁失败,调用方应根据业务决定重试或拒绝请求
         */
        return false;
    }

    /**
     * 删除锁
     * @param string $key
     * @return void
     */
    public static function delMutex(string $key)
    {
        $readMutexKey = "redis:mutex:{$key}";
        Cache::store('redis')->handler()->del($readMutexKey);
    }
}

限流中间件

<?php

namespace app\admin\middleware;

use Closure;
use app\Request;
use app\exception\ApiException;
use app\services\CacheService;

class BlockerMiddleware
{
    /**
     * @param Request $request
     * @param Closure $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        $uid = $request->userInfo()['uid'];
        $key = md5($request->rule()->getRule() . $uid);
        if (!CacheService::setMutex($key)) {
            throw new ApiException('请求过多,请稍后再试');
        }

        $response = $next($request);

        $this->after($response, $key);

        return $response;
    }

    public function after($response, $key)
    {
        CacheService::delMutex($key);
    }
}

中间件绑定配置

将此中间件,绑定到其他场景路由,比如 创建订单 秒杀活动等

// 在路由配置中绑定限流中间件
Route::rule('order/create', 'Order/create')
    ->middleware(BlockerMiddleware::class);
    
Route::rule('seckill/join', 'Seckill/join')
    ->middleware(BlockerMiddleware::class);

高并发测试脚本

测试高并发请求 我使用了swoole的协程来模拟

<?php
/**
 * 高并发压力测试脚本
 * 
 * 使用Swoole协程模拟真实用户并发请求
 * 验证限流中间件的有效性
 */
use Swoole\Coroutine\Http\Client;
use function Swoole\Coroutine\run;

run(function () {
    $concurrent = 10; // 并发请求数
    $url = '/admin/system/info'; // 测试目标接口
    $host = '192.168.1.100'; // 服务器地址
    $port = 8082; // 服务器端口
    
    // 测试数据
    $testData = [
        'timestamp' => time(),
    ];
    
    $startTime = microtime(true);
    $resultsChannel = new \Swoole\Coroutine\Channel($concurrent);
    
    // 启动多个协程并发请求
    for ($i = 0; $i < $concurrent; $i++) {
        go(function () use ($host, $port, $url, $testData, $i, $resultsChannel) {
            $client = new Client($host, $port);
            
            // 设置请求头(包含认证令牌)
            $client->setHeaders([
                'User-Agent' => 'Swoole-StressTest/' . $i,
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3Njk1MjMwNzgsImlzcyI6InlvdXJkb21haW4uY29tIiwibmJmIjoxNzY5NTIzMDc4LCJleHAiOjE3Njk1MjY2NzgsImRhdGEiOnsidXNlcklkIjoyfX0.UbJzml0ExlTR-LLpXOGJ29kwaPYMoe1D3tjpqnDaBzk'
            ]);
            
            $client->set(['timeout' => 5]);
            
            // 发起POST请求
            $client->post($url, json_encode($testData));
            
            $bodyArr = json_decode($client->body, 1);
            // 记录请求结果
            $result = [
                'request_id' => $i,
                'status_code' => $bodyArr['status'],
                'response_body' => $client->body,
                'response_time' => microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'] ?? 0
            ];
            
            echo sprintf(
                "请求 %d 完成 | 状态码: %d | 响应: %s\n",
                $i,
                $result['status_code'],
                substr($result['response_body'], 0, 100)
            );
            
            $client->close();
            $resultsChannel->push($result);
        });
    }
    
    // 收集所有请求结果
    $results = [];
    for ($i = 0; $i < $concurrent; $i++) {
        $results[] = $resultsChannel->pop();
    }
    $resultsChannel->close();
    
    // 统计分析
    $endTime = microtime(true);
    $totalTime = round(($endTime - $startTime) * 1000, 2);
    
    echo "\n============= 测试报告 =============\n";
    echo "总耗时: {$totalTime}ms\n";
    echo "并发数: {$concurrent}\n";
    echo "平均响应时间: " . round(array_sum(array_column($results, 'response_time')) / $concurrent * 1000, 2) . "ms\n";
    
    // 统计成功/失败请求
    $successArr = array_filter($results, function ($r) { 
        return $r['status_code'] === 200;
    });
    $successCount = count($successArr);
    $limitArr = array_filter($results, function($r) {
        return $r['status_code'] === 400;
    });
    $limitCount = count($limitArr);
    
    echo "成功请求: {$successCount}\n";
    echo "被限流请求: {$limitCount}\n";
    echo "====================================\n";
});

使用redis分布式锁解决并发写竞争(Race Condition)

原因分析

  1. 时间窗口问题:两个或多个请求几乎同时到达,都通过了重复性检查,然后都执行了插入操作
  2. 数据库事务隔离级别:不同的隔离级别可能导致读取到过期数据
  3. 分布式环境下的缓存一致性问题:如果使用了缓存,缓存与数据库不一致
  4. 缺乏原子性操作:检查与插入不是原子操作

为什么能解决重复注册问题

时间线示例:

text

时间  请求1                         请求2
T0   检查用户不存在                 
T1   开始插入用户(未提交)          
T2   ───────────────────────────> 检查用户不存在(读到未提交数据或缓存)
T3   提交事务,用户创建成功
T4   ───────────────────────────> 开始插入用户(会报唯一键冲突)

有锁的情况下:

text

时间  请求1                         请求2
T0   获取锁成功                     
T1   检查用户不存在                 
T2   开始插入用户                  ──> 尝试获取锁,失败,等待
T3   提交事务,用户创建成功
T4   释放锁                       ──> 获取锁成功,检查用户,发现已存在,返回错误

代码封装

<?php

namespace app\services;

use think\facade\Cache;

/**
 * 分布式锁服务类
 * 
 * 基于Redis实现的分布式锁,用于解决高并发环境下的数据竞争问题
 * 确保同一时刻只有一个进程可以执行关键代码段
 */
class LockService
{
    /**
     * 执行带锁的业务逻辑
     * 
     * @param string $key 锁的键名
     * @param callable $fn 需要加锁执行的业务逻辑回调函数
     * @param int $ex 锁的过期时间(单位:秒),默认6秒
     * @return mixed 业务逻辑的返回结果
     * 
     * @throws \Exception 当获取锁失败或业务逻辑异常时抛出
     */
    public function exec($key, $fn, int $ex = 6)
    {
        try {
            // 获取分布式锁,如果获取失败会重试直到成功
            $this->lock($key, $key, $ex);
            
            // 执行需要加锁保护的业务逻辑
            return $fn();
        } finally {
            // 无论业务逻辑是否异常,都确保释放锁
            // finally块中的代码总是会执行,防止死锁
            $this->unlock($key, $key);
        }
    }

    /**
     * 尝试获取分布式锁(非阻塞式)
     * 
     * 使用Redis的SET命令配合NX和EX参数实现原子性锁获取
     * NX:只有键不存在时才设置,确保只有一个客户端能成功获取锁
     * EX:设置键的过期时间,防止客户端崩溃导致死锁
     * 
     * @param string $key 锁的键名,会自动添加"lock_"前缀
     * @param string $value 锁的值,用于安全释放锁时验证锁的所有者
     * @param int $ex 锁的过期时间(单位:秒),默认6秒
     * @return bool 获取锁是否成功
     */
    public function tryLock($key, $value = '1', $ex = 6)
    {
        // 使用Redis的set命令配合NX和EX参数
        // NX:只有键不存在时才设置,保证互斥性
        // EX:设置过期时间,防止死锁
        return Cache::store('redis')->handler()->set('lock_' . $key, $value, ["NX", "EX" => $ex]);
    }

    /**
     * 获取分布式锁(阻塞式)
     * 
     * 如果第一次尝试获取锁失败,会等待200微秒后重试
     * 使用递归调用实现阻塞等待,直到成功获取锁
     * 注意:这里使用递归,在极端高并发情况下可能导致栈溢出
     * 
     * @param string $key 锁的键名
     * @param string $value 锁的值,用于安全释放锁时验证锁的所有者
     * @param int $ex 锁的过期时间(单位:秒),默认6秒
     * @return bool 总是返回true,因为会一直重试直到成功
     */
    public function lock($key, $value = '1', $ex = 6)
    {
        // 尝试获取锁
        if ($this->tryLock($key, $value, $ex)) {
            return true;
        }
        
        // 获取失败,短暂等待后重试
        // usleep(200) 等待200微秒(0.2毫秒)
        usleep(200);
        
        // 递归调用,直到成功获取锁
        // 注意:这种实现方式在极端情况下可能导致栈溢出,建议改为循环方式
        return $this->lock($key, $value, $ex);
    }

    /**
     * 释放分布式锁
     * 
     * 使用Lua脚本原子性地释放锁,确保只有锁的持有者才能释放
     * Lua脚本在Redis中原子执行,防止检查锁和删除锁之间的竞争条件
     * 
     * @param string $key 锁的键名
     * @param string $value 锁的值,用于验证锁的所有者
     * @return bool 是否成功释放锁(1表示成功,0表示失败)
     */
    public function unlock($key, $value = '1')
    {
        // Lua脚本,原子性地检查并删除锁
        // KEYS[1] = 锁的键名(不包含前缀)
        // ARGV[1] = 期望的锁值
        $script = <<< EOF
if (redis.call("get", "lock_" .. KEYS[1]) == ARGV[1]) then
    return redis.call("del", "lock_" .. KEYS[1])
else
    return 0
end
EOF;
        
        // 执行Lua脚本
        // 参数说明:
        // $script: Lua脚本
        // [$key, $value]: 传递给脚本的键和值参数
        // 1: 键参数的数量(KEYS的数量)
        return Cache::store('redis')->handler()->eval($script, [$key, $value], 1) > 0;
    }
}

代码使用

// 在业务代码中使用示例
$lockService = new LockService();
$result = $lockService->exec('user_register_' . $account, function() use ($account, $password) {
    // 在这里执行需要加锁的业务逻辑
    // 比如检查用户是否已存在,然后创建用户
    return $this->createUser($account, $password);
});

Redis分布式锁优缺点总结表

类别 优点 缺点
性能表现 高性能:内存操作,毫秒级响应,单节点可达10万+ QPS ⚠️ 锁竞争性能差:大量锁竞争时,客户端频繁重试浪费CPU和网络资源
可用性 自动防死锁:TTL过期机制防止死锁 ⚠️ 单点故障:单节点Redis宕机导致所有锁失效
高可用支持:通过Sentinel/Cluster实现高可用 ⚠️ 网络分区问题:脑裂场景下可能出现多个客户端同时持有锁
实现复杂度 实现简单:使用SET NX EX命令即可实现基本功能 ⚠️ 边界情况复杂:需处理锁续期、误删、超时等多种边界情况
可靠性 原子操作:SET NX EX命令具有原子性 ⚠️ 时钟漂移:服务器与客户端时钟不一致可能导致锁过早或过晚释放
可重入设计:可通过value存储客户端ID和计数实现可重入锁 ⚠️ GC影响:客户端GC暂停可能导致锁过期而业务未完成
功能特性 功能丰富:支持公平锁、读写锁、红锁等多种扩展 ⚠️ 锁续期复杂:业务执行超时需要复杂的续期机制,且续期操作也需原子性
分布式支持 跨进程/跨服务器:适合微服务和分布式架构 ⚠️ 网络延迟影响:网络延迟可能导致锁状态判断不准确
语言无关:任何支持Redis的语言都能使用
适用场景 高频低耗时操作:库存扣减、优惠券领取等 强一致性场景:金融交易核心流程等对一致性要求极高的场景
最终一致性场景:订单创建、消息发送等 长时间持有锁:超过几分钟的业务操作
分布式任务调度:定时任务防重复执行 极端可靠性要求:不能容忍任何锁失效的场景
运维成本 部署简单:Redis安装配置相对简单 ⚠️ 集群维护:高可用集群配置和维护成本较高
资源消耗 内存占用少:锁信息占用内存极小 ⚠️ 内存压力:大量锁键可能占用较多内存,需合理设计过期时间
posted @ 2026-02-05 10:17  需要成长的小哥  阅读(6)  评论(0)    收藏  举报