请选择 进入手机版 | 继续访问电脑版

默认
打赏 发表评论 5
想开发IM:买成品怕坑?租第3方怕贵?找开源自已撸?尽量别走弯路了... 找站长给点建议
知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路
微信扫一扫关注!

本文来自知乎官方技术团队的“知乎技术专栏”,感谢原作者陈鹏的无私分享。


1、引言


知乎存储平台团队基于开源Redis 组件打造的知乎 Redis 平台,经过不断的研发迭代,目前已经形成了一整套完整自动化运维服务体系,提供很多强大的功能。本文作者陈鹏是该系统的负责人,本次文章深入介绍了该系统的方方面面,值得互联网后端程序员仔细研究。

v2-98aa01df20134d401607137eecb11d98_r-2.jpg

2、关于作者


陈鹏:现任知乎存储平台组 Redis 平台技术负责人,2014 年加入知乎技术平台组从事基础架构相关系统的开发与运维,从无到有建立了知乎 Redis 平台,承载了知乎高速增长的业务流量。

3、技术背景


知乎作为知名中文知识内容平台,每日处理的访问量巨大 ,如何更好的承载这样巨大的访问量,同时提供稳定低时延的服务保证,是知乎技术平台同学需要面对的一大挑战。

知乎存储平台团队基于开源 Redis 组件打造的 Redis 平台管理系统,经过不断的研发迭代,目前已经形成了一整套完整自动化运维服务体系,提供一键部署集群,一键自动扩缩容, Redis 超细粒度监控,旁路流量分析等辅助功能。

目前,Redis 在知乎的应用规模如下:

  • 1)机器内存总量约 70TB,实际使用内存约 40TB;
  • 2)平均每秒处理约 1500 万次请求,峰值每秒约 2000 万次请求;
  • 3)每天处理约 1 万亿余次请求;
  • 4)单集群每秒处理最高每秒约 400 万次请求;
  • 5)集群实例与单机实例总共约 800 个;
  • 6)实际运行约 16000 个 Redis 实例;
  • 7)Redis 使用官方 3.0.7 版本,少部分实例采用 4.0.11 版本。

4、知乎的Redis应用类型


根据业务的需求,我们将Redis实例区分为单机(Standalone)和集群(Cluster)两种类型,单机实例通常用于容量与性能要求不高的小型存储,而集群则用来应对对性能和容量要求较高的场景。

而在集群(Cluster)实例类型中,当实例需要的容量超过 20G 或要求的吞吐量超过 20万请求每秒时,我们会使用集群(Cluster)实例来承担流量。集群是通过中间件(客户端或中间代理等)将流量分散到多个 Redis 实例上的解决方案。知乎的 Redis 集群方案经历了两个阶段:客户端分片(2015年前使用的方案)与 Twemproxy 代理(2015年至今使用的方案)。

下面将分别来介绍这两个类型的Redis实例在知乎的应用实践情况。

5、知乎的Redis实例应用类型1:单机(Standalone)


对于单机实例,我们采用原生主从(Master-Slave)模式实现高可用,常规模式下对外仅暴露 Master 节点。由于使用原生 Redis,所以单机实例支持所有 Redis 指令。

对于单机实例,我们使用 Redis 自带的哨兵(Sentinel)集群对实例进行状态监控与 Failover。Sentinel 是 Redis 自带的高可用组件,将 Redis 注册到由多个 Sentinel 组成的 Sentinel 集群后,Sentinel 会对 Redis 实例进行健康检查,当 Redis 发生故障后,Sentinel 会通过 Gossip 协议进行故障检测,确认宕机后会通过一个简化的 Raft 协议来提升 Slave 成为新的 Master。

通常情况我们仅使用 1 个 Slave 节点进行冷备,如果有读写分离请求,可以建立多个 Read only slave 来进行读写分离。

2.jpg

如上图所示,通过向 Sentinel 集群注册 Master 节点实现实例的高可用,当提交 Master 实例的连接信息后,Sentinel 会主动探测所有的 Slave 实例并建立连接,定期检查健康状态。客户端通过多种资源发现策略如简单的 DNS 发现 Master 节点,将来有计划迁移到如 Consul 或 etcd 等资源发现组件 。

