用 sync.map 做 url 去重会出问题,因其不保证“写入即可见”,新协程可能读不到刚存的 url;正确做法是用 loadorstore 并检查返回的 bool 值判断是否首次存入。

为什么用 sync.Map 做 URL 去重会出问题
并发爬虫里最常踩的坑是:把 sync.Map 当成万能去重容器,结果漏爬或重复请求。它确实线程安全,但不保证「写入即可见」——新协程可能读不到刚存进去的 URL,尤其在高频插入+快速判断的场景下。
真正该用的是带原子语义的「判存并设」操作。Go 标准库没直接提供,得自己封装:
-
sync.Map.LoadOrStore(url, struct{}{})返回值第二个 bool 才表示「本次是首次存入」,必须检查这个布尔值,不能只看第一个返回值 - 如果用
sync.Map.Store+sync.Map.Load两步走,中间必然存在竞态窗口,URL 会被重复调度 - 高吞吐下
sync.Map的哈希冲突会导致性能抖动,实际压测发现比map+sync.RWMutex慢 15%~30%
如何让多个协程安全地从队列取 URL
别用 chan string 直接当任务队列——一旦消费者协程 panic 或提前退出,未消费的 URL 就永远卡在 channel 里,后续无法回收或重试。
推荐用带状态管理的「工作池模式」:
立即学习“go语言免费学习笔记(深入)”;
- 用
sync.Pool缓存url.URL实例,避免高频分配 GC 压力 - URL 调度器内部维护一个
list.List+sync.Mutex,出队时先mutex.Lock(),取完立即mutex.Unlock(),不等解析完成再释放锁 - 每个协程取到 URL 后,立刻调用
markInFlight(url)(用sync.Map记录 in-flight 状态),防止超时重试时被其他协程重复领取
context.WithTimeout 在 HTTP 请求里为什么总失效
不是 context 不生效,而是很多人只给 http.Client 设了 Timeout,却忘了给单次请求传 context。结果是:全局超时起作用,但单个请求卡死在 DNS 解析或 TLS 握手阶段,context 根本没机会触发取消。
正确做法是两者都配:
-
http.Client的Timeout控制整个请求生命周期(含重定向) - 每次
client.Do(req.WithContext(ctx))必须传入带超时的ctx,否则 DNS/TLS 阶段不响应 cancel - 如果用了自定义
Transport,还要确保DialContext和TLSHandshakeTimeout也基于同一 context
Redis 做分布式去重时,SETNX 和 SET ... NX EX 差在哪
本地单机用 sync.Map 还能凑合,一上分布式就暴露问题:SETNX 只能设 key,没法同时设过期时间,导致机器宕机后 key 永久残留,整个爬虫系统停摆。
必须用原子命令一次性完成「设值 + 过期」:
-
SET url:xxx "1" NX EX 3600是唯一可靠方案,NX保证不存在才设,EX防止 key 永驻 - 不要用
GET + SET两步,网络分区时可能产生脏数据 - 如果 Redis 版本 SET 不支持
NX EX组合,得降级用 Lua 脚本封装redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2])
去重逻辑越往后越容易被忽略:URL 归一化(去掉 fragment、统一 scheme)、子域名归并(a.example.com 和 b.example.com 是否算同站)、以及重试时要不要跳过已失败过的 URL —— 这些不写进调度器核心,光靠外围补丁很难兜住。










