单 map 存亿级 URL 必崩:内存超 8–12 GB、并发写 panic、扩容卡顿;须分桶(质数个,sha256.Sum32 哈希)、每桶独立锁+预设容量、URL 归一化后再哈希。

直接用 map[string]struct{} 存亿级 URL,内存几秒飙到数 GB,GC 频繁卡顿甚至 OOM——这不是配置问题,是数据规模突破单 map 承载边界后的必然结果。必须分桶,且桶要够细、哈希要够稳、并发要隔离。
为什么单 map 在爬虫 URL 去重里必然崩
URL 字符串平均 80–120 字节,1 亿条就是 8–12 GB 内存(不含哈希表结构开销);更致命的是 Go 的 map 在写入时会动态扩容、rehash,高并发下极易触发 fatal error: concurrent map writes。即使加 sync.RWMutex,锁粒度太粗,吞吐量卡在几百 QPS 就上不去。
-
map[string]struct{}不是线程安全的,多 goroutine 同时seen[url] = struct{}{}必 panic - 哪怕只读多写少,
sync.Map对 URL 这类高频插入场景也不友好——它不支持遍历,你没法 dump 当前去重集合做快照或 debug - 初始化容量设小了,频繁扩容导致内存碎片+GC 压力;设大了又浪费(比如预估 5000 万却只进 200 万)
用 sha256.Sum32 + 取模分桶是最稳的起点
别用 fnv32a 或自定义字符串哈希——它们容易倾斜,某些桶塞满而其他桶空着,内存不均、热点集中。用 sha256.Sum32() 计算 hash 后取模,冲突率低、分布均匀,且 Go 标准库保证跨版本一致性。
- 桶数
N建议选质数:97、199、499,避免与哈希值的低位周期性共振 - 每个桶用独立
map[string]struct{}+sync.RWMutex,写操作只锁当前桶,吞吐量随桶数线性增长 - 计算方式:
bucketID := int(sha256.Sum32([]byte(url)).Sum32() % uint32(N)) - 别省事用
url[len(url)-2:] % N——后缀重复率极高,比如大量/api/v1/users/123和/api/v1/users/456全进同一个桶
桶内去重仍需归一化,否则路径语义重复漏判
原始 URL https://example.com/api/user/123?sort=desc 和 https://example.com/api/user/456?sort=asc 看似不同,但若业务只关心资源路径,就得先模板化再哈希。
立即学习“go语言免费学习笔记(深入)”;
- 先做基础清洗:去掉 query 中的 session_id、ts、sig 等动态参数;统一 scheme/host 小写;解码
%20、%E6%88%91等 - 路径模板化:用正则把数字 ID、UUID 替换为
{:id},如/order/12345→/order/{:id};但注意/user/123和/user/123/profile是不同语义,不能粗暴统一切成/user/{:id} - 归一化后才进桶计算哈希——否则相同语义 URL 被散列到不同桶,去重失效
- 归一化函数本身要幂等、无副作用,别在里头做网络请求或 DB 查询
并发写入时如何避免 map 扩容 panic 和锁争用
即使分桶,单个桶内仍可能高频写入,触发 map 扩容,而扩容期间禁止并发写——这时 sync.RWMutex 也救不了你。
- 每个桶的
map初始化时显式指定容量:make(map[string]struct{}, 10000),按预估该桶承载量设(比如总 URL 1 亿,100 个桶,每桶预估 100 万,就设 1_000_000) - 扩容不是“自动发生”,而是写入时检测负载因子 > 6.5 才触发;预设足够容量可大幅推迟甚至避免扩容
- 如果桶内 key 数量波动极大(比如某桶突然涌入 10 倍流量),考虑用
sync.Map替代普通 map——但它只适合“写后基本不读”的场景;若需定期扫描桶内容,还是老实用带锁普通 map + 容量预估 - 真正高危的是“桶内 key 类型混杂”:比如同时塞进归一化 URL 和原始 URL,导致哈希分布异常,建议用类型标注或命名空间隔离(如
"norm:" + normURL)
分桶不是银弹——桶数太少压不住内存,太多增加调度开销和 GC 压力;归一化逻辑写错,语义去重就形同虚设;而所有这些细节,都在 runtime 里静默失败,不会报错,只会让重复 URL 漏过去或误杀。上线前务必用真实爬虫日志跑一遍端到端验证,别只测单元 case。


















