每秒千万级别的量是重生还是炼狱?

作者: | 更新日期:

上篇文章记录了union服务曾经的架构与问题, 这篇记录目前的优化与计划优化.

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、炼狱者,无尽也

任何现在看着很糟糕的设计与实现,背后肯定有原因的。
且那个背景看来那样做也是合理的,甚至是当时的最优决策。

上篇文章荒蛮时代诞生的union服务(公众号回复1706查看文章)中介绍了大系统中面对众多数据接口网状调用时的解决方案以及面临的问题.
这篇文章记录一下后续的优化以及面临的问题.

解决这些问题的过程中,发现UNION重生的过程中更是一种炼狱.

一、我们把它隔离出来吧!

所谓的高性能海量服务是被围困的城堡,城外的人想冲进去,城里的人想逃出来.

先来看看之前Union的架构.

在上篇文章已经提到这个架构面临的核心问题是 众多数据源扩容难服务使用多线程模式维护成本高.

“我们把它隔离出来吧!”, 有一天, 老板和几位高工拉上我开会时说了这么一句话.

他们经过分析, 众多数据源中, 最最重要的数据其实不多, 一些不重要的播放量和UGC视频数据比较占容量, 可以保持不变.
于是就计划把核心数据独立储存起来, 上层再加个缓存降低底层聚合层的压力, 最后聚合层使用通用网络框架重写.

切换架构如下:

这个项目大概参与的核心人员有5位: 我是缓存层, 聚合层的维护者不变, 统一写服务是新同事A, 一个运维B, 一位DBA C.

期间我去了东北的学校的时候, 还乐呵呵的告诉他们我在负责一个访问量很大的服务. 每秒20多万, 他们想想那些开源分享会议上动不动就是上亿访问量, 这才二十万, 感觉没啥的, 我一想, 好像也是.

但是真正做了的时候, 发现那里不对劲了.

所谓的高性能海量服务是被围困的城堡,城外的人想冲进去,城里的人想逃出来.
做完第一期切换后, 同事A, B, C都不想继续炼狱了, 不过这是后话了.

二、分组的作用是什么?

过早的优化真的是万恶之源

我被拉进那个会议时, 他们说: “目标是把旧的聚合层隔离出来, 然后切成新的更容易维护的聚合层”.
这是我们要做的缓存系统, 架构和优化都想好了, 你只需要实现了.

新增的缓存层和原先聚合成的区别有很多, 这些区别也影响很严重, 其中前三点共享内存的还是没办法避免的.

1. 进程内存 VS 共享内存

原先的聚合层是进程内使用内存缓存数据的.
内存有个好处: 可以使用丰富的复杂结构, 比如聚合层曾经的缓存就是参考REDIS实现的.

现在使用共享内存后, 这里只能使用简单的HASH, 通常是使用多阶HASH或者LINKTABLE.

对于简单的HASH表, key冲突了一般是使用链表连起来, 共享内存也可以实现,但是略微复杂, 且冲突后查找性能就变成线性的了.
多阶HASH就是使用多个HASH表, 每个HASH表的容量是不同的素数.这样冲突时就去下一个HASH表, 顶多扫描所有HASH表, 复杂度也是常数的.
LINKTABLE也类似.

2. 结构化 VS 序列化

其实进程内存和共享内存还有个很重要的区别: 进程内value可以储存结构化的数据, 而共享内存value只能储存序列化的数据.

这个其实是很影响性能的一个点, 原先命中缓存直接copy一份结构化数据, 现在需要先copy字符串, 反序列化, COPY一份到数据结构, 最后COPY一份回包.

PS: 最后为什么COPY一份数据结构, 因为请求可能有相同的key, 相同的KEY都需要返回数据. 之前使用swap不COPY时有人反馈相同的key后面的都为空.

3. LRU VS 多阶扫描淘汰

进程内还可以做到O(1)复杂度的LRU淘汰, 共享内存里面只能扫描多阶HASH, 通常淘汰第一个遇到的过期的KEY.
如果想真正的LRU淘汰, 就需要扫描所有阶数, 复杂度的常数会比较大, 成本有点高.

4. 局部性原理 - 分组优化

如果开会的时候只是说做一个缓存服务这个目标就好了. 但是会议上他们还说了一个优化: 局部性原理.

