为了让Nginx高效地处理复杂请求,子请求机制用好了能事半功倍,用砸了则后患无穷。
在Nginx世界里,有两种不同类型的“请求”:一种是由客户端从Nginx外部发起的“主请求”(main request),另一种则是由Nginx正在处理的请求在Nginx内部发起的“子请求”(subrequest)。
简单来说,主请求是外部客户端发起的真实HTTP请求,而子请求则是Nginx内部的一种抽象调用,和HTTP协议乃至网络通信一点儿关系都没有。
子请求在外观上很像HTTP请求,但实现上却是Nginx内部的一种高效抽象。它的目的是为了方便用户把“主请求”的任务分解为多个较小粒度的“内部请求”,并发或串行地访问多个location接口。
举个例子,假设你要构建一个首页,需要展示用户信息、最新文章和热门话题。你可以创建三个独立的location,分别处理这三块内容,然后在主location中通过子请求将它们组合起来。
子请求的优势在于它的执行效率极高。因为这种通信是在同一个Nginx实例内部进行的,所以Nginx核心在实现“子请求”的时候,只调用了若干个C函数,完全不涉及任何网络或者UNIX套接字(socket)通信。
当一个请求发起一个“子请求”的时候,按照Nginx的术语,习惯把前者称为后者的“父请求”(parent request)。值得一提的是,Apache服务器中其实也有“子请求”的概念。
“子请求”的概念是相对的,任何一个“子请求”也可以再发起更多的“子子请求”,甚至可以玩递归调用(即自己调用自己)。这就好比公司里的项目组,项目经理(主请求)可以把任务分解给多个组员(子请求),而每个组员如果任务太重,还可以进一步分解给其他人(子子请求)。
在前面系列文章中我们已经了解到,变量值容器的生命期是与请求绑定的 。每个请求都有所有变量值容器的独立副本,即便是父子请求之间,同名变量一般也不会相互干扰。
让我们通过一个例子来验证这个说法:
location /main {
set $var main;
echo_location /foo;
echo_location /bar;
echo "main: $var";
}
location /foo {
set $var foo;
echo "foo: $var";
}
location /bar {
set $var bar;
echo "bar: $var";
}
在这个例子中,我们分别在/main、/foo和/bar这三个location中为同名变量
$var设置不同的值并输出。请求/main接口的结果是这样的:
$ curl 'http://localhost:8080/main'
foo: foo
bar: bar
main: main
显然,/foo和/bar这两个“子请求”在处理过程中对变量
$var各自所做的修改都丝毫没有影响到“主请求”/main。这成功印证了“主请求”以及各个“子请求”都拥有不同的变量值容器副本。
然而,并非所有模块发起的子请求都遵循这个规则。一些Nginx模块发起的“子请求”会自动共享其“父请求”的变量值容器,比如第三方模块ngx_auth_request。
下面是一个例子:
location /main {
set $var main;
auth_request /sub;
echo "main: $var";
}
location /sub {
set $var sub;
echo "sub: $var";
}
这里我们在/main接口中先为
$var变量赋初值main,然后使用ngx_auth_request模块的auth_request指令,发起一个到/sub接口的“子请求”,最后输出变量
$var的值。而我们在/sub接口中则故意把
$var变量的值改写成sub。访问/main接口的结果如下:
$ curl 'http://localhost:8080/main'
main: sub
可以看到,/sub接口对
$var变量值的修改影响到了主请求/main。所以ngx_auth_request模块发起的“子请求”确实是与其“父请求”共享一套Nginx变量的值容器。
如ngx_auth_request模块这样父子请求共享一套Nginx变量的行为,虽然可以让父子请求之间的数据双向传递变得极为容易,但是对于足够复杂的配置,却也经常导致不少难于调试的诡异bug。
因为用户时常不知道“父请求”的某个Nginx变量的值,其实已经在它的某个“子请求”中被意外修改了。因共享而导致的不好的“副作用”,让包括ngx_echo、ngx_lua以及ngx_srcache在内的许多第三方模块都选择了禁用父子请求间的变量共享。
第三方ngx_echo模块提供了echo_location指令,用于发起GET类型的子请求。下面是一个简单的例子:
location /main {
echo_location /foo;
echo_location /bar;
}
location /foo {
echo "foo";
}
location /bar {
echo "bar";
}
这里在location /main中,通过echo_location指令分别发起到/foo和/bar的GET类型“子请求”。由echo_location发起的“子请求”,其执行是按照配置书写的顺序串行处理的,即只有当/foo请求处理完毕之后,才会接着处理/bar请求。这两个“子请求”的输出会按执行顺序拼接起来,作为/main接口的最终输出:
$ curl 'http://localhost:8080/main'
foo
bar
ngx_http_auth_request_module模块基于子请求的结果实现客户端授权。如果子请求返回2xx响应代码,则允许访问。如果返回401或403,则拒绝访问并返回相应的错误代码。
配置示例:
location /private/ {
auth_request /auth;
...
}
location = /auth {
proxy_pass ...
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
在这个配置中,访问/private/接口时会先发起一个到/auth的子请求。如果/auth返回2xx状态码,则继续处理/private/请求;如果返回401或403,则立即中断请求并返回错误。
ngx_http_slice_module模块是一个过滤器,它将请求拆分为多个子请求,每个子请求返回响应的特定范围。该过滤器提供了对大型响应更有效的缓存。
配置示例:
location / {
slice 1m;
proxy_cache cache;
proxy_cache_key $uri$is_args$args$slice_range;
proxy_set_header Range $slice_range;
proxy_cache_valid 200 206 1h;
proxy_pass https://127.0.0.1:8000;
}
在这个例子中,响应被拆分为1MB可缓存的片段。该模块会自动发起多个子请求,每个请求文件的不同范围。
Nginx内建变量用在“子请求”的上下文中时,其行为也会变得有些微妙。许多内建变量都不是简单的“存放值的容器”,它们一般会通过注册“存取处理程序”来表现得与众不同。
我们来看$args变量在子请求中的表现:
location /main {
echo "main args: $args";
echo_location /sub "a=1&b=2";
}
location /sub {
echo "sub args: $args";
}
访问/main接口的结果:
$ curl 'http://localhost:8080/main?c=3'
main args: c=3
sub args: a=1&b=2
当$args用在“主请求”/main中时,输出的就是“主请求”的URL参数串c=3;而当用在“子请求”/sub中时,输出的则是“子请求”的参数串a=1&b=2。这种行为正符合我们的直觉。
但不幸的是,并非所有的内建变量都作用于当前请求。少数内建变量只作用于“主请求”,比如由标准模块ngx_http_core提供的内建变量$request_method。
变量$request_method在读取时,总是会得到“主请求”的请求方法,比如GET、POST之类。来看这个例子:
location /main {
echo "main method: $request_method";
echo_location /sub;
}
location /sub {
echo "sub method: $request_method";
}
使用curl发起POST请求测试:
$ curl --data hello 'http://localhost:8080/main'
main method: POST
sub method: POST
$request_method变量即使在GET“子请求”/sub中使用,得到的值依然是“主请求”/main的请求方法POST。
为了取得“子请求”的请求方法,我们需要求助于第三方模块ngx_echo提供的内建变量$echo_request_method:
location /main {
echo "main method: $echo_request_method";
echo_location /sub;
}
location /sub {
echo "sub method: $echo_request_method";
}
此时的输出终于是我们想要的了:
$ curl --data hello 'http://localhost:8080/main'
main method: POST
sub method: GET
假设我们需要构建一个用户仪表盘接口,需要聚合用户基本信息、最近订单和消息通知。我们可以使用子请求机制来实现:
# 用户仪表盘聚合服务
location /dashboard {
# 设置响应类型
default_type application/json;
# 依次发起子请求获取各部分数据
echo "{";
echo '"user":';
echo_location /user_info;
echo ',';
echo '"orders":';
echo_location /recent_orders;
echo ',';
echo '"notifications":';
echo_location /unread_notifications;
echo "}";
}
# 用户基本信息接口
location /user_info {
proxy_pass http://user_service/user_info;
}
# 最近订单接口
location /recent_orders {
proxy_pass http://order_service/recent_orders;
}
# 未读通知接口
location /unread_notifications {
proxy_pass http://notification_service/unread_notifications;
}
在这个配置中,当用户访问/dashboard时,Nginx会依次发起三个子请求,分别调用用户服务、订单服务和通知服务,然后将结果组装成一个统一的JSON响应。
这种方法的优势在于:
解耦后端服务:每个服务可以独立开发、部署和扩展降低客户端复杂度:客户端只需一次请求即可获取所有需要的数据利用Nginx高性能:子请求是Nginx内部的调用,效率远高于外部HTTP调用虽然子请求本身很高效,但使用不当会导致性能问题。最大的陷阱是串行执行的子请求。看这个例子:
location /slow_aggregation {
echo_location /service_a; # 耗时100ms
echo_location /service_b; # 耗时150ms
echo_location /service_c; # 耗时200ms
}
这个接口总响应时间大约是100+150+200=450ms!因为子请求是串行执行的,每个子请求都必须等待前一个完成才能开始。
解决方案:对于无依赖的子请求,应该并行执行。可以使用echo_location_async或OpenResty的ngx.thread.spawn:
# 使用OpenResty的并行执行
location /fast_aggregation {
content_by_lua_block {
local resp1, resp2, resp3
local thread1 = ngx.thread.spawn(function()
resp1 = ngx.location.capture("/service_a")
end)
local thread2 = ngx.thread.spawn(function()
resp2 = ngx.location.capture("/service_b")
end)
local thread3 = ngx.thread.spawn(function()
resp3 = ngx.location.capture("/service_c")
end)
ngx.thread.wait(thread1)
ngx.thread.wait(thread2)
ngx.thread.wait(thread3)
ngx.say("{"service_a":", resp1.body,
","service_b":", resp2.body,
","service_c":", resp3.body, "}")
}
}
这样三个子请求可以并行执行,总响应时间由最慢的子请求决定(约200ms),而不是三者之和。
对于不经常变化的数据,使用缓存可以大幅提升性能:
# 带缓存的用户信息子请求
location /cached_user_info {
proxy_pass http://user_service/user_info;
proxy_cache user_cache;
proxy_cache_valid 200 5m; # 缓存200响应5分钟
proxy_cache_key "$uri$is_args$args";
add_header X-Cache-Status $upstream_cache_status;
}
为子请求设置合理的超时和重试策略:
location /robust_aggregation {
echo_location /service_a;
echo_location /service_b;
}
location /service_a {
proxy_pass http://service_a;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
proxy_next_upstream error timeout http_500;
proxy_next_upstream_tries 2;
}
如前所述,某些模块(如ngx_auth_request)会导致父子请求共享变量。如果发现变量值莫名其妙被修改,可以:
检查使用的模块是否共享变量使用不同的变量名避免冲突在子请求开始时显式重置变量
location /safe_subrequest {
set $temp_var "";
echo_location /sub;
echo "main var: $temp_var";
}
location /sub {
# 使用局部变量而不是修改父请求变量
set $local_var "sub_value";
echo "sub local var: $local_var";
}
子请求支持递归调用(自己调用自己),但这很容易导致无限循环:
# 危险的递归调用!
location /recursive {
echo_location /recursive; # 这会导致无限递归!
}
Nginx有内置保护机制,当子请求层级过深时会报错,但最好在代码层面避免这种情况。
Nginx子请求是一把双刃剑。用好了,它能让复杂的请求处理变得清晰高效;用砸了,则会导致性能瓶颈和调试噩梦。
子请求最适合的场景包括:请求聚合、权限检查、内容组合和分块处理。在这些场景下,子请求能充分发挥Nginx高性能、非阻塞的优势。
需要谨慎使用的场景包括:高性能API网关、大量串行依赖调用、对响应时间极其敏感的应用。在这些情况下,可能需要考虑其他方案,如使用OpenResty的Lua协程、或者在后端服务中直接聚合。
掌握了子请求的运行机制和最佳实践,你就能在合适的场景发挥它的最大价值,让你的Nginx配置更加灵活和强大。