一、为什么值得专门讲 Nginx 的 map 变量
如果你管着不止一个域名,或者同一套代码要服务多个站点,Nginx 配置里最容易失控的东西就是"判断逻辑"。常见做法是到处写 if,写到最后自己都记不清哪个 server 块里有哪些条件,改一处影响一片。map 指令解决的就是这个问题:它把"输入到输出"的映射关系集中声明在 http 块里,server 和 location 里只引用结果变量,逻辑清爽、性能好(map 的结果会被缓存,同一个请求内不会重复计算),而且天然支持正则与哈希表。
这篇文章讲的是多站点场景下 map 的实战用法,不是入门语法介绍。所有例子都对应真实需求:多域名收敛、按 UA 与来源分流、灰度发布、防刷与日志字段补齐。
二、基础但最容易踩的语法陷阱
先看一个最简单的 map:
map $http_host $site_root {
default /var/www/default;
www.example.com /var/www/example;
blog.example.com /var/www/blog;
}第一个陷阱:map 必须写在 http 块里,不能写在 server 或 location 里。写错位置 Nginx 直接启动失败,报 "map" directive is not allowed here。如果你有 conf.d/ 和 sites-enabled/ 两套目录,要确认 map 定义放在被 http 块 include 进来的文件里,而不是放在某一个站点的 server 配置片段中。
第二个陷阱:map 里没有匹配到任何条目时,如果没有 default,结果是空字符串而不是报错。这个特性很容易埋雷。比如你用上面那个 map 去设置 root,然后新增了一个域名但忘了加进 map,Nginx 不会报错,它会拿空字符串去当 root,于是那个域名的请求全部落到一个奇怪的路径上,返回 404 或者更糟——返回了默认目录的内容,造成越权读取。所以建议永远给 map 写一个显式的 default,哪怕只是 default ""; 配合后续的 if 校验。更稳妥的做法是配合一个"白名单校验"的 map 做门禁:
map $http_host $host_allowed {
default 0;
www.example.com 1;
blog.example.com 1;
}
map $host_allowed $host_check {
1 "";
0 "deny";
}
server {
if ($host_check = "deny") { return 444; }
...
}这里 return 444 是 Nginx 特有的用法——直接断开连接,不返回任何响应。对泛解析带来的垃圾请求,它比返回 403 更省资源,也不会被爬虫当作有效页面记录。
三、多域名收敛:用 map 把一堆别名归到一个规范域
站点坐落在多个域名上(主域、带 www、老域名、移动端域名),SEO 上需要全部 301 到唯一的规范域。if 写起来是嵌套地狱,map 只需要一张表:
map $host $canonical_host {
default "";
example.com www.example.com;
old-example.com www.example.com;
m.example.com www.example.com;
www.example.com "";
}
server {
listen 80;
listen 443 ssl;
server_name example.com old-example.com m.example.com www.example.com;
if ($canonical_host != "") {
return 301 $scheme://$canonical_host$request_uri;
}
# 走到这里说明已经是规范域名,继续正常处理
}关键设计是:规范域映射到空字符串,非规范域映射到目标域。这样只需要一个 if 判断"是否需要跳转",不需要为每个域名写一段。新增域名时只改 map 表,server 块一行不动。
有一点必须提醒:if ($canonical_host != "") 这个判断建议只用于 return。Nginx 官方文档明确写过 if 在 location 里的行为有反直觉之处(所谓 "if is evil"),安全用法基本只有两类:直接 return,或者后面接 rewrite ... last。不要在 if 里做 proxy_pass 或 add_header,那会产生难以预期的结果。map + if + return 的组合恰好落在安全区里,这也是它比嵌套判断更值得推荐的原因之一。
四、按 UA 与来源做分流:爬虫、灰产与灰度
下面这个 map 用来识别请求来源类别,之后可以用于限流、拒绝或日志打标:
map $http_user_agent $client_kind {
default "browser";
"" "empty_ua";
~*(bingbot|googlebot|baiduspider|yandex) "search_bot";
~*(curl|wget|python-requests|Go-http) "tool";
~*(semrush|ahrefs|mj12bot|dotbot) "seo_crawler";
~*(scrapy|nmap|masscan|zgrab) "scanner";
}这里有三个实战要点。
要点一:map 的键是"精确匹配优先,正则按书写顺序"。Nginx 会先尝试精确字符串匹配,然后按正则出现的顺序逐个尝试,第一个命中即返回。所以顺序很重要——~*(semrush|ahrefs) 这类 SEO 爬虫如果被写在 ~*(googlebot) 之后倒也没事,但如果你把宽泛的规则写在前面,具体规则就永远轮不到。
要点二:空 UA 单独成类,是最有价值的信号。正常的浏览器、搜索引擎爬虫、主流监控工具都会带 UA。空 UA 的请求绝大多数是脚本扫描器。empty_ua 这个分类可以直接用于限流或者干脆拒绝。
要点三:不要用 UA 做安全决策,只做成本控制。UA 是客户端自己声明的,伪造成本为零。所以 map 出来的 $client_kind 适合用来决定"要不要给它更严的限流"、"要不要记更细的日志",千万不要写成"只要是 googlebot 就放过"。真正的爬虫验证要基于反向 DNS 校验,那需要额外模块或脚本。
把分类结果用起来,一个典型组合是这样的:
map $client_kind $is_bad {
default 0;
empty_ua 1;
scanner 1;
}
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=strict:10m rate=2r/s;
server {
location / {
if ($is_bad) { set $limit_zone strict; }
limit_req zone=general burst=40 nodelay;
limit_req zone=strict burst=5 nodelay;
...
}
}注意这里展示了一个不那么广为人知的技巧:set $limit_zone strict; 本身对 limit_req 没有直接作用,因为 limit_req 的 zone 参数必须是编译期确定的名字。上面这段的真实目的是演示"用 map 结果去决定限流策略"的思路——正确的落地方式是写两个 location,用 map 出来的变量配合 error_page 或内部跳转来切换,或者更简单地直接用 limit_req 的多个 zone 同时生效(Nginx 允许同一 location 上挂多个 limit_req,请求要同时满足所有限制)。最后这种"多 zone 同时生效"的方案最简单可靠,推荐优先使用。
五、灰度发布:用 map 做 1% 的流量切分
没有服务网格的小站也能做灰度,原理是用一个稳定但看起来随机的请求特征做哈希,按比例分流:
map $http_x_gray $gray_target {
default "";
"1" "http://127.0.0.1:8081";
}
# 按 cookie 或 IP 做稳定分流(同一用户始终进同一组)
map $cookie_gray_id $gray_by_cookie {
default "";
~^(0|1|2|3|4|5|6|7|8|9)$ "http://127.0.0.1:8081";
}
server {
location / {
proxy_pass http://127.0.0.1:8080;
# 带上灰度标记的请求转发到新版本
if ($http_x_gray = "1") {
rewrite ^ / break;
proxy_pass http://127.0.0.1:8081;
}
}
}更推荐的做法是用 split_clients 指令,它是专为按比例分流设计的:
split_clients "${remote_addr}${http_user_agent}" $version {
5% "backend_new";
* "backend_old";
}
upstream backend_new { server 127.0.0.1:8081; }
upstream backend_old { server 127.0.0.1:8080; }
server {
location / {
proxy_pass http://$version;
}
}split_clients 的哈希输入可以选择 $remote_addr(同一 IP 稳定进同一组)、$cookie_xxx(同一浏览器稳定),或者加上 UA。要注意的是哈希输入决定稳定性:如果你用 $request_id 这种每次都不同的变量,用户会在两个版本之间来回跳,体验很糟。灰度分流一定要保证"同一个用户落在一组"。
六、用 map 补齐日志字段,让排障不用猜
这是我认为投入产出比最高的用法。Nginx 默认日志格式只有 IP、时间、请求、状态码、UA、referer,缺了很多排障必需的字段。用 map 可以零成本地把它们补齐:
map $http_x_forwarded_for $real_client {
default $remote_addr;
~^([0-9.]+) $1;
}
map $upstream_cache_status $cache_readable {
default "-";
HIT "HIT";
MISS "MISS";
BYPASS "BYPASS";
STALE "STALE";
EXPIRED "EXPIRED";
}
map $status $status_class {
~^2 "ok";
~^3 "redirect";
~^4 "client_error";
~^5 "server_error";
}
log_format detailed escape=json
'{"time":"$time_iso8601","ip":"$real_client","host":"$http_host",'
'"uri":"$request_uri","status":$status,"class":"$status_class",'
'"cache":"$cache_readable","req_time":$request_time,'
'"up_time":"$upstream_response_time","kind":"$client_kind"}';
access_log /var/log/nginx/access.log detailed;这套日志一旦上线,排障方式会发生质变。以前你要猜"这个慢请求是回源慢还是缓存没命中",现在直接 jq 一下就能按 up_time 排序找出真正慢在后端的请求;以前判断 CDN 是否发旧内容要反复 curl,现在直接统计 STALE 的比例。
注意 escape=json 这个参数,它保证日志里的引号、反斜杠等特殊字符被正确转义,是 JSON 日志可被 jq 解析的前提。少了它,一个带引号的恶意请求就能把你的日志文件解析搞崩。
七、性能与维护上的三条经验
第一,map 的求值结果会被缓存。同一个请求内多次引用同一个 map 变量只计算一次,所以放心在多个 location 里引用,不会有性能问题。真正要避免的是把 map 的输出再喂给另一个 map 做多层嵌套——链条越长,可读性越差,收益却微乎其微。
第二,正则 map 有成本,能精确匹配就用精确匹配。精确字符串匹配走哈希表,O(1);正则要逐个尝试。如果一个 map 的条目超过几十条且都是固定字符串,全部写成精确匹配,别用 ~*。
第三,把 map 表当代码管理。多站点场景下这张表会不断增长,建议单独放一个文件(比如 conf.d/00-maps.conf),用 include 引入,并加注释标注每条规则的用途和添加日期。运维事故里有相当一部分是"某个域名加进了 server_name 但忘了加进 map",或者反过来。把这两处紧挨着写、并在文件顶部写一条注释提醒"新增域名请同步此处",能省下很多排查时间。
八、小结
Nginx 的 map 本质上是一个声明式的查表工具,它把散落在各处的条件判断收拢到一处。对多站点站长来说,它的价值体现在三件事上:域名收敛只改一张表;UA 与来源分类可以被限流、日志、拒绝策略同时复用;灰度分流和日志字段补齐都只需要几行配置。代价是要记住两条纪律——map 只能写在 http 块,以及永远给 default 一个明确的值。把这两条守住,map 会比你在 location 里堆的每一个 if 都更可靠。