1. 为什么PHP项目也需要一个API网关
1.1 最初的痛点:散乱的接口调用
我接手这个项目的第一个星期,就被线上日志里密密麻麻的接口调用整懵了。App端、小程序端、管理后台、第三方合作方,全部直接打到业务服务里,各个服务的地址散落在客户端代码中,谁想调哪个接口就直接拼一个URL发出去。最让人头疼的是,有一次一个合作方拿着一个过期的接口地址来对接,负责对接的同事排查了整整两天,最后发现是半年前就废弃的老接口,因为没人清理,服务端还挂着兼容代码。
这就是没有统一入口的典型症状。多个客户端的接口地址各自维护,服务端的鉴权逻辑散落在每个业务模块里,限流基本靠业务层各自为战,日志格式五花八门——有的模块记录请求参数,有的模块什么都不记,出了问题就像大海捞针。
1.2 网关这个角色到底解决了什么
API网关本质上是个入口层的"总调度员",它把客户端跟后端业务服务隔开一层,所有的请求都先经过它,由它完成路由转发、身份认证、参数校验、限流控制、日志记录这些"通用杂活"。业务服务只需要专注自己的核心逻辑,不用再关心"这个请求是谁发的""他有没有权限""他是不是超过了调用频率"这类问题。
用个生活化的类比:没有网关的系统就像一个小区的每栋楼都自己开门,访客想进哪栋楼就自己去敲门;有了网关,小区大门就设了一个统一的门岗,所有访客登记之后,由门岗告诉你去哪栋楼、找哪个人。好处是对内可以统一管理,对外只有一个入口,规则集中可控。
1.3 为什么选择自研一个轻量PHP网关
市面上现成的网关方案其实很多,像某些基于Java生态的重量级网关,功能确实强大,但对于一个日活在百万以下的PHP项目来说,引入一套Java网关意味着要额外维护一套独立的服务集群,技术栈割裂、运维成本翻倍。还有一些基于OpenResty的方案,性能很好,但写着写着就变成在Nginx层堆Lua代码,团队里熟Lua的人凤毛麟角,出了问题没人敢动。
我在这个项目里的选型思路是这样的:团队成员全部是PHP背景,业务服务本身也是PHP技术栈,那么网关层继续用PHP来实现,在技术语言上完全统一,后续维护不需要跨语言。虽然PHP处理长连接和并发的能力不如常驻内存的方案,但通过合理的架构设计和部署方式(比如多实例横向扩展),在当前的业务量级下完全够用。还有一点很现实:自研的灵活度远高于引入框架,你想加什么功能、调什么逻辑,改起来都是自己说了算。
2. 网关整体架构设计与核心模块划分
2.1 一次完整请求在网关里是怎么流转的
客户端发起的每一个请求,进入网关后大体经历这样一条链路:
- 接收请求,先做基础的参数解析
- 根据路由表将URL映射到对应的后端服务
- 执行统一的身份认证(校验Token或签名)
- 经过鉴权、限流、参数校验等中间件处理
- 将请求转发到目标业务服务
- 接收业务服务返回的数据,做统一的响应格式包装
- 记录一条完整的访问日志
这个流程里面,每一步都是可插拔的。比如某些开放平台的接口不需要用户登录态认证,那网关在路由匹配之后就可以跳过认证环节,直接走限流和转发。中间件机制的价值就在这里:每个请求处理的逻辑可以被拆成独立的小模块,按需组合,编排灵活。
2.2 六张核心模块的职责清单
我在实际设计时,把网关拆成了六个核心模块,每个模块只专注干一件事。
路由管理模块负责维护一张路由映射表。这个表不放在代码里写死,而是存到配置中心或数据库里,支持动态下发和热更新。比如某个新服务上线了,运维只需要往路由表里加一条记录,网关就能自动识别,不需要发布代码。
认证鉴权模块承担所有接口的身份核实工作。不管你是App用户还是开放平台合作方,进了网关先验身份。用户端走的是Token校验,合作方走的是签名校验,两套逻辑都收敛在这个模块里。
限流控制模块负责给每个调用方分配"流量配额"——你一天最多调多少次、每秒最多并发多少,超出就拒绝并返回明确的错误码,防止某个调用方把后端服务打挂。
协议转换模块处理请求格式的统一。客户端传上来的参数可能五花八门,网关这里统一做一次清洗和归一化,让后端服务拿到的永远是干净、结构一致的参数。
缓存加速模块承载一些通用的热点数据。比如某些高频查询的基础配置数据,直接在网关级别做一层缓存,能省掉大量的后端访问。
监控日志模块记录每一次调用的完整信息,包括调用方标识、接口路径、响应耗时、状态码、错误信息等。这些日志本身就是后面排查问题和做统计报表的数据底座。
2.3 分层结构怎么划分
网关代码内部我分了三层:入口层、处理层、转发层。
入口层就是最先接触HTTP请求的那一段,负责接收请求、解析头信息和请求体,生成一个统一的请求上下文对象。处理层就是各个中间件跑的地方,认证、限流、鉴权都在这一层。转发层负责真正向后端服务发请求,然后拿回响应。
这个分层的核心思路是"关注点分离",每一层只做自己该做的事。入口层不管业务逻辑,处理层不管HTTP细节,转发层不管参数校验。这样每个层级的代码量都不大,逻辑清晰,测试也好写。
3. 核心实现细节与实操步骤
3.1 路由注册与动态匹配怎么落地
路由是网关的骨架,路由表设计直接影响后续的可维护性。我在项目里维护了一张路由配置表,每条记录包含以下几个核心字段:路由名称、请求方法(GET/POST/PUT/DELETE)、匹配路径(支持占位符)、目标服务标识、是否需要鉴权、是否需要限流、限流规则标识。
匹配路径这里我先用两种模式:精确匹配和参数占位匹配。精确匹配就是/api/user/info这种一字不差的路径;参数占位匹配支持/api/order/{id}这种动态路径。
路由匹配的核心代码如下,这段逻辑我用的是先精确后模糊的优先级策略:
class Router { private array $routes = []; public function register(array $routeConfig): void { $this->routes[] = $routeConfig; } public function match(string $method, string $uri): ?RouteMatchResult { // 第一轮:精确匹配 foreach ($this->routes as $route) { if ($route['method'] === $method && $route['path'] === $uri) { return new RouteMatchResult($route, []); } } // 第二轮:参数占位匹配 foreach ($this->routes as $route) { if ($route['method'] !== $method) { continue; } $pattern = $this->buildPattern($route['path']); if (preg_match($pattern, $uri, $matches)) { $params = $this->extractParams($route['path'], $matches); return new RouteMatchResult($route, $params); } } return null; } private function buildPattern(string $path): string { // 把 /api/order/{id} 转成正则 $escaped = preg_quote($path, '/'); $pattern = str_replace('\\{id\\}', '([0-9]+)', $escaped); return '/^' . $pattern . '$/'; } }为什么精确匹配要放第一轮?因为精确匹配的代价最低,就是简单的字符串比较,性能比正则快得多。而业务系统里大量接口都是静态路径,像/api/user/info、/api/user/logout这种占了绝大多数,先走一遍快速的精确匹配,能挡掉大部分请求,只有少量动态路径才需要走正则。
路由表这里有个实操上的细节:建议把路由配置放到Redis或数据库中,网关启动时加载到内存缓存。我之前踩过坑,把路由写死在代码里,每次调整路由都要发布一次网关,移动端着急联调的时候,被这种低效流程坑惨了。改成配置驱动之后,上线新接口只需要改配置刷新缓存,整个过程一分钟搞定。
3.2 基于中间件模式的鉴权链路设计
鉴权是网关里最关键也最容易出问题的一环。我的设计思路是把鉴权拆成两种模式:用户端Token校验和开放平台签名校验。
用户端走的是标准的Token校验:客户端登录时拿到一个Token,后续每个请求都在Header里带上,网关解析Token并验证签名,验过之后把Token里的用户ID注入到请求上下文里,转发到后端服务时后端就知道当前用户是谁了。
这里我强烈建议用JWT而不是随便存一个随机字符串。JWT的无状态特性让网关不需要查数据库就能验签,高并发下优势明显。但JWT有个天然的坑:无法主动失效。用户注销后Token在过期前依然有效。我的方案是加一层Redis黑名单,用户注销时把Token的ID存进黑名单,网关校验时查一下黑名单就解决了。
开放平台的签名校验走的是另外一套逻辑:
class SignatureValidator { public function validate(array $params, string $signature, string $appSecret): bool { // 1. 防重放:检查时间戳是否在合理时间窗口内 if (abs($params['timestamp'] - time()) > 300) { return false; } // 2. 防重放:检查nonce是否已使用过 if ($this->nonceCache->has($params['nonce'])) { return false; } // 3. 按key排序拼接参数 ksort($params); $stringToSign = urldecode(http_build_query($params)); // 4. 计算签名并比对 $expected = hash_hmac('sha256', $stringToSign, $appSecret); return hash_equals($expected, $signature); } }签名算法选HMAC-SHA256时有三个细节要特别注意。第一个是排序,参数必须按key的字典序升序排列后再拼接,这个规则双方必须完全一致,否则签名永远对不上。第二个是时间戳窗口,我设了5分钟,太短会导致客户端和服务端时钟偏差时频繁验签失败,太长又会放大重放攻击的风险窗口。第三个是nonce(随机字符串)必须配合缓存使用,同一个nonce只能使用一次,这是防重放的关键。实测下来,把这些规则写到接口对接文档里,合作方按文档实现基本不会出问题。
3.3 限流方案选型与实现
限流这块我前后换了三个方案,也算踩了不少坑。
一开始用的是固定窗口计数,实现最简单的思路:Redis里存一个 key,过期时间设为一秒,每来一个请求就 INCR 一次,超过阈值就拒绝。上线后才发现问题:固定窗口存在临界突发问题。假设限制每秒100次请求,前0.9秒内来了99个请求,后0.1秒又来了99个,表面上每秒钟都没超过100,但实际一瞬间涌入了近200个请求,后端还是可能被冲垮。
后来换成了滑动窗口方案。在Redis里记录每次请求的时间戳,用ZSET数据结构来维护,每次请求时把当前时间窗口之外的旧记录删掉,然后统计窗口内的请求数量。这样能有效避免临界问题,但因为每次请求都要操作ZSET,在极高并发下Redis的压力会比较大。
最终采用的是令牌桶算法。令牌按固定速率生成放入桶中,桶的容量有限,请求到来时需要有令牌才能通过。这个方案的天然优势是可以同时控制平均速率和突发流量——比如平均每秒放10个令牌,但桶容量可以设到50,意味着短时间内可以处理一波50个的突发请求,而不至于一刀切拒绝所有超量请求。
用Redis实现令牌桶有一个取巧的写法:不需要真的去生成令牌,而是用当前时间和上次补充时间算出差值,一次性算出这段时间应该补充多少令牌:
class TokenBucketLimiter { private Redis $redis; private string $key; private int $capacity; // 桶容量 private int $rate; // 每秒补充速率 public function allow(): bool { $luaScript = <<<'LUA' local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local bucket = redis.call('HMGET', key, 'tokens', 'timestamp') local tokens = tonumber(bucket[1]) local lastRefill = tonumber(bucket[2]) if tokens == nil then tokens = capacity lastRefill = now end -- 计算时间差内应该补充的令牌数 local delta = math.max(0, (now - lastRefill) * rate) tokens = math.min(capacity, tokens + delta) if tokens >= requested then tokens = tokens - requested redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now) redis.call('EXPIRE', key, 120) return 1 else redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now) redis.call('EXPIRE', key, 120) return 0 end LUA; // 这里直接用Redis Lua脚本原子执行整个逻辑 $result = $this->redis->eval( $luaScript, [$this->key, $this->capacity, $this->rate, time(), 1], 1 ); return $result == 1; } }限流的维度也很重要。我不只按IP限流,更重要是按调用方(App用户、合作方AppId)限流。只按IP限流会导致一个网络出口的多个用户共享一个配额,误伤率极高;按调用方维度限流更精准。
3.4 全链路日志系统怎么搭
日志模块是网关调试和排障的关键基础设施,没有之一。我重构日志模块前的状态是灾难性的:每个服务各打各的日志,格式不统一,排查一个问题要在三个系统里来回切。
我的做法是:网关在请求进入时立即生成一个全局唯一的请求ID(RequestId),这个ID会跟着请求转发到后端服务,后端服务在所有日志里都带上这个ID。这样从入口到出口,一个请求的完整生命周期都串在同一个ID上了。
日志记录采用异步批量写入,请求响应完成后把日志放入内存队列,由专门的任务批量落盘或推送到日志收集系统。一定要避免在请求路径上做同步IO操作,否则接口响应时间会被日志拖慢。我见过同事直接把日志写入通过HTTP同步推到第三方服务的例子,结果日志服务一抖动,业务接口的响应时间从50毫秒飙到800多毫秒,真是血泪教训。
日志的格式我会保留几个固定字段:请求ID、时间戳、来源IP、调用方标识、请求方法、请求URI、转发后端的实际地址、响应状态码、响应耗时(毫秒)、错误信息(如果有)。这些字段覆盖了90%的问题排查场景。
4. 网关上线前后的性能优化与排查实战
4.1 一次压测不过关引发的血案
网关的初版上线前,我们做了一轮压测,结果惨不忍睹:单实例网关的QPS只有200多,稍微上一点并发,CPU直接飙到90%以上,响应时间也跟着涨到3秒钟。当时的第一反应是PHP性能不行,但仔细排查后发现,问题根本不是PHP本身,而是代码里埋了几个大坑。
最大的坑是日志写入用了同步方式。每次请求都要打开文件写入一行日志再关闭,高并发下文件IO成了瓶颈中的瓶颈。第二个坑是路由匹配环节对每条路由都执行了正则匹配,而且没有缓存结果。虽然精确匹配优先,但动态路由的请求每次都要重新编译正则表达式。第三个坑是鉴权环节每次请求都去查一次用户信息缓存,虽然命中Redis,但频繁的网络往返也不容小觑。
逐个优化后,效果非常明显:日志改成异步批量写入;路由编译结果加上内存缓存,同一个路径只编译一次;用户信息做了进程内缓存,短时间内的重复请求直接命中本地缓存。最终单实例QPS从200多提升到了800多,翻了4倍。要说明的是,我这个方案性能比不上Java系网关的关键原因在于PHP的请求生命周期模型——每个请求结束后所有进程内缓存都会释放,所以PHP网关的性能天花板相对有限。这也是我后来建议团队如果业务量再翻一个量级,就要考虑把网关迁移到常驻内存方案或直接采用更成熟网关产品的原因。
4.2 高频问题速查表
网关上线运行一段时间后,我把遇到的典型问题整理成了一个排查表,遇到问题先对照查一遍,大部分都能解决。
- Token校验失败:先看客户端时间戳和服务器时间是否偏差超过5分钟;再看Token是否已过期;最后确认用户是否已经调用过注销接口,被加入了黑名单。
- 签名校验失败:让对接方按规则对参数做排序后拼接,对比双方拼接出来的字符串是否一致;注意排查签名字符串里是否包含了签名本身和值为空的参数。
- 限流误伤:确认限流维度是IP还是调用方;某个App的前端所有用户共用一个出口IP时,IP维度的限流很容易误伤。
- 请求超时:重点看后端服务的响应耗时和网关到后端的网络延迟;在网关日志里按请求ID查后端耗时,定位是转发前慢还是后端处理慢。
- 路由匹配不到:查路由配置是否已加载到缓存,确认使用的是新路由表还是旧缓存数据。
这里分享一个我自己用的排障习惯:网关日志里增加了一个耗时拆分字段,分别记录"网关注入耗时"和"后端返回到网关注入耗时"两个值。通过这个字段能快速区分问题出在哪一段。
4.3 部署与运维的四个实战细节
网关是高可用组件,部署策略直接决定系统稳定性。第一个细节是多实例部署时确保每个实例的无状态性。网关本身不能保存任何会话级别的状态,所有状态(Token黑名单、限流数据、路由缓存)都放到Redis里。这样任何实例宕机都不会影响整体服务。
第二个细节是健康检查接口不能走业务逻辑。我见过有人把健康检查接口设成需要鉴权的路径,结果负载均衡的健康检查请求统统被拦下来,实例被误判为不健康,流量全部打到一个实例上,差点酿成生产事故。健康检查接口一定要用独立的路径,绕开所有中间件。
第三个细节是配置变更要支持动态热更新。路由表和限流规则不能做到代码里,改配置后要有刷新机制。我在项目里做的是:运维在管理后台修改配置后,调用网关的一个内部接口触发缓存刷新。发布海里操作的次数很多,这个能力能省下大量上线时间。
第四个细节是网关自身的日志保留策略要清晰。网关日志量大,如果无限期保留会占用大量磁盘。我的实践是原始日志保留7天,按天分目录存储,超过7天自动清理;另外把一些关键指标单独抽取出来聚合,比如接口调用的PV/UV、错误率、耗时分布,这些聚合数据长期保留,用来做趋势分析。
5. 这套方案后续还能怎么扩展
网关的上线只是第一步,后续扩展方向我在设计时也提前留了接口。
灰度发布能力是目前计划里优先级最高的一项。网关天然适合做灰度开关:在路由转发时,根据用户ID或指定参数计算出灰度比例,把一部分流量导到新版本服务上,验证没问题后再全量切换。这种能力在自研网关里就是一个"转发策略中间件"的事。
多版本兼容也是API网关的经典场景。不同版本的客户端可能调用不同版本的接口,网关可以根据请求头里的版本号自动路由到对应版本的后端服务,这样客户端升级不必强制同步,老版本用户可以平滑过渡。
还有WebSocket协议的支持。目前这套方案主要面向HTTP请求,但后续如果需要网关代理WebSocket连接,需要在入口层单独处理连接升级和长连接管理,这是跟HTTP请求模式完全不同的一套机制,我会单独扩展一个协议适配模块。
我个人的体会是,API网关这种基础设施,做得好不好,不在于技术多炫,而在于边界划得清不清楚。网关做网关的事,业务服务做业务服务的事,两者职责不要互相渗透,这套体系就能稳定跑很久。现在这个网关已经在生产环境连续运行了大半年,平均每天处理几十万次请求,遇到过的严重故障为零。对于PHP团队来说,这是一条性价比很高的路径。