为什么说「Nginx 的 if 是邪恶的」
如果你在搜索引擎里搜 Nginx 配置,一定会看到《If is Evil》这篇官方文档。很多人第一次读的时候不以为然:if 明明能跑,为什么说它邪恶?答案在于 Nginx 的配置解析模型——Nginx 不是一门通用的编程语言,它的 if 是挂在 rewrite 模块下的一个指令,只能出现在 server 和 location 块里,而且它的执行时机和你想的完全不一样。
Nginx 处理一个请求要经过多个阶段(phase):post-read、server-rewrite、find-config、rewrite、post-rewrite、preaccess、access、post-access、try-files、content、log。经典写法里的 if 属于 rewrite 阶段,它在很多模块生效之前就执行完了。这就导致一个非常反直觉的现象:写在 if 里的 proxy_pass 或 add_header 有时候生效、有时候不生效,取决于它们所在的位置。
坑位一:location 块里只允许 return 和 rewrite
这是官方文档里最明确的一条限制。在 location 块中使用的 if,内部只应该出现两个指令:return 和 rewrite。其他指令不是语法错误,但行为不可预期。
最常见的灾难现场是这样的:
# 反面教材:不要这么写
location /api/ {
if ($http_user_agent ~* "bot") {
return 403;
}
proxy_pass http://backend;
proxy_set_header Host $host;
}
上面这段代码在「条件成立」时看起来正常,但当条件不成立时,Nginx 内部的配置块结构会被打乱,某些版本的 Nginx 会出现 proxy_set_header 被忽略、请求头丢失的情况。症状就是:正常用户访问 /api/ 时,后端收到的是没有 Host 头的请求,接口报 400。
正确的做法是把判断逻辑提前到 server 块,或者用 map 把逻辑从「流程控制」变成「变量赋值」。
坑位二:if 里嵌套 if,行为未定义
Nginx 明确不支持 if 嵌套。if 是重写模块的产物,它并不像编程语言那样有作用域和栈的概念。写嵌套 if 的时候,Nginx 会在启动时报错,或者更糟糕——启动通过但运行时行为混乱。
# 这样写会在 nginx -t 时报错
if ($host = "a.com") {
if ($request_uri ~ "^/admin") {
return 403;
}
}
如果你的需求确实是「两个条件同时成立」,正确做法是用 map 组合,或者用正则一次性匹配。
坑位三:add_header 在 if 里会「吃掉」外层配置
Nginx 的 add_header 遵循「继承覆盖」规则:如果当前层级(比如 if 块、location 块)出现了任何 add_header,那么上层(server、http)的 add_header 就全部失效。也就是说,你在 server 块里配了一整套安全响应头(HSTS、CSP、X-Frame-Options),然后在某个 location 的 if 里加了一个 add_header Cache-Control "no-store",结果是这个 location 下的所有安全头都没了。
这是一个非常隐蔽的安全降级,用浏览器开发者工具看响应头才能发现。排查方法:curl -I https://你的域名/xxx,逐个检查安全头是否存在。
替代方案一:用 map 把判断变成变量
map 是 Nginx 里最被低估的指令。它在 http 块中定义,作用是「根据一个变量的值,计算出另一个变量的值」。因为它是纯粹的赋值,没有执行时机问题,所以可以安全地在任何地方使用。
http {
# 根据 UA 计算一个布尔变量
map $http_user_agent $is_bad_bot {
default 0;
"~*SemrushBot" 1;
"~*AhrefsBot" 1;
"~*MJ12bot" 1;
}
# 根据请求方法和 URI 决定是否限流
map $request_method $limit_key {
default "";
POST $binary_remote_addr;
}
server {
listen 443 ssl;
server_name example.com;
# 直接 return,不依赖 if 的副作用
if ($is_bad_bot) {
return 403;
}
location / {
root /var/www/html;
index index.php;
}
}
}
这里 if 只做一件事:return。它满足「location/server 块中 if 只放 return/rewrite」的规则,风险为零。真正的逻辑全部由 map 承担,可读性反而更好——你一眼就能看到所有被封的 UA 列表。
替代方案二:用 try_files 处理「文件不存在」逻辑
很多老配置喜欢这么写:
# 反面教材
if (!-e $request_filename) {
rewrite ^/(.*)$ /index.php?$1 last;
}
这是 WordPress 经典伪静态写法,但它有两个问题:一是 if 与 rewrite 组合在某些场景下会触发重复内部重定向;二是 if 块内不能放 root、expires 这类指令,静态资源缓存没法单独配置。
现代写法直接用 try_files,它在 try-files 阶段执行,语义清晰:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
意思是:先找真实文件,再找目录,最后交给 index.php 处理。try_files 是 Nginx 内部的高效实现,不会有 if 的副作用,而且性能更好。
替代方案三:用 location 匹配代替 if 分流
当你写的 if 是在按 URL 前缀做不同处理时,八成可以直接用 location 匹配来替代。location 是有优先级顺序的——精确匹配 = 最高,其次是前缀 ^~,然后是正则 ~ 和 ~*,最后是普通前缀匹配。这个机制本身就是为「路由分流」设计的。
# 用 if 分流(不推荐)
location / {
if ($uri ~ "^/static/") {
expires 30d;
}
if ($uri ~ "^/api/") {
proxy_pass http://backend;
}
}
# 用 location 分流(推荐)
location ^~ /static/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
}
第二种写法不仅没有副作用,每个 location 还能独立配置缓存、日志、限流,可维护性高出一个数量级。
万一必须用 if,怎么判断是否安全
不是所有 if 都有问题。判断标准很简单,记住这三条:
- if 在 server 块中,且内部只有
return/rewrite—— 安全。 - if 在 location 块中,且内部只有
return/rewrite—— 安全。 - if 内部出现了
proxy_pass、add_header、expires、root、fastcgi_pass等 —— 有风险,必须改写。
另外,if 中用得最稳的条件是「变量是否有值」和「正则匹配」两类,例如 if ($request_method = POST)、if ($http_referer ~* "spam.com")。尽量避免用 $request_uri 做复杂正则匹配,因为 $request_uri 包含查询字符串,很容易误伤。
一次真实的排查过程
最后分享一个实际案例。有位站长的 WordPress 站点在接入 CDN 后,后台偶尔返回 502,前台正常。检查过程如下:
# 第一步:看错误日志有没有 upstream 相关信息
tail -f /var/log/nginx/error.log
# 第二步:连续请求后台,观察是否规律性失败
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" https://example.com/wp-admin/
sleep 1
done
# 第三步:定位到配置里的 if
grep -n "if (" /etc/nginx/conf.d/site.conf
最终发现在 location /wp-admin/ 里有一个 if ($http_cookie ~* "wordpress_logged_in") 包裹着 proxy_pass。当 CDN 回源时,某些请求没带 Cookie,走了另一条配置分支,而那条分支的 upstream 指向了一个已经不存在的后端。把逻辑改成用 map 计算变量、if 只做 return 之后,502 彻底消失。
常见问题答疑
问:我把配置里的 if 都删掉了,站点反而报 404 怎么办? 大概率是原来依赖 if 做的路由分流没有用 location 正确重建。先跑 nginx -t 确认语法无误,再对比迁移前后的访问路径:把原来的 if ($uri ~ "^/xxx/") 逐个翻成 location ^~ /xxx/,注意精确匹配 =、前缀优先 ^~、正则 ~ 的优先级顺序,顺序错了会出现「明明写了 location 却不生效」的现象。
问:map 只能用在 http 块吗?能不能写在 server 里? map 的作用域是 http 块,不能写在 server 或 location 里,这是硬性限制。多个 server 想复用同一个判断,就把 map 提到 http 层,或者放进 conf.d 下一个单独的文件里用 include 引入。变量名建议加前缀(如 $is_bad_bot)避免和内置变量冲突。
问:怎么确认我的 Nginx 是哪个版本、支持哪些模块? 执行 nginx -v 看版本号,nginx -V(大写)看编译参数,里面会列出 --with-http_xxx_module 之类的模块清单。想验证某个指令是否可用,最直接的办法是写进配置跑 nginx -t,报 unknown directive 就是没编译进去,这时候要么改配置绕过,要么换用带该模块的官方源重新安装。
问:配置改完一定要重启 Nginx 吗?reload 有什么区别? systemctl reload nginx 是平滑重载:新配置语法校验通过后,启动新的 worker 处理新请求,旧的 worker 处理完手上的连接再退出,不会掉线。而 restart 会先停再起,中间有几毫秒到几百毫秒的服务中断。日常改配置一律用 reload;只有在改动了 listen 端口、绑定 IP 等需要重新初始化的参数,或者 reload 报错时,才用 restart。
问:万一配置写错了把站点搞挂,怎么快速回滚? 养成改前备份的习惯:cp site.conf site.conf.bak。改完先 nginx -t,通过再 reload。如果已经在 reload 之后挂了,把 .bak 覆盖回去再 reload 即可。关键是:语法错误的配置 reload 会失败并保持原配置运行,不会直接搞挂服务——真正搞挂的往往是语法正确但逻辑写错的配置,所以改完还要 curl -I 实测几个关键 URL。
一个实用的自查清单
每次改完 Nginx 配置,建议照着这张清单过一遍:确认 if 块内只有 return 和 rewrite;确认没有出现嵌套 if;确认 add_header 没有被写进 location 或 if 块(哪怕只有一条也会吃掉上层的全部安全头);确认所有按路径分流的逻辑都交给了 location 而不是 if;确认「文件不存在就转发」的逻辑用了 try_files;确认所有条件判断的变量都由 map 事先算好。这六条过完,你的配置文件基本就摆脱了 if 带来的所有隐性风险,日后排查问题时也会轻松很多。
小结
「if is evil」不是让你永远不用 if,而是提醒你:if 是 rewrite 模块的副产品,不是通用流程控制。让它只做 return 和 rewrite,把条件计算交给 map,把路由分流交给 location,把文件查找交给 try_files。掌握这三条替代路径,你写出的 Nginx 配置不仅能跑,而且可预测、可维护。