☰
wttr.in 的 4 层缓存怎么挡住上亿次天气请求:一次 curl 的完整旅程
2026/10/27 1:50:47 网站建设 项目流程

wttr.in 的 4 层缓存怎么挡住上亿次天气请求:一次 curl 的完整旅程

【免费下载链接】wttr.in:partly_sunny: The right way to check the weather项目地址: https://gitcode.com/gh_mirrors/wt/wttr.in

每天,curl wttr.in/Beijing这样的天气查询会被敲上亿次。最容易被忽略的细节是:同一个 IP、同一个 URL,第二次请求的响应时间会从 200~500ms 直接掉到 1~5ms。这不是网络抖动——是缓存。当前仓库是 Go 重写的 wttr.in,它的缓存不是一层,而是四道闸门叠在一起。

请求进门:先定位你,再解析参数

computeResponse是整个管线的心脏,入口在 internal/weather/weather.go。第一步不是查天气,而是查"你是谁":拿到客户端 IP(优先读X-Forwarded-For,因为前面通常挂着反代),按配置顺序过 IP 定位器——本地 GeoLite2 数据库优先,查不到再走在线服务,定位结果落进 SQLite。接着QueryParser.Parse把?format=j1、?T这类 query 参数解析成一个强类型的Options结构体。

顺带一个细节:IP 落在美国的客户端,默认单位自动切成华氏度——不是硬编码,是定位结果驱动的。

设计权衡:定位放在管线最前面,意味着后面所有步骤(geocode、渲染、单位)都能共享这份结果;代价是多一次本地 DB 查询,换来的是"不写城市名也能出天气"的兜底能力。

城市名到经纬度:geocode 也被本地挡了一道

拿到位置字符串(比如Beijing)后,走Locator.GetLocation。城市名到坐标的映射存在本地 SQLite 里:命中直接返回,miss 才调 OpenCage 之类的 geocoding API,再把结果写回 DB。

设计权衡:geocoding 是按次计费的昂贵操作,本地挡一道之后,真正打到 API 的只有第一次查询——热门城市的 geocoding 请求量从"每天上亿"降到接近零。

第一层缓存:上游天气数据,TTL 45 分钟

geocode 出经纬度,去拉上游天气数据。这里包了一个CachedWeatherClient(见 internal/weather/weatherer_wwo_cached.go),外层是 disk 缓存:TTL 45 分钟,容量上限 2GB。缓存 key 的构造只有一句话:

lat = math.Round(lat*10000) / 10000 lon = math.Round(lon*10000) / 10000 return fmt.Sprintf("weather:wwo:%.4f:%.4f:%s", lat, lon, lang)

上面这几行干的事是:把经纬度四舍五入到 4 位小数(约 11 米精度),拼上语言,生成缓存 key。天气 API 的数据分辨率本来就只有公里级,11 米的精度已经过细——但 4 位小数能让同一区域的请求自然共享同一个 key,避免"差 0.0001 度就 cache miss"的碎片化。设计权衡:牺牲一点点坐标精确度,换来缓存命中率的显著提升。

并发洪峰:1000 个人同时查北京,上游只来 1 次请求

缓存 miss 的瞬间最容易出事:同一时刻 1000 个并发请求都 miss,难道要向上游发 1000 次请求?Cacher接口给每个 key 提供了一个原子标记(internal/weather/cache.go):

if entry := s.Cacher.Get(cacheKey); entry != nil { s.serveFromCache(w, entry) return } if !s.Cacher.SetInProgressIfNotExists(cacheKey) { entry, _ := s.Cacher.WaitForCompletion(cacheKey, 12*time.Second) s.serveFromCache(w, entry) return }

上面这几行干的事是:第一个拿到标记的 goroutine 当 leader 真去拉数据,其余 999 个当 follower,阻塞等 leader 写回结果,最多等 12 秒。这是经典的请求合并(request coalescing):上游 API 实际只收到 1 次请求,1000 个并发拿到的是同一份数据。

设计权衡:follower 必须等 leader,leader 慢了大家一起超时;但比起 1000 个请求同时打爆上游 API 配额,集中等待的代价小得多。

第二层缓存:响应直接进内存,key 里有你 IP 和小时桶

第一层缓存的是"原料"(上游 JSON),第二层缓存的是"成品"(渲染完的终稿)。响应缓存是内存 LRU,TTL 10 分钟,key 长这样:

bucket := time.Now().Unix() / int64(DefaultCacheInterval.Seconds()) return fmt.Sprintf("%s:%s%s:%s:%s:%d", ua, host, uri, ip, lang, bucket)

上面这几行干的事是:把 user agent、host、URI、客户端 IP、语言和"小时桶"(当前时间戳除以 3600)拼成 key——同一小时内、同一 IP、同一 URI 的请求,key 完全相同。命中时直接把缓存的 body 和 header 原样写回去,耗时是微秒级。这就是二次请求 1~5ms 的来源。

小时桶的粒度是权衡点:太短(比如 5 分钟)key 变太快,命中率掉;太长(比如 1 小时以上)用户拿到的是陈旧的渲染结果。1 小时卡在天气更新频率和体验之间。另外注意 key 里有 IP——两个不同 IP 的用户查同一个城市,可能因为单位偏好不同而各自命中各自的缓存,看起来像 bug,其实是设计。还有一个后门:URL 加debug参数会强制跳过缓存走完整流程。

网关分流:部分视图转给后端 worker

还有一层容易被忽略的架构:Go 版其实是网关。internal/uplink/uplink.go 会把部分请求类型按 view 分流到 4 个后端 worker——PNG 图片走 client4,v2 视图走 client2,其余走 client1。worker 返回的响应在网关侧缓存 17~25 分钟的随机 TTL:

设计权衡:随机 TTL 是防雪崩的经典手法——如果所有 key 用统一 TTL,它们会在同一秒集中过期,瞬间打爆下游;随机 17~25 分钟把过期时间打散。把渲染逻辑放在后端 worker,换来的是新格式可以灰度推进:上游格式变了改 worker 就行,网关不用动。代价是多一跳内网转发,排查问题时要看两边日志。

5 分钟验证:两层缓存自己跑一遍

curl -s wttr.in/Beijing?format=j1 | head time curl -s wttr.in/Beijing?format=j1 > /dev/null time curl -s wttr.in/Beijing?format=j1&debug= > /dev/null

上面这几行干的事是:第一次请求走完整链路,第二次命中响应缓存,第三次用debug强制绕过缓存。拿time输出的 real 值对比——正常应该是几百毫秒 → 1~5ms → 回到几百毫秒。如果你看到的差距在百倍量级,四层缓存里至少两层在你眼前真实工作过。

缓存不是玄学,是可测量、可绕过的工程层——wttr.in 把这一点做到了每个请求都能验证。

【免费下载链接】wttr.in:partly_sunny: The right way to check the weather项目地址: https://gitcode.com/gh_mirrors/wt/wttr.in

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询