ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

缓存后端选型实战:Redis、Memcached、Groupcache与本地缓存对比

缓存后端选型实战:Redis、Memcached、Groupcache与本地缓存对比 给Templar这套接入层选缓存后端的时候我确实纠结了一阵。Templar是我们内部一个业务聚合与转发服务每天要承接海量读多写少的查询其中很大一部分请求命中完全相同的结果不缓存的话下游和带宽都会被打爆。候选名单很明确进程内内存缓存、Memcached、Redis、Groupcache。这类对比文章网上不少但多数停留在特性罗列真正把四类方案放在同一个场景里跑压测、排故障、看长期运维成本的经验帖不多。这篇文章就把我在Templar缓存后端选型中的完整思路、实测过程和踩坑记录写出来适合正在做中间件选型或准备优化缓存链路的朋友参考。Templar缓存后端选型内存、Memcache、Redis与Groupcache对比1. Templar缓存需求与四个候选方案的基本盘1.1 为什么Templar不能继续依赖进程内MapTemplar早期是单体部署缓存直接用进程内的 map 加锁实现单实例阶段没什么问题。后来随着流量上涨服务拆成多副本这个方案的短板开始暴露每个副本各存一份数据整体命中率被稀释同一份请求打到不同节点就要分别回源一次更新缓存时还得想办法通知所有节点否则容易出现脏读。最头疼的是 Go 的 map 在高并发下会有并发写风险简单加锁或换成 sync.Map 确实能解决并发安全但淘汰策略、容量上限、过期清理这些东西全部需要自己造轮子。用 go-cache、bigcache、freecache 这类库能解决一部分问题比如 TTL 自动过期、内存淘汰但数据依然被锁死在当前进程里跨节点共享这个根本诉求满足不了。我当时给团队画了一张简单的依赖图发现如果继续在进程里加缓存最终会陷入“每加一个副本就要重新缓存一遍”的死循环。所以结论很直接必须要引入独立的缓存后端让所有副本共享同一份热数据而不是继续在应用进程里打补丁。1.2 四个候选方案的本质差异把候选表格拉出来其实四类方案差异很大并不只是在性能上有高低之分维度进程内内存缓存MemcachedRedisGroupcache部署形态应用进程内库独立服务独立服务进程内库 节点组数据结构KVKVString / Hash / List / Set / ZSetKV可自动填充持久化无无RDB / AOF无失效机制依赖库的TTLLRU TTLTTL / 淘汰 / Lua无删除、无过期集群能力依赖上层客户端一致性哈希主从 / 哨兵 / Cluster内置 Peer 节点典型定位单机热点缓存高吞吐KV缓存功能型缓存/存储分布式只读缓存简单解读一下。进程内内存缓存是零部署成本的选择适合处理单副本内部高频访问的热点但换不来多节点一致性。Memcached 是老牌选手多线程模型做纯KV读取吞吐很高但数据结构太少不支持持久化很多复杂业务逻辑落不了地。Redis 本质上是数据结构服务器缓存只是它的核心用途之一它还能兼职分布式锁、计数器、排行榜、消息队列这些场景生态也最成熟。Groupcache 是 Google 开源的一个 Go 语言分布式缓存库不是独立服务器而是嵌入到应用进程里节点之间自动发现并填充缓存它的设计目标就是解决“缓存穿透”和“重复回源”问题但它有个非常关键的限制没有删除操作也没有过期机制。1.3 选型前必须先明确的三个核心维度四个方案摆在面前不是简单挑性能最强的就行。我在动工前先给自己定了三个维度性能、一致性、运维成本。性能指的是读写延迟和吞吐能到多少尤其是热点请求集中打过来时的表现。Templar 的目标场景是平均延迟在几十毫秒以内缓存命中时最好压在 1 到 5 毫秒所以中间件本身的延迟必须足够低。一致性指缓存与数据库之间的数据同步要求。Templar 很多接口允许秒级或者分钟级的数据延迟但对订单状态这类数据延迟太大会直接导致用户投诉。四类方案里一致性处理难度完全不同进程内缓存最难广播失效Memcached 和 Redis 都支持主动删除Groupcache 则根本不支持主动失效这个差异直接决定了它不适合哪些场景。运维成本是最容易被忽略但后期最容易反噬的维度。团队有没有人熟悉这个组件遇到问题能不能快速搜到解决方案中间件算上部署、监控、告警、扩容的隐性成本是多少Memcached 部署简单但集群和灾备方案远不如 Redis 成熟Redis 虽然重一些但生态完整度高社区资料丰富踩坑之后容易找到答案Groupcache 官方维护状态不活跃所有扩展能力基本都要自己二次开发。把这三个维度拉出来打分我心里基本有数了进程内内存缓存是补充方案不是主干Groupcache 的场景非常特殊不能当通用缓存用真正的核心竞争还是落在 Memcached 和 Redis 之间。2. 四种缓存后端的核心特性对比与实操要点2.1 进程内内存缓存零依赖但容量有限先聊聊最容易上手也最容易被低估的内存缓存。如果你只是想在单进程里缓存少量配置、token 或者用户会话那么map sync.RWMutex其实就能跑但千万别拿它当大容量缓存。标准 map 在存储大量对象时GC 扫描会带来明显压力所以生产环境我建议用 bigcache、freecache 这类专门优化的库它们通过分片锁和零 GC 设计来减少性能损耗。实际使用中有几个坑要注意。第一容量上限不好控制稍不注意就可能吃光实例内存我建议启动时就设置MaxEntrySize和HardMaxCacheSize超过硬上限的写入直接拒绝或者走丢弃策略而不是让进程被 OOM。第二多实例部署时命中率会很惨假设你有 10 个副本热数据只占整体的 20%那分摊到每个副本上的命中率会低很多大量请求依然要回源。第三重启即失效发布一次代码缓存全空如果没有回源保护容易瞬间打爆下游。所以在 Templar 的架构里内存缓存不是被淘汰了而是被降级为 L1 缓存放在 Redis 前面做局部热点加速。它的 TTL 必须设得短比如 30 到 60 秒防止本地数据和 Redis 里的最新值偏差过大。代码上可以再包一层 singleflight 防止并发回源后面会具体说。2.2 Memcached简单可靠的老牌选手Memcached 在缓存领域资格很老它的核心模型非常简单纯 KV数据放内存LRU 淘汰多线程处理请求。因为实现简单它不需要考虑持久化、复制、复杂数据结构这些额外负担所以单实例吞吐可以堆得很高很多早期互联网公司都是用 Memcached 顶住超大流量 KV 读取的。实操层面使用 Memcached 要注意批量接口。客户端可以走gets批量获取而不是循环串行get否则 QPS 一高延迟立刻起来。如果需要分布式部署客户端侧得做一致性哈希节点变更时尽量减少缓存失效范围。CASCheck And Set也是 Memcached 一个很有用的特性适合做乐观锁场景比如防止并发写覆盖。但在我这次的选型评估里Memcached 没有成为最终选择原因不是性能而是灵活性。Templar 很多数据需要用 Hash 结构保存多个字段用 String 结构保存分布式锁用 ZSet 存排行榜或者时间序列这些功能如果放在 Memcached 里都需要应用层自己拼协议、自己做映射等于把一个中间件用得很别扭还要额外维护一套工具链。另外 Memcached 在持久化和主从复制方面的方案比较少一旦节点宕机缓存是全部丢失而不是部分恢复对核心链路来说风险偏高。2.3 Redis功能全面但选型要克制Redis 在 Templar 的选型评估里几乎是一个绕不开的存在。它的数据类型覆盖了绝大多数缓存场景String 可以做分布式锁和计数器Hash 可以保存接口快照List 可以当简单消息队列Set 可以做去重和标签筛选ZSet 可以做排行榜和滑动窗口。有这些原生结构很多业务逻辑直接从应用层下沉到缓存层代码复杂度大幅下降。如果你还没接触过 Redis想先本地跑一跑redis 下载安装配置其实不复杂生产环境我更推荐直接走容器化部署用 docker 安装 redis 主从可以快速搭建一套测试集群。但有一点要克制Redis 的功能丰富不代表应该把什么数据都放进去。缓存就是缓存不要把数据库的核心状态一股脑放进 Redis尤其不要在缓存里做事务、复杂聚合这类数据库该干的事否则数据一致性会变成一场灾难。客户端选型上Go 项目我常用 go-redis 或者 redigo。go-redis 功能更完整支持哨兵和集群模式代码上用一个很短的示例就能跑通基本读写package main import ( context fmt time github.com/redis/go-redis/v9 ) func main() { rdb : redis.NewClient(redis.Options{ Addr: 127.0.0.1:6379, Password: , DB: 0, PoolSize: 50, MinIdleConns: 10, DialTimeout: 2 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, }) ctx : context.Background() if err : rdb.Set(ctx, api:cache:demo, value, 60*time.Second).Err(); err ! nil { panic(err) } val, err : rdb.Get(ctx, api:cache:demo).Result() if err ! nil { panic(err) } fmt.Println(val) }这段代码里的连接池参数值得单独说一下。Redis 是单线程模型连接数不是开得越大越好连接过多反而会让 CPU 消耗在线程调度和套接字读写上。我们压测后最终把连接池压在了 50 到 100 之间具体数值取决于服务的并发模型。另外 Redis 的序列化方式也要提前规划很多人直接塞 JSON读起来方便但体积大、解析慢。Templar 对响应体做缓存时用的是 Protobuf接口打完包再缓存命中后直接反序列化性能比 JSON 高一截。高可用方面Redis 的方案比较成熟主从加哨兵已经是标准配置数据量再大可以走 Cluster 模式。网上很多 redis 高可用方案和 redis 集群部署教程可以参考但生产环境尤其要注意 Cluster 模式下批量操作的限制比如MGET的 key 必须分布在同一个 slot否则客户端会报 CROSSSLOT 错误解决办法是用 hash tag或者干脆在客户端循环分批。我们后来做缓存读取时就把单个 key 尽量收敛到一个业务前缀下避免踩这个坑。2.4 GroupcacheGoogle出品的分布式缓存骨架Groupcache 是这四个方案里最特殊的一个。它不是独立服务而是嵌入应用进程的一个 Go 库安装方式就是一行go get。它解决的问题非常明确多节点共享缓存同时避免重复回源。当一个节点收到请求发现缓存不存在它不会直接打后端而是先通过一致性哈希找到负责这个 key 的 peer让 peer 去加载数据然后自己再填充本地副本。这种机制天然带 singleflight 效果同一时刻同一个 key 的回源请求只会执行一次。我在本地搭了一个最小示例把配置数据加载放到 Getter 里三个节点互相注册结构大概是这样的import ( github.com/golang/groupcache ) var group groupcache.NewGroup(config, 6420, groupcache.GetterFunc( func(ctx context.Context, key string, dest groupcache.Sink) error { // 这里从数据库或远程接口加载数据 value : loadConfig(key) return dest.SetBytes(value) }, ))单看这个模型它很适合配置下发、静态字典、大规模读多写少且数据很少变动的场景。Groupcache 的节点之间会自动复制热数据当一个热点 key 被某台节点加载后后续打到其他节点的请求也能经由 peers 快速获取相当于缓存集群内自然做了一层扩散这就是它的核心价值。但是有个致命限制它没有删除接口也没有过期机制。一旦数据被缓存就只能等进程重启才能清掉。Templar 里很多缓存数据是带有业务状态的订单、库存、用户偏好这些数据更新频率很高如果用了 Groupcache就相当于失去了主动失效能力这在我们场景里完全不可接受。所以最后我把它放到配置同步这类低频更新模块上而不是作为业务数据主缓存。这也提醒大家任何组件都有适用边界Groupcache 适合“基本不变”的数据不适合“每秒在变”的数据。3. 选型决策与真实负载验证3.1 压测环境与测试方法光看特性和文档是不够的做缓存选型必须用实际负载数据说话。我在本地测试环境搭了一套压测集群模拟 Templar 的三种典型缓存对象小对象约 1KB接口返回码和摘要、中对象约 10KB列表页快照、大对象约 50KB详情页组装结果。压测工具用的 vegeta 和 wrk分别测长稳和峰值。场景设定是总 QPS 5000热点集中度遵循 80/20 原则也就是 20% 的 key 承担 80% 的流量。单实例测试时进程内 bigcache 的最高吞吐最猛延迟也最低但这是在单节点且没有跨进程共享需求的前提下。Memcached 和 Redis 的吞吐差距没有想象中那么大Memcached 在纯 KV 小对象上有微弱优势Redis 在中大对象上的读取稳定性和客户端能力明显更胜一筹。Groupcache 的吞吐和延迟跟进程内缓存接近毕竟它也是内存操作但首次回源时有节点间填充的开销压测初期会有部分请求延迟波动。我整理了一张相对对比表数值以实际测试环境为准主要看趋势方案小对象吞吐中对象吞吐大对象稳定度跨节点一致性主动失效bigcache进程内最高高高不支持支持 TTLMemcached高高中客户端哈希支持 DeleteRedis高中高高主从/集群支持 Delete/ExpireGroupcache高高高内置 Peer不支持3.2 关键参数选择与计算过程压测之后还要做容量规划和参数调优这一步不能省。我算了一个粗粒度的容量模型假设 Templar 需要缓存的中对象平均 10KBTTL 设定为 300 秒QPS 4000那么同一时刻缓存里可能存在的积压数据量大约是 10KB × 4000 × 300也就是 12GB。这只是一个上界估算实际不会所有 key 都同时存在但至少说明内存不能拍脑袋给 2GB否则淘汰会非常频繁。再叠加淘汰、压缩比和突发流量我当时给 Redis 节点预留了充足的内存和 8GB 的 maxmemory并设置allkeys-lru淘汰策略。这个策略适合纯粹的缓存场景容错率高不会因为某个缓存 key 占满内存导致写入失败。连接池配置前面给过我再强调一下不要把PoolSize调太大Redis 单线程执行命令连接多了只会增加上下文切换还容易把文件描述符耗尽。合理做法是跑一轮小压测逐步提高连接数找到吞吐不再上行的拐点那才是适合你场景的数值。还有一点是关于 redis 数据类型的规划。开发时比较容易犯的错是很多团队共用一个 Redis 实例key 命名混乱数据类型随意用。我们在 Templar 里按业务域做了 db 隔离又是 api、会话、限流、分布式锁分别放到不同 db配合scan巡检 key 数量。人工排查时用 redis desktop manager 或 another redis desktop manager 这类可视化客户端确实直观但生产环境我更习惯直接用redis-cli可视化客户端只用来快速浏览结构和数据分布。3.3 最终选型结论压测和排障之后我给出的最终结论是Redis 作为主缓存后端bigcache 作为本地 L1 缓存Groupcache 放在配置下发这类低频更新模块。Memcached 在这轮选型里被淘汰了不是因为性能而是因为业务模型里需要的 Hash、分布式锁这些能力它给不了硬上会引入额外复杂度。Redis 能留下来靠的是数据结构和生态的完整度。同一个中间件既能做缓存又能兼职分布式锁、限流计数器、排行榜高可用方案也成熟。Templar 内部很多需要原子递增的场景比如生成递增号直接走 Redis INCR省掉了数据库锁的复杂实现这套组合在之后的线上运行中表现很稳定。本地 L1 缓存之所以还要保留是因为线上热点问题比压测更极端。我们统计过约 5% 的热点 key 占据了 80% 以上的请求流量如果每个请求都打 RedisRedis 的压力还是很大。加一层短 TTL 的本地缓存后热点请求直接在本进程返回Redis 负载降了一个量级。这一层用 bigcache 实现非常简单核心关注点只有一个TTL 一定要短并要接受极端情况下的短暂不一致。对 Templar 来说秒级延迟是完全可以接受的。4. 常见问题与排障实录4.1 缓存穿透、击穿、雪崩的应对上线之后缓存经典三连是绕不开的。穿透指请求的 key 在缓存和数据库中都不存在导致每次都要打数据库击穿指某个热点 key 过期瞬间大量请求同时回源雪崩指大量 key 在同一时间过期下游被瞬间打满。穿透的应对很简单两种手段结合用布隆过滤器拦截一定不存在的 key或者对空结果也做短时间缓存。Templar 里有些接口的过滤条件特别多布隆过滤器不一定覆盖全所以我主要用空值缓存TTL 设置 30 秒左右既能挡住大部分穿透流量又不会让缓存堆积大量的无用 key。击穿的应对是用 singleflight 合并回源。Go 语言里实现这个并不复杂标准思路是给同一个 key 的回源请求加一个共享锁只有第一个请求真正执行加载函数其余请求等待它返回结果。Groupcache 内部就内置了这个能力Redis 侧需要自己在客户端做一层封装可以借助golang.org/x/sync/singleflight来实现。雪崩的应对最实用的一招是给 TTL 加随机扰动。比如基础 TTL 是 300 秒实际设置时上下浮动 30 秒让缓存的过期时间在时间轴上散开避免同一时刻集中失效。这个方法成本极低效果却非常明显值得立刻用起来。4.2 Redis集群部署中踩过的坑Redis 集群部署虽然成熟实际用起来还是有不少细节。前面提到的 MGET 跨 slot 问题就是一个典型坑。集群模式下key 根据 CRC16 算出来的 slot 分布到不同节点MGET如果涉及多个 slot会直接报错。我们在代码里用同步获取多个 key 时优先把这些 key 归到同一个业务前缀下或者拆成多个并发单 key 请求既绕过限制也提高并行度。还有个高频坑是KEYS命令。线上 Redis 禁掉KEYS已经是共识但偶尔还能看到有人通过可视化客户端去执行KEYS *一旦库里有几十万 key整个实例会卡住好几秒。排查时需要遍历 key 就用SCAN它通过游标分批返回不会阻塞服务。Lua 脚本在集群模式下也有约束。Templar 里有几个原子操作是用 Lua 脚本实现的比如库存扣减和分布式锁续期脚本里访问的 key 必须落在同一个 slot否则无法执行。我们初版上线时没注意这个问题集群环境下一跑就报错后来把脚本涉及的 key 加上 hash tag也就是让 key 的公用部分用大括号包起来比如{user:1024}:lock才彻底解决。还有一个运维层面的体验。查看线上 Redis 数据时Redis Desktop Manager 和 Another Redis Desktop Manager 都挺方便但我不建议在可视化工具里直接改数据。生产环境任何批量修改都必须走评审和可回滚方案可视化工具更适合做数据观测而不是操作面板。4.3 缓存一致性问题的排查缓存与数据库的一致性是所有缓存方案里最考验人的地方。Templar 早期有一个订单状态缓存更新时先改数据库再删除缓存逻辑看起来没问题但用户反馈总是看到旧状态。排查后发现两个原因一个是删除缓存前有并发请求把旧数据写回缓存另一个是其他模块通过 HSET 直接改了 Hash 中的某个字段导致整个缓存 key 的删除逻辑没有触发。这个案例让我意识到缓存一致性问题不能只靠晚了再删还需要做两件事一是状态更新走统一消息删除缓存的操作必须和写数据库在同一个处理链路里不要允许在多个地方随意改缓存二是给关键缓存设置较短的兜底 TTL即使删除失败也能保证最终在一段时间后恢复一致。延迟双删也是一个常用技巧先删缓存、更新数据库再延迟一小段后删一次缓存可以覆盖掉并发窗口期写回的脏数据。延迟时间一般取 100 到 500 毫秒具体根据业务耗时压测调整。说到最后选型其实没有标准答案。我的真实体会是缓存后端没有最好的只有跟你的业务模型、团队维护能力最匹配的。如果业务只有几 GB 的静态配置Groupcache 或者进程内缓存已经足够如果追求极简高吞吐的纯 KV 场景Memcached 依然能打但如果你像我一样需要多样数据结构、分布式锁、原子计数还要有成熟的高可用方案那 Redis 会是省心又长远的选择。另一个建议是无论选哪个先把 TTL、淘汰策略、容量规划和缓存穿透保护设计好中间件本身反而很少成为瓶颈。
返回列表