本文最初发布于领英技术博客,经领英官方授权由InfoQ中文站翻译并分享。
在领英,我们常说公司的血液中流淌着实验的基因,因为公司要发布任何产品之前都必须经过实验的检验。所谓“实验”一般指的是“A/B测试”。领英依靠员工通过数据分析来做出决策。实验是决策流程的数据驱动基础,它可以帮助精确衡量每次变更和发布所产生的影响,并评估产品的期望是否与现实情况相符。
领英的实验平台有着庞大的运作规模:
这一平台的核心是领英实验引擎,简称“Lix引擎”。这一引擎需要处理数量巨大的评估和QPS,并在整个公司范围内广泛应用,因此它的库必须具备很高的性能和资源使用效率,并遵循严格的测试、验证和发布流程。我们知道,对该引擎做出的每一点优化和改进,都会对整个公司的生产服务性能产生重大影响。最近我们就完成了一项重大改进工作,以满足不断增长的需求。
领英服务和Lix引擎
图1显示了领英服务与Lix引擎交互的机制。首先,Lix引擎会评估A/B测试请求并返回实验(treatment)或对照(control)组。根据Lix引擎的结果,服务将对应的功能页面返回给会员。
从概念上讲,Lix引擎是一种软件,它可以理解用于实验的领域特定语言(也就是Lix DSL),并能执行以下三种功能:
进行A/B测试的一个前提条件是将测试总体分为随机、独立的样本桶。为Lix引擎提供了样本桶的相对权重后,引擎就可以无缝执行分桶操作。
按1:4.5加权的总体随机分割
实验过程的另一个重要需求是向特定的总体子集(例如,所有应届生)发布功能。Lix引擎可以将总体分为多个群组(称为细分,segmentation),并针对每个细分独立执行随机分割:
总体细分
总体的细分和随机分割都封装在类似Lisp的实验DSL包中。例如,上图在DSL中表示为:
(ab (all) [treatment 18.2 control 81.8]);
而图2中的示例表示为:
(ab (is-student) [treatment 50 control 50] (is-job-seeker) [treatment 25 control 75])。
Lix DSL拥有多个优点:
Lix引擎创建于2012年左右,最初是用Clojure编写的。从那时起它一直运行在领英内部负责评估实验。随着时间的流逝,它开始受到一系列不同问题的困扰,这些问题是引擎的设计缺陷和所选实现语言的特性导致的。我们最近对系统进行改造时,这些都是我们要解决的挑战。
为了支持实验评估,Clojure Lix引擎在DSL中定义了以下数据类型,包括原语和集合:
上一代类型系统
看起来这是一个结构良好,定义明确的类型系统,但是它的实际表现不是很好,原因有二:
多年来,我们在Clojure Lix引擎中发现了多个性能问题。
影响效率的一个主要因素来自于内存管理。由于Clojure延迟评估的特性,引擎会在JVM的堆中创建大量临时对象(例如延迟序列)。使用Clojure引擎时,这些临时对象至少占据了服务中30%的Java堆。
与常规Java集合相比,Clojure的不可变数据结构占用了太多的内存空间。大量对象和巨大的内存占用导致了生产中过长的GC暂停(约0.5秒),因为临时对象可以在多个GC周期中幸存下来并移动到JVM的老对象(old generation)上。
另一大性能问题是速度。性能下降并不是单一原因导致的。相反,它是以下几个因素的共同结果:
另一个问题是公司缺少熟练的Clojure开发人员。公司中只有很少的工程师能理解和编写Clojure代码(后者更为关键),因此这拖累了我们的生产力。
实验平台是领英公司最常用的库之一,在维护它的过程中,我们意识到第一版引擎缺少了很多内容,并存在诸多缺陷;因此我们开始开发v2版本,希望达成以下目标。
新版引擎必须足够快,同时具备:
新版引擎必须易于开发和维护:
新版引擎必须足够安全:
在开始重写前,我们需要做出一些决策:
在v2版引擎的开发过程中,我们提出了许多有趣的想法并发现很多新事物。下面我们会分享其中一些最重要的内容。
为了让新的实现具备类型安全的特性,我们必须指定语言中每个元素的行为,其中包括它们的契约(例如输入参数类型和操作的返回类型)。规范中还加入了元数据,这样我们就可以在实验UI中读取并编辑这些元数据了。具体请参见此处的示例。
我们用规范文件来为操作自动生成Java实现,其中包括用于所有已定义操作重载的抽象方法,还包括了参数解析代码,后者会在后文讨论。
与DSL语法树类似,我们引入了DSL评估树,其中每个节点都可以基于一个输入实体(例如会员/来宾)、上下文和从子树返回的值来计算一个值。
这个树以两种类的形式来定义:这两种类分别是AbstractEvalTree和AbstractEvalNode。EvalTree是一种高级实现,因为我们没有在每个节点的对象中存储子节点,而是将所有信息都移动到了评估树类中,并以三个数组的形式存储了下来(示例见图5):
考虑以下DSL表达式的评估树:
(and (= (string-property “osVersion”) “1.2.3”) (in (country-code) [“us” “gb”]))
评估树
评估树存储在以下数据结构中。
评估树的存储
在这里我们考虑了一种简单方法,以上述结构的节点形式来实现树。
与简单方法相比,我们现行的方法有许多优势:
而简单方法下是208字节:
CPU更喜欢顺序内存访问和较小的数据结构,因为CPU高速缓存较小,而主内存访问速度很慢。
切换到三个普通数组后,我们还消除了Java ArrayList数据结构上虚拟调用的开销。
我们的节点通常执行的是轻量级操作,因此树的遍历速度很关键。
每个评估节点都有其特定的处理逻辑,但它们还需要能应对任何场景,这意味着:
如前所述,我们决定不使用Java反射API,而是让代码根据子节点返回的值类型来解析适当的重载方法。为了避免重复编写样板代码,我们决定以抽象方法的形式自动生成用于操作重载的存根,并根据DSL语言规范自动生成参数解析代码。
使用自动生成的代码可以显著降低实现的复杂度,因为开发人员只需要实现特定参数类型的处理逻辑即可。我们还能调整所有DSL操作实现的行为,从而节省了很多时间。
因为我们这种语言完全在我们的掌控之下,所以我们还可以执行一些高级优化工作。下面就讨论其中一个例子。
为了执行细分,操作通常需要会员属性数据,这会导致网络调用并增加评估延迟。完整的本地评估最快的执行速度是50ns,而远程调用的p99(第99个百分点)延迟为4ms。例如,我们在图1中讨论的is-student操作需要获取会员的教育经历数据以做处理,但是某些操作不需要远程调用,因此我们可以利用这一点并避免昂贵的查询操作。在以下示例中,如果字符串属性“osVersion”未返回“7.1.1”,我们将不执行远程调用:
(ab (and (ge (connection-count) 30) (= (string-property “osVersion”) “7.1.1”)) [treatment 50])
从技术上讲,这里的优化是在本地执行期间禁用“and”和“or”操作的短路,并尝试在其中找到至少一个可以完全在本地执行并返回“false”的子分支。
图7显示了使用和不使用远程调用优化的评估过程。
启用和没有启用远程调用优化的评估
做了这么多工作和优化之后,我们实现了以下改进:
可以说,编程语言是很难验证的,因为你要测试的是具有无限可能状态的高动态系统。因此,我们在新DSL的验证和发布过程中小心谨慎,步步为营。
首先我们编写了大量的单元测试,让引擎运行时代码的行覆盖率至少达到80-90%。
为了声明新代码的功能与旧代码完全相同,我们必须证明v1和v2版本的引擎生成的结果完全相等。我们发现,一种完美的方法是获取所有生产DSL,并在其上分别运行v1和v2版的引擎,同时触发评估树的所有可能执行分支。于是我们想到了自动生成测试数据的简单方法。了解程序的结构以及每个运算符的工作机制后,我们就可以解析树并递归生成输入数据,以触发不同的程序分支。
例如,我们可以从图4中获取以下DSL表达式:
(and (= (string-property “osVersion”) “1.2.3”) (in (country-code) [“us” “gb”]))
可能的执行顺序如下:
应为属性“osVersion”分配一个随机值以触发分支。
应为属性“osVersion”分配“1.2.3”。
“Country-code”会员属性应设置为“us”或“gb”以外的任何值。
应为属性“osVersion”分配“1.2.3”。
“Country-code”会员属性应设置为“us”或“gb”。
自动生成的测试用例
为每个解析树分支构建测试套件并将这些测试递归组合后,我们就可以构建一套全面的测试,触发所有DSL执行分支的99.9%,其中99%的分支返回多个不同的值。
获得99.9%的置信度对我们来说还不够,因此我们在生产Hadoop群集中计算了4,000种运行时参数组合与2,000,000种会员属性集的乘积,从而生成了8,000,000,000个测试用例。我们使用它们在数千个内核上进行了分布式验证来对比v1和v2版本的引擎,并达到了所有生产DSL分支的99.9998%覆盖率。
但是,完成实现并不是故事的终点。实际上,上线这个库并将数百个领英服务迁移到v2版本的引擎上,是该项目最具挑战性的部分之一。
我们为这个库制定了一项上线计划,其目标如下:
为了以中心化的方式控制爬坡过程,我们发布了带有内部A/B测试的实验客户端库版本,可以在v1和v2版的引擎之间切换。使用该库的所有服务都升级到了这个版本,并且我们能够通过A/B测试来控制爬坡并衡量其影响。
上线过程
我们用了37次迭代和8个月的时间来爬坡、测量、更新和迭代。在上线期间,我们发现将引擎代码集成到目标服务中时存在一些问题,即便经过如此严格的测试,我们也无法在本地捕获这些问题。我们还在爬坡过程中理解了远程调用缓存的重要性,并且在生产中同时A/B测试了多达三种缓存逻辑“风味”(flavor)。
鉴于我们的库是领英基础架构中使用最多的库之一,因此我们对其进行的任何更改都会产生巨大的影响。这次重写后:
致谢
我们要感谢T-REX团队的所有成员,如果没有他们在实验平台上的辛勤工作,这个项目将是不可能完成的。非常感谢管理团队的Igor Perisic、Kapil Surlaker、Ya Xu、Suja Viswesan、Vish Balasubramanian和Shaochen Huang,以及T-REX校友Shao Xie的持续投入和指导。领英的许多团队都参与了我们引擎的上线过程。我们感谢他们的合作。特别感谢实验数据科学团队和EPCSRE团队的支持。
领取专属 10元无门槛券
私享最新 技术干货