当 Master 节点发生宕机时,Sentinel 集群会提升 Slave 节点为新的 Master,同时在自身的 pubsub channel +switch-master 广播切换的消息,具体消息格式为:

switch-master <master name> <oldip> <oldport> <newip> <newport>


watcher 监听到消息后,会去主动更新资源发现策略,将客户端连接指向新的 Master 节点,完成 Failover,具体 Failover 切换过程详见 Redis 官方文档(Redis Sentinel Documentation - Redis)。

实际使用中需要注意以下几点:

  • 1)只读 Slave 节点可以按照需求设置 slave-priority 参数为 0,防止故障切换时选择了只读节点而不是热备 Slave 节点;
  • 2)Sentinel 进行故障切换后会执行 CONFIG REWRITE 命令将 SLAVEOF 配置落地,如果 Redis 配置中禁用了 CONFIG 命令,切换时会发生错误,可以通过修改 Sentinel 代码来替换 CONFIG 命令;
  • 3)Sentinel Group 监控的节点不宜过多,实测超过 500 个切换过程偶尔会进入 TILT 模式,导致 Sentinel 工作不正常,推荐部署多个 Sentinel 集群并保证每个集群监控的实例数量小于 300 个;
  • 4)Master 节点应与 Slave 节点跨机器部署,有能力的使用方可以跨机架部署,不推荐跨机房部署 Redis 主从实例;
  • 5)Sentinel 切换功能主要依赖 down-after-milliseconds 和 failover-timeout 两个参数,down-after-milliseconds 决定了 Sentinel 判断 Redis 节点宕机的超时,知乎使用 30000 作为阈值。而 failover-timeout 则决定了两次切换之间的最短等待时间,如果对于切换成功率要求较高,可以适当缩短 failover-timeout 到秒级保证切换成功,具体详见 Redis 官方文档;
  • 6)单机网络故障等同于机器宕机,但如果机房全网发生大规模故障会造成主从多次切换,此时资源发现服务可能更新不够及时,需要人工介入。

6、知乎的Redis实例应用类型2:集群之客户端分片方案(2015以前使用)


早期知乎使用 redis-shard 进行客户端分片,redis-shard 库内部实现了 CRC32、MD5、SHA1 三种哈希算法 ,支持绝大部分 Redis 命令。使用者只需把 redis-shard 当成原生客户端使用即可,无需关注底层分片。

3.jpg

基于客户端的分片模式具有如下优点:

  • 1)基于客户端分片的方案是集群方案中最快的,没有中间件,仅需要客户端进行一次哈希计算,不需要经过代理,没有官方集群方案的 MOVED/ASK 转向;
  • 2)不需要多余的 Proxy 机器,不用考虑 Proxy 部署与维护;
  • 3)可以自定义更适合生产环境的哈希算法。

但是也存在如下问题:

  • 1)需要每种语言都实现一遍客户端逻辑,早期知乎全站使用 Python 进行开发,但是后来业务线增多,使用的语言增加至 Python,Golang,Lua,C/C++,JVM 系(Java,Scala,Kotlin)等,维护成本过高;
  • 2)无法正常使用 MSET、MGET 等多种同时操作多个 Key 的命令,需要使用 Hash tag 来保证多个 Key 在同一个分片上;
  • 3)升级麻烦,升级客户端需要所有业务升级更新重启,业务规模变大后无法推动;
  • 4)扩容困难,存储需要停机使用脚本 Scan 所有的 Key 进行迁移,缓存只能通过传统的翻倍取模方式进行扩容;
  • 5)由于每个客户端都要与所有的分片建立池化连接,客户端基数过大时会造成 Redis 端连接数过多,Redis 分片过多时会造成 Python 客户端负载升高。

具体特点详见:https://github.com/zhihu/redis-shard

早期知乎大部分业务由 Python 构建,Redis 使用的容量波动较小, redis-shard 很好地应对了这个时期的业务需求,在当时是一个较为不错解决方案。

