nginx 不会给所有 location 表达式计算一份通用的具体程度分数。普通前缀按匹配长度选择,精确 location 可以结束搜索,竞争正则则按声明顺序求值。某个表达式在人看来更窄,也可能输给前面更宽的正则。把这些选择规则混在一起,是许多看似仔细的路由变更影响意外路径的原因,必须分别理解每类规则。
坏掉的样子
通用 .css 后缀正则,可以先于后面的 /assets/ 限定正则获胜。后者因为包含目录,看起来似乎更具体,但 nginx 不会比较两个模式在语义上谁更窄。在这里的平面结构中,只要两者都匹配,顺序就决定使用哪个配置。路径更长或正则文字更多,都不是正则之间的额外优先分。
调整普通前缀顺序是另一回事。把 /assets/ 移到 / 之前,不会让它“更优先”;在采用前缀结果时,更长匹配本来就决定它获胜。如果把所有 location 都当成同一个先匹配先使用列表,审阅可能做出无效调整,却漏掉真正控制故障路径的正则顺序。修改文本与修改行为之间,需要通过规则和请求证据连接。
较短的 ^~ 前缀,也不是整个子树的永久保护层。如果存在更长的普通前缀并被选中,较短的标记前缀就不再是抑制正则阶段的候选。因此,新增一个看似无害的更详细前缀,可能让原来停留在标记 location 的请求重新被后缀正则接管。这种变化不能仅凭新增块自身内容看出来。
快速验证
下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建两份临时配置,分别执行 nginx -t,再通过宿主回环端口发送真实请求。每份配置都只使用平面 location,结束时清理自己的容器、镜像标签和私有网络,不修改既有服务:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content)
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
prefixes=[
'location / { return 200 "fallback"; }',
'location /assets/ { return 200 "asset-prefix"; }',
'location = /assets/exact.css { return 200 "exact"; }',
'location ^~ /static/ { return 200 "static-prefix"; }',
'location /static/dynamic/ { return 200 "dynamic-prefix"; }']
regexes=[r'location ~ \.css$ { return 200 "generic-css"; }',r'location ~ ^/assets/ { return 200 "asset-regex"; }']
for reverse,expected in [(False,'generic-css'),(True,'asset-regex')]:
ordered_prefixes=list(reversed(prefixes)) if reverse else prefixes
ordered_regexes=list(reversed(regexes)) if reverse else regexes
locations='\n'.join(ordered_prefixes+ordered_regexes)+'\n'+r'location ~* \.txt$ { return 200 "case-folded-text"; }'
with lab(config(locations)) as (container,request):
cases={'/assets/file.css':expected,'/assets/exact.css':'exact','/static/file.css':'static-prefix','/static/dynamic/file.css':'generic-css','/static/dynamic/plain':'dynamic-prefix','/README.TXT':'case-folded-text','/elsewhere.CSS':'fallback','/elsewhere':'fallback'}
for path,wanted in cases.items():
status,headers,body=request(path);assert status==200 and body.decode()==wanted,(path,status,body)
print('reversing the matching regex declarations changes the winner; the first matching regex is not necessarily the most specific')
print('exact locations and longest-prefix selection ignore declaration order; a longer ordinary prefix can make a shorter ^~ prefix irrelevant')
第一份配置把 .css 正则放在 /assets/ 开头的正则之前,请求 /assets/file.css 返回 generic-css。第二份反转两个正则,相同请求改为返回 asset-regex。请求没有变得更具体或更宽泛,变化的只有两个同时匹配模式的顺序。这为“正则自动选最具体”的说法提供直接反例。
脚本还在两份配置之间反转普通前缀声明,它们相关结果保持稳定。/static/dynamic/plain 仍使用更长普通前缀,无关路径仍进入宽泛默认处理。这个对照把前缀文本重排与正则文本重排分开,不会因为两种操作都叫调整顺序,就假定它们具有相同路由效果。判断改动影响时,应先识别被移动的是哪类块。
精确 /assets/exact.css 在两份配置中都获胜,虽然这个路径也匹配两条正则,它始终返回 exact。实验因此包含一条有意不受正则顺序影响的路径,避免把“第一个匹配正则”说成无条件覆盖所有其他 location 类型的规则。完整优先关系比一句简化口号更能解释边界。
/static/file.css 留在 ^~ /static/ 中,返回 static-prefix。但 /static/dynamic/file.css 具有更长普通前缀,于是 .css 正则仍会被考虑,并返回 generic-css。这验证了 ^~ 对被选中前缀的限定:修饰符属于那个 location,而不是隐藏地继承到每个更深前缀上的保护。
大小写修饰符单独验证。~* 文本文件模式匹配 /README.TXT;区分大小写的 .css 模式不匹配 /elsewhere.CSS,后者进入默认处理。这些结论针对实验中的明确正则和 Linux 环境,不是声称所有文件系统路径或所有平台都遵守相同大小写规则。匹配方式与文件读取方式也应分别判断。
让重叠在审阅中可见
对每条正则,找出也匹配更早表达式的路径,并记录预期由谁处理。测试既要覆盖两个模式重叠,也要覆盖两者都不匹配的情况。只在单条正则孤立存在时测试,无法发现后来有人在前面加入另一个表达式时引入的优先冲突。重叠本身应成为可审阅的契约,而不是偶然顺序。
当精确或前缀 location 能自然表达路由时,可以使用它们,并让有意存在的正则重叠容易理解。正则本身并非错误,但其顺序会成为服务契约的一部分。包含配置发生变化也可能改变有效顺序,因此需要检查完整加载配置,不能只审阅脱离上下文的文件片段,再根据局部排列推断结果。
在已有标记前缀下面增加更长前缀时,重新测试经该路径进入的后缀请求,不要假定旧 ^~ 仍控制它。最后,验证获胜块中的缓存头、代理与访问控制等实际行为。选中预期标记只证明路由,不能证明块内每项策略都正确,更不能替代真实服务处理链的回归检查。
来源
- nginx core location directive,第一个匹配正则、前缀优先关系与大小写修饰符,已核验 2026-10-15。
- How nginx processes a request,已选 server 内的配置选择,已核验 2026-10-15。
- nginx 1.28.0 core implementation,指定版本的前缀与正则 location 搜索行为,已核验 2026-10-15。