Node 缓存优化之路

当今互联网,越来越往大数据的方向发展。大数据的背后是大用户,你的后端服务必须得有能力承载大用户、高并发的冲击。从宏观上讲,提高并发能力,无怪乎横向扩展(增加服务器节点个数)、纵向扩展(提高单点服务器的处理能力)。横向扩展,常见的有负载均衡,是一种部署的拓扑结构;纵向扩展,更多的体现的在应用程序本身性能提升。计算机中,我们常用拿空间换时间,来提升程序性能,缓存正是这种思想的体现。

本文链接地址 https://blog.whyun.com/posts/node/the-cache-design-in-node ,转载请注明出处。

我们这篇文章讲 Node 的缓存设计,不过在切入正题之前,我还是要先讲一下 Node 的线程模型,如下图:

Node 线程模型

图片0.1 Node 线程模型

我们常说 Node 是单线程程序,这是由于我们 JavaScript 代码(基于 V8 引擎)正是运行在这个单线程中的,此外我们常说的事件轮询、网络 IO 都是运行在这个线程中的。不过 Node 进程中,并不仅仅只包含这一个线程,它还有一个线程池,用来处理 文件 IO 、crypto 、zlib,还有异步 C++ 模块等操作,只不过,我们平常在 Node 中运用这些操作比较少。

1. 缓存分类

从生命周期上讲,缓存大体上分以下三类,

  1. 会话 跟用户登陆状态关联的数据,生命周期长

  2. 临时数据,短时间内有效,生命周期短

  3. 镜像 数据库的数据镜像,生命周期特长

2. 缓存设计思路

2.1 会话

现如今,各种设备终端兴起,很多情况下,我们要自己设计用户会话,而不是直接使用传统 web 中使用的 session,以便更好的适配多端设备,并且能够更好的对性能进行调优。

一个简单的 session 设计思路,就是生成一个 token,然后将用户数据序列化成 JSON 字符串,最后将 token 和 JSON 字符串的映射关系写入 redis。在读取的时候,根据 token 查询到 JSON 字符串,然后反序列化即可。

咋一看,这个设计是没有问题的,不过经过性能测试发现,Node 中 JSON 的反序列化性能是不高的,解决这个问题也很简单,我们将 redis 中反序列化的数据缓存到内存即可,这样用户下次请求的 token 验证就直接走内存了。 不过这又会导致一个问题,如果你的用户量数据巨大,那么内存是不够用的。我们必须得有内存清理机制,否则早晚内存会炸掉。

应该怎么清理呢?每天凌晨将内存中的数据清理掉,但是如果白天内存就告急了怎么半?限制内存存储数据个数,在达到内存限制个数后,内存中存储的都是老数据,如果这些老数据都是冷数据,新来的请求还算是会频繁的请求 redis ,做反序列化。 怎么办?有没有既限制内存数量,且又能读写热数据的思路呢?还真有,这就是 L(Least)R(Recently)U(Used) 算法.。不过如果你使用 JavaScript 编写一个 LRU 算法,就会在 V8 引擎中引入一个耗时操作,这在 Node 中是大忌。我们开头图0.1讲过,Node 进程中其实是有一个线程池的,我们何不将这个 LRU 实现代码,在这里实现?

我写了一个 c++ addon 来处理这个 LRU 算法,刚开始测试的时候性能很不错,看上去完美的解决了这个问题。不过随着时间的推移,我最担心的事情还是发生了,就是进程崩溃了!刚才我们说过,我们借用的是线程池来处理 LRU 算法,但是我们的程序中对于多线程操作,并没有加锁,导致处理的过程中出现脏数据,然后读写非法内存地址,进程就崩溃了。

我将线程操作加上了锁,加锁所导致的性能又一步做出了损耗,但依然在可控范围内。但是我们依然忽略了一个问题,我们做 LRU 就是要减少内存使用的,但是现在我们反而把 每个用户的 token 数据都写到内存中 LRU 关联的链表中,长此以往,内存依然会爆。

说到这里,我们的优化之路,貌似进入了死胡同,不过当你为一件事情殚精竭虑的时候,上天一定会眷顾你。一个偶然的机会,我发现了 Redis 的内存清理也是使用 LRU 算法的,同时在启用 LRU 时,性能也会下降,这跟我们使用LRU算法的结果是一样的。不过在 Redis 中还有一种算法,用来清理过期key,就是定期遍历 key 列表,从 key 列表中取出 N 个 key,判断其是否过期,如果过期,就删除。Redis 也是单线程,其线程模型和 Node 是类似的,所以它在遍历列表的时候,不会做全量遍历,而是使用定时器。增加定时定量的清理算法后,做性能测试,性能并没有损耗,同时还保证了内存占用在可控范围之内。

以上就是会话类型的缓存设计历程,对应的源代码已经开源,参见 session-token

2.2 闪存

使用闪存,一定是我们允许在短暂时间内缓存数据和真实数据有一定的误差,但是这个误差一定要在允许的范围之内。比如说做阈值处理时,通过 Redis 的 incr 操作做计数器,在达到阈值时就可以把这个值缓存到内存,在达到阈值的短暂时间内,真实的计数器的值我们并不关心。

整个设计思路类似于 JVM 或者 V8 的新生代 GC 算法:

young/old

图 2 .2 young/old

缓存数据在写入的时候,现如 young 区域,程序内置定时器每隔固定时间将 young 区域赋值到 old 区域,同时清空 young 区域。

使用时要注意缓存击穿问题。比如说笔者曾经犯过的一个问题,在将 Redis 的查询的结果缓存到闪存,但是如果查询结果为空,没有将空的这个结果缓存下来,所以说对于 key 值不存在的情况,每次都是缓存不命中中的,每次都会请求 Redis。解决的问题也很简单,就是在闪存中保存一个空串。