分组的作用是什么?
当时DBA问这样问我.
我想了想, 说不知道, 我想清楚了再告诉你吧.

回忆会议上的内容, 原来他们分析根据局部性原理, 请求的字段也有局部性的.
如果我们把这些局部性字段打包储存在一起, 将可以大大提高性能, 经过共享内存之后, 字段的局部性热点会变化, 所以远程缓存也需要使用不同的分组.

于是具体实现的时候, 程序的逻辑相当复杂:
请求: table, 批量key, 批量field
共享内存: (table, 批量key, 批量分组)组成key, value为批量field.
远程缓存: (table, 批量key)组成key, 另一个批量分组为subkey, value另一个批量field.
聚合层: table, 过期的批量key, 过期的批量field

上线后性能相当低下, DBA也总是找我说缓存层的REDIS流量又满了, 于是我就告诉老板分组是不合理的,但是不敢去掉分组, 一直在等待老板发话, 心里想这样迟早会出现问题的.

5. 支持批量拉取table

实际上第一次会议的时候, 老板还说了一个功能点: 支持批量拉取table.
加上分组, 实际上就有四层循环了。

上面只所以有四层循环, 分别是: 批量table, 批量key, 多个分组, 多个field.

批量真是一个恐怖的事情, 这样四层下来, 稍微复杂一点的一个请求field那一层需要执行几万次, 想想都恐怖.

6. 会议总结

缓存的实现由三个特点: 1. 使用共享内存, 2. 增加分组 3. 增加批量拉去table.
仅仅第一点就可以得出改造后的缓存层的性能和命中率肯定不如之前聚合层的。
现在又要加上字段分组和批量table, 开始敲代码之前心中就预感不安。

我曾问:这样有blabla问题的,为什么要使用共享内存, 我把redis代码fork下来改一下当做我的缓存层不行吗?
答:不行,只能使用共享内存, 这样更”稳定”,服务重启也有数据等等一大堆理由。
于是我不再说什么,既然已经看到前面是悬崖,那就一起跳下去吧。

三、REDIS挂了会怎么样?

墨菲定律: 只要有可能发生的事情, 一定会发生.
Anything that can go wrong will go wrong.

由于分组的存在, 导致共享内存有大量的内存COPY, 大包序列化反序列化, 大流量串到缓存层REDIS等等.
虽然第一期目标隔离缓存层完成了, 但是缓存层需要砸更多的机器来承担那么大的访问量, 缓存层REDIS的流量也越来越大.

REDIS挂了会怎么样?
有一天DBA C问我, 我说底层没有过载保护, 为了保护聚合层, 缓存REDIS挂了就不去拉聚合层了吧, 不能把聚合层压死了,这样缓存REDIS恢复了,服务就正常了.
所以REDIS不能挂, 真的不能挂.

既然分组不能去掉, 流量又太大, 于是我加了一个优化: 冷数据不走缓存了.

于是我对DBA说我想到一个方案: 热key策略.
对访问的key进行计数, 超过指定访问量后才走缓存.

热key策略上线后效果明显, 缓存层的CPU下降不少, REDIS的流量下降一些, 不过聚合层的访问量也上升不少, 但是这个没有解决根本问题.

终于有一天缓存层REDIS流量太大撑不住了, 于是怎么也不能恢复服务了.
我对DBA C说有一个办法: 缓存层总入口有过载保护, 我把阀值调低点就行了, 缓存层的新流量已经调的很低了, 缓存REDIS还是没有恢复.
炳哥说找DBA开发问了, REDIS流量满了, 之前的包还都在排队, 只能重启REDIS服务来解决了.
他于是找DBA开发重启REDIS, 原来之前的DBA C只是DBA运营, 怪不得他不知道具体原因, 还有他们竟然运营开发分离了, 这种我梦寐以求的模式看来也有问题呀!

四、你的服务你做主的

对于一个架构问题, 不存在通过加一层解决不了的, 如果存在, 那就加两层.

后来, 一个好心的老同事告诉我: 既然分组不合理, 你就去掉分组吧, 你的服务你做主的(出了问题你也负责), 老板不关心这些细节的.
感激这位同事, 我瞬间醍醐灌顶, 是的, 我的服务我做主, 自己使用工具收集了好多服务的数据了, 有很多地方可以优化的, 那就开始优化吧.
优化期间, DBA已经换成D同事了, 运维也换成E同事了.

