学习笔记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)
原因分析
- 时间窗口问题:两个或多个请求几乎同时到达,都通过了重复性检查,然后都执行了插入操作
- 数据库事务隔离级别:不同的隔离级别可能导致读取到过期数据
- 分布式环境下的缓存一致性问题:如果使用了缓存,缓存与数据库不一致
- 缺乏原子性操作:检查与插入不是原子操作
为什么能解决重复注册问题
时间线示例:
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安装配置相对简单 | ⚠️ 集群维护:高可用集群配置和维护成本较高 |
| 资源消耗 | ✅ 内存占用少:锁信息占用内存极小 | ⚠️ 内存压力:大量锁键可能占用较多内存,需合理设计过期时间 |
作者:需要成长的小哥
出处:https://www.cnblogs.com/myDreamRealization/
如果觉得这篇文章对你有小小的帮助的话,记得在右下角点个“推荐”哦,博主在此感谢!
如果希望更容易地发现我的新博客,记得在左下角点个“关注我”哦。(如有错误之处,还请指正!)
出处:https://www.cnblogs.com/myDreamRealization/
如果觉得这篇文章对你有小小的帮助的话,记得在右下角点个“推荐”哦,博主在此感谢!
如果希望更容易地发现我的新博客,记得在左下角点个“关注我”哦。(如有错误之处,还请指正!)

浙公网安备 33010602011771号