一、上路
SP(Search Planner) 作为搜索入口,其对高并发,低延时的性能的要求决定了动态型语言很难满足要求,所以从设计起,就决定使用C++/C语言去实现。使用C++/C优点很明显,性能优秀,表达能力极其强大,唯一的限制只有程序员的能力,但是,世界上的事情大致如此:过于厉害的事物,一般都不易驾驭,开发效率低,发布过程周期长,这一定程度上制约了SP的业务发展,那么问题来了,怎么在不牺牲性能的前提下,提高SP的开发效率,同时让SP具备快速上线,最好热部署的功能呢?
二、旅途
我们首先想到是用脚本去替代现在使用C++实现的业务逻辑,通过对SP的现有架构环境分析来看,对这个脚本定位是,首先功能上要求不会太高,但是必须方便与C/C++集成。SP的核心功能仍用C++等语言编写,但为了满足各种功能组合,需要的是一个比C++这种语言简单的,轻量级的,容易学习和使用的,性能又不错的语言。 最好是业界有大量产品使用,得到了实际验证。Lua就是这样的一门语言。
Lua语言特性:
* 可以很方便的和用C/C++编写的逻辑互相调用
* 麻雀虽小但五脏俱全,可以支持很多高级语言才有的特性,比如gc
* 学习成本低,基本上,懂点编程都知道怎么编写Lua
* 性能好, 消耗的计算机资源不多
在定下加脚本呢语言后,我们开始考虑架构问题了,是重构?还是架在现有架构下,扩展脚本特性?重构的成本代价很高,而且重构后,未必会对现有的SP架构有更好的益处,另一方面,重构为以Lua为主角的架构,对未来应用的扩展性也带来潜在的限制和挑战。更何况,现有的SP架构,是基于Processor Chain的链式处理结构,这种设计本身就非常灵活,并且可很好的扩展。
于是,我们脑海有这样的思路,整个环境还是现有的C/C++,当请求过来,会经过一条长长的处理链路,链路上每个节点代表一个业务的处理逻辑,这个处理节点可以是一个引擎的访问,也可以是一个数据包的解压缩,当如果这个节点是通过Lua脚本来实现的话,那就加载一个Lua虚拟机来去运行。运行完后,继续处理下一个节点。
好了,思路大体确认下来了,我们开始深入一些细节问题,看看这个玩意到底可不可以无缝地集成到SP呢?那么,首先我们需要理清SP是一个怎样的运行环境,SP是一个典型的多线程执行环境,通俗讲就是,一个请求一个线程,一个线程一条执行链路。所以我们希望这个Lua虚拟机能够穿梭在不同线程之间,在这个多线程环境生存下来,然而,很不幸,随着对Lua的深入研究,我们发现Lua虚拟机是不能同时被多个线程同时访问!它是线程非安全的!OMG!!那它是怎么做到并发呢!协程!这又是什么玩意!学过Go或者Erlang的同学应该对这个概念不陌生,协程,好东西呀。。。
“BUT!!!你要我们将目前的多线程环境改为协程!臣妾做不到呀!!!宝宝心里很苦!宝宝 。。。选择退而求其次,既然做不到同时访问,那么串行访问。。。”
“啪啪啪!老子给你300个线程,你TMD告诉我这个300个线程要排队执行!!!”
“那。。。一个线程一个Lua虚拟机”
“啪啪啪!一条链路10个Lua处理节点,300个线程!就是10*300=3000个虚拟机,你TMD告诉我一个SP要用3000个虚拟机!你咋不上天啊!啊!啊!”
不用串行,又不想那么多虚拟机,那就是池子(pool)。怎么设计这个池子,首先,我们对这个池子有两个基本动作,从池子快速取出(take)一个虚拟机,用完了,我们还需要归还这个虚拟机,迅速放回(put)这个池子里,这两个动作能不阻塞最好不要阻塞,其次,我们需要能随时控制池子大小,如果访问频繁的话,能加大池子容量,如果访问低谷,能缩减容量(销毁池子里的一些虚拟机),我们开始搜寻什么样的数据结构满足这样的要求。Java的Concurrent包(java.util.concurrent)是个好东西呀,包里面的数据结构的设计非常精良,值得借鉴,其实,LinkedBlockingQueue就是这样数据结构,我们最后参考了这个设计,用C++实现了一版(稍微做了改动,我们采用环式的队列模型)。
好,有Lua 虚拟机池,解决了内存和性能问题,我们再深入一点,我们希望Lua和C++能协同工作起来,他们之间的一些东西希望能共享,并且统一管理,但是,Lua有自己的变量和内存管理,而且Lua对内部自己的对象是有GC(Garbage Collect)的,是可以自动回收,但是C++不同,和绝大部分的基于虚拟机的语言或者脚本语言不同,在C++里new出的对象,是需要程序员自己管理,需要跟踪它的整个生命周期,试想一下,这样new出的对象如果传给Lua,然后在某一刻被Lua自动回收(你完全不知道它什么时候被回收),但是在C++环境又在引用它,访问已被销毁的对象!Bang!!!
有什么可以解决呢?引用计数!我们在C++和Lua之间建立一套简易的跨环境GC,基本原理是,当new出一个对象,同时初始化这个对象的引用计数,如果这个对象被赋予一个局部变量时候,而不管这个变量是C++局部变量,还是Lua的local变量,引用计数自增1,当局部变量跳出scope时候,引用计数自减1,只有当引用计数为0,也就是没有变量再引用这个对象时候,对象才会销毁。这种设计简单,性能也最好,不过这个有存在交叉引用问题,要求程序员对类的设计不要存在交叉引用,缓解此类问题,可以引入弱引用的概念,只是增加了设计的复杂性,但要根本解决这个问题,需要引入类似Jdk的gc机制,非常复杂,很幸运的是,引用计数对于SP已经足够了。
好,既然业务用Lua脚本实现,开发效率提高了,那我们能不能更激进一点,让Lua脚本的部署更加快速,最好做到热替换!!对,你没听错,热替换!换句话说,有什么办法可以直接将更新的脚本推送到200多台SP机器,并且告知它们“脚本有更新,请重新加载”。稍微想一下,就能得出这样的一个结论,我们需要一个推送平台,它能够和SP保持长链接,并且能够把更新消息实时推送过去。Bingo!Diamond!!
三、终点
我们把想法画成图,就是这样
Lua代码存在Diamond上,每个Lua文件代表一个处理逻辑,App.lua表示总控逻辑的代码,QP.lua 表示处理QP请求的Lua代码等等,当这些代码有变更,SP的Diamond工作线程会监听到这个变更,并开始初始化Lua虚拟机并新建一个代表该Lua代码的虚拟机池,跟这个Lua代码有关的新请求都将从这个池取出,旧的池子将被标记为待清除,虚拟机池的Lua虚拟机按需创建,并通过一个环形的链式串联起来,不同虚拟机池又通过一个大的链路串联起来,组成一个更大的池子,这个大池子由专门的一个线程来管理,它会负责跟踪大池子里面的每个虚拟机池的运行情况而动态调节池里的虚拟机数量,同时销毁掉被标记为待清除的池子。
当请求过来,分配一个请求工作线程(Request Worker Thread),线程会流经Processor链路,如果链路上某个Processor是被注册为Lua,则从虚拟机池取出相应的一个Lua VM,运行完后,放回池里。
其实,除了上面的processor节点可以用lua写,processor节点与节点的拓扑逻辑也是通过lua来生成的,这么说,除了一些CPU密集的计算仍保留C++,只要你脑洞够大,可脚本化的地方可以无处不在。再结合diamond的推送,一个可热替换、无需重启、灵活的脚本化的SP就这么诞生了。