1. 去掉分组

第一件事情肯定是去掉分组.
去掉分组后, 本地缓存储存是一维的. key是”table_key_field_version_platform_ext”, value就是对应的值的序列化以及缓存相关信息.
在缓存REDIS里, 由于可以使用HASH, 所以key是”table_key”, sub_key是”field_version_platform_ext”, value和本地结构一致.

去掉分组逻辑后, 代码马上看着简洁多了, 缓存层的性能也里面上升一个层次, REDIS的流量也下降一大半.

PS:这里REDIS使用了HASH, 我一直在问自己为什么要使用HASH, 为什么不直接使用k-v,竟然没有找到理由,没有理由不直接使用k-v.

2. 去掉批量table

之前的逻辑,四层,每个循环一个函数, 后来为了性能改为四个宏, 最后为了优雅改为一个函数四层循环, 看着那代码我都不愿意承认那是我写的.

这次自己做主了, 就顺便把批量table的功能也去掉了, 代码又清晰不少, 性能提升不大, 因为线上没有人批量来拉table.

3. 远程REDIS缓存通知

上线了这个缓存后, 发现透传量很大, 分析数据发现大多是过期透传下去了.
那针对这个问题的方案自然是增大过期时间.

我的过期时间都是基于字段单独配置化的, 配置上线后陆续有人反馈: 数据更新太慢.
于是讨论后, 需要接入现有的ZMQ中转通知, 然后在REDIS中打上更新的标记(更新时间戳).

为什么不在本地缓存上直接接入中转呢?
问了ZMQ中转的负责人, 机器太多, 中转服务器会撑不住的.
即使ZMQ中转废弃了, 未来的微博中转也接受不了这么多机器.

又考虑到要废弃就中转,引入新中转, 于是我这个接中转的服务需要灵活点, 不然后续改造风险太大.
于是最终架构就是下面的样子了:
插件可以是任何适配器,中转,服务,接口等

4. 自己管理内存

使用性能工具perf分析后, 发现CPU性能主要消耗在内存的申请与释放上.
首先尝试引入tcmalloc和Jemalloc库优化内存申请释放问题, 但是实际测试后发现我的数据模型是大内存,小内存完全随机, 这些库并没有提升多少性能, 瓶颈点在内存申请与释放上.

于是我就实现了自己的内存管理, 说白了就是只申请内存, 不真正释放内存, 申请内存前优先使用空闲的旧内存.
由于秒级访问量存在上限, 所以内存不会被撑爆.

代码大概如下, 由于返回的是指针, 为了防止内存泄露, 这里使用另一个类来自动回收内存.
结果使用的时候就需要先申请内存, 然后使用引用的方式定义具体的实例, 当然引用也会占用8字节的内存.

PS: 这个优化后,性能的TOP1瓶颈点就转移了。

5. 热key再次优化

之前的热key是直接使用多阶hash共享内存库实现的, key是字符串, 查找还是消耗性能的, 并且key的计数更新规则也存在问题.

为了性能, 自己写了一个基于共享内存的热key库, 实际储存会对key进行hash映射取模, 然后直接使用下标找到对应的数据.
对于计数规则, 原先是到达周期,按比例缩小, 计数波动较大, 优化后分成环, 环内计数求和, 大大提高计数稳定性, 不过对于冲突的key,按相同key处理了.

PS: 这个之前也进入TOP10瓶颈点了,优化后性能完全可以忽略了。

6. string与map优化

一个服务如果有CPU有性能瓶颈, 大多数时候都是在string上.
而我的string大多用在map的key和value上, 所以有必要优化一下, 提高map查找的性能.

这里主要有两个优化点:

  1. 能使用指针当做key的话, 使用指针当做map的key. 然后传入比较函数, 这样可以避免key的copy.
  2. 对于map查询, 主要消耗在字符串比较上, 这里应该优化比较操作, 比如生成字符串的时候就计算hash, 后面字符串一般不会变,可以直接使用hash了.
  3. map的value全部改成指针或者只能指针

PS: 这个之前也进入TOP10瓶颈点了,优化后性能提升不少。

