同一个数据库,用命令行工具连得好好的,用应用代码连就报"主机名解析失败"。密码是对的,主机也是对的,但客户端把密码的一部分当成了主机名。
现象
从某个应用用连接串连接 PostgreSQL 时报错,连不上,错误信息指向一个奇怪的主机名——那串东西你根本没在配置里写过。
而另一个客户端用"看起来同样的"连接信息却能正常连接。于是问题被误判成"这个应用的驱动有问题",甚至有人开始考虑换驱动。
could not translate host name "ssw0rd!@dbhost" to address
如果你仔细看这个主机名,会发现它是密码的后半截拼上真正的主机名——这就是线索。
根因
密码里含有特殊字符,而连接串没有做 URL 编码。
连接串的格式大致是:
postgresql://用户名:密码@主机:端口/库名
解析逻辑是:从 :// 之后开始,第一个 : 之前是用户名,: 之后到第一个 @ 之前是密码,@ 之后是主机部分。
如果你的密码里含 @,比如 p@ssw0rd!,那么:
- 用户名 =
appuser - 密码 =
p - 主机 =
ssw0rd!@dbhost:5432(错误!)
客户端把第一个 @ 当作"用户信息与主机信息的分隔符",于是密码被截断,剩下的内容被当成主机名。报错说"解析出错误的主机名"就是这么来的。
另一个客户端之所以"正常",是因为某些驱动对连接串的解析更宽容(或者接受别的格式),恰好没触发这个问题——这正是最误导人的地方:"另一个能连,说明配置没错。"
顺便说明:! 本身不是关键,关键是那些在连接串里有语法含义的字符。
解决
连接串里所有密码特殊字符做 URL 编码(percent-encoding):
@ → %40
! → %21
: → %3A
/ → %2F
? → %3F
# → %23
% → %25
& → %26
所以密码 p@ssw0rd! 应该写成 p%40ssw0rd%21:
postgresql://appuser:p%40ssw0rd%21@dbhost:5432/mydb
规矩就是:密码里出现 @ : / ? # % & 一律编码。
在代码里可以用工具函数做,别手写:
from urllib.parse import quote
user_pw = quote("p@ssw0rd!", safe="")
url = f"postgresql://{user}:{user_pw}@{host}:{port}/{dbname}"
注意 safe=""——它表示"连 / 也一并编码",避免留下任何有可能被当成分隔符的字符。
反过来也要注意不要编码过头:编码只作用于密码(以及用户名)部分,主机名、端口、库名不需要编码——它们本身就不该包含这些特殊字符。把所有部分一律编码,反而会把 : 和 / 这些结构分隔符也改写掉,让连接串彻底失效。
延伸与预防
第一条:两个客户端都要各自验证一遍。 不要因为"命令行能连"就认为应用也能连——不同的驱动对连接串的解析实现不一样,宽容度也不一样。凡是涉及连接串的地方,用实际的那个客户端跑一次。
第二条:能拆开传就拆开传。 比如 psycopg 支持分开传参:
conn = psycopg.connect(
host="dbhost", port=5432, dbname="mydb",
user="appuser", password="p@ssw0rd!" # 原样传,不用编码
)
这样密码不需要编码,从根本上避开了解析歧义。绝大多数驱动都支持这种参数化写法,优先用它。
第三条:如果密码是随机生成的(很常见,尤其是自动部署场景),一定要检查里面有没有这些特殊字符。随机生成器可不会避开 @ 和 :。可以在生成密码时就限制字符集,或者生成后立即做一次连接测试。
最后补一条:改密码时也要同步检查连接串。轮换密码是一件独立于"改密码"的动作——如果新密码里带了特殊字符而连接串没编码,应用会在下一次重连时突然失败,而密码本身其实是对的。把它们放进同一个变更流程里,能避免这种"半夜挂掉"的事故。
再补一个几秒钟定位的技巧:把报错里的"主机名"和你配置里的密码原样对照一下。如果那个主机名明显是密码的后半截,就基本可以确诊是 URL 编码问题,而不是 DNS 或网络问题——这个动作能立刻把方向从"查 DNS"扭到"检查连接串编码"。