webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化
webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化把 webman 写出来是一回事，让它安稳跑在生产上是另一回事。没有 PHP-FPM 和 supervisord 的成熟套路兜底，常驻内存服务的部署、发布、监控都有一套自己的打法。这篇是 webman 系列收官：从部署形态、平滑发布到监控告警体系，最后聊聊什么时候该拆微服务。一、部署形态：三种选择1. 裸机/虚拟机 + systemd（中小项目首选）# /etc/systemd/system/webman.service [Unit] Description=webman app After=network.target mysql.service redis.service [Service] Type=forking WorkingDirectory=/var/www/app ExecStart=/usr/bin/php start.php start -d ExecReload=/usr/bin/php start.php reload ExecStop=/usr/bin/php start.php stop Restart=always RestartSec=3 [Install] WantedBy=multi-user.targetsystemctl daemon-reload systemctl enable --now webman systemctl reload webman # 平滑发布用 reload要点：Type=forking（start.php -d 是 daemonize 模式）、Restart=always 兜底进程崩溃、ExecReload 挂 reload 实现无感发布。2. Docker 容器化FROM php:8.2-cli-alpine RUN apk add --no-cache libpq libzip-dev && docker-php-ext-install pdo_mysql opcache COPY . /app WORKDIR /app RUN composer install --no-dev --optimize-autoloader EXPOSE 8787 # 容器内前台运行（容器本身就是守护者，不需要 -d） CMD ["php", "start.php", "start"]容器化要点：容器里必须前台运行（去掉 -d），让 PID 1 就是 master 进程，容器崩溃重启策略（restart: always）接管守护职责。K8s 场景用 readiness 探针指向健康检查接口。3. Supervisor 托管（多语言混合部署环境）老牌方案，配置直观，适合还有 Python/Go 混跑的机器。二、平滑发布：让 reload 真正"无感"php start.php reload 的机制是逐个重启 worker（上一篇讲过），但要真正做到无感发布，还有四个坑要填：坑一：reload 不加载的东西reload 只重新加载 worker，master 进程内存里的东西不会更新：路由配置文件、config/ 目录、bootstrap 钩子。改了这些必须 restart（master 重建，有感知）。发布脚本要区分：#!/bin/bash # 简易发布脚本 git pull composer install --no-dev --optimize-autoloader php start.php reload # 常规发布 # 改了 config/route/bootstrap 时：php start.php restart坑二：发布瞬间的连接抖动worker 逐个重启期间，正在处理中的请求要等它完成（超过 max_wait 会被强杀）。发布时把 max_wait_time 临时调大、避开高峰期，长任务（导出、批处理）走独立进程不随 worker 重启。坑三：静态文件缓存webman 自带静态文件支持时，浏览器缓存的旧 JS 配合新 HTML 可能白屏——资源文件名带 hash（app.a3f8c2.js）彻底解决。坑四：发布失败的回滚# 用软链接做版本切换，回滚 = 切回旧链接 ln -sfn /var/www/releases/20260823-0200 /var/www/app_current php start.php restart # 软链指向变了，restart 生效保留最近 5 个版本目录，回滚 10 秒完成。没有回滚方案的发布都是赌博。三、监控告警体系：四个层次常驻内存服务必须主动监控——它出问题的方式是"安静地僵死"，不是崩溃报错。层次一：进程存活（最基础）# 心跳脚本（cron 每分钟） process_num=$(pgrep -f "start.php" | wc -l) [ "$process_num" -lt 1 ] && curl -s "https://alert.example.com?msg=webman进程消失"systemd/Docker 的 restart 能自动拉起，但频繁重启本身就是告警信号——监控 restart 次数。层次二：业务健康检查// 健康检查接口：不只返回 200，要真的探测依赖 Route::get('/healthz', function () { try { Db::query('SELECT 1'); $redis = Redis::connection(); $redis->ping(); return json(['status' => 'ok']); } catch (\Throwable $e) { return json(['status' => 'degraded', 'error' => $e->getMessage()], 503); } });K8s 的 liveness/readiness、负载均衡的健康剔除都挂这个接口。层次三：业务指标埋点在全局中间件里做请求级指标采集，按分钟聚合推到 Prometheus Pushgateway（或直接暴露 /metrics）：// 指标维度：路由 × 状态码 → count / 耗时分布 $metricKey = $request->path() . ':' . $response->getStatusCode(); Metrics::tick($metricKey, microtime(true) - $start);核心告警规则参考：告警阈值说明错误率5xx 占比 > 1%（5 分钟窗口）最核心的质量指标P99 延迟> 500ms体验底线QPS 突降环比跌 50%流量异常或半死状态内存水位worker RSS > 256M内存泄漏前兆重启频率10 分钟内重启 > 3 次崩溃循环worker 内存监控：php start.php status 能看到每个 worker 的内存，采集脚本每分钟解析一次，单独盯内存曲线——常驻内存服务的专属指标，FPM 时代不存在这个告警维度。层次四：日志聚合日志按天/大小切割，错误日志单独通道。EFK/Loki 都行，关键是错误日志要能按 trace-id 串起完整调用链——中间件生成 trace-id 写入 header 和日志上下文，跨服务传递。四、日志与排障生产排障三板斧：php start.php status # 进程/连接/请求分布 php start.php connections # 当前连接明细 tail -f runtime/logs/*.log # 慢请求日志、错误日志两个必开配置：// config/log.php：慢请求单独通道 'slow' => [ 'handler' => ..., 'formatter' => ..., ], // config/app.php 'default_timezone' => 'Asia/Shanghai', // 别让日志时间戳漂移OOM 被 kill 的进程：dmesg | grep -i kill 确认是内核 OOM killer 干的，然后查内存泄漏（系列第一篇的水位监控法）。五、微服务化：什么时候拆、怎么拆webman 常被用作微服务基座，但拆分要跟着业务痛点走：该拆的信号发布互相拖累：营销活动改一行，核心交易要陪着全量回归资源冲突：批量任务把 CPU 打满，拖垮在线接口团队协作：多组改一个仓库，merge 冲突不断不该拆的时候单体 + 分层（Controller / Service / Dao）+ 自定义进程隔离（在线/离线分开跑）能解决 80% 的问题。微服务是运维成本换研发效率的交易，团队没有 3 人以上运维能力前，慎拆。webman 微服务的标准拼图├── API 网关（webman，路由聚合/鉴权/限流） ├── 业务服务（webman，HTTP 或 JSON-RPC 内部通信） ├── 注册发现（consul / nacos / redis） ├── 异步解耦（Redis 流 / RabbitMQ，webman 的自定义进程消费） └── 链路追踪（trace-id 贯穿 + 日志聚合）服务间通信的务实选择：初期用 HTTP + JSON（简单直观，好排查），流量大了再换 gRPC 或原生 TCP 协议。webman 的 workerman/http-client 支持连接池复用，内部调用开销可控。进程内优先原则：同一个 webman 实例里，用"多应用目录"（app/api、app/admin）做逻辑隔离，比拆进程便宜得多。拆物理服务的唯一理由是上面的三个信号，而不是"架构图好看"。六、上线 Go/No-Go 清单发布前最后一遍：[ ] systemd / Docker 的自动拉起与重启告警就位[ ] healthz 探测 DB + Redis，负载均衡已接健康检查[ ] 发布脚本区分 reload/restart，回滚软链方案演练过[ ] 错误率 / P99 / 内存 / 重启频率四条告警全部在线[ ] 慢请求日志与 trace-id 链路打通[ ] 压测报告：目标 QPS × 2 压过，P99 达标[ ] 数据库连接预算核算（worker × 池上限 < max_connections × 0.8）[ ] 灰度方案：先切 10% 流量观察 30 分钟写在最后webman 系列到这里完整闭环：架构模型（常驻内存的边界）→ 工程骨架（中间件与容器）→ 数据层（连接与事务）→ 性能（协程与压测）→ 生产化（部署监控与演进）。常驻内存框架的一切实践都围绕同一个心法——你运维的不是"每次请求"，而是"活着的进程"。把这个视角建立起来，webman 就是你手里性价比极高的高并发利器。
webman 高并发进阶：协程实战、连接池调优与压测方法论
webman 高并发进阶：协程实战、连接池调优与压测方法论webman 裸奔就能跑出很高的 QPS，但"框架快"不等于"业务快"——一个同步 HTTP 调用就能让 worker 的事件循环卡住，吞吐瞬间跌到个位数。这篇讲 webman 高并发的三板斧：协程化改造、连接池调优、压测方法论。这是 webman 中高级的分水岭。一、先理解瓶颈：worker 事件循环是怎么被卡死的webman 的 worker 是单进程单线程事件循环（Reactor 模型）。在同一个 worker 里，请求是串行处理的：worker-1 事件循环： 事件：请求A到达 → 处理A（其中调用外部API耗时 200ms，同步阻塞！） （这 200ms 内，分配给 worker-1 的所有其他请求全部排队） 事件：请求B到达 → 处理B推论：平均响应时间 = 排队时间 + 处理时间。 只要处理路径上有阻塞调用，worker 的吞吐上限就是 1 / 平均阻塞时长。200ms 的外部调用意味着单个 worker 每秒最多 5 个请求，16 个 worker 也就 80 QPS——和框架本身的几十万 QPS 上限毫无关系。解法只有两条：消灭阻塞（异步/协程化），或者增加并行度（协程）。二、协程：webman 的三种姿势1. 内置 Swoole/Swow 协程驱动webman 支持通过配置切换协程运行时：// config/server.php 'settings' => [ 'event_loop' => \Workerman\Events\Swoole::class, // 或 Swow ],开启后，worker 内自动协程化：遇到 IO（MySQL、Redis、HTTP）时挂起当前协程，让出事件循环处理其他请求——同一个 worker 从串行变成并发。Swoole 与 Swow 的选择：Swoole 生态成熟但与 PHP 原生扩展偶有兼容性问题（hook 覆盖不全的扩展会退化为阻塞）；Swow 是 PHP 官方系方案更干净但生态年轻。生产选型建议：现有业务全是标准 PDO/cURL 的选 Swoole 一把梭；有奇怪扩展依赖的先在预发验证 Swow。2. 协程客户端协程化后最大的红利是并发聚合调用：use Swoole\Coroutine; use function Swoole\Coroutine\go; use function Swoole\Coroutine\waitGroup; // 聚合页：并行拉取用户/商品/优惠券，总耗时 = max 而不是 sum $wg = new waitGroup(); $results = []; go(function () use ($wg, &$results) { $wg->add(); $results['user'] = $this->userApi->get($uid); $wg->done(); }); go(function () use ($wg, &$results) { $wg->add(); $results['item'] = $this->itemApi->get($itemId); $wg->done(); }); $wg->wait(3.0); // 总超时 3s // 原来 200+150+100=450ms，现在 max(200,150,100)=200msBFF/聚合接口的标准优化手段，P99 延迟直接砍半。3. 协程的三个纪律纪律一：协程切换点后不信任局部状态。 yield 期间其他协程可能改了共享数据。请求级数据老老实实走 $request 属性，全局状态放 Redis。纪律二：连接必须绑协程。 协程 A 半截的事务，切换后协程 B 接着用同一个连接 = 事务串台。Swoole 的 PDO hook + webman/database 的连接池已处理绑定关系，但你自己 new PDO 存静态属性再跨协程用，必炸。纪律三：CPU 密集任务协程救不了。 协程只解决 IO 并发，CPU 密集计算照样卡死事件循环。重计算丢给自定义进程或队列，别在 worker 里跑。三、连接池：协程时代的正确配置协程化后并发量上来了，单连接成了新瓶颈，必须配连接池（webman 通过 webman/database/webman/redis 的连接池实现，Swoole 驱动下生效）：// config/database.php（连接池相关参数） 'pool' => [ 'max_connections' => 32, // 每进程最大连接数 'min_connections' => 2, // 保底连接 'connect_timeout' => 3, // 取连接超时 'wait_timeout' => 3, // 等待池中连接的超时 'heartbeat' => -1, // 心跳检测间隔（配合 wait_timeout 用负数自动探测） 'max_idle_time' => 60, // 空闲连接回收 ],容量估算公式：每进程需要的连接数 ≈ 协程并发度（同时进行中的查询数） 总连接 = worker 数 × max_connections 例：16 worker × 32 连接 = 512 连接 < MySQL max_connections(1000) ✓常见翻车：池配了 32，MySQL max_connections 只有 100，另一台应用共用它——高峰期互相挤兑连接，报错 connection timeout 却以为是网络问题。跨应用共享数据库时，连接预算要全局规划。池的监控指标每个指标都应该有告警：指标健康值异常含义池利用率< 80%持续 100% = 池太小或查询太慢等待取连接耗时< 1ms高 = 池不够用活跃连接数平稳锯齿状抖动 = 连接泄漏慢查询数趋近 0高 = 先治慢查询再扩池四、压测方法论：别让数据骗了你压测的正确姿势预热：先压 1 分钟丢弃数据（连接懒加载、JIT、文件缓存都要热身）阶梯加压：并发 10 → 50 → 100 → 200，每档 3-5 分钟，找到拐点看 P99 而不是平均值：平均值 50ms、P99 3s 的服务，用户体感是灾难压测端别成为瓶颈：用独立机器跑 wrk/vegeta，本机压本机数据不可信# wrk 示例：16 线程 200 并发，持续 3 分钟 wrk -t16 -c200 -d180s -s post.lua http://10.0.0.5:8787/api/orders拐点诊断表现象第一嫌疑验证手段CPU 打满 100%业务计算 / 序列化开销perf top 看热点函数CPU 很低但延迟飙升阻塞 IO（同步调用没协程化）strace 看 worker 阻塞在哪个 syscall连接池等待耗时高池容量不足或慢查询慢查询日志 + 池监控MySQL CPU 高缺索引 / 慢 SQLexplain 逐条过延迟毛刺reload / GC / 心跳任务对齐时间线看毛刺时刻发生了什么一个容易被忽略的真相：压测时开着 Xdebug、开着 debug 日志、没开 opcache/jit，得出的"webman 不行"结论全是冤案。压测环境配置必须对齐生产：php start.php start -d（daemonize）、opcache 开启、日志降级。webman 侧的性能自检php start.php status # 每个worker的连接数、请求统计关注 total_request（累计请求数）和 request_per_second。如果各 worker 请求分布严重不均，检查是否用了 sticky 逻辑（比如按 uid hash 到特定连接）。五、一个真实调优案例场景：订单列表接口，压测到 800 QPS 后延迟从 40ms 涨到 2s。排查过程：status 显示 worker CPU 只有 30% → 不是 CPU 瓶颈strace 发现 worker 大量时间阻塞在 recvfrom（MySQL 等待）→ IO 等待慢查询日志：一条 ORDER BY created DESC LIMIT 20 OFFSET 5000 没有覆盖索引，深分页扫全表 1.2s修复：游标分页（WHERE id < last_id LIMIT 20）+ 联合索引复测：5000 QPS 稳定，P99 = 65ms教训：框架层的优化（协程/连接池）解决的是"传导"问题，SQL 层的慢查询是"源头"问题。源头不治，协程只是让请求更快地堵在数据库门口。六、优化优先级清单按投入产出比排序，从上往下做：1. 慢 SQL 治理（索引、深分页、N+1） ← 90% 的性能问题在这 2. 聚合接口并行化（协程并发调用） 3. 热点数据缓存（Redis，注意缓存一致性） 4. 协程运行时 + 连接池配置 5. opcache / JIT / preload 6. worker 数与压测验证写在最后高并发的本质是让每个 worker 的每一毫秒都在干活：协程消灭 IO 空转，连接池消灭建连开销，压测验证每一项优化真实生效。记住那条优先级清单——先治 SQL，再谈协程。最后一篇我们讲生产化：部署、进程守护、监控告警和微服务化实践。
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 的数据库层就稳了。下一篇进入性能深水区：协程、连接池调优与压测。
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 必须无状态。下一篇讲数据库层——连接池怎么配、事务怎么包、大数据怎么跑，那是常驻内存框架最容易翻车的第二现场。
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"，而是一种不同的运行时模型。把进程模型、启动流程、常驻内存的边界刻进脑子，后面的中间件、连接池、协程实践才有意义。下一篇我们进入请求处理的核心链路：中间件洋葱模型与依赖注入容器的实战。
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打散过期时间 + 多级缓存 + 熔断降级生产环境建议三者组合使用，形成完整的缓存防护体系。
前言分布式锁是分布式系统中控制资源访问的核心机制。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% 场景。
前言随着前后端分离架构的普及，FastAdmin 的 API 模块越来越重要。本文将系统讲解如何使用 FastAdmin 开发 RESTful API 接口，包括 Token 认证、参数验证、数据返回格式规范以及与前端框架的对接实践。一、API 模块概述FastAdmin 的 API 模块位于 application/api/，专门用于提供接口服务：application/api/ ├── controller/ │ ├── Common.php # 公共控制器 │ ├── Index.php # 首页接口 │ ├── User.php # 用户接口 │ ├── Api.php # 基类控制器 │ └── Demo.php # 示例接口 ├── model/ │ └── User.php # API 用户模型 └── config.php # API 模块配置二、API 基类控制器所有 API 控制器继承自 app\common\controller\Api：<?php // application/common/controller/Api.php namespace app\common\controller; use think\Controller; use app\common\library\Token; use app\common\library\Auth; class Api extends Controller { // 不需要登录的方法 protected $noNeedLogin = ['*']; // 不需要权限验证的方法 protected $noNeedRight = ['*']; // 当前请求的 Token protected $token = ''; // 当前登录用户 protected $auth = null; // 初始化 public function _initialize() { parent::_initialize(); // 跨域处理 $this->checkCors(); // Token 认证 $this->token = $this->request->server('HTTP_TOKEN', $this->request->request('token', '')); // 初始化认证 $this->auth = Auth::instance(); // 检查是否需要登录 $action = strtolower($this->request->action()); if (!in_array($action, array_map('strtolower', $this->noNeedLogin))) { if (!$this->auth->isLogin()) { $this->error(__('Please login first'), null, 401); } } } // 跨域设置 protected function checkCors() { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, GET, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: token, Content-Type, Authorization'); if ($this->request->method() == 'OPTIONS') { $this->success(); } } // 成功返回 protected function success($msg = '', $data = null, $code = 1) { $this->result($msg, $data, $code); } // 失败返回 protected function error($msg = '', $data = null, $code = 0) { $this->result($msg, $data, $code); } // 统一返回格式 protected function result($msg, $data = null, $code = 1) { $result = [ 'code' => $code, 'msg' => $msg, 'time' => time(), 'data' => $data, ]; throw new \think\exception\HttpResponseException( json($result) ); } }统一返回格式{ "code": 1, "msg": "获取成功", "time": 1721800000, "data": { "id": 1, "username": "admin" } }字段说明code1=成功，0=失败msg提示消息time服务器时间戳data业务数据三、用户认证3.1 注册接口<?php // application/api/controller/User.php namespace app\api\controller; use app\common\controller\Api; use app\common\model\User; use app\common\library\Token; use think\Validate; class User extends Api { protected $noNeedLogin = ['register', 'login']; protected $noNeedRight = ['*']; /** * 用户注册 * POST /api/user/register */ public function register() { $username = $this->request->post('username'); $password = $this->request->post('password'); $email = $this->request->post('email'); $mobile = $this->request->post('mobile'); // 参数验证 $validate = new Validate([ 'username' => 'require|length:3,30', 'password' => 'require|length:6,30', 'email' => 'require|email', 'mobile' => 'require|regex:/^1[3-9]\d{9}$/', ], [ 'username.require' => '用户名不能为空', 'username.length' => '用户名长度3-30个字符', 'password.require' => '密码不能为空', 'password.length' => '密码长度6-30个字符', 'email.email' => '邮箱格式不正确', 'mobile.regex' => '手机号格式不正确', ]); $data = [ 'username' => $username, 'password' => $password, 'email' => $email, 'mobile' => $mobile, ]; if (!$validate->check($data)) { $this->error($validate->getError()); } // 检查用户名是否已存在 if (User::where('username', $username)->find()) { $this->error('用户名已存在'); } // 创建用户 $user = new User(); $user->allowField(true)->save([ 'username' => $username, 'password' => \app\common\library\Hash::make($password), 'email' => $email, 'mobile' => $mobile, 'jointime' => time(), 'createtime' => time(), 'updatetime' => time(), ]); // 生成 Token $token = Token::create($user->id, 'user', 86400 * 7); $this->success('注册成功', [ 'token' => $token, 'user' => [ 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, 'mobile' => $user->mobile, ], ]); } /** * 用户登录 * POST /api/user/login */ public function login() { $account = $this->request->post('account'); $password = $this->request->post('password'); if (!$account || !$password) { $this->error('账号和密码不能为空'); } // 支持用户名/邮箱/手机号登录 $user = User::where('username|email|mobile', $account)->find(); if (!$user) { $this->error('账号不存在'); } if (!\app\common\library\Hash::check($password, $user->password)) { $this->error('密码错误'); } if ($user->status != 'normal') { $this->error('账号已被禁用'); } // 更新登录信息 $user->save([ 'logintime' => time(), 'loginip' => $this->request->ip(), ]); // 生成 Token $token = Token::create($user->id, 'user', 86400 * 7); $this->success('登录成功', [ 'token' => $token, 'user' => [ 'id' => $user->id, 'username' => $user->username, 'avatar' => $user->avatar, 'email' => $user->email, ], ]); } /** * 获取当前用户信息 * GET /api/user/info */ public function info() { $user = $this->auth->getUser(); $this->success('获取成功', [ 'id' => $user->id, 'username' => $user->username, 'avatar' => $user->avatar, 'email' => $user->email, 'mobile' => $user->mobile, 'createtime'=> $user->createtime, ]); } /** * 退出登录 * POST /api/user/logout */ public function logout() { Token::clear($this->token); $this->success('退出成功'); } }3.2 Token 机制FastAdmin 的 Token 存储在 fa_token 表中：// 生成 Token（有效期 7 天） $token = Token::create($userId, 'user', 86400 * 7); // 验证 Token $userId = Token::get($token); // 刷新 Token $token = Token::refresh($token); // 清除 Token Token::clear($token);前端请求时在 Header 中携带 Token：// axios 请求拦截器 axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['token'] = token; } return config; }); // 响应拦截器处理 401 axios.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );四、RESTful 接口开发4.1 文章接口完整实现<?php // application/api/controller/Article.php namespace app\api\controller; use app\common\controller\Api; use app\common\model\Article as ArticleModel; use think\Validate; class Article extends Api { protected $noNeedLogin = ['index', 'detail']; protected $noNeedRight = ['*']; /** * 文章列表 * GET /api/article/index?page=1&limit=10&keyword=xxx */ public function index() { $page = $this->request->get('page/d', 1); $limit = $this->request->get('limit/d', 10); $keyword = $this->request->get('keyword', ''); $categoryId = $this->request->get('category_id/d', 0); $query = ArticleModel::where('status', 'normal'); // 关键词搜索 if ($keyword) { $query->where('title', 'like', "%{$keyword}%"); } // 分类筛选 if ($categoryId) { $query->where('category_id', $categoryId); } // 分页查询 $total = $query->count(); $list = $query->order('id', 'desc') ->field('id,title,author,views,createtime') ->page($page, $limit) ->select(); $this->success('获取成功', [ 'total' => $total, 'page' => $page, 'limit' => $limit, 'list' => $list, ]); } /** * 文章详情 * GET /api/article/detail?id=1 */ public function detail() { $id = $this->request->get('id/d', 0); if (!$id) { $this->error('参数错误'); } $article = ArticleModel::where('id', $id) ->where('status', 'normal') ->find(); if (!$article) { $this->error('文章不存在'); } // 浏览量 +1 ArticleModel::where('id', $id)->inc('views')->update(); $this->success('获取成功', $article); } /** * 创建文章 * POST /api/article/add */ public function add() { $data = $this->request->post(); $data['admin_id'] = $this->auth->id; $validate = new Validate([ 'title' => 'require|length:2,100', 'content' => 'require|min:10', ], [ 'title.require' => '标题不能为空', 'content.require' => '内容不能为空', ]); if (!$validate->check($data)) { $this->error($validate->getError()); } $data['createtime'] = time(); $data['updatetime'] = time(); $article = new ArticleModel(); $article->allowField(true)->save($data); $this->success('创建成功', ['id' => $article->id]); } /** * 编辑文章 * PUT /api/article/edit?id=1 */ public function edit() { $id = $this->request->get('id/d', 0); $data = $this->request->post(); $article = ArticleModel::find($id); if (!$article) { $this->error('文章不存在'); } // 权限检查：只能编辑自己的文章 if ($article->admin_id != $this->auth->id) { $this->error('无权操作', null, 403); } $data['updatetime'] = time(); $article->allowField(true)->save($data); $this->success('修改成功'); } /** * 删除文章 * DELETE /api/article/del?id=1 */ public function del() { $id = $this->request->get('id/d', 0); $article = ArticleModel::find($id); if (!$article) { $this->error('文章不存在'); } if ($article->admin_id != $this->auth->id) { $this->error('无权操作', null, 403); } $article->delete(); $this->success('删除成功'); } }4.2 路由配置在 application/api/route.php 中定义路由规则：<?php use think\Route; // 文章相关 Route::get('articles', 'api/article/index'); Route::get('article/:id', 'api/article/detail'); Route::post('article', 'api/article/add'); Route::put('article/:id', 'api/article/edit'); Route::delete('article/:id', 'api/article/del'); // 用户相关 Route::post('register', 'api/user/register'); Route::post('login', 'api/user/login'); Route::get('user/info', 'api/user/info'); Route::post('logout', 'api/user/logout');五、接口文档自动生成FastAdmin 内置 API 文档生成功能：# 生成 API 文档 php think api --force=true文档输出到 public/api.html，包含所有接口的参数说明、返回示例和在线测试功能。注释规范/** * @ApiTitle (文章列表) * @ApiSummary (获取文章列表，支持分页和搜索) * @ApiMethod (GET) * @ApiRoute (/api/article/index) * @ApiParams (name="page", type="integer", required=false, description="页码，默认1") * @ApiParams (name="limit", type="integer", required=false, description="每页条数，默认10") * @ApiParams (name="keyword", type="string", required=false, description="搜索关键词") * @ApiReturn ({"code":1,"msg":"获取成功","data":{"total":100,"list":[...]}}) */ public function index() { // ... }六、与前端对接6.1 Vue3 对接示例// api/article.ts import axios from 'axios' const API_BASE = '/api' export interface Article { id: number title: string author: string views: number createtime: number } export interface ArticleListResponse { total: number page: number limit: number list: Article[] } export const getArticles = (params: { page?: number limit?: number keyword?: string }) => { return axios.get<{ code: number; data: ArticleListResponse }>( `${API_BASE}/article/index`, { params } ) } export const getArticleDetail = (id: number) => { return axios.get<{ code: number; data: Article }>( `${API_BASE}/article/detail`, { params: { id } } ) }6.2 小程序对接示例// utils/request.js const BASE_URL = 'https://api.example.com' export function request(options) { const token = wx.getStorageSync('token') return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data, header: { 'token': token, 'Content-Type': 'application/json' }, success(res) { if (res.data.code === 1) { resolve(res.data.data) } else if (res.data.code === 0 && res.statusCode === 401) { wx.removeStorageSync('token') wx.redirectTo({ url: '/pages/login/login' }) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail(err) { reject(err) } }) }) } // 调用 request({ url: '/api/article/index', data: { page: 1, limit: 10 } }) .then(data => { console.log(data.list) })七、安全防护7.1 接口限流// 使用缓存实现简单限流 public function _initialize() { parent::_initialize(); $ip = $this->request->ip(); $key = 'api_rate_' . $ip; $count = \think\Cache::inc($key); if ($count == 1) { \think\Cache::expire($key, 60); // 60秒内 } if ($count > 60) { $this->error('请求过于频繁', null, 429); } }7.2 签名验证// 客户端生成签名 // sign = md5(参数按key排序拼接 + secret_key) public function verifySign($params, $sign) { unset($params['sign']); ksort($params); $str = http_build_query($params) . '&key=' . config('api_secret'); return md5($str) === $sign; }总结FastAdmin API 模块提供了完整的前后端分离方案：统一返回格式和错误处理Token 认证机制，支持多端登录RESTful 路由设计，接口语义清晰内置 API 文档自动生成配合 Vue3/小程序等前端框架，快速搭建完整应用
FastAdmin 插件开发实战：从零开发自定义插件
前言FastAdmin 的插件系统是其生态的核心——通过在线安装插件，可以快速扩展论坛、CMS、商城等功能模块，无需修改框架核心代码。本文将从零开始，带你完整开发一个 FastAdmin 插件。一、插件目录结构一个标准的 FastAdmin 插件目录如下：addons/ └── mydemo/ # 插件目录（插件标识） ├── Mydemo.php # 插件主类 ├── config.php # 插件配置文件 ├── info.ini # 插件信息文件 ├── controller/ │ └── Index.php # 插件控制器 ├── model/ │ └── Demo.php # 插件模型 ├── view/ │ └── index/ │ └── index.html # 插件视图 ├── public/ │ ├── css/ │ │ └── mydemo.css # 插件样式 │ └── js/ │ └── mydemo.js # 插件脚本 └── hooks.php # 钩子定义（可选）二、创建插件2.1 使用命令行创建php think addon -a mydemo -c create这会在 addons/mydemo/ 目录下自动生成插件骨架文件。2.2 info.ini 插件信息name = mydemo title = 示例插件 description = 这是一个 FastAdmin 示例插件 author = Your Name version = 1.0.0 state = 1字段说明name插件标识（目录名，全小写）title插件名称description插件描述author作者version版本号state状态：1=正常，0=禁用2.3 插件主类 Mydemo.php<?php namespace addons\mydemo; use think\Addons; class Mydemo extends Addons { // 插件安装时执行 public function install() { // 创建数据表 $sql = "CREATE TABLE IF NOT EXISTS `fa_mydemo` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL DEFAULT '', `content` text, `createtime` int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; \think\Db::execute($sql); return true; } // 插件卸载时执行 public function uninstall() { // 删除数据表 \think\Db::execute("DROP TABLE IF EXISTS `fa_mydemo`"); return true; } // 插件启用时执行 public function enable() { return true; } // 插件禁用时执行 public function disable() { return true; } // 钩子：在后台首页显示 public function adminIndexTop($params) { echo ' 欢迎使用 Mydemo 插件！ '; } }2.4 config.php 插件配置<?php return [ [ 'name' => 'title', 'title' => '插件标题', 'type' => 'text', 'content' => '', 'value' => 'Mydemo Plugin', 'rule' => 'required', 'msg' => '请填写插件标题', 'tip' => '显示在插件管理页面的标题', ], [ 'name' => 'display_count', 'title' => '显示数量', 'type' => 'number', 'content' => '', 'value' => '10', 'rule' => 'required', 'msg' => '请填写显示数量', 'tip' => '前台显示的数据条数', ], [ 'name' => 'is_open', 'title' => '是否开启', 'type' => 'radio', 'content' => ['0' => '关闭', '1' => '开启'], 'value' => '1', 'rule' => 'required', 'msg' => '', 'tip' => '', ], ];配置在后台 → 插件管理 → 配置 中可编辑，读取方式：$config = get_addon_config('mydemo'); $title = $config['title'];三、插件控制器3.1 后台控制器<?php // addons/mydemo/controller/Index.php namespace addons\mydemo\controller; use app\common\controller\Backend; use think\Db; class Index extends Backend { // 不需要登录的方法 protected $noNeedLogin = []; // 不需要权限验证的方法 protected $noNeedRight = []; /** * 插件后台首页 */ public function index() { $list = Db::name('mydemo')->order('id', 'desc')->paginate(15); $this->view->assign('list', $list); return $this->view->fetch(); } /** * 添加数据 */ public function add() { if ($this->request->isPost()) { $data = $this->request->post('row/a'); $data['createtime'] = time(); Db::name('mydemo')->insert($data); $this->success('添加成功'); } return $this->view->fetch(); } /** * 删除数据 */ public function del($ids = null) { Db::name('mydemo')->where('id', 'in', $ids)->delete(); $this->success('删除成功'); } }3.2 访问路由插件控制器的 URL 格式为：http://yourdomain.com/addons/mydemo/index/index对应：addons - 固定前缀mydemo - 插件标识index - 控制器名index - 方法名3.3 前台控制器<?php // addons/mydemo/controller/Api.php namespace addons\mydemo\controller; use app\common\controller\Api; class Api extends Api { // 不需要登录的方法 protected $noNeedLogin = ['getList']; /** * 获取数据列表（API 接口） */ public function getList() { $count = \think\Db::name('mydemo')->count(); $list = \think\Db::name('mydemo') ->order('id', 'desc') ->limit(10) ->select(); $this->success('获取成功', [ 'total' => $count, 'list' => $list, ]); } }四、钩子机制（Hook）4.1 注册钩子在插件主类中定义钩子方法，方法名即钩子名：// addons/mydemo/Mydemo.php class Mydemo extends Addons { // 钩子：文章详情页底部 public function articleDetailBottom($params) { // $params 是传递的参数 $article = $params['article']; // 输出自定义内容 echo ' '; echo ' 相关推荐 '; echo ' '; } // 钩子：后台导航栏 public function adminSidebar($params) { echo ' '; echo ' Mydemo'; echo ' '; } }4.2 触发钩子在任意位置触发钩子：// 传递参数给钩子 \think\Hook::listen('article_detail_bottom', ['article' => $article]);4.3 FastAdmin 内置常用钩子钩子名触发位置参数admin_index_top后台首页顶部无admin_sidebar后台左侧菜单无article_detail_bottom文章详情底部article 数据user_register_after用户注册后user 数据config_init配置初始化配置数组五、插件打包发布5.1 打包# 将插件目录打包为 zip cd addons zip -r mydemo.zip mydemo/注意：打包时不要包含 runtime、temp 等临时目录。5.2 发布到插件市场注册 FastAdmin 官网账号进入"开发者中心"点击"发布插件"上传 zip 包，填写插件信息等待审核通过5.3 版本更新更新 info.ini 中的版本号后重新打包上传，用户可在后台一键升级。六、调试技巧6.1 开启插件调试// application/config.php 'app_debug' => true; // 确保插件目录有读写权限 chmod -R 755 addons/mydemo/6.2 查看插件日志// 在插件代码中记录日志 \think\Log::info('Mydemo: ' . json_encode($data)); // 查看日志 // runtime/log/202607/24.log6.3 热更新开发阶段修改插件代码后，清除缓存即可生效：php think clear --type=cache php think clear --type=temp七、完整插件开发流程1. php think addon -a mydemo -c create → 创建骨架 2. 编辑 info.ini → 填写插件信息 3. 编辑 Mydemo.php → 实现安装/卸载/钩子 4. 编辑 config.php → 定义插件配置项 5. 编写 controller/ → 实现业务逻辑 6. 编写 view/ → 编写视图模板 7. 编写 public/ → 添加前端资源 8. 后台安装插件 → 测试 → 调试 9. 打包 zip → 发布总结FastAdmin 插件系统通过目录约定 + 钩子机制实现了松耦合的功能扩展。核心要点：插件主类继承 think\Addons，实现 install/uninstall 生命周期通过 config.php 定义可视化配置项通过 Hook::listen() 在框架任意位置触发钩子插件可以有自己的 MVC 结构，独立于主应用
前言FastAdmin 内置了完整的 RBAC（基于角色的访问控制）权限体系，支持细粒度的菜单管理、操作权限和数据权限控制。本文将深入讲解 FastAdmin 权限认证的工作原理和实际配置方法。一、RBAC 权限模型概述FastAdmin 的权限体系包含三个核心实体：管理员（Admin）──→ 角色组（Group）──→ 权限规则（Rule）实体数据表说明管理员fa_admin后台用户，每个用户属于一个或多个角色组角色组fa_group角色定义，每个角色关联一组权限规则权限规则fa_auth_rule权限节点，对应控制器的每个操作方法二、权限认证流程2.1 认证类 AuthFastAdmin 的权限认证由 application/common/library/Auth.php 实现：namespace app\common\library; use think\Session; use think\Cookie; use app\admin\model\Admin as AdminModel; class Auth { protected $logined = false; // 是否登录 protected $admin = null; // 当前管理员信息 protected $error = ''; // 错误信息 protected $requestUri = ''; // 当前请求 URL // 检查是否已登录 public function isLogin() { if ($this->logined) { return true; } $admin = Session::get('admin'); if (!$admin) { return false; } // 验证管理员状态 $this->admin = AdminModel::get($admin['id']); if (!$this->admin || $this->admin['status'] != 'normal') { return false; } $this->logined = true; return true; } // 检查权限 public function check($name = '') { if (!$this->isLogin()) { return false; } // 超级管理员直接通过 if ($this->admin['id'] == 1) { return true; } // 获取当前用户的所有权限规则 $rules = $this->getRuleList(); if (!$name) { $name = strtolower($this->request->module() . '/' . $this->request->controller() . '/' . $this->request->action()); } $name = strtolower($name); // 检查是否有匹配的权限 foreach ($rules as $rule) { if ($this->checkRule($rule, $name)) { return true; } } return false; } }2.2 权限检查链路请求进入 → Backend 控制器初始化 → Auth::check() 检查当前 URL 是否有权限 → 无权限 → 返回 403 / 跳转登录 → 有权限 → 继续执行2.3 控制器中的权限注解FastAdmin 通过注解方式声明权限节点：// application/admin/controller/content/Article.php namespace app\admin\controller\content; use app\common\controller\Backend; class Article extends Backend { // 默认不需要登录的方法 protected $noNeedLogin = []; // 默认不需要权限验证的方法 protected $noNeedRight = []; /** * 文章列表 */ public function index() { return parent::index(); } /** * 添加文章 */ public function add() { return parent::add(); } /** * 编辑文章 */ public function edit($ids = null) { return parent::edit($ids); } /** * 删除文章 */ public function del($ids = null) { return parent::del($ids); } }每个 public 方法自动注册为一个权限节点，格式为 content/article/index。三、菜单管理3.1 菜单结构菜单存储在 fa_auth_rule 表中，采用树形结构：-- 查看菜单结构 SELECT id, pid, name, title, type FROM fa_auth_rule ORDER BY weigh DESC;字段说明id节点 IDpid父节点 ID（0 为顶级）name权限规则名（URL 路径）title菜单显示名称type类型：menu=菜单，file=操作节点icon菜单图标（FontAwesome）ismenu是否在左侧菜单显示（1=显示）3.2 命令行生成菜单# 根据控制器自动生成菜单 php think menu -c content/article # 强制覆盖 php think menu -c content/article -f 13.3 后台手动管理菜单进入 后台 → 权限管理 → 菜单规则，可以：添加/编辑/删除菜单节点拖拽排序设置菜单图标控制菜单是否显示3.4 菜单与权限的关系菜单规则（fa_auth_rule） ├── 内容管理 (ismenu=1, type=menu) │ ├── 文章管理 (ismenu=1, type=menu) │ │ ├── 查看 (ismenu=0, type=file) → content/article/index │ │ ├── 添加 (ismenu=0, type=file) → content/article/add │ │ ├── 编辑 (ismenu=0, type=file) → content/article/edit │ │ └── 删除 (ismenu=0, type=file) → content/article/delismenu=1 的节点显示在左侧导航栏，ismenu=0 的是操作按钮权限。四、角色组管理4.1 创建角色组进入 后台 → 权限管理 → 角色组：点击"添加"填写角色名称（如"内容编辑"）勾选该角色拥有的权限保存4.2 角色权限继承角色组支持树形结构，子角色自动继承父角色的所有权限：超级管理员（id=1，拥有所有权限） ├── 内容主管（拥有内容管理全部权限） │ ├── 内容编辑（拥有文章增改权限，无删除权限） │ └── 内容审核（拥有文章审核权限） └── 系统管理员（拥有系统设置权限）4.3 数据权限FastAdmin 支持基于角色组的数据隔离：// 在控制器中启用数据权限 public function index() { // 只能查看自己创建的数据 $this->dataLimit = 'personal'; // 只能查看本角色组的数据 $this->dataLimit = 'auth'; // 自定义数据限制字段 $this->dataLimitField = 'admin_id'; return parent::index(); }数据权限模式说明：模式说明null不限制，可查看所有数据personal只能查看自己创建的数据auth只能查看本角色组及子角色组的数据五、API Token 认证除了后台 Session 认证，FastAdmin 的 API 模块支持 Token 认证：5.1 Token 生成// application/api/controller/User.php namespace app\api\controller; use app\common\controller\Api; use app\common\library\Token; class User extends Api { // 登录获取 Token public function login() { $username = $this->request->post('username'); $password = $this->request->post('password'); $admin = \app\admin\model\Admin::get(['username' => $username]); if (!$admin || $admin->password != md5(md5($password) . $admin->salt)) { $this->error('用户名或密码错误'); } // 生成 Token $token = Token::create($admin['id'], 'admin', 86400 * 7); $this->success('登录成功', [ 'token' => $token, 'admin' => $admin, ]); } }5.2 Token 验证中间件// API 请求头携带 Token // Authorization: Bearer xxx-xxx-xxx // 在 API 控制器中验证 namespace app\api\controller; class Order extends Api { // 需要登录的方法 protected $noNeedLogin = []; public function list() { // $this->auth 已自动初始化 $adminId = $this->auth->id; $orders = model('Order')->where('admin_id', $adminId)->select(); $this->success('获取成功', $orders); } }六、安全加固建议6.1 密码安全// FastAdmin 使用双重 MD5 加盐 // password = md5(md5(明文密码) . salt) // 自定义更安全的加密方式 \app\admin\model\Admin::event('before_insert', function ($admin) { $admin->salt = \fast\Random::alnum(8); $admin->password = password_hash($admin->password, PASSWORD_BCRYPT); });6.2 登录失败限制FastAdmin 默认 5 次登录失败后锁定 15 分钟，可在配置中调整：// application/extra/site.php 'login_failure_retry' => 5, // 最大失败次数 'login_failure_lock' => 900, // 锁定时间（秒）6.3 操作日志所有后台操作自动记录到 fa_admin_log 表：// 手动记录日志 \app\admin\model\AdminLog::record([ 'title' => '手动操作文章', 'content' => '修改了文章ID:' . $id, ]);总结FastAdmin 的权限体系基于标准 RBAC 模型，通过菜单规则表实现细粒度权限控制。核心要点：权限规则对应控制器的每个方法，自动注册角色组支持树形继承，子角色继承父角色权限数据权限支持 personal 和 auth 两种模式API 模块通过 Token 实现无状态认证