7. 多级过载保护

之前缓存REDIS之所以没有过载保护, 是因为之前负载统计都是进程内统计的, 而我的服务是多进程, 由于短时间内各进程实际处理量偏差较大, 这样统计意义就不大了.
于是我又实现了一个基于共享内存的单机过载保护库, 默认支持请求三个维度的过载保护, 远程缓存三个维度的过载保护, 聚合层三个维度的过载保护, 还可以自定义其他维度的过载保护.
大致逻辑如下:

在这个过载保护功能上线之前, 很不幸的事情在某个连续两周内又发生了几次., 还都是REDIS出现故障,旧版本redis存在问题,没来得及升级。redis恢复后服务就正常了.
后来炼狱者又重新刷新, DBA换成 F, 运维换成G了.
多级过载保护功能终于上线了, UNION系统再也没有发生重大故障了.

8. 设计缓存库与缓存策略

关于缓存,之前在谈谈cache(公众号回复1704查看文章)曾介绍了很多策略与实践遇到各种问题。

而现在主要面临一个问题:共享内存纯粹使用多阶HASH实现,key和value共存,内存使用率极低, 性能也存在很多问题。

于是就有了下面的一系列优化了。

第一件事时编写一个linktable, 索引节点与数据节点隔离,索引节点尽量小点,数据量多点,数据节点使用链表维护,所以不存在浪费。

第二件事是索引节点和数据节点都设计成2的若干次幕, 查找直接使用位操作, 避免大量的乘法与加法操作。

第三件事是对key生成多个不同的hash值, 避免key冲突时进行字符串比较。实践证明两次就过滤所有冲突了,第三次hash和key长度的兜底比较一天也没有一次。

第四件事是对概率学上的应用,既然三次hash比较就可以99.99…%的概率避免冲突了,那我们就可以避免key字符串比较了.

第五件事是cache库私有化。如果编写一个通用的cache库,通用必然有对应的成本。
前面说过业务的field数据是个结构化的数据, 而cache库的value只能是字符串, 那就存在结构化数据到字符串的序列化与反序列化。
而cache库私有化直接储存部分结构化数据,则可以减少很多序列化与反序列化, 大大提高性能。

第六件事是hash起点阶数: 这个优化尚未进行,需要收集一些数据来证明是否有必要进行优化,然后需要时间来优化。
多阶hash一个很明显的缺点是我们不知道数据存在哪一阶,于是只好从第一阶开始扫描,直到找到或者最后一阶(有个优化当前使用的最高阶,但是意义不大)。
而起点阶数hash的含义是我们不再从第一阶开始查找,而是hash出一个阶数开始循环查找,最差情况和不优化一样,但是大大减少了比较次数。
毕竟一个是线性查找,一个是hash查找。

9. 其他优化

其他还有很多很多优化, 这里就不一一细讲了, 经过收集数据一步一步的优化, 单机吞吐量越来越高.

有一天DBA X曾问过我这样一个问题:
这几个月来你优化了那么多功能, 怎么你的性能和我的流量怎么没那么明显呢?
我也纳闷的看了下数据, 我优化了那么多, 总请求量竟然也涨了很多, 都抵消了.
城外的人只看到CPU依旧很高, 流量依旧很高, 却没看到请求量曾经是20W/s, 现在是90W/s, 城墙太高了.

五、需要引入一致性hash提高命中率

对于multiget命令来说,分布式部署更多的节点,并不能提升multiget的承载量,甚至出现节点数越多,multiget的效率反而会降低,这就是multiget黑洞。

其实现在缓存层的命中率已经很高了, 但是很容易让人产生错觉: 性能低是由于命中率低, 命中率低了我们要想办法提高命中率, 如加上一致性hash.

终于有一天被告知: “需要引入一致性hash提高命中率”.
虽然我分析数据发现透传下去的都是过期的, 上一致性HASH没什么用.,但是我说了不算.

考虑到不能将所有节点挂载一个hash下, 所以要分很多小set, 一个set一组一致性hash.
架构如下图:

multiget黑洞是一件很恐怖的事情.
假设四台机器一组小set, 每个机器访问量1w/s, 一组总共4W/s.
由于请求是批量的, 一个请求将被拆分为4个, 这样到达下层的请求量就是16W/s, 单机4w/s.
路由层和实际处理数据层是一台机器, 所以单机请求就有5W/s了.
这个结论当初讨论的时候就已经得出了,但是还是不得不上。