对应的源代码解决方案参见 flash-cache

2.3 镜像

前段时间,我们接手了一个 IM 项目,要求实现类似于钉钉的功能,在线可以实时接收数据,离线后上线,可以查看未读历史记录。考虑到是一个实时系统,我们的没有选择直接读取 mongodb,而是将数据先写入 Redis 中。同时将实时聊天记录打入 Kafka,然后通过消费者程序同步到 mongodb。Redis 中存储的数据可以理解成 mongodb 中的镜像,读取的时候,先读取 Redis,只有在 Redis 中查询不到的时候才读取 mongodb (实际上这种情况几乎不可能发生,因为我们在 Redis 中缓存失效时间很长)。

镜像中一般用来映射数据库中的表记录,采用的 key 类型为 sorted set。但是数据库中的数据量是巨大的,全部缓存到一个 key 中,对于 Redis 是不友好的,所以说需要按天拆分 key。

前面说到 key 的类型为 sorted set,那么 score 是什么呢?score 是一个自增数字,我们用自增数字来唯一标识一条聊天记录。者个自增数字使用的是 Redis 的 incr 操作客户端本地存留一个上次阅读到的聊天 Id,服务器端可以根据上次阅读Id,就可以给客户端返回历史记录(操作过程中会涉及到分页查询)。

3. 性能测试

关于性能调优的方法和工具,可以参见我写的《Node 基础教程》中的第11章 Node 调优

Comments

跨越万水千山认识你,跨根域的单点登陆设计

现在我们越来越喜欢使用第三方账号(QQ、微博)来登陆各种网站,比如说只要你的 QQ 在线,点几下鼠标就可以登陆一个第三方网站,最多让你在第一次登陆的时候补充一下手机号或者邮箱地址,下次就可以直接使用 QQ登陆了。但是,我们这篇文章并不是解释这种登陆模式的,这种登陆模式用户体系其实在 QQ 和 微博获取,如果需要单点登陆的网站的用户数据本来就拿到,就不用费这么都周折了,比如说下面这种。

本教程链接 https://blog.whyun.com/posts//the-design-of-sso-between-diffrent-domain/ ,转载请注明出处

想想这种情况,假设你是一个电商网站,叫某宝,后来你的业务发展了,你又做了一个子产品网站,叫某猫,你肯定想用户登陆一次,就能畅游两个网站。也许你会想,某宝和某猫可以做成挂在同一个一级域名下的二级域名,比如说 tao.xxx.com 和 mao.xxx.com,然后登陆的时候 cookie 写入 xxx.com,就能实现 session 共享了。很遗憾的告诉你,某宝和某猫是两个平行的子公司,根本就不会共用一个一级域名。我们姑且将这两个公司的域名记为 tao.com 和 mao.com 。

用户在一个节点登陆完成之后,我们在前端需要将登陆凭证写入 cookie ,这样用户再在这个节点上发起请求的时候,会把 cookie 带入请求头发给这个节点的服务器端,服务器端就能判断用户是否在线了。不过现在用户在节点 A 登陆后,根本就没法将 cookie 写入 B 节点,好像问题陷入了死结。我们再来从头分析一下,问题的症结是 cookie 不能跨一级域名写入,那么我们就让它写入一个节点好了,解决问题的关键就是增加一个中心节点,专门用来记录 cookie,哪个节点需要读取 cookie,就从这个中心节点读取即可了。

sso登陆时序图

关键的一个步骤,就是第 2 步,jsonp 的原理其实就是生成一个异域的 script 标签,但是这里请求 sso.com 的时候,会在请求上带入 sso.com 本身的 cookie!然后我们再在 JavaScript 代码中将这个 cookie 信息赋值给 js 变量,这样 tao.com 就能通过 js 得到 sso.com 的 cookie 了。

为了简化教程,这里只给出 tao.com 前端代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>首页</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    <h1>首页</h1>
    <input type="hidden" id="js-data-hidden" js-domain="<%=domain%>">
    <script src="https://upcdn.b0.upaiyun.com/libs/jquery/jquery-2.0.3.min.js"></script>
    <script>
        $(document).ready(function() {
            var $data = $('#js-data-hidden');
            var domain = $data.attr('js-domain');
            var redirect = encodeURIComponent ('http://' + domain);
            var ssoBaseUrl = 'http://sso.com';
            $.getJSON(ssoBaseUrl + '/get-ticket?callback=?').then(function(data) {
                if (data.ticket) {
                    return $.get('/get-user-info?ticket='+data.ticket);
                }
                location.href = ssoBaseUrl + '/login?redirect=' + redirect;
            }).then(function(result) {
                if (result.code != 0) {
                    return alert(result.msg || '逻辑错误');
                }
                location.href = '/user-backend';
            }).fail(function() {
                alert('网络错误');
            });
        });
    </script>
  </body>
</html>

代码 1.1 tao.com 登陆状态获取

这里我们用 ticket 来表示登陆成功之后写入 sso.com 的 cookie 的变量。上面代码的逻辑还是比较清晰的,首先通过 jsonp 获取 ticket :如果获取到了就到了就在 tao.com 的后端,通过 ticket 查询出对应的用户 ID 回来,然后将登陆用户数据写入 tao.com 本身的 session中;如果没有获取到 ticket,就跳转到 sso.com 完成登陆,并生产成 ticket,登陆完成后再跳转会 tao.com。

本教程关联的完整代码,参见这里 https://gitee.com/yunnysunny/sso-demo

Comments