
go 的 `url.url` 结构在设置 `rawquery` 时会对 url 路径中已存在的 `%` 字符进行二次编码,导致出现 `%2525` 等重复转义现象;根本原因是 `%` 本身是 url 编码的元字符,必须被转义为 `%25`,而若输入中已含 `%25`(即原始 `%` 的编码形式),则会被再次转义。
在 Go 中,net/url 包对 URL 的处理严格遵循 RFC 3986,其中明确规定:
- URL 的路径(Path)和查询(RawQuery)字段必须是已正确编码的 ASCII 字符串;
- 字符 % 是保留的转义起始符,任何原始数据中的 % 都必须被编码为 %25;
- 若你传入的 path 字符串本身已包含 %25(例如来自前端或日志解析的“已编码 URL”),而你又将其直接赋值给 u.Path 或 u.RawQuery,url.URL.String() 在拼接时会将其中的 % 视为需转义的原始字符,从而把 %25 → %2525(即 % → %25,后接原 25)。
问题复现示例
package main
import (
"fmt"
"net/url"
"strings"
)
func main() {
baseURL, _ := url.Parse("http://localhost:9000")
path := "/buckets/test%?bucket_uuid=7864b0dcdf0a578bd0012c70aef58aca"
u := *baseURL
u.User = nil
if q := strings.Index(path, "?"); q > 0 {
u.Path = path[:q]
u.RawQuery = path[q+1:]
} else {
u.Path = path
}
fmt.Println("Result:", u.String())
// 输出:http://localhost:9000/buckets/test%2525?bucket_uuid=7864b0dcdf0a578bd0012c70aef58aca
}正确做法:避免双重编码
✅ 方案一:确保输入 path 是原始未编码字符串(推荐)
若 test% 是业务中真实存在的字面量(如 bucket 名含 %),应先用 url.PathEscape 编码路径部分,再拆分:
rawPath := "/buckets/test%" // 原始路径(含特殊字符) rawQuery := "bucket_uuid=7864b0dcdf0a578bd0012c70aef58aca" u := *baseURL u.Path = url.PathEscape(rawPath) // → "/buckets/test%25" u.RawQuery = rawQuery // 查询参数不额外编码(已是合法格式) // 注意:RawQuery 应为已编码字符串;若含非 ASCII 或保留字符,需用 url.QueryEscape()
✅ 方案二:若 path 已是完整编码 URL,用 url.Parse 解析而非手动拆分
fullURL := "http://localhost:9000/buckets/test%25?bucket_uuid=7864b0dcdf0a578bd0012c70aef58aca"
u, err := url.Parse(fullURL)
if err != nil {
panic(err)
}
// u.Path 和 u.RawQuery 已自动解码并安全分离,String() 不会重复转义⚠️ 关键注意事项
- url.URL.Path 字段必须是已编码的路径字符串(如 /a%20b),不能是原始 /a b;
- url.URL.RawQuery 同理,必须是已编码的查询字符串(如 q=a%2Bb),不可含未编码的 &, =, %, 空格等;
- 永远不要混合使用「原始字符串」和「已编码字符串」——统一用 url.PathEscape() / url.QueryEscape() 处理输入;
- Go 1.3.3 及后续版本行为一致,此非 bug,而是 RFC 合规实现。
总结
%2525 的出现本质是「对已编码字符串进行了第二次编码」。解决核心在于:明确区分原始数据与 URL 编码数据,并始终通过标准函数(url.PathEscape, url.QueryEscape, url.Parse)进行转换。手动字符串切分 + 直接赋值 Path/RawQuery 是高危操作,务必校验输入来源是否已编码。










