架构师(JiaGouX)我们都是架构师!
目录
1 背景
数据是微博推荐引擎的基础, 无论是未加工,过滤的由微博平台部门推送过来的原始数据还是由推荐引擎实时,准实时,离线计算出的微博关键词,推荐候选集等非原始数据, 在整个微博推荐引擎运作的过程中, 数据的读写操作随处可见.
推荐引擎在不断的进化过程中, 也逐步将数据层单独剥离开, 抽象出数据导入, 存储和对外访问的业务模型. 数据导入部分(Rin)已经较为完善, 采用消息中间件解决了经典的削峰填谷(发布, 订阅吞吐能力不对等)和上下游解耦问题, 统一采用memcache协议发布订阅; 存储部分则是主要依赖于Redis和Lushan; 数据的对外访问业务则采用了twemproxy做代理的解决方案.
代理在目前的互联网行业中应用十分广泛, 本文所关注的是7层业务代理, 即将访问请求按照一定策略路由到后端是App Server或者Data Server集群的一类服务, 换句话说, 所有叫云DB或者分布式DB的服务, 流量一定是经由此类代理来转发的, 这里不再赘述, 有兴趣的同学自行google学习.
2 问题
在微博业务增量发展的过程中, 使用twemproxy遇到了如下的一些场景:
推荐的Lushan存储服务采用了memcache协议, twemproxy自身是支持此类协议的, 但是Lushan特有的键名格式包含db号(以微博用户为例, 键名会是”$db-$uid”,详见Lushan文档), db号依据于约定的hash规则产生, twemproxy可以自写hash模块解析键名计算路由方案, 但需要上游调用方在进行数据读取时自行hash并拼接出完整key名, 从功能划分上来说, 没有解耦, 如果hash规则有改变, 例如存储进行了扩缩容, 业务方也要更新相应代码.
twemproxy目前只支持redis,memcache两种协议, 对于有其它存储服务(例如MySQL,MongoDB,http等)的业务需求, twemproxy还需要扩展, 且扩展成本较高
twemproxy的高并发异步回调模型和业务代码有很强的关联, 尤其是存储代理这种更特殊的情况(向前接收的请求和向后转发的请求都得是异步): 异步回调的模型需要定义一个精巧的struct conn(其中的一个field要还包含一个任务链表), 依靠网络IO来触发任务状态检查和next action操作, 完整的业务逻辑被打散在不同的地方
twemproxy的路由转发功能是单层, 即配置中会写好代理实例对应的后端集群是redis还是memcache, twemproxy根据key值hash, 然后转发汇总结果. 对于微博推荐, 有一类业务场景是某类关键数据(例如用户取消关注)会用Mysql存储, Mysql的读性能对于推荐计算层的高并发访问显然不能够胜任. 大家立马能想到的是加缓存服务, 先读缓存, 读不到读Mysql. 这个逻辑放到计算层来去做可行但不合适(再默念一遍:IO密集型的操作很多DB Client Lib都没有考虑到, 基本实现就是一个网络封包请求加阻塞式的等待应答, 即便实现了异步接口, 基于历史原因调用方也仍然大量使用同步请求方式), 由代理层屏蔽上述流程整个架构才会解耦.
同时, 从纯技术角度来看, twemproxy也存在一些问题:
twemproxy主干逻辑采用的是单进程, 异步I/O的方案去解决高并发, 同样是单核的场景, twemproxy理论上会是最快的解决方案. 不过目前的服务器大都在8核以上, 想要充分利用CPU, 就需要单机多进程的部署, 从运维角度而言, 增加了不必要的复杂度.
twemproxy采用C语言开发, 如果对源码有过了解, 就知道twemproxy自己实现了数组, string, 红黑树, 队列等基础数据结构, 对于二次开发而言, 非语言built-in层面或者有专门社区维护的稳定库层面的数据结构库都会引发风险; 基于异步回调的并发编程模型, 导致其核心代码量也较大, 难于理解, 不易吃透.
PS: twemproxy已经不再作为twitter公司内部的数据访问解决方案
3 技术调研
在有了业务需求和技术驱动的双重push下, 接下来要做的很明显: 实现一个支持高并发, 易扩展, 易维护的代理.
首先从业界开源的代理服务来着手, 除了前文说的twemproxy, 这里另外着重调研了两个:
McRouter 由Facebook开源的代理, 用C++0x开发, 只支持memcache协议, 相比twemproxy的异步回调模型, McRouter自行开发了轻量级协程(fiber), 以期达到one fiber per connection的易编程模型, 这点很赞. 同时, 在业务层面, McRouter还考虑了跨机房的容错, 日志持久化等功能.
由于McRouter大量采用了Facebook自己二次开发的库, 所以整个代码量较之twemproxy更为庞大, 虽然已经声明此开源软件FB公司内部一直在用, 也在不断维护, 不过从我们自身的项目需求而言, 依然不是一个清爽的解决方案, 更倾向于把其当做一个良好的学习用的开源框架.
Codis 由豌豆荚开源的代理, 用Golang开发, 只支持Redis协议, 但在架构上引入了zookeeper做配置管理, 同时对redis也进行了fork开发, 结合代理做了透明,自动化数据的sharding,resharding等, 目标是朝着Cloud key-value DB的方向迈进!
再次结合twemproxy来看, 三种开源的代理各自有一些技术上或者业务上胜出的地方, 但直接在其之上做二次开发的可行性必要性均不高, 微博推荐业务的需求需要一个从零开始的特色代理.
经过调研以及和同事们的技术讨论, 最终决定用Golang进行开发, 原因如下:
Golang的开发周期相当快, demo大概一周左右实现, 很快就可以和twemproxy做benchmark对比, 如果性能成为瓶颈,很快可以重选技术方案返工:)
从易维护的角度考虑, Golang相比写高并发的C/C++, 语言层面已经提供了良好的支持, 完全同步化的代码和思路, 最终实现的却是异步I/O的性能.
单从实现扩展技术的角度而言, 静态编译的Golang相比于C/C++没有明显优势, 但是结合易维护的扩展角度考虑, Golang的协程模型让其再次胜出.
High Concurrency和High Performance最大的区别在于高并发引入了IO密集型操作, IO操作的时延和Go VS C/C++的性能差距完全不是一个量级, 所以引入Golang做代理理论上不会造成qps数量级的差异.
4 代理设计
从业务需求出发, 主干功能如下:
访问协议支持redis,memcache,http, 后端服务支持redis,memcache,mysql(有需求可添加新货), 按照定义的hash规则(取余, 一致性hash等), 路由请求到对应后端服务器.
给代理定的目标是支持到二级数据服务集群管理, 即缓存数据服务层当做第一层存热点数据, 持久化数据服务层当做第二层存全量数据(事实证明, 这个二层的设计很快就被用到了)
支持双机房自动切换, 目前组内的数据都会是双机房全量保存, 减轻跨机房调用, 一旦出现单点故障, 心跳检测会触发对等节点构建连接池, 并对请求进行响应.
回写功能, 即一级缓存层不命中, 从二级获取到数据要回写入缓存层, 由此还要解决掉因为通常数据库有的主从读写权限引发的问题.
除去主干功能, 配置,日志和监控也是做为24*7代理服务的不可或缺的部分, 这里花些时间描述下
4.1 配置
由于最先接触的是twemproxy的线上环境, 对其配置格式(yaml)和规范印象较深, 在设计代理配置时也借鉴了其一些声明规范, 最终拍板用toml格式, 相比yaml的好处不多言, 样例配置如下:
上图中需要特殊说明的是连接池列表, 实例中用连续四个中划线分隔开, 是为了表明左右的服务分别属于不同机房, 数据通常是全量备份, 这样可以有效预防单机房硬件网络故障导致的服务不可用, 代理会透明切换到另一个机房上去. 当然如果某类存储服务就是一个单机房的单点, 删除掉四个中划线及其以后的部分就可以了
4.2 日志
IO密集型的日志本身需要缓冲+异步写的功能, 防止日志写性能影响qps, 这里Golang原生的日志库是无法满足需求的, 自己也没有从头造轮子, 直接捡了现成的库seelog, 功能很强大, 除了异步, 还支持日志按大小, 或者日期(比如一小时切分一次)切分, 还会自行维护最大日志数(再也不用维护crontab+logrotate了), 样例配置如下:
4.3 监控
因为要保证24*7, 代理的工作状态是需要及时汇报给监控平台的, 例如当前连接数, qps请求数, 已经请求的连接数等等. 这里采用了一个简单的方式, 直接暴露一个监控http服务, 监控平台直接可以进行抓取.
有一些开源代理, 例如codis, 会在代理中嵌入一个大的Web MVC框架(martini), 结合js做一个漂亮的dashboard, 除了代理本身, 无状态代理自身的peer node(zookeeper协同), 代理管理的后端存储节点状态都会进行展示, 考虑到组内已经有相关的监控平台, 这个包袱就不扛了~
4.4 代理模块
代理在设计上大致划分为四层, 每层包含若干个模块, 模块在Go项目中体现为一个个单独的package, 即同一类功能的代理放置到一个folder下, 具体划分见下图:
这里着重介绍其中一些:
协议模块protocol: 包含redis和memcache两类, 因为Golang的net.conn可以绑定bufio.Reader, redis和memcache的协议解析函数入参均为bufio.Reader, 可以方便进行流式网络数据读取. 解析协议的函数配合官方协议描述即可看懂(操作符 操作数\r\n ), 目前还没有考虑支持二进制协议.
hash模块: 目前抽象了一个hash函数类型 type HashFunc func(key string) (dbIndexNum int, serverPosition int, err error). 包含db索引是因为最早的需求里组内的Lushan服务是需要有DB号来进行访问的, 如果是像redis服务, 可以忽略db号, 目前支持的hash规则除了较为简单的取模取余还包括一致性Hash支持.
tunnel模块: 这里指的是与上游客户端的一个物理连接建立后, 不断侦听通道数据, 做解析, 处理以及回复的处理函数. 注意只有同步编程模型才能够抽象出一个理解完整的通道函数, 通道里的处理函数也是整个代理的核心, 充分利用了多协程+本地map reduce的模型来处理一次请求, 这个后文会加以说明.
entry模块: 由于proxy支持http,redis,memcache, 而后两者是直接基于tcp连接的编程, 所以这里抽象了两类入口服务, tcp和http, 其中tcp采用了任务队列加协程池的方式来避免协程在运行期的大量创建和销毁, 占用内存, 影响性能; http是golang内建的功能, 这里做封装, 主要是加入对gzip,deflate请求的数据压缩, 以及平滑关闭http服务, 同时封装一个context上下文, 让tunnel模块中的业务处理函数能够得知某一个请求对应的实例名, 对应后端连接池等信息
conn-pool模块: 连接池模块是用来维护初始固定长度的和后端数据存储服务的连接资源, 避免每一次client请求触发一个和后端DB服务的新连接, 除此之外, 连接池模块还要做是应对DB服务不稳定, 超时等带来的连接失效问题, 以及跨机房切换, 心跳检测. 这里涉及最多的就是锁的运用, 例如心跳检测失败事件导致的连接池资源关闭,重置和不断来的获取连接池资源请求的race condition, 让获取连接池资源变得透明(例如:后端机器无响应能很快实时返回; 有跨机房节点, 返回跨机房的连接; 本机房数据服务恢复后能再次切换回来)
common模块: common模块就是上图中的蓝色基础层, 此模块的子模块包含了封装日志;配置;Mysql DB操作(因为目前没有支持mysql协议代理的需求, 只是后端存储可能会用到, 所以直接用了现成稳定的client库);监控服务;异常库;util工具库, 看似此模块最边缘, 其实起的作用非常大, 没好轮子的车绝对跑不快.
业务逻辑模块: 上图中最上层标黄的业务层对应代理main函数所在的hyper_proxy package, 完成的事情包括但不限于: 加载外部配置文件; 启动监控以及调试服务; 初始化连接池并绑定实例; 启动对应实例; 在程序结束时, 走关闭实例监听连接, 关闭对应连接池的操作.
4.5 业务
在立项之初, 能够完成一次典型的multi-key读操作就是代理业务层(tunnel模块)的设计核心, 考虑到Golang的并发模型, 采用层次化的多协程worker协同工作对于此类需求易理解, 易开发, 易扩展(见下图) 其它的写操作, 获取DB状态操作或者取single-key的操作会自动收敛成单任务链完成
例如, 上游的推荐计算层需要一次性的获取多个key(可能是用户uid或者是微博mid)的内容, 返回结果内容后应用计算策略计算并生成推荐结果. 如果是走redis协议, 命令会是mget key1,key2,key3…keyN, 这N个键值会hash到不同的后端DB服务上去, 我们的处理流程如下:
将N个key利用配置Hash方法, 计算出K个集合, 集合1代表落到后端DB服务器1上的key set, 以此类推, 集合K代表落到后端DB服务器K上的key set;
针对步骤1中的K个集合, 创建K个goroutine, 每个goroutine针对自己对应的集合对后端DB服务进行mget读操作, 读完结果后push到Go channel中, 这里要注意的一点是, push的结构体里要包含key在客户请求中的原始位置, 方便汇总时拼接返回结果; 在此之后, 不断的从Go channel中读取数据, 汇总结果;
步骤2中的K个goroutine都完成各自任务后, 关闭步骤2中的channel.
以上的三个步骤看似简单, 用分治的方法去各自解决问题, 实际上背后还隐藏着Golang语言的同步编程模型+异步IO高性能的特性, 不过这是另一个话题, 不在本文的讨论范围之内.
其实以上的处理流程很容易扩展成解决多层次存储的代理解决方案, 例如步骤2中可以递归嵌套下一层中相似的1,2,3的处理逻辑, 这也是@传鹏一直强调的分形(fractal)设计思想. 如果还不大明白, 请看下图:
上图是一个推荐业务中常见的面向二级存储的代理工作流, 比如一级存储是有热点数据的内存数据库, 二级存储是有全量数据的传统关系型数据库
外部请求对应的goroutine协程(一级控制逻辑)首先通过对键名集合的一级Hash, 分成若干个key子集合, 每个子集合都会落在同一台一级存储的物理节点上, 一级控制逻辑会启动专门的goroutine协程去和对应的节点请求数据. 如果有查询不到的非命中key, 跳转到步骤2, 嵌套走一个分形的解决方案; 如果都能过查询到, 把结果push到一级的channel中, 一级控制逻辑做整合输出.
步骤1中一级控制逻辑启动的goroutine(二级控制逻辑)通过对一级非命中键名集合做二级Hash, 分成若干个key子集合, 每个子集合都会落在同一台二级存储的物理节点上, 二级控制逻辑会启动专门的goroutine协程去和对应的节点请求数据. 如果能查询到数据结果, 把结果push到二级的channel中, 二级控制逻辑做整合输出, 并再次把结果push到一级channel中.
从工程实现的角度来说, worker是一个goroutine(轻量级线程), 数目小于等于存储节点, channel就是一个线程/协程安全的先进先出队列(语言层面已经实现), 不同的worker取完后端数据就会push进当前层队列, 整合数据的逻辑则是不断从队列里尝试取数据(没有的话, 会阻塞等待, 所以不影响性能)
上图中workerN是一个无法在一级存储查询中命中的goroutine, 一旦遇到这种情况, workerN必须先计算非命中keys在二级存储中的内容, workerN通过二级channel的数据汇总, 把二级查询到的keys内容填充到打算反馈给一级channel的数据单元中, 然后再push进一级channel, 最终handler2会处理一级worker 1到N的所有push消息, 因为消息次序依赖于下游数据存储服务的响应, 所以汇总的时候要根据事先绑定key的offset来将内容在对应位置填充, pack完毕后返回给上游.
5 性能
整个代理的开发过程中, 从一开始的demo压测(redis-benchmark, memtier_benchmark)到后期代理原型完成后进行的python脚本高并发压测一直贯穿. 最终的吞吐性能瓶颈依赖于后端数据存储的类型, 内存数据库的IO要明显高于文件系统的IO, 而代理的目标是让业务层直接访问数据存储服务和访问代理的请求耗时没有量级差距
6 总结
目前新代理在线上的部署已经覆盖了很多业务线, 从主干功能开发完成后, 应对业务变化的上线业务基本可以在不改动代码或者少量修改的前提下完成.
问题和不足:
在业务抽象和框架化方面还存在很大不足, 参照hadoop的MR模型, 一个技术上无可挑剔的代理应该是让二次开发人员只关注在写serialize, unserialize(协议扩展), split, hash, map(拆分请求), reduce(整个请求结果)逻辑上, 其余的部分完全不必关心
代理不该被永久性的隔离为一个请求路由服务, 站在云存储和云数据库的角度来说, 请求路由只是一个基本功能, 数据自动分片(sharding),分布式的事务, 锁等一系列需求涉及到的业务和技术上的难点只会更多
来源:微博推荐
原文:?p=649
转载文章,向原作者致敬!如有侵权或不周之处,敬请劳烦联系若飞(:)马上删除,谢谢!
·END·
架构师我们都是架构师!
架构师订阅号,关注获取更多技术分享
现已开通多个群,有兴趣交流学习的同学
可加若飞:进群
合作邮箱:[email protected]