7、知乎的Redis实例应用类型2:集群之Twemproxy 集群方案(2015之今在用)


2015 年开始,业务上涨迅猛,Redis 需求暴增,原有的 redis-shard 模式已经无法满足日益增长的扩容需求,我们开始调研多种集群方案,最终选择了简单高效的 Twemproxy 作为我们的集群方案。

由 Twitter 开源的 Twemproxy 具有如下优点:

  • 1)性能很好且足够稳定,自建内存池实现 Buffer 复用,代码质量很高;
  • 2)支持 fnv1a_64、murmur、md5 等多种哈希算法;
  • 3)支持一致性哈希(ketama),取模哈希(modula)和随机(random)三种分布式算法。

具体特点详见:https://github.com/twitter/twemproxy

但是缺点也很明显:

  • 1)单核模型造成性能瓶颈;
  • 2)传统扩容模式仅支持停机扩容。

对此,我们将集群实例分成两种模式,即缓存(Cache)和存储(Storage):

如果使用方可以接收通过损失一部分少量数据来保证可用性,或使用方可以从其余存储恢复实例中的数据,这种实例即为缓存,其余情况均为存储。


我们对缓存和存储采用了不同的策略,请继续往下读。

7.1存储


4.jpg

对于存储我们使用 fnv1a_64 算法结合 modula 模式即取模哈希对 Key 进行分片,底层 Redis 使用单机模式结合 Sentinel 集群实现高可用,默认使用 1 个 Master 节点和 1 个 Slave 节点提供服务,如果业务有更高的可用性要求,可以拓展 Slave 节点。

当集群中 Master 节点宕机,按照单机模式下的高可用流程进行切换,Twemproxy 在连接断开后会进行重连,对于存储模式下的集群,我们不会设置 auto_eject_hosts, 不会剔除节点。

同时,对于存储实例,我们默认使用 noeviction 策略,在内存使用超过规定的额度时直接返回 OOM 错误,不会主动进行 Key 的删除,保证数据的完整性。

由于 Twemproxy 仅进行高性能的命令转发,不进行读写分离,所以默认没有读写分离功能,而在实际使用过程中,我们也没有遇到集群读写分离的需求,如果要进行读写分离,可以使用资源发现策略在 Slave 节点上架设 Twemproxy 集群,由客户端进行读写分离的路由。

7.2缓存


考虑到对于后端(MySQL/HBase/RPC 等)的压力,知乎绝大部分业务都没有针对缓存进行降级,这种情况下对缓存的可用性要求较数据的一致性要求更高,但是如果按照存储的主从模式实现高可用,1 个 Slave 节点的部署策略在线上环境只能容忍 1 台物理节点宕机,N 台物理节点宕机高可用就需要至少 N 个 Slave 节点,这无疑是种资源的浪费。

5.jpg

所以我们采用了 Twemproxy 一致性哈希(Consistent Hashing)策略来配合 auto_eject_hosts 自动弹出策略组建 Redis 缓存集群。

对于缓存我们仍然使用使用 fnv1a_64 算法进行哈希计算,但是分布算法我们使用了 ketama 即一致性哈希进行 Key 分布。缓存节点没有主从,每个分片仅有 1 个 Master 节点承载流量。

Twemproxy 配置 auto_eject_hosts 会在实例连接失败超过 server_failure_limit 次的情况下剔除节点,并在 server_retry_timeout 超时之后进行重试,剔除后配合 ketama 一致性哈希算法重新计算哈希环,恢复正常使用,这样即使一次宕机多个物理节点仍然能保持服务。

6.jpg

在实际的生产环境中需要注意以下几点:

  • 1)剔除节点后,会造成短时间的命中率下降,后端存储如 MySQL、HBase 等需要做好流量监测;
  • 2)线上环境缓存后端分片不宜过大,建议维持在 20G 以内,同时分片调度应尽可能分散,这样即使宕机一部分节点,对后端造成的额外的压力也不会太多;
  • 3)机器宕机重启后,缓存实例需要清空数据之后启动,否则原有的缓存数据和新建立的缓存数据会冲突导致脏缓存。直接不启动缓存也是一种方法,但是在分片宕机期间会导致周期性 server_failure_limit 次数的连接失败;
  • 4)server_retry_timeout 和 server_failure_limit 需要仔细敲定确认,知乎使用 10min 和 3 次作为配置,即连接失败 3 次后剔除节点,10 分钟后重新进行连接。

