系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界(本篇)
把文件传输从业务进程里挪出去
2016 年做 IM 文件转发时,Java 流对流方案被否决了。方案二的链路是 业务 nginx -> Java 服务 -> MSS nginx -> Swift 集群,Java 服务从 MSS 拉文件再流式写回客户端,文件字节在内部多走一程。云盘在非核心链路试过这个方案,每 3W 次请求约有 1~2 次失败。链路节点增多后,网络或机器内存任一环节出现波动都可能导致失败,不满足 IM 图片消息的稳定性要求。
最终采用的方案把鉴权和文件寻址留在 nginx 层,文件字节由 nginx 的 proxy_pass 直接代理,业务进程不再处理文件体。完整过程写在 IM 消息文件实现 一文里,这里不复述。本文补充说明:将流量处理交由 nginx 在哪些场景下能简化实现,以及接入层需要保持的边界。
同类的机制还有 nginx 自带的 X-Accel-Redirect:业务进程只返回一个响应头指明文件位置,nginx 拦截这个头后自己读文件并回给客户端,字节同样不经过业务进程。无论是 07-23 那篇里的 Lua 动态设 proxy_pass,还是 X-Accel-Redirect,共同点都是将文件传输这一慢 IO 操作交由业务进程以外的组件处理。
nginx 能在文件转发这一步替代 Java 进程,前提是它处理大量并发连接的方式与 Java 容器不同。那时的 Jetty/Tomcat 每来一个连接占一个处理线程,文件传输是慢 IO,线程被占住的时长约等于传输时长;几百个并发下载就能耗尽线程池。nginx 用事件驱动,一个 worker 进程能同时维持上万个连接,文件传输期间不占专门的线程。接入层与业务进程的容量模型不同。将通用流量处理交由 nginx 后,业务进程只处理业务逻辑,二者的容量瓶颈和故障范围更容易区分。
master/worker 与 epoll
nginx 的进程模型是 master 加若干 worker。master 负责 fork 出 worker、监控 worker 存活、加载配置、绑定监听端口;真正处理请求的是 worker。worker 之间对等,每个 worker 用 epoll 一次获取一批就绪连接的事件,在自己的事件循环里处理。
一个 worker 能同时维持大量连接,靠的是 epoll 的事件通知,每个连接不需要一个线程。连接空闲等待时,worker 不为它分配执行线程,只在 epoll 的监听集合里登记一项;连接可读可写时,epoll 将它放入就绪队列并通知 worker,worker 在事件循环里处理这批就绪事件,处理完回到等待。上下文切换只发生在这几个 worker 进程之间,worker 数量不随连接数增长。worker 数量通常配成 CPU 核数,更多 worker 会增加进程间切换;连接数上限由 worker_connections 控制(默认 512/worker,实际会调高)。调高 worker_connections 后,单机可维持数万连接。
事件模型要求处理逻辑写成非阻塞的事件回调风格。任何一步阻塞(同步读磁盘、调一个慢下游)都会阻塞这个 worker 上的所有连接。nginx 自身的核心模块是非阻塞的;用 OpenResty 写 Lua 逻辑时,这条约束依然存在,Lua 中调用阻塞 IO 同样会阻塞 worker。
与Web 容器与 Netty:线程模型之下的 IO 模型要讲的 Web 容器线程模型相比,容器给每个请求分配一个线程,可以使用阻塞式编程,容量受线程池大小限制;nginx 用少量 worker 加事件循环,容量受 CPU 和文件描述符限制,处理逻辑必须非阻塞。容器适合业务代码,nginx 适合逻辑固定、无阻塞的通用流量处理。
反向代理与负载均衡
nginx 作为反向代理,通过 upstream 和 proxy_pass 配置后端。upstream 定义一组后端地址,proxy_pass 将请求转发过去。常见分配策略如下:
- 轮询(默认,可配
weight加权):按顺序逐个分,机器配置不一时按权重比例分。 ip_hash:按客户端 IP 哈希固定到某台后端,解决 session 粘性。代价是后端扩缩容时哈希分布会变,同一个出口 IP 后面的大量请求会压到一台。least_conn:优先分给当前连接数最少的后端,适合请求耗时差异大的场景。
健康检查分被动和主动两种。被动健康检查是 nginx 自带的:proxy_next_upstream 配置哪些错误(连接拒绝、超时、5xx)算后端不可用,失败的请求会被重试到下一台,连续失败次数超过 max_fails(在 fail_timeout 窗口内)的后端会被临时摘掉。主动健康检查需要第三方模块(如 nginx_upstream_check_module),nginx 定期探测后端健康状态,在真实请求失败前摘掉故障节点。被动检查的限制是,必须先有真实请求访问故障节点并失败才能发现;首次访问故障节点的请求会失败或被转发到下一台。
proxy_next_upstream 的失败重试对非幂等请求(POST、PUT)有潜在风险。自 nginx 1.9.13(2016-02)起,默认不会重试已发送给上游的非幂等请求(POST/LOCK/PATCH),需显式加入 non_idempotent 才会重试。显式加入 non_idempotent,或者请求在连接建立阶段失败(请求尚未发出)时,POST 仍可能被重试到下一台后端;第一台可能已经收到请求但响应尚未返回,重试可能导致重复执行。支付、下单这类不能重复的接口,如需按方法精细控制,可用 map $request_method 条件化设置 proxy_next_upstream,或直接在业务层保证幂等。接入层的重试与本系列后面 RPC 篇、消息语义篇要讲的重试放大属于同一类问题:重试可提高可用性,也可能导致重复执行。
接入层的通用处理
除了转发,接入层还承载几类与具体业务无关的处理(以下范围来自实际接触,不保证完整):
- TLS 终止。 HTTPS 在 nginx 层卸载,后端走 HTTP,业务进程无需处理证书和加解密。加解密是 CPU 密集操作,集中在接入层便于统一调度。
- 限流。
limit_req按漏桶限速,limit_conn限制并发连接数,按 key(URI、IP、自定义变量)分级限流。 - 访问控制。
allow/deny按 IP 段做黑白名单;更细的规则用map或 Lua 实现。 - 请求日志。
log_format记录每个请求的方法、URI、状态码、耗时、上游响应时间,提供接入层的基础可观测数据。 - 请求改写与头注入。
rewrite调整路径,proxy_set_header注入X-Real-IP、X-Forwarded-For、自定义追踪头,把客户端信息透给后端。
这些处理通用、无状态,与具体业务规则弱耦合。TLS 终止不依赖业务;按 URI 或 IP 限流不包含业务逻辑;请求日志记录的是横切数据。将这类处理放在接入层,业务进程无需为每项业务重复实现,配置变更通过 nginx -s reload 生效,无需业务发版。
OpenResty 在接入层执行 Lua 逻辑
OpenResty 把 Lua 嵌进 nginx,在请求处理的多个阶段(rewrite、access、content、log 等)插入 Lua 代码。access_by_lua 做鉴权,content_by_lua 直接生成响应,log_by_lua 异步上报。07-23 那篇里在 Lua 阶段(rewrite/access 阶段)做鉴权与文件寻址,动态设置 proxy_pass 的目标。
接入层可以执行逻辑,也可能逐渐积累业务判断:鉴权、灰度、A/B 测试或临时降级等需求不断加入后,Lua 文件可能包含数百行业务判断。这些代码可能不在业务仓库里,也不进入业务发布流程,单元测试和 review 也可能缺失。接入层逻辑越多,常规工程治理以外的维护代价越高。发生故障时,排查还需查看 nginx 配置和 Lua 脚本,而业务研发通常不熟悉这两类文件。
可以为接入层逻辑维护一份显式清单。每增加一段逻辑,都检查两个条件:它是否通用(更换业务后也成立)?它是否无状态(不在 nginx 中存会话、不依赖本地文件状态)?同时满足才保留;不满足的逻辑即使能用 Lua 实现,也应交由业务进程处理。
边界清单
- 接入层适合放什么。 通用流量处理:反向代理、负载均衡、TLS 终止、限流、黑白名单、请求日志、请求头注入。判断标准是通用、无状态、与业务弱耦合;更换业务后这些处理仍然成立。
- 接入层不适合放什么。 业务规则(订单状态校验、权限模型、促销逻辑)、有状态逻辑(会话、本地计数器)、需要强一致的事务。这类逻辑放到接入层会脱离业务仓库和发布流程,带来额外治理成本。
- 重试可能重复执行非幂等请求。
proxy_next_upstream默认会对超时和错误重试到下一台,POST/PUT 这类非幂等请求可能被重复执行。自 1.9.13 起,默认不会重试已发送的非幂等请求,但显式加入non_idempotent或连接建立阶段的失败仍会重试;可用map $request_method条件化设置proxy_next_upstream,或在业务层保证幂等。 - 被动健康检查要等请求失败。 自带的被动检查要等真实请求失败才摘节点,主动检查需要第三方模块。对可用性要求高的链路要确认检查方式,不应假设 nginx 自带的检查已满足要求。
- OpenResty 逻辑需要清单。 每段接入层逻辑都检查「通用 + 无状态」两个条件,不满足的交由业务进程处理。接入层逻辑会逐渐增加;没有清单,维护范围难以控制。
- 配置通过 reload 生效。 配置变更不在业务发布流程里,需要独立的配置 review 和回滚机制。
未深入的实现细节
Nginx 的模块开发(C 模块)我当时只到使用现成模块和 OpenResty Lua 的程度,没有自己写过 C 模块。epoll 在内核侧的实现细节(红黑树管理监听、就绪队列、水平触发与边缘触发的差异)只到使用层认知,没有读内核源码。nginx_upstream_check_module 等第三方模块我只到配置使用,没深入其实现。
参考资料
- Nginx 官方文档(ngx_http_upstream_module、ngx_http_proxy_module、ngx_http_limit_req_module 章节)
- OpenResty 官方文档(ngx_http_lua_module 的各执行阶段)
- IM 消息文件实现(云盘同步系列文章,接入层方案来源)
- Web 容器与 Netty:线程模型之下的 IO 模型
