做高并发系统时,缓存几乎是绕不开的话题。但不少团队的实践只停留在加一层Redis,然后发现数据库压力确实降了,可接口响应时间依然不理想,跨地区的用户访问依然很慢。原因其实很简单:Redis部署在源站机房,用户请求还是要跨越很长的网络路径才能触达它,而这一段网络开销,Redis再快也解决不了。这时候就需要CDN边缘缓存出场了。把Redis和CDN组合成多级缓存,让静态内容和热点数据尽量终结在离用户最近的节点上,才是完整的技术方案。

一、先理清各级缓存的位置和职责
一套完整的缓存链路通常是四层结构:浏览器本地缓存、CDN边缘节点、Redis集群、数据库。每一层解决的是不同性质的问题,混在一起讨论只会把架构做乱。
浏览器缓存处理的是单个用户的重复访问,通过Cache-Control、ETag这些HTTP头控制,成本最低但只对同一用户有效。CDN边缘缓存处理的是地理距离问题,节点遍布全国乃至全球,用户请求会被调度到最近的节点,命中时响应时间可以从几百毫秒降到几十毫秒。Redis处理的是源站内部的计算和查询压力,比如热点商品详情需要实时组装多个数据源,这部分计算结果放在Redis里,回源时可以避免打穿数据库。数据库则是最终的数据源头。
职责划分上有一条经验法则:内容与用户无关、变化不频繁的,推到CDN;内容需要按用户或上下文动态生成、但生成成本高的,放Redis;剩下真正实时的部分才落库查询。比如电商首页的轮播图、商品图片这些可以直接上CDN;商品详情接口的聚合结果适合放Redis;库存余量这种强一致数据则每次都要查库或走专门的实时通道。
二、CDN边缘缓存的工作机制与配置要点
CDN的核心思想是把内容复制到离用户近的地方。当用户请求一个URL时,DNS智能解析会把请求调度到最近的边缘节点,节点先查本地缓存,命中则直接返回;未命中则向上层节点或源站回源,拿到内容后按缓存规则存一份再返回给用户。这样同一个URL的第二次访问在边缘就能终结。
与CDN协同的关键在于缓存键和TTL的约定。CDN默认以完整URL(含查询参数)作为缓存键,但很多接口的查询参数里包含时间戳、签名这类每次都变化的值,会导致命中率暴跌。所以在配置CDN时,要么在控制台里忽略掉指定参数,要么在后端生成URL时就规范好参数结构。下面是一个典型的响应头配置示例:
func setCacheHeaders(w http.ResponseWriter, isHot bool) {
// 边缘节点缓存60秒,浏览器缓存10秒
w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=60")
if !isHot {
// 冷门内容缩短边缘缓存时间,减少失效窗口
w.Header().Set("Cache-Control", "public, max-age=10, s-maxage=30")
}
// 提供验证手段,过期后走条件请求
w.Header().Set("ETag", computeEtag(payload))
}
这里有个容易踩的坑:s-maxage是给CDN等共享缓存看的,max-age是给浏览器看的,两者要分开设置。浏览器缓存时间过长会让用户端的数据更新很被动,一般给短一点,比如10到30秒,而边缘节点可以给到1到5分钟,靠主动刷新机制来兜底一致性。
三、Redis在链路中承担什么角色
CDN解决不了的问题也很明显:它只会原样转发和存储,不会帮你算。当边缘节点未命中需要回源时,源站要能扛住瞬时回源流量,这就是Redis的主战场。回源请求到达源站后,先查Redis,命中则直接返回聚合好的结果,避免重新执行数据库查询、RPC调用和业务计算。
典型的回源处理逻辑大致是这样的:
func getProductDetail(ctx context.Context, id string) ([]byte, error) {
key := "product:detail:" + id
// 第一跳:查Redis
data, err := redisClient.Get(ctx, key).Bytes()
if err == nil {
return data, nil
}
// 未命中,加互斥锁防止缓存击穿
locked := redisClient.SetNX(ctx, key+":lock", 1, 3*time.Second).Val()
if !locked {
time.Sleep(50 * time.Millisecond)
return getProductDetail(ctx, id)
}
defer redisClient.Del(ctx, key+":lock")
// 真正的回源:查库并聚合
detail := loadFromDatabaseAndAssemble(id)
payload, _ := json.Marshal(detail)
// 热点数据缓存5分钟,加随机抖动防止集中过期
ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
redisClient.Set(ctx, key, payload, ttl)
return payload, nil
}
这段代码里有三个细节值得注意。第一是互斥锁,防止某个热点key过期的瞬间大量回源请求同时打到数据库,也就是缓存击穿问题。第二是TTL加随机抖动,避免一批key同时过期造成周期性的数据库压力尖峰。第三是缓存序列化后的完整响应体,而不是中间对象,这样回源路径上连序列化开销都省了。
另一个角色是热点探测与预热。可以通过统计接口访问频次,把热点数据提前写入Redis,甚至主动推送到CDN(通过内部预热接口刷新边缘节点),让用户第一次访问也能命中边缘。运营活动前对秒杀商品做这种预热,效果非常明显。
四、缓存一致性:两级缓存最大的难点
链路上的缓存层越多,数据不一致的窗口就越长。CDN边缘节点遍布各地,你没法直接登录每个节点去删缓存,只能通过CDN服务商提供的刷新API(purge)来清指定URL或目录。而Redis的失效是应用内部操作,两者之间的时序配合就是方案设计的核心。
推荐的做法是分层失效、以短TTL兜底。数据变更时,应用先更新数据库,再删除Redis对应的key,最后调用CDN的刷新接口清除边缘缓存。如果这三个步骤之间有短暂的不一致,靠预先设定的较短TTL自然过期来兜底。以商品价格更新为例,最坏情况是TTL时间内的旧价格被展示,只要TTL控制在1分钟以内,业务上通常可以接受。
对于一致性要求更高的场景,可以在响应里带上版本号或数据指纹,前端拿到低版本数据后主动发一次校验请求,只校验不传内容,开销很小。下面是调用CDN刷新接口的示意代码:
func purgeCDNCache(urls []string) error {
body, _ := json.Marshal(map[string][]string{"urls": urls})
req, _ := http.NewRequest("POST", "https://cdn-api.ipipp.com/v2/purge",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+cdnToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
还要提醒一点:刷新API通常有频率配额,不能每改一条数据就刷一次。批量变更时应该聚合URL后统一刷新,或者干脆依赖TTL自然过期,只对真正紧急的变更做主动刷新。
五、整体架构与监控指标
把前面的内容串起来,一次完整的请求路径是:用户发起请求,DNS调度到最近的CDN边缘节点,命中则直接返回;未命中则回源到源站网关,网关查Redis,命中则带上合适的Cache-Control头返回,同时边缘节点会按这个头缓存一份;Redis也未命中才执行数据库查询和业务聚合。数据变更时走反向链路:更新数据库、删Redis、刷新CDN。
上线后要重点盯三个指标:CDN命中率(目标是90%以上,低了说明缓存键设计或TTL配置有问题)、Redis命中率(回源量能接受的范围内越高越好,低了说明TTL太短或key设计不合理)、回源接口的P99延迟(这个决定了未命中用户的体验上限)。如果发现CDN命中率上不去,优先排查查询参数是否污染了缓存键;如果Redis命中率正常但数据库压力仍大,要怀疑是否存在大量不重复的冷key请求,这类请求在缓存层永远命中不了,需要从业务上做限流或者合并。
总结一下,Redis和CDN不是互相替代的关系,而是分工明确的上下游:CDN把流量挡在离用户最近的地方,Redis把回源流量挡在数据库前面。配置好缓存键、TTL和失效链路,再配上命中率监控,这套多级缓存架构就能稳定支撑绝大多数读多写少的业务场景。