导读:知乎存储平台团队基于开源Redis 组件打造的知乎 Redis 平台,通过不断的研发迭代,目前已经造成了一整套完整自动化运维服务体系,提供不少强大的功能。本文做者是是该系统的负责人,文章深刻介绍了该系统的方方面面,做为后端程序员值得仔细研究。git
做者简介:陈鹏,现知乎存储平台组 Redis 平台技术负责人,2014 年加入知乎技术平台组从事基础架构相关系统的开发与运维,从无到有创建了知乎 Redis 平台,承载了知乎高速增加的业务流量。程序员
知乎做为知名中文知识内容平台,每日处理的访问量巨大,如何更好的承载这样巨大的访问量,同时提供稳定低时延的服务保证,是知乎技术平台同窗须要面对的一大挑战。github
知乎存储平台团队基于开源Redis 组件打造的 Redis 平台管理系统,通过不断的研发迭代,目前已经造成了一整套完整自动化运维服务体系,提供一键部署集群,一键自动扩缩容, Redis 超细粒度监控,旁路流量分析等辅助功能。redis
目前,Redis 在知乎规模以下:算法
● 机器内存总量约70TB,实际使用内存约40TB;后端
● 平均每秒处理约1500万次请求,峰值每秒约2000万次请求;缓存
● 天天处理约1万亿余次请求;安全
● 单集群每秒处理最高每秒约400万次请求;网络
● 集群实例与单机实例总共约800个;架构
● 实际运行约16000个Redis 实例;
● Redis 使用官方3.0.7版本,少部分实例采用4.0.11版本。
根据业务的需求,咱们将实例区分为单机(Standalone)和集群(Cluster)两种类型,单机实例一般用于容量与性能要求不高的小型存储,而集群则用来应对对性能和容量要求较高的场景。
对于单机实例,咱们采用原生主从(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 来进行读写分离。
如图所示,经过向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 [1]
实际使用中须要注意如下几点:
● 只读Slave 节点能够按照需求设置 slave-priority 参数为0,防止故障切换时选择了只读节点而不是热备 Slave 节点;
● Sentinel 进行故障切换后会执行 CONFIG REWRITE 命令将SLAVEOF 配置落地,若是 Redis 配置中禁用了 CONFIG 命令,切换时会发生错误,能够经过修改 Sentinel 代码来替换 CONFIG 命令;
● Sentinel Group 监控的节点不宜过多,实测超过 500 个切换过程偶尔会进入 TILT 模式,致使Sentinel 工做不正常,推荐部署多个 Sentinel 集群并保证每一个集群监控的实例数量小于 300 个;
● Master 节点应与 Slave 节点跨机器部署,有能力的使用方能够跨机架部署,不推荐跨机房部署 Redis 主从实例;
● Sentinel 切换功能主要依赖 down-after-milliseconds 和failover-timeout 两个参数,down-after-milliseconds 决定了Sentinel 判断 Redis 节点宕机的超时,知乎使用 30000 做为阈值。而 failover-timeout 则决定了两次切换之间的最短等待时间,若是对于切换成功率要求较高,能够适当缩短failover-timeout 到秒级保证切换成功,具体详见Redis 官方文档[2];
● 单机网络故障等同于机器宕机,但若是机房全网发生大规模故障会形成主从屡次切换,此时资源发现服务可能更新不够及时,须要人工介入。
当实例须要的容量超过20G 或要求的吞吐量超过 20万请求每秒时,咱们会使用集群(Cluster)实例来承担流量。集群是经过中间件(客户端或中间代理等)将流量分散到多个 Redis 实例上的解决方案。
知乎的Redis 集群方案经历了两个阶段:客户端分片与 Twemproxy 代理
早期知乎使用redis-shard 进行客户端分片,redis-shard 库内部实现了 CRC3二、MD五、SHA1三种哈希算法,支持绝大部分Redis 命令。使用者只需把 redis-shard 当成原生客户端使用便可,无需关注底层分片。
基于客户端的分片模式具备以下优势:
● 基于客户端分片的方案是集群方案中最快的,没有中间件,仅须要客户端进行一次哈希计算,不须要通过代理,没有官方集群方案的MOVED/ASK 转向;
● 不须要多余的Proxy 机器,不用考虑 Proxy 部署与维护;
● 能够自定义更适合生产环境的哈希算法。
可是也存在以下问题:
● 须要每种语言都实现一遍客户端逻辑,早期知乎全站使用Python 进行开发,可是后来业务线增多,使用的语言增长至 Python,Golang,Lua,C/C++,JVM 系(Java,Scala,Kotlin)等,维护成本太高;
● 没法正常使用MSET、MGET 等多种同时操做多个Key 的命令,须要使用 Hash tag 来保证多个 Key 在同一个分片上;
● 升级麻烦,升级客户端须要全部业务升级更新重启,业务规模变大后没法推进;
● 扩容困难,存储须要停机使用脚本Scan 全部的 Key 进行迁移,缓存只能经过传统的翻倍取模方式进行扩容;
● 因为每一个客户端都要与全部的分片创建池化链接,客户端基数过大时会形成Redis 端链接数过多,Redis 分片过多时会形成 Python 客户端负载升高。
具体特色详见zhihu/redis-shard[3]。早期知乎大部分业务由Python 构建,Redis 使用的容量波动较小, redis-shard 很好地应对了这个时期的业务需求,在当时是一个较为不错解决方案。
2015 年开始,业务上涨迅猛,Redis 需求暴增,原有的 redis-shard 模式已经没法知足日益增加的扩容需求,咱们开始调研多种集群方案,最终选择了简单高效的 Twemproxy 做为咱们的集群方案。
由Twitter 开源的 Twemproxy 具备以下优势:
● 性能很好且足够稳定,自建内存池实现Buffer 复用,代码质量很高;
● 支持fnv1a_6四、murmur、md5 等多种哈希算法;
● 支持一致性哈希(ketama),取模哈希(modula)和随机(random)三种分布式算法。
具体特色详见twitter/twemproxygithub.com[4]
可是缺点也很明显:
● 单核模型形成性能瓶颈;
● 传统扩容模式仅支持停机扩容。
对此,咱们将集群实例分红两种模式,即缓存(Cache)和存储(Storage):
若是使用方能够接收经过损失一部分少许数据来保证可用性,或使用方能够从其他存储恢复实例中的数据,这种实例即为缓存,其他状况均为存储。
咱们对缓存和存储采用了不一样的策略:
对于存储咱们使用fnv1a_64 算法结合modula 模式即取模哈希对Key 进行分片,底层 Redis 使用单机模式结合 Sentinel 集群实现高可用,默认使用 1 个 Master 节点和 1 个 Slave 节点提供服务,若是业务有更高的可用性要求,能够拓展 Slave 节点。
当集群中Master 节点宕机,按照单机模式下的高可用流程进行切换,Twemproxy 在链接断开后会进行重连,对于存储模式下的集群,咱们不会设置 auto_eject_hosts, 不会剔除节点。
同时,对于存储实例,咱们默认使用noeviction 策略,在内存使用超过规定的额度时直接返回OOM 错误,不会主动进行 Key 的删除,保证数据的完整性。
因为Twemproxy 仅进行高性能的命令转发,不进行读写分离,因此默认没有读写分离功能,而在实际使用过程当中,咱们也没有遇到集群读写分离的需求,若是要进行读写分离,可使用资源发现策略在 Slave 节点上架设 Twemproxy 集群,由客户端进行读写分离的路由。
考虑到对于后端(MySQL/HBase/RPC 等)的压力,知乎绝大部分业务都没有针对缓存进行降级,这种状况下对缓存的可用性要求较数据的一致性要求更高,可是若是按照存储的主从模式实现高可用,1 个 Slave 节点的部署策略在线上环境只能容忍 1 台物理节点宕机,N 台物理节点宕机高可用就须要至少 N 个 Slave 节点,这无疑是种资源的浪费。
因此咱们采用了Twemproxy 一致性哈希(Consistent Hashing)策略来配合 auto_eject_hosts 自动弹出策略组建Redis 缓存集群。
对于缓存咱们仍然使用使用fnv1a_64 算法进行哈希计算,可是分布算法咱们使用了ketama 即一致性哈希进行Key 分布。缓存节点没有主从,每一个分片仅有 1 个 Master 节点承载流量。
Twemproxy 配置 auto_eject_hosts 会在实例链接失败超过server_failure_limit 次的状况下剔除节点,并在server_retry_timeout 超时以后进行重试,剔除后配合ketama 一致性哈希算法从新计算哈希环,恢复正常使用,这样即便一次宕机多个物理节点仍然能保持服务。
在实际的生产环境中须要注意如下几点:
● 剔除节点后,会形成短期的命中率降低,后端存储如MySQL、HBase 等须要作好流量监测;
● 线上环境缓存后端分片不宜过大,建议维持在20G 之内,同时分片调度应尽量分散,这样即便宕机一部分节点,对后端形成的额外的压力也不会太多;
● 机器宕机重启后,缓存实例须要清空数据以后启动,不然原有的缓存数据和新创建的缓存数据会冲突致使脏缓存。直接不启动缓存也是一种方法,可是在分片宕机期间会致使周期性server_failure_limit 次数的链接失败;
● server_retry_timeout 和server_failure_limit 须要仔细敲定确认,知乎使用10min 和 3 次做为配置,即链接失败 3 次后剔除节点,10 分钟后从新进行链接。
在方案早期咱们使用数量固定的物理机部署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 给客户端,客户端进行轮询或者随机选择,该方案可用,可是不够优雅。
以后咱们修改了Twemproxy 源码, 加入 SO_REUSEPORT 支持。
Twemproxy with SO_REUSEPORT on Kubernetes
同一个容器内由Starter 启动多个 Twemproxy 实例并绑定到同一个端口,由操做系统进行负载均衡,对外仍然暴露一个端口,可是内部已经由系统均摊到了多个 Twemproxy 上。
同时Starter 会定时去每一个 Twemproxy 的 stats 端口获取 Twemproxy 运行状态进行聚合,此外 Starter 还承载了信号转发的职责。
原有的Agent 不须要用来启动 Twemproxy 实例,因此 Monitor 调用 Starter 获取聚合后的 stats 信息进行差值计算,最终对外界暴露出实时的运行状态信息。
咱们在2015 年调研过多种集群方案,综合评估多种方案后,最终选择了看起来较为陈旧的 Twemproxy 而不是官方 Redis 集群方案与 Codis,具体缘由以下:
● MIGRATE 形成的阻塞问题
Redis 官方集群方案使用 CRC16 算法计算哈希值并将 Key 分散到 16384 个 Slot 中,由使用方自行分配 Slot 对应到每一个分片中,扩容时由使用方自行选择 Slot 并对其进行遍历,对 Slot 中每个 Key 执行 MIGRATE 命令进行迁移。
调研后发现,MIGRATE 命令实现分为三个阶段:
1. DUMP 阶段:由源实例遍历对应 Key 的内存空间,将 Key 对应的 Redis Object 序列化,序列化协议跟 Redis RDB 过程一致;
2. RESTORE 阶段:由源实例创建 TCP 链接到对端实例,并将 DUMP 出来的内容使用RESTORE 命令到对端进行重建,新版本的 Redis 会缓存对端实例的链接;
3. DEL 阶段(可选):若是发生迁移失败,可能会形成同名的 Key 同时存在于两个节点,
此时 MIGRATE 的REPLACE 参数决定是是否覆盖对端的同名Key,若是覆盖,对端的 Key 会进行一次删除操做,4.0 版本以后删除能够异步进行,不会阻塞主进程。
通过调研,咱们认为这种模式并不适合知乎的生产环境。Redis 为了保证迁移的一致性, MIGRATE 全部操做都是同步操做,执行MIGRATE 时,两端的Redis 均会进入时长不等的 BLOCK 状态。
对于小Key,该时间能够忽略不计,但若是一旦 Key 的内存使用过大,一个 MIGRATE 命令轻则致使 P95 尖刺,重则直接触发集群内的 Failover,形成没必要要的切换
同时,迁移过程当中访问处处于迁移中间状态的Slot 的 Key 时,根据进度可能会产生 ASK 转向,此时须要客户端发送 ASKING 命令到Slot 所在的另外一个分片从新请求,请求时延则会变为原来的两倍。
一样,方案初期时的Codis 采用的是相同的 MIGRATE 方案,可是使用 Proxy 控制 Redis 进行迁移操做而非第三方脚本(如 redis-trib.rb),基于同步的相似 MIGRATE 的命令,实际跟 Redis 官方集群方案存在一样的问题。
对于这种Huge Key 问题决定权彻底在于业务方,有时业务须要不得不产生 Huge Key 时会十分尴尬,如关注列表。一旦业务使用不当出现超过 1MB 以上的大 Key 便会致使数十毫秒的延迟,远高于平时 Redis 亚毫秒级的延迟。有时,在 slot 迁移过程当中业务不慎同时写入了多个巨大的 Key 到 slot 迁移的源节点和目标节点,除非写脚本删除这些 Key ,不然迁移会进入进退两难的地步。
对此,Redis 做者在 Redis 4.2 的 roadmap[5] 中提到了Non blocking MIGRATE 可是截至目前,Redis 5.0 即将正式发布,仍未看到有关改动,社区中已经有相关的 Pull Request [6],该功能可能会在5.2 或者 6.0 以后并入 master 分支,对此咱们将持续观望。
● 缓存模式下高可用方案不够灵活
还有,官方集群方案的高可用策略仅有主从一种,高可用级别跟Slave 的数量成正相关,若是只有一个 Slave,则只能容许一台物理机器宕机, Redis 4.2 roadmap 提到了 cache-only mode,提供相似于Twemproxy 的自动剔除后重分片策略,可是截至目前仍未实现。
● 内置Sentinel 形成额外流量负载
另外,官方Redis 集群方案将 Sentinel 功能内置到 Redis 内,这致使在节点数较多(大于 100)时在 Gossip 阶段会产生大量的 PING/INFO/CLUSTER INFO 流量,根据 issue 中提到的状况,200 个使用 3.2.8 版本节点搭建的 Redis 集群,在没有任何客户端请求的状况下,每一个节点仍然会产生 40Mb/s 的流量,虽然到后期 Redis 官方尝试对其进行压缩修复,但按照 Redis 集群机制,节点较多的状况下不管如何都会产生这部分流量,对于使用大内存机器可是使用千兆网卡的用户这是一个值得注意的地方。
● slot 存储开销
最后,每一个Key 对应的 Slot 的存储开销,在规模较大的时候会占用较多内存,4.x 版本之前甚至会达到实际使用内存的数倍,虽然 4.x 版本使用 rax 结构进行存储,可是仍然占据了大量内存,从非官方集群方案迁移到官方集群方案时,须要注意这部分多出来的内存。
总之,官方Redis 集群方案与 Codis 方案对于绝大多数场景来讲都是很是优秀的解决方案,可是咱们仔细调研发现并非很适合集群数量较多且使用方式多样化的咱们,场景不一样侧重点也会不同,但在此仍然要感谢开发这些组件的开发者们,感谢大家对 Redis 社区的贡献。
对于单机实例,若是经过调度器观察到对应的机器仍然有空闲的内存,咱们仅需直接调整实例的maxmemory 配置与报警便可。一样,对于集群实例,咱们经过调度器观察每一个节点所在的机器,若是全部节点所在机器均有空闲内存,咱们会像扩容单机实例同样直接更新maxmemory 与报警。
可是当机器空闲内存不够,或单机实例与集群的后端实例过大时,没法直接扩容,须要进行动态扩容:
● 对于单机实例,若是单实例超过30GB 且没有如 sinterstore 之类的多Key 操做咱们会将其扩容为集群实例;
● 对于集群实例,咱们会进行横向的重分片,咱们称之为Resharding 过程。
Resharding 过程
原生Twemproxy 集群方案并不支持扩容,咱们开发了数据迁移工具来进行 Twemproxy 的扩容,迁移工具本质上是一个上下游之间的代理,将数据从上游按照新的分片方式搬运到下游。
原生Redis 主从同步使用 SYNC/PSYNC 命令创建主从链接,收到SYNC 命令的Master 会 fork 出一个进程遍历内存空间生成 RDB 文件并发送给 Slave,期间全部发送至 Master 的写命令在执行的同时都会被缓存到内存的缓冲区内,当 RDB 发送完成后,Master 会将缓冲区内的命令及以后的写命令转发给 Slave 节点。
咱们开发的迁移代理会向上游发送SYNC 命令模拟上游实例的Slave,代理收到 RDB 后进行解析,因为 RDB 中每一个 Key 的格式与 RESTORE 命令的格式相同,因此咱们使用生成 RESTORE 命令按照下游的Key 从新计算哈希并使用 Pipeline 批量发送给下游。
等待RDB 转发完成后,咱们按照新的后端生成新的 Twemproxy 配置,并按照新的 Twemproxy 配置创建 Canary 实例,从上游的 Redis 后端中取 Key 进行测试,测试 Resharding 过程是否正确,测试过程当中的 Key 按照大小,类型,TTL 进行比较。
测试经过后,对于集群实例,咱们使用生成好的配置替代原有Twemproxy 配置并 restart/reload Twemproxy 代理,咱们修改了 Twemproxy 代码,加入了 config reload 功能,可是实际使用中发现直接重启实例更加可控。而对于单机实例,因为单机实例和集群实例对于命令的支持不一样,一般须要和业务方肯定后手动重启切换。
因为Twemproxy 部署于 Kubernetes ,咱们能够实现细粒度的灰度,若是客户端接入了读写分离,咱们能够先将读流量接入新集群,最终接入所有流量。
这样相对于Redis 官方集群方案,除在上游进行 BGSAVE 时的fork 复制页表时形成的尖刺以及重启时形成的链接闪断,其他对于 Redis 上游形成的影响微乎其微。
这样扩容存在的问题:
对上游发送SYNC 后,上游fork 时会形成尖刺;
对于存储实例,咱们使用Slave 进行数据同步,不会影响到接收请求的 Master 节点;
对于缓存实例,因为没有Slave 实例,该尖刺没法避免,若是对于尖刺过于敏感,咱们能够跳过 RDB 阶段,直接经过 PSYNC 使用最新的SET 消息创建下游的缓存。
切换过程当中有可能写到下游,而读在上游;
对于接入了读写分离的客户端,咱们会先切换读流量到下游实例,再切换写流量。
一致性问题,两条具备前后顺序的写同一个Key 命令在切换代理后端时会经过 1)写上游同步到下游 2)直接写到下游两种方式写到下游,此时,可能存在应先执行的命令却经过 1)执行落后于经过 2)执行,致使命令前后顺序倒置。
这个问题在切换过程当中没法避免,好在绝大部分应用没有这种问题,若是没法接受,只能经过上游停写排空Resharding 代理保证前后顺序;
官方Redis 集群方案和 Codis 会经过 blocking 的 migrate 命令来保证一致性,不存在这种问题。
实际使用过程当中,若是上游分片安排合理,可实现数千万次每秒的迁移速度,1TB 的实例 Resharding 只须要半小时左右。另外,对于实际生产环境来讲,提早作好预期规划比遇到问题紧急扩容要快且安全得多。
因为生产环境调试须要,有时会须要监控线上Redis 实例的访问状况,Redis 提供了多种监控手段,如 MONITOR 命令。
但因为Redis 单线程的限制,致使自带的 MONITOR 命令在负载太高的状况下会再次跑高 CPU,对于生产环境来讲过于危险,而其他方式如 Keyspace Notify 只有写事件,没有读事件,没法作到细致的观察。
对此咱们开发了基于libpcap 的旁路分析工具,系统层面复制流量,对应用层流量进行协议分析,实现旁路 MONITOR,实测对于运行中的实例影响微乎其微。
同时对于没有MONITOR 命令的 Twemproxy,旁路分析工具仍能进行分析,因为生产环境中绝大部分业务都使用 Kubernetes 部署于 Docker 内 ,每一个容器都有对应的独立 IP,因此可使用旁路分析工具反向解析找出客户端所在的应用,分析业务方的使用模式,防止不正常的使用。
因为Redis 5.0 发布在即,4.0 版本趋于稳定,咱们将逐步升级实例到 4.0 版本,由此带来的如 MEMORY 命令、Redis Module 、新的 LFU 算法等特性不管对运维方仍是业务方都有极大的帮助。
知乎架构平台团队是支撑整个知乎业务的基础技术团队,开发和维护着知乎几乎全量的核心基础组件,包括容器、Redis、MySQL、Kafka、LB、HBase 等核心基础设施,团队小而精,每一个同窗都独当一面负责上面提到的某个核心系统。
随着知乎业务规模的快速增加,以及业务复杂度的持续增长,咱们团队面临的技术挑战也愈来愈大,欢迎对技术感兴趣、渴望技术挑战的小伙伴加入咱们,一块儿建设稳定高效的知乎云平台。有意向可移步知乎网站招聘页投递简历。
1. Redis Official site https://redis.io/
2. Twemproxy Github Page twitter/twemproxy
3. Codis Github Page CodisLabs/codis
4. SO_REUSEPORT Man Page socket(7) - Linux manual page
5. Kubernetes Production-Grade Container Orchestration