本来一台机器1w/s的请求, 上了一致性HASH后请求量变成5w/s了, 网络框架的收发包部分逐渐也成为CPU的瓶颈了.

后来发现每晚高峰期,推荐set CPU会抖动几分钟,后来发现绑定CPU可以解决,于是绑定了CPU.
再后来播放set要上线一个功能,说今晚放量5W/s, 一看CPU才不到30%,结果一下涨了8W/s, CPU虽然不超过40%,但是也抖动起来.

查询一下这个网络框架的资料,原来这个网络框架在高访问量下确实有问题,好吧。
这个网络框架又不支持多端口, 于是只好一台机器部署多个相同的服务然后开不同的端口, 来缓解框架收发包的瓶颈, 查了资料要想彻底解决这个问题要么自己实现一套网络框架要么使用go语言.

六、中转不行那就做一个版本号系统吧

矛盾无处不在!

透传率较高, 透传下去的都是过期的, 这个问题摆在眼前.

一个同事问到: 为什么不每台机器接入中转呢? 哦, 想起来了, 那个量太大, 中转承受不了.
老板说道: 中转不行那就做一个版本号系统吧!

于是就讨论架构, 最终设计一套版本号系统上线了.
版本号上线后, 发现这个和有序列号的中转没多大区别, 可能定制化的轻量级的中转了.

其实上线版本号系统后命中率提升不少, 但也没有那么多, 原因是热key逻辑已经存在。
当我热key关闭后, 请求命中率瞬间就到92%了,而field的命中率也有97%以上了.
现在field的计算逻辑是有问题的, 等下个迭代优化后, field的命中率应该会变成99.X%了吧.

版本号agent是我实现的,版本号中心服务是新加入的同事实现的。
有段时间总是有人反馈数据不更新,最终把所有agent流水上报发现版本号中心服务下发的数据不可靠,于是agent上做了各种强检查,兼容各种异常才正常了。 当然,此时统一写已经是另外一个同事加入进来了,对底层数据同步与写入做了大量的优化。

七、给你一个小任务吧

面对一个问题时, 不管宣称找到如何”完美”的解决方案, 都会引入一系列更”复杂”的问题!

现在的架构已经成型了, 命中率优化的余地已经不多了, 但是性能却还存在问题.

请求访问量是90+W/s了, 一致性hash扩散后访问量是166W/s, key级别扩散后是500W/s, field级别扩散后是5500W/s了. field级别那一层做得任何事情都会被放大无数倍。

于是我使用性能工具定位到性能较低的地方.
然后反汇编服务, 通过符号找到对应的大概位置, 然后把汇编代码和源代码手动一一对应起来.
最后发现了一个惊人的现象: CPU的L1, L2缓存没命中, 去内存读数据, 从而导致性能较低.

但是最近我准备重构一个中转系统了, 没时间去分析以及测试怎么优化L1, L2缓存了.
于是我对另外一个同事说: 给你一个小任务吧.

现在union CPU的瓶颈点是cache比较查找, 我反汇编后, 和代码比较, 发现性能都消耗在第一次加载数据上了.
这个还有很大的优化空间, 这个涉及到 CPU L1 cache, L2 cache的技术点, 你测试一下, 怎么样才能更好的利用上L1 cache吧.

对了,你也研究一下tcpcopy吧,很成熟的东西,我们要引入。我曾对那个同事说另外一个任务。

八、炼狱 ING

接近一种本质

上篇文章也介绍了, 视频的春天来了, 各大业务都在做各种功能。
这个业务做一个与播放级别一样量的服务, 需要来拉数据, 那个服务也做一个和播放级别一样量的服务, 也需要来拉数据.

现在只是春暖花开, 真正繁荣正在逐渐来临, 各种服务都在上线, 来拉数据的量会翻几十倍, 甚至几百倍.

所以我坚信, 炼狱仍然持续, 就在不就得将来.

下篇文章记录什么还没想好,可能是重构的中转或者是union的运营。 看情况吧.


长按图片关注公众号, 接受最新文章消息.

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越