7.3Twemproxy 部署


在方案早期我们使用数量固定的物理机部署 Twemproxy,通过物理机上的 Agent 启动实例,Agent 在运行期间会对 Twemproxy 进行健康检查与故障恢复,由于 Twemproxy 仅提供全量的使用计数,所以 Agent 运行时还会进行定时的差值计算来计算 Twemproxy 的 requests_per_second 等指标。

后来为了更好地故障检测和资源调度,我们引入了 Kubernetes,将 Twemproxy 和 Agent 放入同一个 Pod 的两个容器内,底层 Docker 网段的配置使每个 Pod 都能获得独立的 IP,方便管理。

最开始,本着简单易用的原则,我们使用 DNS A Record 来进行客户端的资源发现,每个 Twemproxy 采用相同的端口号,一个 DNS A Record 后面挂接多个 IP 地址对应多个 Twemproxy 实例。

初期,这种方案简单易用,但是到了后期流量日益上涨,单集群 Twemproxy 实例个数很快就超过了 20 个。由于 DNS 采用的 UDP 协议有 512 字节的包大小限制,单个 A Record 只能挂接 20 个左右的 IP 地址,超过这个数字就会转换为 TCP 协议,客户端不做处理就会报错,导致客户端启动失败。

当时由于情况紧急,只能建立多个 Twemproxy Group,提供多个 DNS A Record 给客户端,客户端进行轮询或者随机选择,该方案可用,但是不够优雅。

7.4如何解决 Twemproxy 单 CPU 计算能力的限制


之后我们修改了 Twemproxy 源码, 加入 SO_REUSEPORT 支持。

Twemproxy with SO_REUSEPORT on Kubernetes:
7.jpg

同一个容器内由 Starter 启动多个 Twemproxy 实例并绑定到同一个端口,由操作系统进行负载均衡,对外仍然暴露一个端口,但是内部已经由系统均摊到了多个 Twemproxy 上。

同时 Starter 会定时去每个 Twemproxy 的 stats 端口获取 Twemproxy 运行状态进行聚合,此外 Starter 还承载了信号转发的职责。

原有的 Agent 不需要用来启动 Twemproxy 实例,所以 Monitor 调用 Starter 获取聚合后的 stats 信息进行差值计算,最终对外界暴露出实时的运行状态信息。

7.5为什么没有使用官方 Redis 集群方案


我们在 2015 年调研过多种集群方案,综合评估多种方案后,最终选择了看起来较为陈旧的 Twemproxy 而不是官方 Redis 集群方案与 Codis,具体原因如下:

1)MIGRATE 造成的阻塞问题:

Redis 官方集群方案使用 CRC16 算法计算哈希值并将 Key 分散到 16384 个 Slot 中,由使用方自行分配 Slot 对应到每个分片中,扩容时由使用方自行选择 Slot 并对其进行遍历,对 Slot 中每一个 Key 执行 MIGRATE 命令进行迁移。

调研后发现,MIGRATE 命令实现分为三个阶段:

  • a)DUMP 阶段:由源实例遍历对应 Key 的内存空间,将 Key 对应的 Redis Object 序列化,序列化协议跟 Redis RDB 过程一致;
  • b)RESTORE 阶段:由源实例建立 TCP 连接到对端实例,并将 DUMP 出来的内容使用 RESTORE 命令到对端进行重建,新版本的 Redis 会缓存对端实例的连接;
  • c)DEL 阶段(可选):如果发生迁移失败,可能会造成同名的 Key 同时存在于两个节点,此时 MIGRATE 的 REPLACE 参数决定是是否覆盖对端的同名 Key,如果覆盖,对端的 Key 会进行一次删除操作,4.