在探探后端的微服务体系中,User服务作为一个业务基础服务,承担了用户的基本信息、扩展资料、用户状态等核心数据的存储、更新和查询工作,特点是访问流量大,稳定性要求高,其中,缓存机制发挥了非常重要的作用,因此,这里分享一下User服务的缓存架构和相关变迁。
本文中,“User服务”主要指代
grpc.user.tt
服务。
基于业务规模和维护成本的考虑,目前,与其它大多数微服务一样,User服务及其依赖的数据库、缓存等中间件都是只部署一套服务,未按业务场景或单元化的方式做进一步的拆分,因此,下面主要针对单套服务内部的缓存介绍。
仅数据库支撑数据读写
探探早期的业务规模小,并没有采用任何独立的缓存组件,主要靠PostgreSQL数据库承担读写流量。数据库实例都是物理机部署,部署和使用方式也很直接:
硬件配置高,内存够大,业务热点数据可以直接命中数据库缓存。
机器数量多,印象中CoreDB达到1主20从(多业务混用)。
可以看到,对数据库的使用挺狠的,纵向扩展和横向扩展都到位了。
Redis作为分布式缓存
Ring模式
User是一个典型的读多写少的业务,随着业务的发展,使用缓存是一个很自然的选择。
2018年探探后端进行微服务化升级改造后,User服务上线了Redis缓存,采用Ring模式,由Redis的Client端根据key的一致性hash路由到相应Server实例,而Redis的Server实例不维护任何路由信息。
Redis的Ring模式部署
注:图中的Client是Redis Client,Server是Redis Server。为了便于展示,这里没有体现出一致性hash环上的虚拟节点。
简单Ring模式的优点是不需要从库,因此内存利用率很高。最初是16个实例,随着数据和流量的增长,经历了几次扩容,实例数量翻了几倍。
缓存数据格式:
key:数据类型+用户ID
value:数据结构体的Protobuf编码
TTL:参考下面“数据过期”部分的表格
缓存更新策略:
主动修改:用户或后台主动变更数据,从PostgreSQL的master读取数据,然后Set到Redis。
被动加载:一般读取缓存数据miss后,从PostgreSQL的slaves读取数据,然后Set到Redis。
核心数据的缓存过期时间采用了2套标准(非核心数据只有1套标准):
过期标准 | 过期时间 | 设置方式 |
---|---|---|
物理TTL | 2小时 | 由Redis的key过期时间控制 |
逻辑TTL | 3分钟 | 由业务逻辑设置和检查,复用数据结构体的UpdateTime 或添加独立字段 |
为什么采用2套过期时间呢?
为了保持读写性能和控制复杂度,业务上没有采用任何事务来保证缓存数据与数据库的严格一致,较短的逻辑TTL可以让缓存数据尽快刷新,将潜在的不一致情况控制在分钟级别。
业务强依赖数据库的持久存储,而当时的PostgreSQL数据库版本较低,故障转移和稳定性方面存在一些问题,较长的物理TTL可以让数据在缓存中多留一段时间,为可能出现的数据库故障争取时间,缩小影响范围。后来数据库的问题解决了,这个方案也保留下来,以防出现其它问题时进行服务降级。
Ring模式基于一致性hash进行Redis实例的故障转移,少量实例故障时,只有一小部分实例发生数据转移,影响范围较小,恢复时间也较快,但是扩容缩容比较麻烦。
以扩容为例,业务扩容时,一般都会将Redis实例数量翻倍,或者添加比较多的实例,
如果一个实例一个实例地扩容,那么每次影响范围都比较可控,但是业务需要一次一次地变更配置和部署程序,整体操作过程很耗时。
如果一次完成全部实例的扩容,那么会同时出现很多实例的数据转移,影响范围大,数据库难以承受穿透缓存的瞬时流量,因此,只能在流量低峰期执行。
另外,随着服务流量的不断增大,上述两种方式都无法做到业务无感知或弱感知。
为什么不采用Redis官方提供的Cluster模式呢?
User服务的查询量、存储量,以及服务资源保持3倍buffer的稳定性标准,要求Redis集群至少支持300w的QPS,并为QPS的继续增长预留线性扩展能力。
Redis实例的瓶颈主要在CPU上,而单线程模型使得每个实例只适合使用一个CPU(高版本的Redis可能会改变),这使得Redis集群的实例数量保持在较高的水平。
Redis Cluster模式的领导选举、消息同步和故障转移等机制,造成了其内部实例之间有很多通信开销,规模是N*(N-1)
,随着实例数量增多,集群内部通信开销会越来越大,最终,通信风暴会影响性能和稳定性。
如果拆分为多个Redis Cluster集群,那么又会面临跨集群数据分配和迁移的问题,读写复杂度和维护成本明显增大。
Ring+Sentinel模式是对Ring模式的扩展。
Ring模式下,一致性hash环上的每个物理节点对应一个Redis实例。
Ring+Sentinel模式下,一致性hash环上的每个物理节点对应一个Redis小集群,这个Redis小集群可以包括1主N从,并基于Sentinel实现小集群内部的故障转移。
Redis的Ring+Sentinel模式部署
注:图中的FailoverClient是Redis Client,Server是Redis Server。FailoverClient基于Sentinel实现故障转移,可以切换读操作到其它Server实例。为了便于展示,这里没有体现出一致性hash环上的虚拟节点。
具体实现时,实际是一个Write Ring,一个Read Ring。Write Ring基于Ring模式的一致性hash实现故障转移;Read Ring具备双重故障转移能力:小集群内单实例故障基于Sentinel模式实现故障转移,小集群全部实例故障基于Ring模式实现故障转移(后者的概率低于前者)。
在规划好Ring上的初始节点数量后,扩容时主要是增加查询能力,在相应的Redis小集群上增加从库即可,操作方便快捷,影响范围可控,业务服务不用更改配置或部署程序。
受限于当时go-redis官方包的功能和探探common库的维护能力,实际应用时,采用了探探后端团队魔改的go-redis和common库版本。这样做有利有弊,是另外一个问题了。
缩容过程也是类似的,不再详述。
上面解决了比较快捷地扩容和缩容的问题,线上还会遇到另外一个问题:Redis集群服务器整体搬迁或更换。如果遇到机房搬迁或批量的服务器问题,那么整个Redis集群是要更换全部机器的。
如果是官方的Redis Cluster模式,那么这个问题比较好解决:先扩容新实例,再缩容旧实例就好了。
对于Ring模式或Ring+Sentinel模式,需要业务程序感知到配置变化并执行部署发布,同时,也要注意避免大量请求穿透缓存压垮数据库。
User服务选择了对common库的cache接口进行二次封装,在上层逻辑无感知的情况下,通过服务配置决定是否双写新旧两个Redis集群。双写一段时间后,数据预热和数据同步完成,就可以自由地在新旧两个集群之间切换了。验证迁移无误后,下线双写配置就关闭了对旧集群的读写。
现在DBA有了更好的Redis迁移工具,不一定需要业务自己双写数据。
添加Server本地缓存
单机独立
在升级服务器机型后,User服务的实例具备了万兆网卡,以及较大的内存容量,主要性能瓶颈在CPU。由于用户数据的更新频率不算高,如果由User服务实例本地内存缓存一部分数据,那么可以:
减少User服务实例到Redis的网络流量,从而减轻网络抖动对服务稳定性的影响。
降低调用链路的复杂度和I/O时间,从而降低接口查询响应时间,提高并发处理能力。
比较项 | 方案一:多机分片缓存 | 方案二:单机独立缓存 |
---|---|---|
流量切分 | 按用户ID 或来源IP 将请求路由到相应实例 | 请求以Round Robin的形式等概率路由到任意实例 |
总容量 | 高,单实例内存量*分片数量 | 低,单实例内存量 |
均衡性 | 依赖流量切分方法与实际数据情况 | 均衡 |
组件依赖 | 要求RPC框架具备流量切分能力 | 无 |
批量查询 | 要求RPC框架或Client端改造升级 | 在RPC Server端的业务逻辑实现,Client端无感知 |
数据同步 | 如果按照每个实例切分流量,那么不需要 | 如果要求较高的一致性,那么需要 |
考虑业务实际情况,结合上面两种实现方式的优缺点,最终选择了方案二:单机独立缓存,理由如下:
单实例的内存容量就可以支撑活跃用户的规模,保持较高的缓存命中率,总容量够用。
方案二能够保证各个服务实例的QPS均衡、内存使用率均衡、整体负载均衡。
方案二不需要改造RPC框架,RPC的Client端无感知。
模拟真实的线上请求方式和数据使用场景,对比了三种常见的缓存淘汰策略:
比较项 | FIFO | LFU | LRU |
---|---|---|---|
实现成本 | 较低 | 较高 | 中等 |
实测命中率 | 不够高 | 不够高 | 较高 |
结果是LRU比较适合业务实际使用。
缓存组件选型:(Go语言版本)
比较项 | 优点 | 缺点 |
---|---|---|
groupcache | 精确LRU,value支持interface{} | GC开销高,非并发安全 |
bigcache | GC开销小 | 不支持LRU,命中率相对偏低 |
fastcache | GC开销小,读写性能相对较好 | 不支持LRU,命中率相对偏低 |
freecache | GC开销小,近似LRU性价比高 | value必须是[]byte |
普通array | GC开销最小,value支持类型丰富 | 开发成本高,通用性差,部分空间浪费 |
普通slice | 通用性好,value支持类型丰富 | 开发成本高,GC开销高,非并发安全 |
普通map | 通用性好,value支持类型丰富 | GC开销高,非并发安全 |
最终选择了基于freecache
来实现本地缓存。
前面表格中有提到,如果要求较高的数据一致性,那么单机独立缓存方案需要考虑数据同步,因为每个实例管理各自的本地内存缓存,实例之间可能出现数据不一致的情况。
User服务正好属于这种情况。虽然用户数据不需要很强的一致性,但是,受到App与服务端数据通信方式以及App上的数据存储、展现形式的限制,不一致的缓存数据会导致app上显示异常:较短的时间内反复查看相同的用户,可能展现内容不一致。秒级以上的缓存数据同步延迟,就可能感知到这一点。
为了解决这个问题,采取了两种措施:
本地缓存数据的过期时间与Redis缓存的逻辑TTL一致,这作为一个兜底的策略,确保即使出现缓存不一致,也仅仅维持在分钟级别。
更新某一条数据时,借助一个Pub/Sub通道来通知其它实例清除本地缓存中的这条数据。我们复用相同的Redis服务器搭建了一套Pub/Sub专用的小集群,由于数据更新频率不高,这项操作不会消耗太多资源。
第2条的数据同步策略与Redis 6的客户端缓存特性有相似之处,但更定制化一些,精度高,开销小。目前线上主要使用Redis 4版本,还不能直接使用Redis 6的新特性。
除了上述实际应用的缓存数据同步策略之外,我们还验证了另外一种同步策略:
每条缓存数据维护一个版本号,独立存储在Redis里面,每次更新Redis数据时,同步增大数据版本号。
User服务每次读取到本地缓存数据后,带着版本号请求Redis:
如果请求数据版本号与Redis中的数据版本号一致,那么,Redis不返回数据,User服务直接把本地缓存数据返回给调用方。
如果请求数据版本号与Redis中的数据版本号不一致,那么,Redis返回数据,User服务使用新数据覆盖本地缓存,再返回给调用方。
由于这种实现方式需要借助Redis的Lua脚本定制Get
和Set
命令,单线程模型的Redis多做了一些事情,导致CPU使用率上升为正常的2倍。而Redis的主要瓶颈也在CPU使用率,因此,User服务放弃了这种方案。
从另一个角度看,这种方案类似于HTTP协议的缓存机制,对于全链路的“客户端缓存”的数据同步是有意义的,它不会减少网络I/O次数,但是能够有效减少网络传输数据量。
User服务目前的缓存架构是在业务发展过程中逐步演化形成的,主要考虑因素包括业务规模、查询性能、故障转移、开发成本、维护成本等,它不一定适合其它业务,但是其中的一些设计思路和权衡结果,可以拿来参考。