上一篇文章介绍了HTTP请求匹配server{ }配置块的过程,接着请求会继续匹配location{ }配置块,并最终决定哪些指令及Nginx模块处理请求。本文将介绍location的匹配规则,以及rewrite指令与location匹配顺序的关系。
生产环境中的nginx.conf往往含有上百条location,这是因为Nginx常常身兼多职:充当提供静态资源CDN、作为负载均衡为分布式集群提供扩展性、作为API gateway提供接口服务等等。location一旦配置错误,Nginx上巨大的并发连接数会将错误放大上万倍,很容易导致严重的线上事故。
而location也很容易配置错误,它既支持前缀匹配,也支持正则表达式匹配,当二者同时出现时,为了获得更高的性能,Nginx设计了复杂的location匹配优先级。这是因为前缀匹配是对静态的location多叉树检索完成的,它的性能要比正则表达式高得多,唯有搞清楚具体的匹配流程,我们才能设计出匹配速度更快的location。
而且rewrite指令修改URL的功能也让location匹配变得更为复杂。特别是rewrite出现在server{ }和location{ } 里,会导致完全不同的结果。设计location时,我们还需要考虑到rewrite的效率,以及它是否会导致循环重定向。
这篇文章将从底层讲清楚URL匹配location { }配置块的流程,以及rewrite指令修改URL后,Nginx又是怎样重新匹配location的。
如何匹配前缀location?
location { }中定义了哪些Nginx模块会处理以及如何处理HTTP请求,因此,URL与location的匹配关系到功能的正确性,它是学好Nginx的必要条件。
location有两类匹配URL的方式,一类是前缀匹配,一类是正则表达式匹配。我们先来看前缀匹配。
URL通过/正斜杠符号分隔对象,因此URL从前至后具有天然的层级关系。比如,/wp-content/uploads/2019/07/test.jpg就具备以下意义:第1级wp-content说明它属于wordpress的内容,第2级uploads说明这是用户自行上传的文件,第3、4级2019/07描述了它的上传日期,第5级则是文件名称及格式。所以,从前至后进行前缀匹配最自然不过,像location /wp-content/uploads { } 就可以匹配wordpress中所有用户上传的文件。
当请求同时匹配上多个location时,Nginx会选择前缀最长的location { }处理请求。比如,location /wp-content/uploads { }和location /wp-content/uploads/2019 { }同时存在时,/wp-content/uploads/2019/07/test.jpg请求只会命中后者。最长前缀匹配,是location匹配的核心原则。
由于许多location处于包含关系,因此很容易出现重复匹配,那么,当数百个前缀location同时配置时,Nginx怎样基于最长前缀原则,最有效率的关联URL呢?事实上,Nginx会在启动过程中,将server{ }内的所有location基于前缀的包含关系,建立一颗多叉树。
比如,如下12个location将会构造出1颗4层的静态树,其中子树中的所有location,都是比父结节更长的前缀location;在同一层的结点中,它们互不相属,但却是基于字母表有序的(注意,同级location的排序与长度无关):
1 | location /test {root html;} |
举个例子,location = /50x.html和location /res都是/结点的子结点,因此它们处于树的第2层。且因为首字母5的ASCII码比r要小,因此50x.html是res的左兄弟结点。为了提高检索效率,Nginx会在构造树的过程中,取每一层兄弟结点中间的那一个,作为父结点的直接子结点。就像50x.html、his、res、test四个结点并存时,res将作为/的直接子结点,这能够减少检索的时间复杂度。
我们以一个具体的例子来看下location树的匹配流程。比如/his/2001/test.jpg请求到达时,它的匹配顺序如下图蓝色箭头所示:
事实上,/his/2001/test.jpg请求的匹配共包含6步:
- 请求首先命中/,暂时/将被设置为最长前缀,再进入子树看看有没有更长的前缀;
- 未匹配上直接子结点res,由于h在字母表的顺序小于r,因此到左兄弟结点his中继续匹配;
- 匹配上his后,此时/his被设置为最长前缀;
- 匹配上直接子树/his/20,将其设为最长前缀,仍然进入子树尝试更长的前缀匹配;
- 未匹配上直接子树20,由于1在字母表的顺序中小于2,因此到左兄弟结点中去看看;
- /20未匹配命中,且在字母表中/先于1,匹配到此结束。这时,最长匹配是/his/20,于是使用此location处理请求/his/2001/test.jpg。
这样我们搞清楚了最长前缀匹配的底层逻辑,接下来再来看正则表达式location的用法。
如何匹配正则表达式location?
当遇到前缀匹配无法覆盖的URL时,可以使用正则表达式匹配请求。当然,与上一篇介绍过的server_name类似,使用正则表达式的前提是将pcre开发库编译进Nginx。一次写对正则表达式很难,在Linux下我建议你用pcretest命令行工具提前测试正则表达式。关于正则表达式和pcretest工具的用法,你可以观看下我在极客时间上的视频课程《Nginx核心知识100讲》第46课《Nginx中的正则表达式》。
在location中使用正则表达式,只需要在表达式前加入或者*符号,其中前者表示字母大小写敏感,而后者对大小写不敏感,例如:
1 | location ~* *\.(gif|jpg|png|webp|)$ |
它可以匹配各类图片,且忽略文件格式后缀的大小写。
多个正则表达式location之间的匹配次序很简单,按照它们在server{ }块中出现的位置,依次匹配,直接使用最先命中的location即可。所以使用正则表达式要小心,当上方的正则表达式匹配范围过大时,下方的正则表达式location可能永远也无法命中。
当正则表达式与前缀location同时出现时,事情就变得复杂起来。我们前面介绍过,前缀location构成的多叉树匹配效率很高,而正则表达式的匹配要慢得多。因此,Nginx会优先进行前缀location匹配,再进行正则表达式location的匹配,而且Nginx额外给前缀location提供了2个跳过正则表达式匹配的武器:=和^~。
在执行前缀匹配时,如果URL与location完全相等,那么Nginx不会再检索子树寻找更长的前缀匹配,但还会执行正则表达式匹配。如果你希望URL完全相等后,不必再匹配正则表达式location,那么可以在location前增加=号。比如,当location = / {}与location / {}同时出现时,前者是为了匹配访问首页的请求,而后者可以匹配任何请求,常用来兜底。因此,如果某些页面访问频率非常高,你应该用=号加快location的匹配速度。
另外,^也可以跳过正则表达式匹配阶段,加快location的执行速度,而且它比=号的应用范围更广,^不需要URL完全相等,只需要匹配上前缀即可跳过后续的正则表达式。注意,只有最长匹配上携带^~符号,才能够跳过正则表达式。比如,你觉得/res/blog/js/1.js访问下面3个location时会获得什么响应?
1 | location ^~ /res/blog {return 200 'res blog';} |
答案是’js’!虽然这个请求同时命中了3个location,但2个前缀location中,/res/blog虽然带有^~符号,可惜它却不是最长的前缀匹配;而/res/blog/js虽然是最长前缀,但又不能阻止正则表达式;*最终第3个location ~ .*.js匹配上了URL! **
简单的总结下location匹配规则(见下图):
- 先对前缀location执行最长前缀匹配
- 若最长前缀location前,携带有=或者^~,那么使用此location配置块处理请求;
- 按server{ }中正则表达式的出现顺序,依次匹配。成功后就选中此location;
- 若所有正则表达式皆未匹配上,则使用第1步中检索出的最长前缀location处理请求。
你可能会问,如果第1步中就没有找到能匹配上的前缀location,那该怎么办?很简单,Nginx会直接返回404。当然,为了避免这种情况发生,通常我们都会添加location / { }兜底,它可以匹配任意URL。
注意:location中的正则表达式,就像server_name中一样,可以用小括号()提取变量,供后续其他Nginx模块的指令使用。
配置location时,还有一个技巧需要你掌握:由于客户端的URL中可能含有重复的正斜杠/,因此Nginx会自动合并连续的重复正斜杠/。比如,//res/blog///a.js会被合并成/res/blog/a.js。如果你想关闭这一功能,可以添加下面这行配置:
1 | merge_slashes off; |
由于location的匹配规则相当复杂,所以Nginx会在debug级别的日志中,打印出最终选中了哪个location。比如:
1 | test location: "/" |
其中,using configuration指明了最终选择了哪个location。当然,要想开启debug日志,除了在nginx.conf里将error_log的日志级别设为debug外,还需要在configure时加入了—with-debug选项。
rewrite指令是如何工作的
虽然我们已经清楚了location的匹配规则,但是,匹配的URL未必是客户端的原始URL,因为rewrite指令可以修改URL!因此,我们还需要了解rewrite指令的用法,这样才能全面掌握location的匹配规则。
当系统升级、维护或者数据迁移时,往往需要重写URL后,再执行location匹配。rewrite指令就是用来重写URL的,它的用法非常简单,比如下面这行指令就可以将/reg1/a.js修改为/reg2/a.js:
1 | rewrite /reg1/(.*) /reg2/$1; |
显然,rewrite可以反复地修改URL,并导致location被反复匹配命中。因此,为了防止不当的rewrite指令导致死循环,Nginx在代码层面将1个请求的rewrite次数限制为10次,超过后会直接返回500错误码:
1 |
rewrite指令既可以直接出现在server{ }块中,也可以出现在location { }块中,但它们的工作流程却完全不同!比如,你觉得下面的rewrite会导致请求/reg1/a.js无限循环吗?
1 | rewrite /reg1/(.*) /reg2/$1; |
其实不会,因为server{ }中的rewrite指令只会执行1次。要说清楚rewrite、location的执行时机,我们得先清楚HTTP请求的11个执行阶段。
当Nginx接收完HTTP头部后,会让各Nginx模块基于Pipe And Filter模型依次处理请求。其中,为了让模块的处理次序更加可控,Nginx基于Web语义将其分为11个阶段,每个Nginx模块通常会选择1个阶段介入请求的处理流程。rewrite与location涉及到其中的4个阶段,下面看看它们究竟做了些什么:
我们依次分析这4个阶段:
- server{ }块中的rewrite指令,将在NGX_HTTP_SERVER_REWRITE_PHASE阶段执行。从图中可以看到,它只会执行1次;
- 前2节介绍的location匹配流程,就发生在NGX_HTTP_FIND_CONFIG_PHASE阶段;
- location{ }块中的rewrite指令,在NGX_HTTP_REWRITE_PHASE阶段执行;
- NGX_HTTP_POST_REWRITE_PHASE阶段中,判断location中的rewrite指令是否重写了URL,如果是,那么跳转到NGX_HTTP_FIND_CONFIG_PHASE阶段再做1次location匹配,否则继续向下,由其他Nginx模块处理请求。
因此,不同于server{ }块,location中的rewrite指令是可能反复执行多次的。
其实,rewrite指令还可以携带4种不同的flag参数,它还将影响if、set等其他脚本类指令的执行。本文聚焦于location的匹配,后续我在脚本指令的介绍文章中,还会讲到rewrite指令的其他用法。
小结
本文介绍了HTTP请求匹配location的流程。
location支持URL按最长前缀进行location匹配。Nginx启动时会将所有前缀location构造出一颗静态的多叉树,其中子树中的结点都是父结点的更长前缀,而兄弟结点间则按字母表排序。这样,前缀URL的匹配效率就很高。
相比起来,正则表达式则按照在nginx.conf中的出现顺序进行匹配,效率要低得多。当二者同时出现时,虽然正则表达式优先级更高,但=号和^~号可以让前缀location跳过正则表达式匹配,提升性能。然而这样让location匹配更容易出错,如果你在开发环境中,可以借助debug级别的error.log日志,通过using configuration确认Nginx究竟选择了什么location来处理请求。
rewrite指令可以反复修改URL,其中server{ }块中的rewrite指令只会执行1次,而location中的rewrite则可能最多执行10次,超出后Nginx会返回500错误码。只有理解了11个HTTP阶段的执行顺序,才能掌握rewrite与location的匹配关系。
你可能知道,location { }配置块内可以嵌套location { },虽然这不是一种推荐的配置方式,但它确实是被语法规则支持的。那么,在嵌套发生时,基于本文的理论,location是如何匹配的?rewrite指令又是怎样工作的?欢迎你在帖子下方留言,与我一起探讨更好的热部署实现方案。