首页
直播
壁纸
友链
搜索
1
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
327 阅读
2
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
308 阅读
3
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
280 阅读
4
小程序接口安全攻防:签名、防重放、防刷与数据加密实战
276 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
272 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
Golang
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
沿途的风景
累计撰写
82
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
27
篇与
» 后端技术
的结果
2026-08-15
webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理
webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理数据库是 webman 与传统 FPM 框架差异最大的环节。FPM 下每个请求开新连接、请求结束关闭,天然隔离;webman 的每个 worker 进程持有自己的连接并跨请求复用——连接管理从"框架的事"变成了"你的事"。这篇讲透连接池配置、think-orm 与 Eloquent 的选型、事务的正确边界,以及千万级数据的处理姿势。一、常驻内存下的连接模型先看清楚 webman 的数据库连接结构:worker-1 ─── 连接A(MySQL)──┐ worker-2 ─── 连接B ├── MySQL max_connections 上限(如 1000) worker-3 ─── 连接C │ ...(16 个 worker) │ └── Redis 连接同理推论一:最大连接数 = worker 进程数 × 每进程连接数。 16 个 worker、每个用了 2 个连接配置(读写分离),就是 32 个常驻连接。上线前先算这笔账,别和 MySQL 的 max_connections 打架。推论二:连接是懒建立的。 进程启动时不连库,第一次查询才建立。所以刚 reload 完的进程有冷启动延迟,压测时记得先预热。推论三:连接会"死"。 MySQL wait_timeout(默认 8 小时)内没有活动,服务端单方面断开,webman 侧还握着死连接,下一个请求直接 MySQL has gone away。二、断线重连:三层配置兜底以 think-orm(webman 默认集成)为例:// config/thinkorm.php return [ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'app', 'username' => 'root', 'password' => 'root', 'charset' => 'utf8mb4', 'break_reconnect' => true, // ① 断线自动重连 'fields_cache' => true, // ② 字段信息缓存(常驻内存的红利) ], ], ];三层兜底缺一不可:break_reconnect => true:ORM 层检测断连自动重连心跳保活:定时进程每 5 分钟发一条 SELECT 1,让连接永不超时(webman 的 config/process.php 里加自定义进程实现)重试幂等:断线重连只对查询安全,写操作重试必须保证幂等,否则可能双写// 心跳进程示例 return [ 'db-keepalive' => [ 'handler' => function () { while (true) { Db::query('SELECT 1'); sleep(300); } }, ], ];三、ORM 选型:think-orm 还是 Laravel Eloquent?webman 官方两者都支持,选型看团队基因:维度think-ormEloquent(illuminate/database)上手成本ThinkPHP 系团队零成本Laravel 系团队零成本模型功能基础够用关联预加载、访问器、软删更丰富常驻内存适配fields_cache 等针对性优化需要自己注意静态缓存生态迁移TP 项目平移Laravel 项目平移中大型项目的建议:ORM 只做"查询构造 + 结果映射",复杂查询直接裸 SQL 或查询构造器。模型层越薄,常驻内存下的心智负担越小。一个 Eloquent 用户必须知道的区别:FPM 下 DB::connection() 每请求新建,webman 下连接跨请求驻留——事务没有提交/回滚,下一个请求会接着在这个事务里跑,数据不一致的幽灵bug就是这么来的。think-orm 的 Db::startTrans() 同理。// ❌ 事故现场:异常没回滚,连接带着事务回到池里 try { Db::startTrans(); // ... 写操作 throw new \RuntimeException('boom'); // 没有 catch 回滚! } finally { Db::commit(); // finally 里 commit,异常时也 commit 了半截数据? }四、事务的正确姿势铁律一:事务边界必须完整闭合try { Db::startTrans(); $this->deductStock($skuId, $num); // 扣库存 $this->createOrder($uid, $skuId); // 建订单 Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; // 异常继续抛,别吞 }铁律二:事务里禁止外部 IO事务持锁期间调外部 HTTP / 发 MQ / 写 Redis,一旦外部服务慢,数据库连接和行锁被拖住,并发一上来就是雪崩。// ❌ 事务里调第三方风控接口(300ms),行锁拖 300ms Db::startTrans(); $stock = Stock::lock(true)->find($skuId); // SELECT ... FOR UPDATE RiskApi::check($uid); // 外部调用! $stock->num -= 1; $stock->save(); Db::commit(); // ✅ 外部检查前置,事务只做纯 DB 操作 RiskApi::check($uid); // 先调外部 Db::startTrans(); $stock = Stock::lock(true)->find($skuId); if ($stock->num < 1) throw new OutOfStock(); $stock->num -= 1; $stock->save(); Db::commit(); PublishOrderEvent::dispatch($order); // 后置异步动作铁律三:事务尽量短,锁尽量准悲观锁(lock(true))只锁必要的行,锁定前先校验数据存在的条件能用唯一索引防重(幂等)就别用"先查再插 + 锁"补充:嵌套事务与 savepointthink-orm 支持 startTrans 嵌套(底层转 savepoint),但事务边界只写在 Service 层一处,不要 Controller 包一层、Service 再包一层——嵌套事务的回滚语义很容易把人绕晕。五、大数据量:分批处理的三个工具1. chunk 分批读User::where('status', 1)->chunkById(1000, function ($users) { foreach ($users as $user) { // 逐批处理 } });用 chunkById 而不是 chunk:处理过程中如果更新了 offset 相关字段,chunk(基于 offset/limit)会漏数据或死循环,chunkById(基于主键游标)不会。2. 慎用 ORM 批量,必要时裸 SQLORM 逐条 save 在万级数据下慢得无法接受:// ❌ 10 万条逐条 update:几十分钟 foreach ($rows as $row) { Model::where('id', $row['id'])->update($row); } // ✅ 事务 + 批量拼接(注意分批,每批 ≤ 1000) Db::startTrans(); foreach (array_chunk($rows, 500) as $batch) { Db::table('users')->upsert($batch, ['id'], ['name', 'status']); // 批量 upsert } Db::commit();3. 超大导出:游标 + 流式输出百万行导出别 ORM ->get() 全量载入(内存直接爆),用游标逐行读、边读边写 CSV 流:$fp = fopen('php://output', 'w'); User::where('created', '>', $start)->select()->cursor()->each(function ($u) use ($fp) { fputcsv($fp, $u->toArray()); });cursor() 用 PDO 的 unbuffered 模式,内存占用恒定。六、读写分离与多库配置'connections' => [ 'mysql' => [ /* 主库写 */ ], 'mysql_read' => [ /* 从库读 */ ], ],think-orm/Eloquent 都支持配置读写分离自动路由,但强制主读(写后立读的场景:下单后立即查详情)必须显式指定主库连接,否则撞上主从延迟会查到旧数据:// 写后立刻要读的,强制走主库 $inDb = Order::on('mysql')->find($orderId);七、上线前检查清单[ ] 最大连接数核算:worker 数 × 每进程连接数 < MySQL max_connections × 0.8[ ] break_reconnect 开启 + 心跳进程就位[ ] 事务全部 try/catch/rollback 完整闭合(全局搜 startTrans 逐个人工审计)[ ] 长事务里无外部 IO(APM 看事务平均持锁时长)[ ] 大数据任务用 chunkById / cursor,无全量 ->get()[ ] ORM 字段缓存开启,生产关掉 SQL 日志写在最后数据库层的所有坑都指向同一个根因:连接的生命周期从"请求级"变成了"进程级"。把连接当进程级资源来管理(池化、心跳、重连),把事务当危险品来对待(短小、闭合、无 IO),webman 的数据库层就稳了。下一篇进入性能深水区:协程、连接池调优与压测。
2026年08月15日
34 阅读
0 评论
0 点赞
2026-08-06
webman 中间件与依赖注入实战:洋葱模型、PHP-DI 容器与 AOP
webman 中间件与依赖注入实战:洋葱模型、PHP-DI 容器与 AOPwebman 的请求处理链路看似简单(路由 → 中间件 → 控制器),但中间的洋葱模型和 PHP-DI 容器藏着不少中级开发者没吃透的细节。这篇文章把中间件的执行顺序、全局/路由级/方法级三种挂载方式、依赖注入的边界一次性讲清楚,最后再聊一个常驻内存框架特有的话题——AOP。一、洋葱模型:代码顺序与执行顺序是两回事class AuthMiddleware implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { // ── 前置逻辑(进入时执行)── $userId = $this->jwt->parseToken($request); $request->userId = $userId; $response = $handler($request); // ← 下一层洋葱 // ── 后置逻辑(返回时执行)── $response->withHeader('X-Trace-Id', $request->header('x-trace-id')); return $response; } }三层中间件的执行顺序:请求 → [A前置] → [B前置] → [C前置] → Controller 响应 ← [A后置] ← [B后置] ← [C后置] ←最容易踩的坑:异常发生时的执行路径。 如果控制器抛异常,中间件的后置代码默认不会执行——除非你在中间件里 try/catch 包住 $handler($request)。跨域中间件经常因此"时灵时不灵":异常响应没带 CORS 头,浏览器报的不是后端错误而是跨域错误。所以 CORS 中间件要这样写:try { $response = $handler($request); } catch (\Throwable $e) { $response = response($e->getMessage(), 500); } return $response->withHeaders([ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', ]);二、三种挂载粒度1. 全局中间件(config/middleware.php)return [ // 每个请求都会经过 app\middleware\AccessLog::class, app\middleware\Cors::class, ];适合:日志、CORS、限流。注意全局中间件每个请求都会跑,别放重逻辑。2. 路由/应用级中间件// config/route.php Route::group('/admin', function () { Route::resource('/users', app\admin\controller\UserController::class); })->middleware([ app\middleware\AdminAuth::class, ]); // 或整个应用目录生效:app/admin/config/middleware.php return [ app\middleware\AdminAuth::class, ];多应用(多入口目录)模式下,app/{应用名}/config/middleware.php 只对该应用生效——这是做多租户后台、API 版本隔离的天然工具。3. 控制器方法级(中间件别名)// config/middleware.php 里注册别名 return [ 'auth' => [app\middleware\Auth::class], ]; // 控制器里按方法指定 class OrderController { #[Middleware(['auth'])] // 整个控制器生效 public function index() {} #[Middleware(['auth', 'throttle:60,1'])] public function create() {} }执行优先级:全局 → 应用级 → 路由级 → 方法级。同层内按注册顺序。三、实战:三个高频中间件的正确写法限流中间件(基于 Redis 滑动窗口)class Throttle implements MiddlewareInterface { public function __construct(private Redis $redis) {} public function process(Request $request, callable $handler): Response { $key = 'throttle:' . $request->getRealIp() . ':' . date('YmdHi'); $current = $this->redis->incr($key); if ($current === 1) { $this->redis->expire($key, 60); } if ($current > 120) { return json(['code' => 429, 'msg' => 'too many requests'], 429); } return $handler($request); } }注意 incr 后再 expire 的写法:只在第一次设置过期,避免每请求都重置 TTL。请求日志(含慢请求标记)class AccessLog implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $start = microtime(true); $response = $handler($request); $cost = round((micro(true) - $start) * 1000, 2); Log::info('access', [ 'ip' => $request->getRealIp(), 'path' => $request->path(), 'cost' => $cost, 'slow' => $cost > 500 ? '⚡' : '', ]); return $response; } }JWT 认证 + 用户上下文注入class Auth implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $token = $request->header('authorization', ''); try { $payload = JWT::decode(trim(str_replace('Bearer ', '', $token)), new Key(config('jwt.secret'), 'HS256')); } catch (\Throwable $e) { return json(['code' => 401, 'msg' => 'unauthorized'], 401); } // 挂到 request 上传递(请求级隔离,不用静态属性) $request->uid = $payload->uid; return $handler($request); } }控制器里 $request->uid 直接取——请求结束自动销毁,没有跨请求污染。四、依赖注入:webman 的 PHP-DI 容器webman 内置 PHP-DI。最常见的三个用法:1. 构造器注入(推荐)class OrderController { public function __construct( private OrderService $orderService, // 自动解析 private LoggerInterface $logger, ) {} }常驻内存的特性让注入几乎零成本:控制器和依赖都在进程启动阶段实例化一次,之后每个请求直接复用。这也意味着——注入的依赖必须是"无状态"或"请求安全"的。Service 里存了请求相关的属性就会串数据。2. 接口绑定// config/container.php return [ PayInterface::class => \DI\create(WechatPay::class), ]; // 之后注入 PayInterface 拿到的就是 WechatPay切支付渠道只改这一行。多环境(沙箱/生产)也可以在这里做条件绑定。3. 自动装配的边界PHP-DI 按类型提示递归解析依赖,但碰到标量参数就无能为力,需要显式定义:// config/container.php return [ \DI\create(SmsClient::class) ->constructor(\DI\value(config('sms.appkey'))), ];五、AOP:常驻内存框架的专属福利AOP(面向切面编程)在 FPM 下性能代价大,但 webman 的进程常驻让织入成本只付一次。webman 通过 php-di/AOP 代理实现,最常见的场景是缓存注解:#[Cacheable(prefix: 'user:', ttl: 300)] public function getUserProfile(int $uid): array { return $this->userDao->findWithRelations($uid); }切面拦截方法调用:先查 Redis,命中直接返回;未命中执行原方法并写缓存。类似的还有 #[Transactional](自动事务包裹)、#[Retry](失败重试)。两个注意点:AOP 只对容器管理的对象生效——new OrderService() 出来的裸对象没有代理拦截逻辑发生在方法级,粒度比中间件(请求级)细,适合业务横切(缓存/事务/重试),不适合请求横切(认证/日志)六、一张图理清请求全链路请求 → 全局中间件(日志/CORS/限流) → 应用/路由中间件(认证) → 方法级中间件(权限) → 中间件层容器解析控制器(构造器注入,进程级单例) → 方法内 AOP 切面(缓存/事务) → Service(业务逻辑,保持无状态) → Model/Dao每一层解决不同粒度的横切问题:请求级用中间件,业务级用 AOP,对象组装用容器——三者各司其职,别把认证写进 Service,也别把缓存注解挂在中间件上。写在最后中间件和依赖注入是 webman 工程化的骨架。写中间件时时刻记住两件事:异常会跳过后置逻辑、常驻内存下禁止请求级状态进静态属性;用容器时记住注入对象是进程级复用的,Service 必须无状态。下一篇讲数据库层——连接池怎么配、事务怎么包、大数据怎么跑,那是常驻内存框架最容易翻车的第二现场。
2026年08月06日
34 阅读
0 评论
0 点赞
2026-08-04
webman 架构解析:常驻内存进程模型与 PHP-FPM 的本质差异
webman 架构解析:常驻内存进程模型与 PHP-FPM 的本质差异webman 是基于 Workerman 构建的高性能 PHP 框架,官方基准 QPS 常态化压到十几万。但很多人把 webman 当"换个写法的 Laravel"来用,结果内存泄漏、全局状态污染这些经典问题轮番上演。要真正用好 webman,必须先理解它与 PHP-FPM 在架构上的本质差异——这是所有后续实践的地基。一、两种执行模型:请求级生命周期 vs 常驻内存PHP-FPM:每次请求都是新生浏览器 → Nginx → PHP-FPM(worker) → 加载框架 → 初始化 → 处理请求 → 返回 → 销毁一切FPM 模式下,每个请求结束都会释放全部内存、销毁所有变量。代价是每次请求都要重新加载框架(几十毫秒的 bootstrap),但好处是状态绝对隔离——你再怎么写全局变量,下一个请求都是白纸。webman:一次加载,永久服务浏览器 → webman(worker 进程) → 直接路由分发 → 处理请求 → 返回(框架和对象留在内存)webman 启动时把框架、路由表、配置全部加载进内存,之后每个请求只是"触发一次回调"。QPS 高的根源就在这:省掉了每请求一次的框架 bootstrap 开销。这个差异带来两个直接推论:性能提升的本质是"摊销":初始化成本只付一次,请求越多收益越大内存不自动释放:请求之间共享进程内存,全局状态会"存活"——这是福也是祸二、进程模型:master / worker 的分工master 进程(不处理业务) ├── 监听端口、管理 worker ├── fork 出 N 个 worker 进程 └── 收到 reload 信号时逐个平滑重启 worker │ ├── worker-1 ← 每个进程独立事件循环,可同时 hold 住数千连接 ├── worker-2 ├── ... └── worker-N(默认 = CPU 核数)三个关键认知:1. worker 数不是越多越好。 webman 是多进程 + 每进程事件循环(非阻塞 IO)模型,worker 数建议等于 CPU 核数。如果你的业务有阻塞调用(同步 HTTP 客户端、同步 RPC),连接会"卡住"事件循环,这时才需要加进程数来对冲——但更好的解法是消灭阻塞调用(后面协程篇细讲)。2. 进程间不共享内存。 你在 worker-1 里写的静态变量,worker-2 看不见。跨进程共享状态要用 Redis 或进程间通信,别指望 static 属性全站生效。3. request 对象请求间隔离,但对象池里的组件不是。 框架层面的组件(DB 连接、日志实例)跨请求复用,这是性能来源;你自己的类如果注册成了单例,它同样跨请求存活。三、启动流程:从 public/index.php 说起// public/index.php(简化) require_once __DIR__ . '/../vendor/autoload.php'; support\App::run();App::run() 内部做了什么:1. 加载 .env 与 config/*.php(此后修改配置需重启) 2. 扫描 app/controller 生成路由表(注解路由另算) 3. 初始化容器(webman 基于 PHP-DI,支持构造器注入) 4. 加载 bootstrap/*.php(进程启动时执行一次的钩子) 5. 初始化数据库/Redis 连接(懒加载,首次使用时建立) 6. master fork workers → 每个 worker 进入事件循环等待请求bootstrap 目录是理解 webman 的钥匙:放在这里的代码每个进程只执行一次。数据库连接的建立、连接池的初始化、Swoole 协程运行时的开启,都在这个阶段。官方的 webman/database、webman/redis 等插件都是通过 bootstrap 文件挂载的。四、常驻内存的四宗罪与解法罪一:静态属性 / 全局变量跨请求污染// ❌ 经典事故:上一单用户的数据泄漏给下一单 class OrderService { public static ?array $currentUser = null; } // 请求A设置了 currentUser,请求B进来时它还活着!解法:请求级数据一律走 Request 对象传递,或用 $request->xxx 属性挂载。静态属性只允许存"进程级不变量"(配置、编译好的正则、容器实例)。罪二:内存泄漏每请求增长的数组、未释放的大结果集、监听器越挂越多……FPM 下这些泄漏随请求结束被清零,webman 下会累积到 memory_limit 进程崩溃。排查手段:// 在中间件里记录内存水位 public function process(Request $request, callable $handler): Response { $before = memory_get_usage(); $response = $handler($request); $this->logger->info('mem delta', [ 'delta' => memory_get_usage() - $before, 'peak' => memory_get_peak_usage(), ]); return $response; }内存水位持续爬升就是泄漏信号。常见元凶:往静态数组里 push 不清理、查询返回百万行、日志 handler 无限累积。罪三:数据库连接断开(MySQL gone away)worker 空闲超过 wait_timeout(默认 8 小时)后,MySQL 单方面断开,而 webman 还握着死连接。解法:配置连接心跳 + 断线重连。用 ORM 插件时开启:// think-orm 配置 'break_reconnect' => true,更彻底的方案是连接池(数据库篇细讲),池化组件自带健康检查。罪四:代码改了不生效FPM 的肌肉记忆是"保存即生效",webman 下路由、配置、类定义都在内存里,必须 restart 或 reload。开发环境用 php start.php restart 太重,webman 有监视文件变自动 reload 的方案(workerman/webman-framework 配合 monitor 进程,dev 模式自动开启)——但注意生产环境要关掉。五、平滑重启的原理与边界php start.php reload # 平滑重启:逐个重启 worker php start.php restart # 硬重启:master 也重启 php start.php stop # 停止 php start.php status # 查看进程状态reload 的机制:master 收到信号后,先通知一个 worker 停止接收新请求,等它处理完当前请求再杀掉重建,依次轮转。这意味着:reload 只更新"启动时加载"的代码:controller 文件是每次请求 include 的吗?——webman 中 controller 默认支持 reload 更新(优化级别 support/App 的 reloadable 路径),但 config/、bootstrap/、路由定义的改动必须 restartreload 期间正在执行的长任务会被等待,但超过 $maxWaitTime(可配)会被强杀,注意保护长任务六、与 FPM 共存:渐进式迁移策略存量 Laravel/TP 项目不必推倒重来。实战迁移路径:新接口用 webman 写,旧接口留在 FPM,Nginx 按路径分流复用原有 Model 层:webman 支持 think-orm 和 Laravel Eloquent(laravel/database 插件),业务模型代码几乎平移逐步搬迁高频接口:把 QPS 最高、逻辑最薄的接口先迁过去,验证稳定性后再迁复杂业务分流配置示例:location /api/v2/ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8787; # webman } location /api/ { try_files $uri /index.php?$query_string; # 旧 FPM 应用 }写在最后webman 不是"更快的 Laravel",而是一种不同的运行时模型。把进程模型、启动流程、常驻内存的边界刻进脑子,后面的中间件、连接池、协程实践才有意义。下一篇我们进入请求处理的核心链路:中间件洋葱模型与依赖注入容器的实战。
2026年08月04日
36 阅读
0 评论
0 点赞
2026-05-10
Redis 缓存实战:穿透、击穿、雪崩三大问题解决方案
前言Redis 作为缓存使用时,会遇到三大经典问题:缓存穿透、缓存击穿和缓存雪崩。这三个问题在实际生产中频繁出现,处理不当可能导致数据库被压垮甚至服务不可用。本文将深入分析每个问题的成因并给出完整解决方案。一、缓存穿透1.1 问题描述用户请求的数据在缓存和数据库中都不存在,每次请求都直接打到数据库。常见于恶意攻击(用不存在的 ID 大量请求)或业务异常数据。请求 → 缓存MISS → 数据库MISS → 返回空 ↑ │ └──── 不断重复 ──────────────────────┘1.2 危害数据库承受大量无效查询如果是恶意攻击,可能打垮数据库浪费服务器资源1.3 解决方案方案一:缓存空值function getUser($id) { $cacheKey = "user:{$id}"; $data = $redis->get($cacheKey); if ($data !== false) { // 命中缓存(包括空值缓存) return $data === 'NULL' ? null : json_decode($data, true); } // 查数据库 $data = $db->query("SELECT * FROM users WHERE id = ?", [$id]); if (empty($data)) { // 缓存空值,设置较短过期时间(60秒) $redis->setex($cacheKey, 60, 'NULL'); return null; } $redis->setex($cacheKey, 3600, json_encode($data)); return $data; }优点:实现简单缺点:可能缓存大量空值,占用内存;数据后来新增了也不会被感知(除非过期)方案二:布隆过滤器(Bloom Filter)// 系统初始化时,将所有存在的ID加入布隆过滤器 function initBloomFilter() { $redis->del('bloom:user_ids'); $userIds = $db->query("SELECT id FROM users"); foreach ($userIds as $id) { // 使用多个哈希函数 bloomAdd('bloom:user_ids', $id); } } // 检查ID是否可能存在 function bloomAdd($key, $value) { $hashes = multiHash($value); foreach ($hashes as $h) { $redis->setBit($key, $h, 1); } } function bloomExists($key, $value) { $hashes = multiHash($value); foreach ($hashes as $h) { if ($redis->getBit($key, $h) == 0) { return false; // 一定不存在 } } return true; // 可能存在(有误判率) } // 请求处理 function getUser($id) { // 先过布隆过滤器 if (!bloomExists('bloom:user_ids', $id)) { return null; // 一定不存在,直接返回 } // 再查缓存和数据库 // ... }方案三:接口层参数校验// 在入口处拦截非法参数 function getUser($id) { // ID 必须是正整数 if (!is_numeric($id) || $id <= 0 || $id > PHP_INT_MAX) { throw new InvalidArgumentException('Invalid user ID'); } // 其他业务规则校验 if (strlen($id) > 10) { throw new InvalidArgumentException('ID too long'); } // 正常流程... }二、缓存击穿2.1 问题描述某个热点 key 突然过期(或被删除),大量并发请求同时访问这个 key,全部穿透到数据库。热点key过期 │ ├──→ 请求1 → 缓存MISS → 查DB ──┐ ├──→ 请求2 → 缓存MISS → 查DB ──┤ ├──→ 请求3 → 缓存MISS → 查DB ──┤ ├──→ 请求N → 缓存MISS → 查DB ──┤ │ │ └──────── 数据库被压垮 ◄────────┘2.2 解决方案方案一:互斥锁(推荐)function getHotData($key) { $data = $redis->get($key); if ($data !== false) { return json_decode($data, true); } // 获取互斥锁 $lockKey = "lock:{$key}"; $lockValue = uniqid('', true); // SET NX EX 尝试加锁 $locked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]); if ($locked) { try { // 再次检查缓存(双重检查) $data = $redis->get($key); if ($data !== false) { return json_decode($data, true); } // 查数据库 $data = $db->query("SELECT * FROM hot_table WHERE key_name = ?", [$key]); // 写入缓存 $redis->setex($key, 3600, json_encode($data)); return $data; } finally { // 释放锁(Lua脚本保证原子性) $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; $redis->eval($lua, [$lockKey, $lockValue], 1); } } else { // 没拿到锁,短暂等待后重试 usleep(100000); // 100ms return getHotData($key); // 递归重试 } }方案二:逻辑过期不给 key 设置 TTL,而是在 value 中存储逻辑过期时间。后台异步刷新:function getWithLogicalExpire($key, $expireSeconds) { $data = $redis->get($key); if ($data === false) { return null; // 首次加载,需要手动初始化 } $cached = json_decode($data, true); $now = time(); // 未逻辑过期,直接返回 if ($cached['expire_at'] > $now) { return $cached['data']; } // 已逻辑过期,尝试获取锁刷新 $lockKey = "lock:refresh:{$key}"; $locked = $redis->set($lockKey, '1', ['NX', 'EX' => 10]); if ($locked) { // 后台异步刷新(非阻塞) // 实际项目中可以用队列或协程 go(function() use ($key, $expireSeconds) { $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]); $redis->set($key, json_encode([ 'data' => $data, 'expire_at' => time() + $expireSeconds, ])); $redis->del("lock:refresh:{$key}"); }); } // 无论是否刷新,先返回旧数据(不阻塞用户) return $cached['data']; }方案三:永不过期 + 主动更新// 热点数据不设置过期时间 $redis->set('hot:ranking', json_encode($data)); // 不设置 TTL // 数据变更时主动更新缓存 function updateRanking() { $data = $db->query("SELECT * FROM ranking ORDER BY score DESC LIMIT 100"); $redis->set('hot:ranking', json_encode($data)); // 仍然不设置 TTL } // 定时任务刷新 // crontab: */10 * * * * php artisan ranking:refresh三、缓存雪崩3.1 问题描述大量 key 在同一时间集中过期,或者 Redis 宕机,导致大量请求同时打到数据库。大量key同时过期 │ ├──→ 请求1 → MISS → 查DB ──┐ ├──→ 请求2 → MISS → 查DB ──┤ ├──→ 请求3 → MISS → 查DB ──┤ ├──→ 请求N → MISS → 查DB ──┤ │ │ └──── 数据库被压垮 ◄────────┘3.2 与缓存击穿的区别对比缓存击穿缓存雪崩影响范围单个热点 key大量 key原因热点 key 过期大量 key 同时过期 / Redis 宕机量级小大3.3 解决方案方案一:随机过期时间function cacheData($key, $data, $baseTTL = 3600) { // 在基础TTL上加随机偏移,避免同时过期 $randomTTL = $baseTTL + mt_rand(0, 600); // 0~10分钟随机 $redis->setex($key, $randomTTL, json_encode($data)); } // 批量缓存时尤其重要 function batchCache($items) { foreach ($items as $key => $value) { $ttl = 3600 + mt_rand(0, 1800); // 1~1.5小时随机 $redis->setex($key, $ttl, json_encode($value)); } }方案二:多级缓存function getWithMultiLevel($key) { // L1: 本地缓存(进程内) $data = $localCache->get($key); if ($data !== null) { return $data; } // L2: Redis 缓存 $data = $redis->get($key); if ($data !== false) { $localCache->set($key, $data, 60); // 本地缓存60秒 return json_decode($data, true); } // L3: 数据库 $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]); if ($data) { $redis->setex($key, 3600 + mt_rand(0, 600), json_encode($data)); $localCache->set($key, $data, 60); } return $data; }方案三:熔断降级function getProductList($categoryId) { try { $data = $redis->get("products:{$categoryId}"); if ($data !== false) { return json_decode($data, true); } // 数据库查询带超时和限流 $data = $db->query("SELECT * FROM products WHERE category_id = ?", [$categoryId], ['timeout' => 1]); $redis->setex("products:{$categoryId}", 3600 + mt_rand(0, 600), json_encode($data)); return $data; } catch (Exception $e) { // 降级:返回默认数据或静态数据 return getDefaultProductList(); } }方案四:Redis 高可用# Redis Sentinel 哨兵模式自动故障转移 sentinel monitor mymaster 192.168.1.10 6379 2 sentinel down-after-milliseconds mymaster 30000 sentinel failover-timeout mymaster 180000 # Redis Cluster 集群模式 # 数据分散在多个节点,单节点宕机不影响整体四、综合对比问题本质核心方案推荐组合缓存穿透查询不存在数据布隆过滤器 + 空值缓存布隆过滤器 + 短TTL空值缓存击穿热点key过期互斥锁 + 双重检查互斥锁 + 逻辑过期缓存雪崩大量key同时过期随机TTL + 多级缓存随机TTL + 熔断降级五、完整防护代码示例class CacheService { private $redis; private $db; private $localCache = []; /** * 安全缓存读取(防穿透+击穿+雪崩) */ public function get($key, $dbQuery, $ttl = 3600) { // 1. 本地缓存 if (isset($this->localCache[$key])) { return $this->localCache[$key]; } // 2. Redis 缓存 $data = $this->redis->get($key); if ($data !== false) { if ($data === 'NULL') return null; $this->localCache[$key] = json_decode($data, true); return $this->localCache[$key]; } // 3. 互斥锁防击穿 $lockKey = "lock:{$key}"; $locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 10]); if (!$locked) { usleep(100000); // 等待100ms return $this->get($key, $dbQuery, $ttl); // 重试 } try { // 双重检查 $data = $this->redis->get($key); if ($data !== false) { return $data === 'NULL' ? null : json_decode($data, true); } // 4. 查数据库 $data = $dbQuery(); // 5. 缓存(随机TTL防雪崩) $randomTTL = $ttl + mt_rand(0, 300); if ($data === null) { // 空值缓存(短TTL防穿透) $this->redis->setex($key, 60, 'NULL'); } else { $this->redis->setex($key, $randomTTL, json_encode($data)); $this->localCache[$key] = $data; } return $data; } finally { $this->redis->del($lockKey); } } } // 使用 $cache = new CacheService(); $user = $cache->get("user:1001", function() use ($db) { return $db->query("SELECT * FROM users WHERE id = 1001"); }, 3600);总结缓存三大问题的核心防护策略:穿透:布隆过滤器拦截 + 空值缓存兜底击穿:互斥锁保证只有一个请求查DB + 双重检查雪崩:随机TTL打散过期时间 + 多级缓存 + 熔断降级生产环境建议三者组合使用,形成完整的缓存防护体系。
2026年05月10日
19 阅读
0 评论
0 点赞
2026-04-02
Redis 分布式锁实现:从 SET NX 到 Redlock 算法
前言分布式锁是分布式系统中控制资源访问的核心机制。Redis 因其单线程特性和高性能,是实现分布式锁的首选方案。本文将从最简单的实现出发,逐步演进到生产级的 Redlock 算法。一、为什么需要分布式锁1.1 单机锁的问题// 单机环境:PHP文件锁或flock可以解决 $fp = fopen('/tmp/lock.txt', 'w+'); flock($fp, LOCK_EX); // 加锁 // 执行业务逻辑 flock($fp, LOCK_UN); // 释放锁 fclose($fp);但在分布式环境中,请求可能被分配到不同服务器,文件锁无法跨机器生效。1.2 分布式锁的核心要求要求说明互斥性同一时刻只有一个客户端持有锁避免死锁锁必须有过期时间,防止持有者宕机导致永久锁死解铃还须系铃人只有锁的持有者才能释放锁高可用锁服务自身不能成为单点二、Redis 分布式锁演进2.1 版本一:SETNX(初版)// 最简单的实现 $lockKey = 'lock:order:1001'; // 尝试加锁 $redis->setnx($lockKey, 1); // 返回1表示成功,0表示失败 // 设置过期时间(防死锁) $redis->expire($lockKey, 30); // 业务逻辑 processOrder(); // 释放锁 $redis->del($lockKey);致命问题:setnx 和 expire 是两条命令,不保证原子性。如果 setnx 成功后服务崩溃,锁将永远不会过期,导致死锁。2.2 版本二:SET NX EX(推荐基础版)$lockKey = 'lock:order:1001'; $lockValue = uniqid('', true); // 生成唯一标识 // 一条命令完成加锁+过期,原子操作 $result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]); if ($result) { try { // 获取锁成功,执行业务 processOrder(); } finally { // 释放锁 $redis->del($lockKey); } } else { // 获取锁失败 throw new Exception('系统繁忙,请稍后重试'); }改进:SET key value NX EX 30 是原子操作,解决了死锁问题。遗留问题:释放锁时直接 DEL,可能删掉别人的锁。2.3 版本三:锁标识 + Lua 释放(安全版)$lockKey = 'lock:order:1001'; $lockValue = uniqid('', true); // 加锁 $result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]); if (!$result) { throw new Exception('获取锁失败'); } try { processOrder(); } finally { // 用Lua脚本安全释放锁:只有value匹配才删除 $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; $redis->eval($luaScript, [$lockKey, $lockValue], 1); }为什么需要 Lua 脚本?时间线: T1: 客户端A GET lock → value=A T2: 锁过期,自动删除 T3: 客户端B SET lock NX → 成功,value=B T4: 客户端A DEL lock → 删掉了B的锁!Lua 脚本保证了 GET + 判断 + DEL 三步原子执行。2.4 版本四:锁续期(生产可用版)业务执行时间可能超过锁的过期时间,需要后台续期:class RedisLock { private $redis; private $lockKey; private $lockValue; private $ttl = 30; private $renewTimer = null; public function lock($key, $ttl = 30) { $this->lockKey = $key; $this->lockValue = uniqid('', true); $this->ttl = $ttl; // 尝试加锁,最多重试3次 for ($i = 0; $i < 3; $i++) { $result = $this->redis->set( $this->lockKey, $this->lockValue, ['NX', 'EX' => $this->ttl] ); if ($result) { $this->startRenew(); return true; } usleep(100000); // 100ms后重试 } return false; } /** * 后台续期(守护进程/定时器) * 每隔 TTL/3 秒续期一次 */ private function startRenew() { $renewInterval = intval($this->ttl / 3); // 使用Swoole定时器(或PCNTL信号) if (function_exists('swoole_timer_tick')) { $this->renewTimer = swoole_timer_tick( $renewInterval * 1000, function() { $this->renew(); } ); } } private function renew() { $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end "; $this->redis->eval( $luaScript, [$this->lockKey, $this->lockValue, $this->ttl], 1 ); } public function unlock() { // 停止续期 if ($this->renewTimer) { swoole_timer_clear($this->renewTimer); } // 安全释放 $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; $this->redis->eval( $luaScript, [$this->lockKey, $this->lockValue], 1 ); } }2.5 版本五:可重入锁同一线程多次获取同一把锁不应死锁:class ReentrantLock { private $redis; /** * 加锁(可重入) * 使用Hash结构:key=锁名, field=客户端ID, value=重入次数 */ public function lock($lockKey, $clientId, $ttl = 30) { $luaScript = " -- 锁不存在或当前客户端已持有 if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then -- 重入次数+1 redis.call('hincrby', KEYS[1], ARGV[1], 1) -- 设置过期时间 redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end "; return $this->redis->eval( $luaScript, [$lockKey, $clientId, $ttl], 1 ) === 1; } /** * 释放锁(可重入) * 重入次数-1,减到0时删除锁 */ public function unlock($lockKey, $clientId) { $luaScript = " -- 不是锁的持有者 if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil end -- 重入次数-1 local count = redis.call('hincrby', KEYS[1], ARGV[1], -1) if count > 0 then -- 还有重入次数,续期 redis.call('expire', KEYS[1], 30) return count else -- 重入次数归零,删除锁 redis.call('del', KEYS[1]) return 0 end "; return $this->redis->eval( $luaScript, [$lockKey, $clientId], 1 ); } } // 使用 $lock = new ReentrantLock(); $clientId = 'client-' . getmypid(); $lock->lock('lock:order', $clientId); // count=1 $lock->lock('lock:order', $clientId); // count=2(可重入) $lock->unlock('lock:order', $clientId); // count=1 $lock->unlock('lock:order', $clientId); // count=0, 锁删除三、Redlock 算法3.1 为什么需要 Redlock单 Redis 实例的分布式锁有一个问题:如果 Master 宕机,锁数据还没同步到 Slave,新 Master 上没有锁信息,其他客户端可以加锁成功,导致互斥性被破坏。Redlock 由 Redis 作者提出,通过多个独立 Redis 实例投票解决此问题。3.2 算法流程客户端 Redis集群 │ │── 加锁请求 ──→ Node1 ──→ 成功 (1/5) │── 加锁请求 ──→ Node2 ──→ 成功 (2/5) │── 加锁请求 ──→ Node3 ──→ 成功 (3/5) ← 超过半数 │── 加锁请求 ──→ Node4 ──→ 超时 │── 加锁请求 ──→ Node5 ──→ 超时 │ │ 加锁耗时 = 50ms │ 锁TTL = 30s │ 实际有效时间 = 30s - 50ms = 29.95s │ │←── 加锁成功(3/5节点同意)─────步骤:获取当前时间 T1依次向 N 个 Redis 节点请求加锁(相同的 key、value、较短TTL)获取当前时间 T2,计算加锁耗时 = T2 - T1如果加锁成功节点数 ≥ ⌈N/2⌉ + 1 且加锁耗时 < TTL,则加锁成功实际锁有效时间 = TTL - 加锁耗时如果加锁失败,向所有节点发送释放锁请求3.3 PHP 实现class Redlock { private $servers = []; public function __construct(array $redisServers) { foreach ($redisServers as $server) { $redis = new Redis(); $redis->connect($server['host'], $server['port']); if (isset($server['password'])) { $redis->auth($server['password']); } $this->servers[] = $redis; } } public function lock($resource, $ttl = 10000) { $lockValue = bin2hex(random_bytes(16)); $successCount = 0; $startTime = microtime(true) * 1000; // 向所有节点请求加锁 foreach ($this->servers as $redis) { try { $result = $redis->set( $resource, $lockValue, ['NX', 'PX' => $ttl] ); if ($result) { $successCount++; } } catch (Exception $e) { // 节点不可用,继续尝试其他节点 } } $elapsed = microtime(true) * 1000 - $startTime; $quorum = floor(count($this->servers) / 2) + 1; // 判断是否加锁成功 if ($successCount >= $quorum && $elapsed < $ttl) { // 返回锁信息(包含实际有效时间) return [ 'key' => $resource, 'value' => $lockValue, 'validity_time' => $ttl - intval($elapsed), ]; } // 加锁失败,释放所有已加的锁 $this->unlock($resource, $lockValue); return false; } public function unlock($resource, $lockValue) { $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; foreach ($this->servers as $redis) { try { $redis->eval($luaScript, [$resource, $lockValue], 1); } catch (Exception $e) { // 忽略 } } } } // 使用 $redlock = new Redlock([ ['host' => '192.168.1.10', 'port' => 6379, 'password' => 'pass1'], ['host' => '192.168.1.11', 'port' => 6379, 'password' => 'pass2'], ['host' => '192.168.1.12', 'port' => 6379, 'password' => 'pass3'], ['host' => '192.168.1.13', 'port' => 6379, 'password' => 'pass4'], ['host' => '192.168.1.14', 'port' => 6379, 'password' => 'pass5'], ]); $lock = $redlock->lock('lock:order:1001', 10000); // TTL=10秒 if ($lock) { try { // 执行业务逻辑(注意在 validity_time 内完成) processOrder(); } finally { $redlock->unlock($lock['key'], $lock['value']); } } else { throw new Exception('获取分布式锁失败'); }3.4 Redlock 的争议Martin Kleppmann(《DDIA》作者)曾质疑 Redlock:时钟漂移可能导致锁失效GC 暂停可能导致客户端持有过期锁实际建议:如果对正确性要求极高(如金融场景),用 ZooKeeper 或 etcd如果是普通业务场景(如防重复下单),单 Redis + Lua 足够Redlock 适合需要比单 Redis 更高可靠性,但又不想引入 ZK 的场景四、实战:防重复下单class OrderService { private $redis; /** * 防重复下单 */ public function createOrder($userId, $productId, $quantity) { $lockKey = "lock:order:{$userId}:{$productId}"; $lockValue = uniqid('', true); // 加锁(10秒过期) $locked = $this->redis->set( $lockKey, $lockValue, ['NX', 'EX' => 10] ); if (!$locked) { throw new Exception('请勿重复提交,10秒后重试'); } try { // 1. 检查库存 $stock = $this->redis->get("stock:{$productId}"); if ($stock < $quantity) { throw new Exception('库存不足'); } // 2. 扣减库存 $this->redis->decrBy("stock:{$productId}", $quantity); // 3. 创建订单 $orderId = $this->db->insert('orders', [ 'user_id' => $userId, 'product_id' => $productId, 'quantity' => $quantity, 'status' => 'pending', 'created_at' => date('Y-m-d H:i:s'), ]); // 4. 发送延迟消息(30分钟未支付自动取消) $this->mq->publish('order_timeout', [ 'order_id' => $orderId, ], 1800); return $orderId; } catch (Exception $e) { // 回滚库存 $this->redis->incrBy("stock:{$productId}", $quantity); throw $e; } finally { // 安全释放锁 $lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; $this->redis->eval($lua, [$lockKey, $lockValue], 1); } } }五、各方案对比方案互斥性防死锁容错性性能复杂度SETNX+EXPIRE✅❌❌高低SET NX EX✅✅❌高低SET NX EX + Lua释放✅✅❌高中锁续期✅✅❌高中高可重入锁✅✅❌高高Redlock✅✅✅中高总结Redis 分布式锁的演进路线:SET NX EX 原子加锁 → 基础方案Lua 脚本安全释放 → 必须做后台续期 → 长任务必须做可重入 → 递归/嵌套场景需要Redlock → 对容错性要求高时考虑生产推荐:单 Redis + SET NX EX + Lua 释放 + 续期,覆盖 95% 场景。
2026年04月02日
36 阅读
0 评论
0 点赞
1
2
3
...
6
0:00