普通视图

发现新文章,点击刷新页面。
昨天以前美团技术团队

小程序可测性能力建设与实践

测试活动从本质上可以视为被测系统因为某个激励产生相应的响应,并对这些响应进行全面检测的过程。这个过程(激励->响应->检查)涉及到两个角色:测试者以及测试对象,测试者执行激励与检查响应,由机器(程序)或者人来完成;被测对象接受激励,产生响应。从这个过程来看:激励可控,响应可观,称之为可测。以实际业务测试为例,修改缓存、网络请求MCOK、页面跳转、用户登录态设置等都属于可测性能力。

在未经过任何可测性改进的终端产品中,测试人员只能通过UI交互,从UI界面观察来完成最基本的质量保障。然而应用内部存在各种各样复杂的逻辑、状态,要进行更加深入的测试则需要对这些信息进行介入与观测。例如,在进行打点测试时,操作页面后,需确认打点信息是否被正常上报,这一过程通常依赖网络代理调试工具来完成校验。同样,在用户登录测试环节中,登录完成后,需要检查缓存是否已正确记录登录信息,这要求具备缓存查看的能力,这些体现了实际业务测试场景对可测性能力的需求。

整体而言,完备地构造出目标场景进行测试涉及到多个复杂的方面,同时观测它是否符合预期也比较困难,如下图所示。终端测试长期面临着挑战。为应对这些挑战,我们以增强可测性为基础,将其贯穿测试活动的始终,使得测试能更细粒度地检查系统,提高测试深度和效率。

作为终端产品的一种形态,小程序是运行在宿主应用(如微信、快手、百度等)之上的“轻应用”,在2017年由微信推出后发展迅速。由于小程序非常依赖于宿主应用环境,因此在测试过程中,除了面临终端测试固有的难点外,它还存在一些特殊的影响因素。

从运行机制的角度来看,小程序的代码逻辑运行在宿主应用提供的容器环境内,它无法直接控制宿主应用本身和手机系统,这在一定程度上增大了测试与可测性改进的难度。

在目前的实践中,针对小程序的测试主要存在以下几种工具和策略:

  1. 采用如Charles、Fiddler等网络代理工具进行HTTP/HTTPS请求和响应的代理分析与校验。虽然这类工具适合进行数据包的抓取和分析,但它们通常无法深入小程序的内部架构,因此无法全方位控制或感知应用的内部状态。
  2. 运用图像处理技术的自动化测试工具如Airtest进行测试,它们主要关注于界面层面的操作,未能触及应用程序背后的逻辑处理,因此仍属于“黑盒测试”的范畴。
  3. 利用微信官方提供的Minium小程序测试工具来执行更为精细的测试操作,能够进行诸如API Mocking等内部控制。然而,该方法操作复杂,并依赖于微信开发者工具,而后者与真机环境之间存在一定差异,可能影响测试结果的准确性。
  4. 开发专用的自研调试面板用以验证程序逻辑和测试特定场景,但这些工具设计时常常专注于特定小程序,不易迁移至其他应用,而且它们通常不支持自动化测试流程。

综上所述,尽管存在多种测试工具和方法,但目前尚缺乏一套综合性的、易于使用的测试工具集,能够全面提升小程序的可测性。

2. 小程序可测性介绍

终端可测性能力全景图

小程序可测性的目标在于构建一套全方位的通用小程序可测性能力集合。该体系无缝支持真机和模拟器环境,兼容多端、多平台,并允许不同应用以低成本轻松接入。它能深入核心,为小程序提供全面而多元的可观测性与可控性,覆盖应用界面、内部状态、存储等关键领域。这一体系旨在赋能测试者更便捷地应对复杂测试场景,显著提高测试的效率与深入度。

经过了长期的建设积累,目前我们已经构建了一套比较全面的终端可测性能力集,包含Android、iOS、小程序、Web等技术栈。其中小程序由于系统的结构特殊性,可测性能力相对其它端会有一些不同。小程序可测性主要包括业务逻辑可测性、应用可测性、系统&设备可测性三个层级,在每个层级中包含多个垂直的细分方向,除了支持多技术栈的公共可测性能力,还提供了如AppData、宿主应用信息可观可控等特有能力。下面以几个典型能力说明小程序可测性使用方式与效果。

2.1 使用方式与效果

在实际的手工以及自动化测试工作中,小程序可测性能力能够很方便的使用,并在多个场景下发挥了重要的作用。

2.1.1 手工测试

下面将以缓存管理、页面跳转功能为例介绍小程序在手工测试中的使用方式以及效果。

在实际的测试工作中,会结合Lyrebird使用小程序可测性,Lyrebird是美团到店研发平台自研的终端测试工作台,包含终端状态数据管理、网络请求代理与Mock、缺陷记录、自定义插件扩展等能力。同时它还提供了图形化操作界面,是手工与自动化测试中使用可测性能力的入口。

在小程序接入可测性能力SDK之后,可以通过可测性SDK提供的扫码功能与Lyrebird建立连接,后续就可以通过Lyrebird在PC端利用可测性对小程序进行控制以及观测。

缓存管理

我们可以通过缓存管理功能验证依赖缓存的业务逻辑正确性,如表单信息\用户信息暂存到缓存功能等。

  • 如下图所示,1处为缓存编辑框,展示当前选择设备上的小程序所有的缓存信息,并对这些缓存进行管理,支持批量的增删改。
  • 2处展示目标小程序的缓存变更事件信息,包括在该页面对缓存的编辑以及小程序自身内部对缓存的增删改操作事件,会随着事件的触发实时更新。

页面跳转

页面跳转是小程序业务测试中重度使用的能力,可以利用该功能跳转到如表单页,商品详情页等中间页面,不再需要从首页一步一步操作进入目标被测页面,减少测试前置准备工作,具体可以在该Lyrebird页面中输入页面路径进行跳转。

2.1.2 自动化测试

将可测性能力结合Lyrebird应用于自动化测试。如通过页面跳转能力直达测试场景,然后利用通过可测性录制的页面状态数据进行场景状态还原后进行页面渲染,获取页面上的数据/布局展示,最后将实际运行图和预先设置好的页面基准图进行对比,提供渲染的差异结果,进行视觉DIFF测试。

这类“视觉测试”以页面为单位,通过深度链接跳转技术配合一系列终端应用本身的可测性改进,直达测试场景,并通过图像处理技术如长图融合、图像增量对比和文本识别能力进行视觉DIFF测试。

可测性建设的是对应用内部状态的可观可控能力,对于任何测试方法,只要涉及应用内部,可测性都能发挥重要作用。比如在健壮性测试中通过可测性构造破坏性异常场景,或者在功能测试中模拟小程序不同的进入方式(如二维码、视频号、搜索等)来测试所有可能的使用场景下小程序的运行情况。

2.2 接入方式

小程序可测性能力SDK被封装为一个NPM包,在小程序源代码或者编译产物项目中引入此NPM包,便可实现可测性能力的接入,无需进行额外适配工作。

跨平台运行

除了对微信小程序的支持之外,小程序可测性能力SDK通过集成一个适配器(Adapter)将能力扩展到多个宿主应用,包括美团、支付宝、快手、百度等平台的支持。这些平台的基础库API与微信类似,适配器会根据不同平台的特点,对代码进行相应的调整,包括基础库API、前端语法或文件类型等,以保证在各个平台上的兼容性和一致性,实现跨平台运行。

2.3 实现原理

小程序可测性实现的核心思路是通过JavaScript Hook的方式,在小程序JavaScript Runtime中对如微信小程序JS基础库、业务公共基础组件等目标模块进行透明化介入,实现对其内部的可观可控。在此之后,通过可测性SDK内的中控与外部建立网络链接,从而实现在远端对小程序内部状态与功能的可观可控。

JavaScript Hook介绍

JavaScript Hook基于JavaScript的动态特性,有以下方法:

函数Hook:直接覆盖或修改原函数:

let _originAlert = alert;  // 保存原函数
alert = function () {
  console.log('alert执行开始');
  _originAlert.apply(this, arguments); //执行原函数
  console.log('alert执行结束');
}

对象属性Hook:通过Object defineProperty定义新的或直接修改某个对象的属性,如修改Getter/Setter方法,控制对某个对象的获取/设置流程。

Object.defineProperty(document, 'cookie', {
  set: function(val) {  // 控制cookie的设置流程
    console.log('获得cookie: ', val);
    currentCookie = val;
    return val;
  },
  get: function() {  // 控制cookie的获取流程
    return null;
  }
});

原型链Hook:修改原型链上的数据,如StringDate

let _originalGetTime = Date.prototype.getTime;  // 保存原型链原方法
Date.prototype.getTime = function() {
  console.log('getTime has been called'); 
  return originalGetTime.apply(this, arguments); //执行原方法
};

Proxy对象:创建代理模式替代原始对象,可以重新定义获取、设置和定义属性等基本对象操作。

// 创建Proxy有两个参数:
// target:要代理的原始对象
// handler:定义哪些操作将被拦截以及如何重新定义被拦截操作的对象
let handler = {
  get: function(target, prop) {
    console.log(`获取 ${prop}`);
    return target[prop];
  },
  set: function(target, prop, val) {
    console.log(`设置 ${prop} 值为 ${val}`);
    target[prop] = val;
    return true;
  }
};

let proxy = new Proxy(window, handler);

proxy.test = 'test';     // 输出: Setting test to test
console.log(proxy.test); // 输出: Getting test
                         //      test   
                         

静态Hook:小程序构建时在特定文件中直接修改其JavaScript源代码。

其他方式这里就不详细展开了。

可测性SDK的大体可分为四层:

  • 通信层:与外部进行通信,负责指令和数据与远端(如Lyrebird)的双向流动。
  • 指令分发层:对通信层接收到的参数指令进行解析,依次调用控制小程序相关状态的功能层模块。
  • 功能层:实现小程序特定功能可观可控的业务逻辑,包括UI、网络请求、存储、应用状态等模块,实现如请求代理与修改、切换登录态或者控制缓存可测性功能。
  • Hook层:实现对实际逻辑模块状态和方法的透明化介入。由于小程序应用内部的状态/数据与开发者代码相关联,Hook层通过JavaScript Hook对宿主应用基础库、公共组件、业务特定逻辑三种类型的功能模块进行拦截介入,使得其状态/数据可观和可控,为功能层提供实现基础。Hook层一般需要先于业务代码加载,保证拦截的有效性。
    1. 宿主应用基础库。通用性改造,对小程序容器提供的系统级接口进行介入,如网络请求、地理信息等。
    2. 公共组件。组件级通用,如美团的公共登录组件,对其进行改造后,接入登录组件的小程序都能够使用相应的可测性能力,比如切换登录态/模拟登出等能力。
    3. 业务特定逻辑。某个小程序特有的业务逻辑,通过可测性SDK提供的API对这些逻辑进行改造后以插件形式集成定制化能力。

下面将以网络请求可观可控为例介绍小程序可测性的实现原理。

网络请求代理

当外部希望控制小程序设置网络代理时,整体流程如下:

  1. 外部(人/机器)首先通过HTTP/WebSocket方式传递包含设置小程序请求代理的指令,如图即拦截小程序发送的请求转发到127.0.0.1:1234代理服务器;
  2. 可测性SDK在通信层接收相应的指令后。将其传递给指令分发层。在指令分发层中,收到指令后进行解析,并按预定规则对指令执行进行编排,确定执行顺序;
  3. 指令分发层按编排顺序调用功能层设置网络代理并传入开启状态和代理服务器地址参数,功能层通过修改这两个变量,控制Hook层对请求API的拦截,从而改变请求代理的状态;
  4. Hook层拦截微信基础库里wx对象的request方法,如下图代码所示,分为以下流程:

    a.保存wx.request原始方法的引用(3行),并通过Object.defineProperty将wx对象设置为可写状态(4-8行);

    b.将wx.request修改为Hook的新方法。新方法的入参与原始wx.request一致,包括请求头、请求地址、响应体等,因此可以对这些参数进行修改(12行),比如替换请求域名、增加请求头、修改响应体数据等;

    c.最后用修改后的参数使用原始方法进行执行(13行)。

Hook层通过mockStatus和mockUrl两个变量控制到小程序是否被代理以及代理服务器地址(19-22行),当开发者代码中使用wx.request发起请求时,会先经过Hook指向的新方法。如果被设置代理,请求地址将会根据代理服务器协议进行修改,从而使得请求被代理。

3. 美团门票业务小程序测试实践

在到店众多应用了小程序可测性能力的业务中,美团门票业务从2021年开始即参与了小程序可测性建设,目前在门票质量保障工作中,可测性相关能力均深度应用在新需求测试、回归测试、线上巡检等各种类型的测试活动中。

3.1 可测性落地

下面通过门票业务一个具体的新需求测试例子来介绍可测性如何在测试活动中进行落地。

需求背景

用户从商品详情页进入到填单页,在选择日期、数量或填写游玩人等信息后,为了减少用户的操作路径,再次进入该填单页需要保持之前填写的这些信息不变。

操作路径划分

该过程需要经过以下步骤:进入填单页 —> 打开价格日历弹层,选择相应的日期 —> 添加数量 —> 填写或者选择游玩人 —> 点击返回退出填单页 —> 再次进入填单页,查看它当前的状态。我们选择对缓存进行可测性改进,依靠指令数据驱动+内部方法调用来达到同等UI操作的效果,保障此类场景测试的稳定性并提高执行效率。

技术实现

整体通过缓存实现。在进入填单页时,首先会读取小程序上的缓存并渲染;在选择日期、数量和游玩人时,分别对相关信息进行暂存;在退出填单页时,将这些暂存的数据写入缓存。

测试分析

由于进入填单页需要读取缓存进行渲染,因此测试过程中首先应从UI上进行验证,判断第二次进入的日期、数量和游玩人是否与上一次进入时选择的状态一致;其次还应从数据上进行验证,即进入填单页有“读”缓存的动作;在退出填单页时,需要将暂存的数据写入缓存,因此测试过程中应验证数据能正确地写入缓存,而且缓存里有正确的值。

可测性能力实践落地

  • 通过可观校验“写”的正确性。对于“写”,验证缓存的写入动作,并且写入缓存的数据是正确的。缓存的可观性改造能够将“写”的动作、“写”的当前值以及当前缓存具体信息,进行上报,这样就可以自动化校验当前操作后是否缓存值是否发生了正确的变化,以此完成对缓存“写”的校验。

  • 通过可控校验“读”的正确性。对于“读”,首先验证UI能够正确展示,其次从数据上验证有缓存的“读”动作。由于测试缓存必须经历选择日期、选择数量、选择游玩人,返回退出填单页等多个步骤。测试路径较为繁琐,因此,对缓存的可控性改造后,传入相应的配置指令(如2.2部分介绍),控制缓存中的数据,直达被测页面和状态,并通过自动化测试比对当前运行的页面和页面基准图,判断它是否正确被渲染,以此分别从数据和UI上完成对缓存“读”的校验。

门票业务在小程序测试上目前已经落地多种可测性能力,如下图所示,包括控制页面跳转、请求代理、控制登录、日志上报、隐私治理、前后端环境、录制回放、自动化交互控制等都在门票测试活动中有相应的落地,发挥着非常重要的作用。

3.2 业务实践总结

门票业务借助可测性改进使得测试的覆盖更加全面,目前30%+的测试场景依赖于可测性能力进行构建。在美团小程序和点评小程序的门票频道以及门票独立小程序上均有上百个自动化测试用例,页面覆盖率已经达到100%,场景覆盖程度达到80%+。这些测试用例在门票新需求测试、回归测试等各个阶段都会触发自动执行,累计已辅助发现上百个有效问题。

4. 总结与展望

美团核心本地商业/到店研发平台从2021年开始系统化建设小程序可测性,到目前融入到店终端测试工具链以及质量保证体系之中,通过具备扩展性的通用能力框架,融合手工和自动化测试,贯穿测试活动始终。未来我们还将持续关注于基础可测性能力的稳定性,聚焦具备更多业务特性的可测性能力建设。

Q&A

Q:代理逻辑如果有Bug会不会影响比较大

A:代理逻辑本身很简单,出错概率不大。进行Hook时,会有异常监控能力以及相应的兜底策略,即使出问题,也尽量降低对业务实际使用的影响。

Q:可测性SDK需要对业务代码进行改造吗?

A:不需要,可测性SDK对于业务应用是透明的。

Q:Lyrebird项目和小程序可测性SDK的关系是什么?

A:Lyrebird与小程序可测性是两个独立的项目。小程序可测性SDK是以一个NPM包的形式实现的,在小程序里安装NPM包,即可使小程序具有可测性。Lyrebird可以与小程序可测性SDK的通信接口进行连接,然后用户可通过Lyrebird中小程序可测性页面使用小程序可测性能力。

Q:针对小程序可测试性能力建设与实践,我想问下,如果我们要用你们的测试工具,需要做什么适配吗?

A:不需要进行额外适配,最终的呈现会是NPM包形式,在产物里安装就可以接入我们的可测性能力,可以对它进行控制。

Q:生产环境会接入可观测SDK吗?如果接入对性能有多大影响?

A:首先是对它的性能的影响,我们实际上是对小程序里的基础库的API或者一些状态数据进行了拦截,会对性能产生一定的影响,但目前这个影响范围对业务来说比较小,是可接受的。生产环境的不会引入可测性SDK,因此不会对线上质量造成影响。

Q:小程序可测性有不适合使用的场景?

小程序可测性主要针对小程序前端手工与自动化场景进行能力提升,它是具备一套通用可扩展框架,可以按照业务需求低成本进行可测性能力扩展,然而,存在特定情况下其适用性受限:首先,由于运行环境的约束,针对宿主应用如微信或支付宝自身的可测性需求,小程序的可测性无法支持。此外,小程序可测性专注于终端测试,因此对于那些需求后端服务链路验证的场景,并不适用,需配合针对性工具使用。

Spark向量化计算在美团生产环境的实践

让我们从一个简单问题开始:假设要实现“数组a+b存入c”,设三个整型数组的长度都是100,那么只需将“c[i] = a[i] + b[i]”置于一个100次的循环内,代码如下:

void addArrays(const int* a, const int* b, int* c, int num) {
  for (int i = 0; i < num; ++i) {
    c[i] = a[i] + b[i];
  }
}

我们知道:计算在CPU内完成,逻辑计算单元操作寄存器中的数据,算术运算的源操作数要先放置到CPU的寄存器中,哪怕简单的内存拷贝也需要过CPU寄存器。所以,完成“c[i] = a[i] + b[i]”需经三步:

  1. 加载(Load),从内存加载2个源操作数(a[i]和b[i])到2个寄存器。
  2. 计算(Compute),执行加法指令,作用于2个寄存器里的源操作数副本,结果产生到目标寄存器。
  3. 存储(Store),将目标寄存器的数据存入(拷贝)到目标内存位置(c[i])。

其中,加载和存储对应访存指令(Memory Instruction),计算是算术加指令,循环执行100次上述三步骤,就完成了“数组a + 数组b => 数组c”。该流程即对应传统的计算架构:单指令单数据(SISD)顺序架构,任意时间点只有一条指令作用于一条数据流。如果有更宽的寄存器(超机器字长,比如256位16字节),一次性从源内存同时加载更多的数据到寄存器,一条指令作用于寄存器x和y,在x和y的每个分量(比如32位4字节)上并行进行加,并将结果存入寄存器z的各对应分量,最后一次性将寄存器z里的内容存入目标内存,那么就能实现单指令并行处理数据的效果,这就是单指令多数据(SIMD)。

图1:向量化计算“数组a+b存入c”

单指令多数据对应一类并行架构(现代CPU一般都支持SIMD执行),单条指令同时作用于多条数据流,可成倍的提升单核计算能力。SIMD非常适合计算密集型任务,它能加速的根本原因是“从一次一个跨越到一次一组,从而实现用更少的指令数完成同样的计算任务。”

1996年,Intel推出的X86 MMX(MultiMedia eXtension)指令集扩展可视为SIMD的起点,随后演进出SSE(1999年)SSE2/3/4/5、AVX(2008)/AVX2(2013)、AVX512(2017)等扩展指令集。在linux系统中可以通过lscpu或cpuid命令查询CPU对向量化指令的支持情况。

1.2 向量化执行框架:数据局部性与运行时开销

执行引擎常规按行处理的方式,存在以下三个问题:

  1. CPU Cache命中率差。一行的多列(字段)数据的内存紧挨在一起,哪怕只对其中的一个字段做操作,其他字段所占的内存也需要加载进来,这会抢占稀缺的Cache资源。Cache命失会导致被请求的数据从内存加载进Cache,等待内存操作完成会导致CPU执行指令暂停(Memory Stall),这会增加延时,还可能浪费内存带宽。
  2. 变长字段影响计算效率。假设一行包括int、string、int三列,其中int类型是固定长度,而string是变长的(一般表示为int len + bytes content),变长列的存在会导致无法通过行号算offset做快速定位。
  3. 虚函数调用带来额外开销。对一行的多列进行处理通常会封装在一个循环里,会抽象出一个类似handle的接口(C++虚函数)用于处理某类型数据,各字段类型会override该handle接口。虚函数的调用多一步查表,且无法被内联,循环内高频调用虚函数的性能影响不可忽视。

图2:row by row VS blcok by block

因此,要让向量化计算发挥威力,只使用SIMD指令还不够,还需要对执行框架层面进行改造,变Row By Row为Block By Block:

  1. 数据按列组织替代按行组织(在Clickhouse和Doris里叫Block,Velox里叫Vector),这将提高数据局部性。参与计算的列的多行数据会内存紧凑的保存在一起,CPU可以通过预取指令将接下来要处理的数据加载进Cache,从而减少Memory Stall。不参与计算的列的数据不会与被处理的列竞争Cache,这种内存交互的隔离能提高Cache亲和性。
  2. 同一列数据在循环里被施加相同的计算,批量迭代将减少函数调用次数,通过模版能减少虚函数调用,降低运行时开销。针对固定长度类型的列很容易被并行处理(通过行号offset到数据),这样的执行框架也有利于让编译器做自动向量化代码生成,显著减少分支,减轻预测失败的惩罚。结合模板,编译器会为每个实参生成特定实例化代码,避免运行时查找虚函数表,并且由于编译器知道了具体的类型信息,可以对模板函数进行内联展开。

图3:向量化执行框架示例

1.3 如何使用向量化计算

  1. 自动向量化(Auto-Vectorization)。当循环内没有复杂的条件分支,没有数据依赖,只调用简单内联函数时,通过编译选项(如gcc -ftree-vectorize、-O3),编译器可以将顺序执行代码翻译成向量化执行代码。可以通过观察编译hint输出和反汇编确定是否生产了向量化代码。

    • 编译hint输出,编译:g++ test.cpp -g -O3 -march=native -fopt-info-vec-optimized,执行后有类似输出“test.cpp:35:21: note: loop vectorized”。
    • 反汇编,gdb test + (gdb) disassemble /m function_name,看到一些v打头的指令(例如vmovups、vpaddd、vmovups等)。
  2. 使用封装好的函数库,如Intel Intrinsic function、xsimd等。这些软件包中的内置函数实现都使用了SIMD指令进行优化,相当于high level地使用了向量化指令的汇编,详见:https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html

  3. 通过asm内嵌向量化汇编,但汇编指令跟CPU架构相关,可移植性差。

  4. 编译器暗示:

    • 使用编译指示符(Compiler Directive),如Cilk(MIT开发的用于并行编程的中间层编程语言和库,它扩展了C语言)里的#pragma simd和OpenMP里的#pragma omp simd。
    • Compiler Hint。通过__restrict去修饰指针参数,告诉编译器多个指针指向不相同不重叠的内存,让编译器放心大胆的去优化。
  5. 如果循环内有复杂的逻辑或条件分支,那么将难以向量化处理。

以下是一个向量化版本数组相加的例子,使用Intel Intrinsic function:

#include <immintrin.h> // 包含Intrinsic avx版本函数的头文件

void addArraysAVX(const int* a, const int* b, int* c, int num) {
  assert(num % 8 == 0); // 循环遍历数组,步长为8,因为每个__m256i可以存储8个32位整数
  for (int i = 0; i < num; i += 8) {  
    __m256i v_a = _mm256_load_si256((__m256i*)&a[i]); // 加载数组a的下一个8个整数到向量寄存器
    __m256i v_b = _mm256_load_si256((__m256i*)&b[i]); // 加载数组b的下一个8个整数到向量寄存器
    __m256i v_c = _mm256_add_epi32(v_a, v_b); // 将两个向量相加,结果存放在向量寄存器
    _mm256_store_si256((__m256i*)&c[i], v_c); // 将结果向量存储到数组c的内存
  }
}

int main(int argc, char* argv[]) {
  const int ARRAY_SIZE = 64 * 1024;
  int a[ARRAY_SIZE] __attribute__((aligned(32))); // 按32字节对齐,满足某些向量化指令的内存对齐要求
  int b[ARRAY_SIZE] __attribute__((aligned(32)));
  int c[ARRAY_SIZE] __attribute__((aligned(32)));
  srand(time(0));
  for (int i = 0; i < ARRAY_SIZE; ++i) {
    a[i] = rand(); b[i] = rand(); c[i] = 0; // 对数组a和b赋随机数初始值
  }

  auto start = std::chrono::high_resolution_clock::now();
  addArraysAVX(a, b, c, ARRAY_SIZE);
  auto end = std::chrono::high_resolution_clock::now();
  std::cout << "addArraysAVX took " << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << " microseconds." << std::endl;
  return 0;
}

addArraysAVX函数中的_mm256_load_si256、_mm256_add_epi32、_mm256_store_si256都是Intrinsic库函数,内置函数命名方式是

  • 操作浮点数:_mm(xxx)_name_PT
  • 操作整型:_mm(xxx)_name_epUY

其中(xxx)代表数据的位数,xxx为SIMD寄存器的位数,若为128位则省略,AVX提供的__m256为256位;name为函数的名字,表示功能;浮点内置函数的后缀是PT,其中P代表的是对矢量(Packed Data Vector)还是对标量(scalar)进行操作,T代表浮点数的类型(若为s则为单精度浮点型,若为d则为双精度浮点);整型内置函数的后缀是epUY,U表示整数的类型(若为无符号类型则为u,否在为i),而Y为操作的数据类型的位数。

编译:g++ test.cpp -O0 -std=c++11 -mavx2 -o test。选项-O0用于禁用优化(因为开启优化后有可能自动向量化),-mavx2用于启用AVX2指令集。

测试发现:非向量化版本addArrays耗时170微秒,而使用Intrinsic函数的向量化版本addArraysAVX耗时58微秒,耗时降为原来的1/3。

2 为什么要做Spark向量化计算

从业界发展情况来看,近几年OLAP引擎发展迅速,该场景追求极致的查询速度,向量化技术在Clickhouse、Doris等Native引擎中得到广泛使用,降本增效的趋势也逐渐扩展到数仓生产。2022年6月DataBricks发表论文《Photon- A Fast Query Engine for Lakehouse Systems》,Photon是DataBricks Runtime中C++实现的向量化执行引擎,相比DBR性能平均提升4倍,并已应用在Databricks商业版上,但没有开源。2021年Meta开源Velox,一个C++实现的向量化执行库。2022 Databricks Data & AI Summit 上,Intel 与Kyligence介绍了合作开源项目Gluten,旨在为Spark SQL提供Native Vectorized Execution。Gluten+Velox的组合,使Java栈的Spark也可以像Doris、Clickhouse等Native引擎一样发挥向量化执行的性能优势

从美团内部来看,数仓生产有数万规模计算节点,很多业务决策依赖数据及时产出,若应用向量化执行技术,在不升级硬件的情况下,既可获得可观的资源节省,也能加速作业执行,让业务更快看到数据和做出决策。根据Photon和Gluten的公开数据,应用向量化Spark执行效率至少可以提升一倍,我们在物理机上基于TPC-H测试Gluten+Velox相Spark 3.0也有1.7倍性能提升。

图4:Gluten+Velox在TPC-H上的加速比,来自Gluten

3 Spark向量化计算如何在美团实施落地

3.1 整体建设思路

  1. 更关注资源节省而不单追求执行加速。Spark在美团主力场景是离线数仓生产,与OLAP场景不同,时间相对不敏感,但资源(内存为主)基数大成本敏感。离线计算历史已久,为充分利用存量服务器,我们不能依赖硬件加速的手段如更多的内存、SSD、高性能网卡。我们评估收益的核心指标是总「memory*second」降低。
  2. 基于C++/Rust等Native语言而非Java进行开发。Java语言也在向量化执行方面做尝试,但JVM语言对底层控制力弱(如无法直接内嵌SIMD汇编),再加上GC等固有缺陷,还远远谈不上成熟,而系统向的语言(C/C++、Rust)则成为挖掘CPU向量化执行潜能的首选。
  3. 可插拔、面向多引擎而非绑定Spark。虽然面向不同工作负载的各类大数据引擎层出不穷,但其架构分层则相似,一般包括编程接口、执行计划、调度、物理执行、容错等,尤其执行算子部分有较多可复用实现。如Meta内部主要大数据引擎有Presto和Spark,建设一个跨引擎的执行库,优化同时支持Presto和Spark显然是更好的选择;OLAP引擎向量化计算本身就是标配;流计算引擎出于性能考虑,也可以攒批而非一条条处理数据(mini batch),因此向量化执行也有性能提升空间。我们认为面向不同场景设计的大数据引擎,有可能共用同一个高性能向量化执行库。
  4. 使用开源方案而非完全自研。Spark有几百个function和operator,向量化改造的工作量巨大,从性能、完成度、适配成本、是否支持多引擎、社区的活跃度等方面综合考虑,我们最终选择了Gluten+Velox的方案。
  5. 迁移过程对用户透明,保证数据一致。Spark的几百个function和 operator都要通过C++重新实现,同时还涉及Spark、Gluten、Velox版本变化,很容易实现出现偏差导致计算结果不一致的情况。我们开发了一个用于升级验证的黑盒测试工具(ETL Blackbox Test),可以将一个作业运行在不同版本的执行引擎上进行端到端验证,包括执行时间、内存及CPU资源使用情况、作业数据的对比结果(通过对比两次执行的行数,以及每一列所有数据md5的加和值来确定数据是否一致)。

3.2 Spark+Gluten+Velox计算流程

通过Spark的plugin功能,Gluten将Spark和向量化执行引擎(Native backend,如Velox)连接起来,分为Driver plugin和Executor Plugin。在Driver端,SparkContext初始化时,Gluten的一系列规则(如ColumnarOverrideRules)通过Spark Extensions注入,这些规则会对Spark的执行计划进行校验,并把Gluten支持的算子转换成向量化算子(如FileScan会转换成NativeFileScan),不能转换的算子上报Fallback的原因,并在回退的部分插入Column2Row、Row2Column算子,生成Substrait执行计划。在Executor端,接收到Driver侧的LaunchTask RPC消息传输的Substrait执行计划后,再转换成Native backend的执行计划,最终通过JNI调用Native backend执行。

Gluten希望能尽可能多的复用原有的Spark逻辑,只是把计算部分转到性能更高的向量化算子上,如作业提交、SQL解析、执行计划的生成及优化、资源申请、任务调度等行为都还由Spark控制。

图5:Spark+Gluten+Velox架构图

3.3 阶段划分

在我们开始Spark向量化项目时,开源版本的Gluten和Velox还没有在业界Spark生产环境大规模实践过,为了降低风险最小代价验证可行性,我们把落地过程分为以下五个阶段逐步进行:

  1. 软硬件适配情况确认。Velox要求CPU支持bmi、bmi2、f16c、avx、avx2、sse指令集,需要先确定服务器是否支持;在生产环境运行TPC-DS或者TPC-H测试,验证理论收益;公司内部版本适配,编译运行,跑通典型任务。当时Gluten只支持Spark3.2和Spark3.3,考虑到Spark版本升级成本更高,我们暂时将相关patch反打到Spark3.0上。这个阶段我们解决了大量编译失败问题,建议用社区推荐的OS,在容器中编译&运行;如果要在物理机上运行,需要把相关依赖部署到各个节点,或者使用静态链接的方式(开启vcpkg)。
cat /proc/cpuinfo | grep --color -wE "bmi|bmi2|f16c|avx|avx2|sse"
  1. 稳定性验证。确定测试集,完善稳定运行需要的feature,以达到比较高的测试通过率,包括支持ORC、Remote shuffle、HDFS适配、堆内堆外的内存配置等。本阶段将测试通过率从不足30%提升到90%左右。

  2. 性能收益验证。由于向量化版本和原生Spark分别使用堆外内存和堆内内存,引入翻倍内存的配置,以及一些高性能feature支持不完善,一开始生产环境测试性能结果不及预期。我们逐个分析解决问题,包括参数对齐、去掉arrow中间数据转换、shuffle batch fetch、Native HDFS客户端优化、使用jemelloc、算子优化、内存配置优化、HBO适配等。本阶段将平均资源节省从-70%提升到40%以上。

  3. 一致性验证。主要是问题修复,对所有非SLA作业进行大规模测试,筛选出稳定运行、数据完全一致、有正收益的作业。

  4. 灰度上线。将向量化执行环境发布到所有服务器,对符合条件的作业分批上线,增加监控报表,收集收益,对性能不及预期、发生数据不一致的作业及时回退原生Spark上。此过程用户无感知。

整个实施过程中,我们通过收益转化漏斗找到收益最大的优化点,指导项目迭代。下图为2023年某一时期的相邻转化情况。

图6:Spark向量化项目收益转化漏斗图

4 美团Spark向量化计算遇到的挑战

4.1 稳定性问题

  1. 聚合时Shuffle阶段OOM。在Spark中,Aggregation一般包括Partial Aggregation、Shuffle、Final Aggregation三个阶段,Partial Aggregation在Mapper端预聚合以降低Shuffle数据量,加速聚合过程、避免数据倾斜。Aggregation需要维护中间状态,如果Partial Aggregation占用的内存超过一定阈值,就会提前触发Flush同时后续输入数据跳过此阶段,直接进入ShuffleWrite流程。Gluten使用Velox默认配置的Flush内存阈值(Spark堆外内存*75%),由于Velox里Spill功能还不够完善(Partial Aggregation不支持Spill),这样大作业场景,后续ShuffleWrite流程可能没有足够的内存可以使用(可用内存<25%*Spark堆外内存),会引起作业OOM。我们采用的策略是通过在Gluten侧调低Velox Partial Aggregation的Flush阈值,来降低Partial Aggregation阶段的内存占用,避免大作业OOM。这个方案在可以让大作业运行通过,但是理论上提前触发Partial Aggergation Flush会降低Partial Aggretation的效果。更合理的方案是Partial Aggregation支持Spill功能,Gluten和Velox社区也一直在完善对向量化算子Spill功能的支持。

  2. SIMD指令crash。Velox对数据复制做了优化,如果该类型对象是128bit(比如LongDecimal类型),会通过SIMD指令用于数据复制以提升性能。如下图所示,Velox库的FlatVector<T>::copyValuesAndNulls()函数里的一行赋值会调用T::operator=(),调用的movaps指令必须作用于16B对齐的地址,不满足对齐要求会crash。我们在测试中复现了crash,通过日志确定有未按16B对齐的地址出现。无论是Velox内存池还是Gluten内存池分配内存都强制做了16B对齐,最终确认是Arrow内存池分配出的地址没对齐(Gluten用了三方库Arrow)。这个问题可以通过为LongDecimal类型重载operator=操作符修复,但这样做可能影响性能,也不彻底,因为不能排除还有其他128bit类型对象存在。最终我们与Gluten社区修改了Arrow内存分配策略,强制16B对齐。

图7:Crash代码示例

4.2 支持ORC并优化读写性能

Velox的DWIO模块原生只支持DWRF和Parquet两种数据格式,美团大部分表都是基于ORC格式进行存储的。DWRF文件格式是Meta内部所采用的ORC分支版本,其文件结构与ORC相似,比如针对ORC文件的不同区域,可通过复用DWRF的Reader来完成相关数据内容的读取。

图8:Dwrf文件格式

  • DwrfReader:用于读取文件层面的元数据信息,包括PostScript、Footer和Header。
  • DwrfRowReader:用来读取StripeFooter,以便确定每个column的Stream位置。
  • FormatData:用来读取StripeIndex,从而确定每个RowGroup的位置区间。
  • ColumnReader:用来读取StripeData,完成不同column的数据抽取。

我们完善了Velox ORC格式的支持,并对读取链路做了优化,主要工作包括:

  1. 支持RLEv2解码(Velox-5443)并在解码过程中完成Filter下推(Velox-6647)。我们将Apache RLEv2解码逻辑移植到了Velox,通过BMI2指令集来加速varint解码过程中的位运算,并在解码过程中下推过滤不必要的数据。
  2. 支持Decimal数据类型(Velox-5837)以及该数据类型的Filter下推处理(Velox-6240)。
  3. ORC文件句柄复用以降低HDFS的NN处理压力(Velox-6140)。出于线程安全层面的考虑,HdfsReadFile每次pread都会开启一个新文件句柄来做seek+read,客户端会向NameNode发送大量open请求,加重HDFS的压力。我们通过将文件的读取句柄在内部做复用处理(thread_local模式),减少向NN发送的open请求。
  4. 使用ISA-L加速ORC文件解压缩。我们对ORC文件读取耗时trace分析得出,zlib解压缩占总耗时60%,解码占30%,IO和其他仅占10%,解压效率对ORC文件读取性能很关键。为此,我们对ZlibDecompressor做了重构,引入Intel的解压缩向量化库ISA-L来加速解压缩过程。

基于这些优化,改造后的Velox版ORC Reader读取时间减少一半,性能提升一倍。

图9:Apache ORC与改造后的Velox ORC读取性能对比,上为Apache ORC

4.3 Native HDFS客户端优化

首先介绍一下HDFS C++客户端对ORC文件读取某一列数据的过程。第一步,读取文件的最后一个字节来确定PostScript长度,读取PostScript内容;第二步,通过PostScript确定Footer的存储位置,读取Footer内容;第三步,通过Footer确定每个Stripe的元数据信息,读取StripeFooter;第四步,通过StripeFooter确定每个Column的Stream位置,读取需要的Stream数据。

图10:ORC文件读取过程

在生产环境测试中,我们定位到两个数据读取相关的性能问题:

  1. 小数据量随机读放大。客户端想要读取[offset ~ readEnd]区间内的数据,发送给DN的实际读取区间却是[offset ~ endOfCurBlock],导致[readEnd ~ endOfCurBlock]这部分数据做了无效读取。这样设计主要是为了优化顺序读场景,通过预读来加快后续访问,然而针对随机读场景(小数据量下比较普遍),该方式却适得其反,因为预读出的数据很难被后续使用,增加了读放大行为。我们优化为客户端只向DN传递需要读取的数据区间,DN侧不提前预取,只返回客户端需要的数据。

图11:读放大过程示意图

  1. DN慢节点导致作业运行时间变长。我们发现很多大作业的HDFS长尾耗时非常高,HDFS的平均read时延只有10ms左右,P99.99时延却达到了6秒,耗时最长的请求甚至达到了5分钟,但在不启用EC场景下,HDFS的每个block会有三副本,完全可以切换到空闲DN访问。为此我们对客户端的读请求链路做了重新的设计与调整,实时监测每个DN的负载情况,基于P99.9分位请求时延判定慢节点,并将读请求路由到负载较低的DN上面。

HDFS Native客户端读优化之后,平均读写延迟降低了2/3,吞吐提升2倍。

4.4 Shuffle重构

Gluten在shuffle策略的支持上,没有预留好接口,每新增一种shuffle模式需要较大改动。美团有自研的Shuffle Service,其他公司也可能有自己的Shuffle Service(如Celeborn),为了更好适配多种shuffle模式,我们提议对shuffle接口重新梳理,并主导了此讨论和设计。

Gluten中的shuffle抽象第一层是数据格式(Velox是RowVector,Gluten引入的Arrow是RecordBatch),第二层是分区方式(RoundRobin、SinglePart、Hash、Range),如果要支持新shuffle模式(local、remote),需要实现2*4*2=16个writer,将会有大量冗余代码。分区具体实现应该与数据格式和shuffle模式无关,我们用组合模式替代继承模式。另外,我们在shuffle中直接支持了RowVector,避免Velox和Arrow对应数据类型之间的额外转换开销。

图12:重构前后shuffle模块UML对比

4.5 适配HBO

HBO(Historical Based Optimization)是通过作业历史运行过程中资源的实际使用量,来预测作业下一次运行需要的资源并设置资源相关参数的一种优化手段。美团过去在原生Spark上通过调配堆内内存取得了8%左右的内存资源节省。

Gluten主要使用堆外内存(off-heap),这与原生Spark主要使用堆内内存(on-heap)不同。初期出于稳定性考虑Gluten和原生Spark的运行参数整体一致,总内存大小相同,Gluten off-heap 占比75%, on-heap占比25%。这样配置既会导致内存利用率不高(原生Spark的内存使用率58%,向量化版作业内存使用率 38%),也会使一部分作业on-heap内存配置偏低,在算子回退时导致任务OOM。

我们把HBO策略推广到堆外内存,向量化计算的内存节省比例从30%提升到40%,由于heap内存配置不合理的OOM问题全部消除。

图13:HBO流程图

4.6 一致性问题

  1. 低版本ORC数据丢失。hive-0.13之前使用的ORC,Footer信息不包含列名,只有ID用来表示第几列(如Col1, Col2…)。Velox TableScan算子在扫表的时候,如果下推的Filter里包含IsNotNull(A),会根据列名A查找该列数据,由于无法匹配到列名,会误判空文件,导致数据缺失。Spark在生成读ORC表的执行计划时,通过访问HiveMetaStore得到表的Schema信息,并在物理算子FileSourceScanExec中保存了表的Schema信息。Gluten对该算子进行doTransform()转换时,会把表的Schema信息序列化到Substrait的ReadRel里。接下来的Substrait计划转Velox计划阶段,我们把表的Schema信息传给Velox的HiveTableHandle,在构造Velox的DwrfReader时修正ORC文件Footer里的Schema信息(如果Footer的Schema不包含列名,就读取表Schema里的对应列的名称进行赋值),解决了这个问题。

  2. count distinct结果错误。比如这样一条SQL:select A, B, count(distinct userId), sum(amt) from t group by 1,2 ,Gluten会把count(distinct userId) 变为count(userId),通过把userId加到GroupingKey里来实现distinct语义。具体处理过程如下:

表1:示例SQL在Spark中的处理步骤

在第3步的Intermediate Aggregation中,为了节省内存和加速执行,当Velox的HashAggregate算子满足触发Flush的条件时(HashTable内存占用超过阈值或者聚合效果低于阈值),Velox会标记 partialFull=true,触发Flush操作(计算HashTable里已经缓存数据的Intermediate Result),并且后续输入的数据不再执行Intermediate Aggregation的计算,直接进入第4步的Partial Aggregation。如果后续输入的数据里包含重复的userId,count(userId)会因为去重不彻底而结果错误。我们短期的修复方案是禁用Intermediate Aggregation的提前Flush功能,直到所有数据都输入之后再获取该阶段的聚合结果。

这个方案的弊端有两个:1)HashTable的内存占用会变大,需要同时开启HashAggregate算子的Spill功能避免OOM;2)直接修改了Velox的HashAggregate算子内部代码,从Velox自身的角度来看,没有单独针对Distinct相关的聚合做处理,随着后续迭代,可能影响所有用到Intermediate Aggregation的聚合过程。

鉴于此,Gluten社区提供了一个更加均衡的解决方案,针对这类Distinct Aggregation,生成执行计划时,Spark的Partial Merge Aggregation不再生成Intermediate Aggregation,改为生成Final Aggregation(不会提前flush、不使用merge_sum),同时配合聚合函数的Partial Companion函数来返回Intermediate结果。这样就从执行计划转换策略层面规避这个问题,避免对Velox里Final Aggregation内部代码做过多的改动。

  1. 浮点类型转换精度错误。形如查询SELECT concat(col2, cast(max(col1) as string)) FROM (VALUES (CAST(5.08 AS FLOAT), 'abc_')) AS tab(col1, col2) group by col2; 在Spark中返回abc_5.08,在Gluten中返回abc_5.079999923706055。浮点数5.08不能用二进制分数精确表达,近似表示成5.0799999237060546875。Velox通过函数folly::to<std::string>(T val)来实现float类型到string类型的转换,这个函数底层依赖开源库google::double-conversion, folly里默认设置了输出宽度参数DoubleToStringConverter::SHORTEST(可以准确表示double类型的最小宽度),转换时经过四舍五入之后返回 5.079999923706055。我们把宽度参数改为DoubleToStringConverter::SHORTEST_SINGLE(可以准确表示float类型的最小宽度),转换时经过四舍五入之后返回 5.08。

5 上线效果

我们已上线了2万多ETL作业,平均内存资源节省40%+,平均执行时间减少13%,证明Gluten+Velox的向量化计算方案生产可行。向量化计算除了能提高计算效率,也能提高读写数据的效率,如某个作业的Input数据有30TB,过去需要执行7小时,绝大部份时间花在了读数据和解压缩上面。使用向量化引擎后,因为上文提到的ISA-L解压缩优化,列转行的开销节省,以及HDFS Native客户端优化,执行时间减少到2小时内。

图14:上线优化效果

6 未来规划

我们已上线向量化计算的Spark任务只是小部分,计划2024年能让绝大部分的SQL任务运行在向量化引擎上。

6.1 Spark向量化之后对开源社区的跟进策略

Spark、Gluten、Velox三个社区各有自己考虑和版本发布节奏,从一个社区到多个社区的引擎维护复杂度上升。我们的应对有二,一是计算引擎有不同层次,Spark升级主要考虑功能语义实现、执行计划、资源和task调度,Gluten和Velox的升级主要考虑物理算子性能优化,各取所长;二是尽量减少和社区的差异,公司内部适配只在Spark中实现,公司内的UDF以git submodule形式单独维护。

  1. 升级到Spark3.5。Gluten最低支持的Spark版本为3.2,23年我们为了降低验证成本,选择在Spark3.0兼容Gluten,但继续升级迭代成本比较高,在推广之前,应该升级到更新的Spark版本。Spark3.5将会是Gluten社区对Spark3.x上长期支持的稳定版本。高版本Spark也有一些额外收益,我们基于TPC-H实测,Spark3.5相比Spark3.0,「memory*second」减少40%,执行时间减少17%,根据之前升级经验,生产环境大约能达到一半效果。
  2. 保持Spark版本长期稳定。高版本Spark对Hadoop版本的升级迭代带来比较高适配成本,内部迭代的feature也有比较高的迁移成本,因此我们平均3年才会升级一次Spark版本,更多是将需要的feature合并到内部分支。
  3. 快速跟进Gluten/Velox新版本。升级到Spark3.5之后,我们内部Spark版本与Gluten社区的兼容性成本很低,并且向量化相关feature还会持续迭代,预计每半年可升级一次线上版本。

6.2 提升向量化覆盖率的策略

  1. 扩大向量化算子和UDF范围。我们整理了影响权重最高的几十个算子回退问题与Gluten社区一起解决,对于大量内部UDF,则会探索用大模型来将UDF批量改写为C++版本的向量化实现。

  2. 扩大File format支持向量化范围。美团内部有约20%的表为textfile格式,还有接近10%的表使用内部开发的format,只能按行读取也不支持下推,加上行转列都会有额外性能开销,影响最终效果。我们将会把textfile全部转为ORC,为自研format提供C++客户端,进一步提升向量化计算性能。

7 致谢

感谢Intel Gluten合作伙伴高明、周渊、宾伟、韦廷、宏泽、莫芮、飞龙、马榕、镇辉、成成等的大力支持和辛勤付出,也感谢Gluten和Velox社区贡献者的开源精神和无私奉献。

8 本文作者

luhao、左军、lux、kecookier、cx14、陈皮兔、dju,均来自美团基础研发平台。

CVPR 2024 | 美团技术团队精选论文解读

CVPR全称为IEEE Conference on Computer Vision and Pattern Recognition,国际计算机视觉与模式识别会议。该会议始于1983年,与ICCV和ECCV并称计算机视觉方向的三大顶级会议。根据谷歌学术公布的2022年最新学术期刊和会议影响力排名,CVPR在所有学术刊物中位居第4,仅次于Nature、NEJM和Science。

本文精选了美团技术团队被CVPR 2024收录的7篇论文进行解读,这些论文既包括OCR预训练、长尾半监督学习等基础学习范式升级,也包括图生视频、数字人驱动、视听分割(AVS)等视觉AIGC技术创新。这些论文有美团视觉智能部的独立产出,也有跟高校、科研机构合作的成果。希望能给从事相关研究工作的同学带来一些帮助或启发。

01 | ODM: A Text-Image Further Alignment Pre-training Approach for Scene Text Detection and Spotting

论文作者:Chen Duan(Meituan),Pei Fu(Meituan),Shan Guo(Meituan),Qianyi Jiang(Meituan),Xiaoming Wei(Meituan)

论文地址PDF

论文简介:近年来,文本-图像联合预训练技术在多个领域展现出了卓越的性能。然而,在光学字符识别(OCR)任务中,将文本提示与图像中相应的文本区域对齐是一个挑战,现有的基于MIM(Masked Image Modeling)或者基于MLM(Masked Language Modeling)的方法存在一定的局限性。

本文提出了一种创新的预训练方法,称为OCR-Text Destylization Modeling(ODM),它可以将图像中不同风格的文本转换为基于文本提示的统一风格文本。通过ODM,我们可以更好地对齐文本提示和图像中OCR文本,并使预训练模型适应场景文本检测和端到端任务中复杂多样的字体风格。此外,我们还设计了一种新颖的标签生成方法,并将其与我们提出的文本控制器模块相结合,有效降低了OCR任务中的标注成本,使得更多未经人工标注的数据能够被用于预训练。在多个公共数据集上的实验表明,我们的方法在场景文本检测和端到端识别任务中显著提高了性能,并超过了现有的预训练方法。

02 | BEM: Balanced and Entropy-based Mix for Long-Tailed Semi-Supervised Learning

论文作者:Hongwei Zheng(Meituan),Linyuan Zhou(Meituan), Han Li(SJTU), Jinming Su(Meituan), Xiaoming Wei(Meituan),Xiaoming Xu(Meituan) 备注:SJTU(Shanghai Jiao Tong University)

论文地址PDF

论文简介:长尾半监督学习(LTSSL)最近受到了广泛关注。本文探讨了数据混合在 LTSSL中的应用。传统的数据混合方法通常采用批量混合,无法解决类不平衡的问题。此外,类的平衡不仅与数据量有关,还与类的不确定性有关,而类的不确定性与数据量的分布存在差距。例如,一些有足够样本的类可能拥有无法区分的特征,从而导致高不确定性。

为此,本论文介绍了基于平衡和熵的混合(BEM),这是一种开创性的混合方法,可重新平衡数据量和不确定性的类别分布。具体来说,利用类平衡混合库来存储类数据,并根据对数据分布的估计对其进行采样混合,从而重新平衡类数据量。此外,我们还引入了一种基于熵的学习方法来重新平衡类的不确定性,包括基于熵的采样策略、基于熵的选择模块和基于熵的类平衡损失。实证结果表明,在多个基准测试中,BEM 与重新平衡方法相辅相成,显著提高了重新平衡方法的性能。作为首个利用数据混合来改进 LTSSL 的策略,BEM 证明了其在补充再平衡方法方面的多功能性。在不同的数据分布、数据集和 SSL 学习者之间,证明了 BEM 在补充再平衡方法方面的通用性。

03 | Animating General Image with Large Visual Motion Model

论文作者:Dengsheng Chen(Meituan),Xiaoming Wei(Meituan),Xiaolin Wei

论文简介:传统基于光流构建的图像驱动算法往往受限于一些特定的使用场景,例如人脸表情驱动、手势驱动等,而无法广泛用于预测任意场景的动态特征。我们认为这主要是由于相关领域缺乏大规模高质量的训练数据和学习能力足够强的模型结构导致。

鉴于近期扩散模型在文生图上表现出令人惊艳的效果,我们首次尝试构建一个大规模的网络结构用于预测复杂场景的光流,并称之为大型视觉运动模型(LVMM)。LVMM主要由神经渲染网络(R),光流预测网络(P),压缩和重建网络(E和D)以及一个潜在空间的扩散模型e构成。整个模型需要经过三个阶段的独立训练。

首先,LVMM通过光流预测网络P生成给定两张图像之间存在的光流信息,然后通过神经渲染网络R用于将光流信息渲染成逼真的图像运动效果。在这个阶段,我们发现可以使用两个不同的网络分支分别预测光流中的高频和低频信息(如上图c所示)。其中高频信息则善于捕捉物体边缘的一些细微的运动特征,而低频信息则用于描述物体整体的运动趋势。两种特征相互补充,从而有效的估计出各种复杂场景下细微的运动差异。在完成R和P的联合训练以后,我们固定光流预测网络P的参数,将它作为一个数据预处理的操作,用于训练压缩和重建网络(E和D)。在实验过程中发现,直接使用扩散模型e去预测光流训练得到的模型无法取得很好的泛化性能,这是因为光流特征的推断往往严重依赖物体的视觉特征。如果想要得到足够好的泛化性,我们需要设计算法更好的将物体的视觉和运动特征解耦。我们发现通过压缩网络E可以将光流信息中的视觉和运动特征分别映射到两个不同的空间,从而保证扩散模型e的有效训练。最后,扩散模型e通过在高维特征空间解构物体的视觉特征和运动特征来准确预测图像中蕴含的动态特征,从而驱动静态图像表现出符合自然规律的动态效果,大大增加了图像的视觉吸引力。

04 | CustomListener: Text-guided Responsive Interaction for User-friendly Listening Head Generation

论文作者:Xi Liu* (Meituan), Ying Guo*(Meituan),Cheng Zhen(Meituan),Tong Li(Meituan), Yingying Ao(Meituan),Pengfei Yan(Meituan)

论文地址PDF

论文简介:近年来,数字人生成技术逐渐发展并应用于虚拟对话交互场景中,通过模拟真实Speaker和Listener的表情和肢体语言,来创造生动和更具沉浸感的交流场景。然而,现有Listener生成中,用户只能通过简单情绪标签去控制Listener属性,可控力有限。本文中,我们提出CustomListener,用户可以使用任意自由文本自定义想要的Listener属性(身份、性格、行为习惯、社会关系等),模型结合自定义的文本属性以及交流场景中Speaker的讲话内容/语音/动作,实时生成合理且逼真的Listener反应。

具体而言,我们首先基于ChatGPT,依据用户定义文本和Speaker讲话内容,得到指导Listener动作的静态文本先验,从语义层面分析刻画来得到Listener的行为基调。该静态先验只提供了窗口时间内Listener的静态基调动作,然而对话中,Listener的行为需要配合Speaker的实时状态,来确定静态基调动作的完成节奏和幅度信息。为实现这种Speaker-Listener行为的协调性,SDP模块根据Speaker语音-静态文本先验的响应式交互来获得基调动作的完成节奏引导,根据Speaker动作对交互结果进行精炼来获得基调动作的幅度引导,由此将静态文本先验转换为包含Listener动作完成节奏和幅度信息的动态肖像Token。为实现长视频生成的片段间连贯性,PGG模块基于片段间动态肖像tocken的相似性生成运动先验,以此保持片段间Listener行为的连贯性和属性的一致性,并基于以运动先验和动态肖像Token为条件的diffusion结构,最终实现听者的可控生成。

05 | Cooperation Does Matter: Exploring Multi-Order Bilateral Relations for Audio-Visual Segmentation

论文作者:Qi Yang(UCAS,CASIA),Xing Nie(UCAS,CASIA),Tong Li(Meituan),Pengfei Gao(Meituan),Ying Guo(Meituan),Cheng Zhen(Meituan),Pengfei Yan(Meituan),Shiming Xiang(UCAS,CASIA)

备注:UCAS(School of Artificial Intelligence, University of Chinese Academy of Sciences);CASIA(Institute of Automation, Chinese Academy of Sciences)

论文地址PDF

论文简介:人类的视觉注意力常受听觉引导,即我们倾向于专注发声目标。基于此,我们引入了视听分割(AVS)任务,旨在像素级分割视频中的发声目标。该任务需对场景进行音频驱动的像素级理解,极具挑战性。

本论文提出了一种创新的视听Transformer框架,名为COMBO,即COoperation of Multi-order Bilateral relatiOns。该框架首次探讨了视听分割中三种双边纠缠关系:像素纠缠、模态纠缠和时间纠缠。针对像素纠缠,图像和发声目标掩码之间存在像素级关系,图像中的无关背景往往会影响掩码预测的精度,目前大部分方法所依赖的基础分割模型如SAM(Segment Anything Model)系列,在通用分割任务中展示出了很好的鲁棒性和泛化性,但迁移到AVS任务中后,无法达到很好的性能,因为AVS目的是得到所有发声目标的像素级分割,而SAM是在无语音引导条件下的类别级分割,无法直接进行适配。因此我们采用了孪生编码模块,利用先验知识生成更精确的视觉特征。针对模态纠缠,两种模态之间存在内在联系,如图像可以用文字描述,声音可以对应图像中的目标物,已有的方法往往聚焦在音频模态对视觉模态的影响,而忽略了视觉对音频的影响,相较于以上单边融合方法,我们认为两种模态的相互融合能带来更优的效果,因此设计了双边融合模块,来实现视觉特征和听觉信号的双向对齐,该模块使视觉特征更聚焦在发声目标,同时使语音信号更关注视觉目标。针对时间纠缠,在视频序列中,能够根据过去的帧序列结果来估计当前帧,同时也可以根据当前帧结果预测未来帧,基于以上时序间内在关系,我们引入了一种自适应帧间一致性损失算法。综合实验和消融研究表明,COMBO在AVSBench-Object和AVSBench-Semantic数据集上均优于现有的最先进方法。

06 | Intelligent Grimm - Open-ended Visual Storytelling via Latent Diffusion Models

论文作者:Chang Liu* (SJTU,Shanghai Al Laboratory),Haoning Wu*(SJTU),Yujie Zhong(Meituan),Xiaoyun Zhang(SJTU),Yanfeng Wang(SJTU,Shanghai Al Laboratory),Weidi Xie(SJTU,Shanghai Al Laboratory)

备注:SJTU(Shanghai Jiao Tong University)

论文地址PDF

论文简介:生成模型最近在文本到图像生成方面展示了出色的能力,但在生成连贯的图像序列方面仍然存在困难。在本研究中,我们专注于根据给定的故事情节生成连贯图像序列的新颖而具有挑战性的任务,称为开放式视觉叙事。我们的工作有以下三个贡献:

  • 为了完成视觉叙事的任务,我们提出了一种基于学习的自回归图像生成模型,称为StoryGen,它具有一个新颖的视觉-语言上下文模块,可以在依据相应的文本提示和之前的图像-字幕对的条件下生成当前帧;
  • 为了解决视觉叙事数据的不足,我们通过从在线视频和开源电子书中收集配对的图像-文本序列,建立了处理流水线,构建了一个具有多样化人物、情节和艺术风格的大规模数据集,命名为StorySalon;
  • 定量实验证明了我们的StoryGen的优越性,我们展示了StoryGen可以推广到未见过的角色而无需任何优化,并生成具有连贯内容和一致人物的图像序列。

07 | InstaGen: Enhancing Object Detection by Training on Synthetic Dataset

论文作者:Chengjian Feng(Meituan),Yujie Zhong(Meituan),Zequn Jie(Meituan),Weidi Xie(SJTU), Lin Ma(Meituan)

备注:SJTU(Shanghai Jiao Tong University)

论文地址PDF

论文简介:近年来,文本到图像的生成模型在生成高质量图像方面取得了显著的成功,这为使用合成图像训练视觉系统提供了可能。现有的文本到图像生成模型通常可以根据某些自由形式的文本提示来生成图像。尽管这些生成的图像看起来很逼真,但无法满足训练复杂系统的需求,因为这些系统通常需要有实例级的注释,例如目标检测需要物体边界框。

在本文中,我们探索了一种创新的数据集合成范式,用于训练目标检测器以提高其性能,例如扩展类别或改进检测能力。具体而言,我们成功地将一个实例级的检测头(Grounding head)集成到一个预训练的生成模型中,以增强其在生成图像中定位物体实例的能力。检测头通过使用来自现成目标检测器的监督,以及一种针对目标检测器未覆盖的类别的新颖自训练方案,将类别名称的文本嵌入与扩散模型的区域视觉特征进行对齐。我们进行了详细的实验,结果表明这个增强版的生成模型,即InstaGen,可以作为一个数据合成器,通过使用其生成的样本来增强目标检测器的性能,无论是在开放词汇(+4.5 AP)还是数据稀缺的情况下(+1.2 ∼ 5.2 AP),都比现有最先进的方法表现出更好的性能。

美团科研合作

&#x7F8E;团科研合作致力于搭建美团技术团队与高校、科研机构、智库的合作桥梁和平台,依托美团丰富的业务场景、数据资源和真实的产业问题,开放创新,汇聚向上的力量,围绕机器人、人工智能、大数据、物联网、无人驾驶、运筹优化等领域,共同探索前沿科技和产业焦点宏观问题,促进产学研合作交流和成果转化,推动优秀人才培养。面向未来,我们期待能与更多高校和科研院所的老师和同学们进行合作。欢迎老师和同学们发送邮件至:[email protected]

领域驱动设计DDD在B端营销系统的实践

通过营销活动实现客户/用户拉新、留存和促活是业界普遍采用的方法。为实现商户增长和留存,美团核心本地商业/商业增值技术部也构建了相应的营销系统来支撑商户的线上营销运营。在系统建设过程中,面临着业务体量大、行业跨度大、场景多样、客户结构复杂,需求多变等挑战。本文试图还原从0到1构建面向商户的营销系统过程中,并通过DDD(领域驱动设计)来应对系统设计和建设中遇到的业务复杂度高、需求多变、维护成本大等问题。

2 基本概念

软件系统的复杂性主要体现在三个方面。

  • 隐晦:一是抽象层面的隐晦,抽象系统时,每个人都有自己特定的视角,你需要站在对方的角度才能明白他为什么这么做;其次是实现层面的隐晦,代码是一种技术实现,通常与现实世界的业务概念脱节,无形中增加了理解成本。
  • 耦合:代码层面的耦合扩大了修改范围;模块层面的耦合需要跨模块/服务交互;系统层面的耦合则需要跨团队协作。从代码到模块再到系统,耦合的影响逐渐扩大,成本随之增加。
  • 变化:业务需求决定了系统功能,不同的用户需求不一样,不同的业务发展阶段需求在不断变化,系统功能要随着业务需求的变化不断调整,这时就涉及到系统改动的频次和范围。

DDD(Domain-Driven Design,领域驱动设计)是应对软件设计复杂性的方法之一,它能很好的解决上述三个问题,但其概念体系复杂(如下图所示),学习曲线陡峭,即便深入研读DDD的两本经典著作,项目落地时依然有点“捉襟见肘”。

在展开介绍DDD之前,这里先回顾一下历史:

  • 早期,计算机创新更多聚焦在语言方面,为软件工程师提供功能更强大的语言来操作计算机,充分使用计算机的算力。
  • 60年代,面向对象语言诞生,通过封装、继承、多态等特性进一步增强了语言的表达能力。
  • 80年代,出现面向对象的分析与设计,解决了如何构建类模型的问题,帮助我们更好地使用面向对象语言来实现系统,但没有解决如何把物理世界映射到计算机世界的问题。
  • 2000年,出现领域驱动设计方法,通过分析业务,抽取概念,建立对应的领域模型,再采用面向对象的分析与设计方法构建对应的类模型,达成了从物理世界到计算机世界的映射。

什么是领域?领域由三部分组成:领域里有用户,即涉众域;用户要实现某种业务价值,解决某些痛点或实现某种诉求,即问题域;面对业务价值,痛点和诉求,有对应的解决方案,这是解决方案域。什么是领域驱动设计?通俗地讲,针对特定业务,用户在面对业务问题时有对应的解决方案,这些问题与方案构成了领域知识,它包含流程、规则以及处理问题的方法,领域驱动设计就是围绕这些知识来设计系统。

以营销为例,营销系统所服务的用户有4类:运营、销售、电销人员和商户。解决3个核心问题:如何发券、发给谁、发什么(红包还是折扣券)。解决方案:通过营销活动来承载发券,不同的活动类型对应不同的玩法(如买赠、折扣、充送等);通过目标人群来确定发给谁;通过权益来定义发什么(如:红包、代金券、折扣券等)。

  本文将从战略设计、战术设计和代码架构分3个部分介绍领域驱动设计的落地:

  • 战略设计:确定用例,统一语言和划分边界。
  • 战术设计:概念模型转化成类(代码)模型。
  • 代码架构:将系统设计映射为系统实现。

3 战略设计实践

战略设计之前,先要确定用例,也就是业务是怎么玩的,有几种常见的方法:

  1. 用例图:最简单直观的表达了用户与系统的交互。
  2. 用户故事:敏捷开发模式下用的较多,从Who、What和Why三个维度描述了业务需求。
  3. 交互原型:用户操作的页面及其操作流程,其缺点是过于关注用户体验,而忽略了业务底层逻辑。
  4. 事件风暴:关注业务的底层逻辑,但使用门槛较高,适用于大型而复杂的业务分析。

下图是营销系统的用例图(起初并没有这么完整,这是多次迭代后的结果):

确定业务玩法后,接下来是统一语言。从用例里抽取概念,并对概念进行甄别(去伪存真,抽象合并)找到真正描述业务的概念。比如,有多种方式来描述活动规则:充值送规则、返还规则和档位等,技术可能会泛泛地称其为规则,业务人员则用档位来描述(比如充值送活动,充1000送100红包,充2000送300红包,充3000送500红包,那1000、2000、3000就是业务所认为的档位)。抽取概念时,尽量采纳业务侧的叫法,这样统一语言比较容易推行。

接着是明确概念的含义,概念由术语、Term(术语的英文版)和含义三部分构成。含义明确的术语就是统一语言,这些术语将用在日常需求沟通、产品文档,技术设计以及代码实现中。

明确概念后,接着理清概念之间的关系(1对1,多对1,多对多),确定概念所代表的的业务实体的核心属性和行为,从而得到概念模型。后续在业务需求讨论、产品和技术方案设计时,基于这个概念模型,使用统一语言进行描述,大家能很容易对齐;同时精心抽出的概念和建立的概念模型更接近业务本质,为后续的战术设计打下了基础。

基于统一语言和概念模型,业务 - 产品 - 技术三个角色比较容易就需求达成共识,保障沟通的一致性。

缺少这些就很容易出问题,如:刚开始做营销系统时,在如何描述“商户”上,没有统一语言,资金域有三个概念来描述商户(资金账户、账号ID、资金账号),商家域有四个概念描述商户(商家账号、商家ID、登录号、登录ID),到了营销域,不同的人采用不同的概念来描述商户,造成了沟通的混乱。给商户发红包时,“资金账户、账号ID、资金账号、商家账号、商家ID、登录号、登录ID”这些概念都可以描述商户,但业务人员弄不清这些概念之间的区别,导致ID误用,红包发错。事后对这些概念进行了梳理和统一,营销域只关注资金账户和商家账号,系统功能上明确使用资金账户或商家账号来发送红包,这样就不易出错了。

概念模型是一张大网,描述了概念间的关系以及关键属性,但还不能直接映射为代码模型,要映射为代码模型,还需拆解,化繁为简。

本源论认为世界的本质是简单的,复杂问题由多个简单问题构成;康威原理认为系统架构受制于组织沟通架构,系统落地时,首先要确定系统边界,再依据系统边界组织分工。这两个原理表明:我们可以将复杂问题拆解为多个简单问题,并针对团队资源组织分工协作。

这里提供一种拆解方法(来自美团内部)给出了一种拆解方法:按纵和横两个维度来拆,纵是从业务价值和目标维度划分,横是从功能的通用性维度划分。这里尝试从业务角度来拆,没有系统支持时,业务要在线下运转,通常根据要达成的业务目标,将业务流程或业务组分拆解为多个节点,并定义每个节点的职责以及对应的规范和标准,安排对应的组织或人员执行。简单地说,就是从业务问题和解决方案出发,拆解到对应的人。因此基于业务的拆分通常能实现系统用户、业务问题和解决方案之间的一致性。业务系统是把业务的玩法从线下搬到线上,在进行系统拆分时,也可以使用这个思路。从三个层面来进行:

  1. 基于涉众域拆解:也就是按用户相关性进行拆解,不同的用户使用不同的系统功能,如:CRM由市场人员、销售人员、客服人员三类角色协同完成客户触达,签约合作,售后服务三大职能,针对这三个角色建设相应的系统能力。这种拆解方式比较简单,但也存在较大的局限性,可能导致功能的重复建设。
  2. 基于问题域拆解:不同角色/用户要解决的问题是相同/相似的,可基于问题域进行拆解,如营销系统的用户包括销售、商户、销运等角色,但它核心是要解决如何发券(活动),发给谁(人群),发什么(权益)的问题。基于问题域的拆解相较于基于涉众域的拆解更加抽象,但也可能复用性不够。
  3. 基于解决方案域拆解:不同的问题,可能有相同的解决方案,如HR域有请假审批、财务上有报销流程、CRM领域存在客户资质审批,三个领域各自需要解决审批流程的问题,可以构建通用的审批流引擎来统一解决,这是基于解决方案域进行拆解。基于解决方案域的拆解最抽象,也最贴合业务本质,但也容易陷入过度设计的陷阱。

营销系统基于问题域拆解为五个子域(活动域,权益域,人群域,推送域,数据域),每个子域解决特定的问题,各子领域相对内聚和简单:

业务系统要运转起来,需要子域之间相互配合,这就要定义上下文映射,实现不同子域间的协作。如活动域关注的两个目标人群:一是资金账户(表示已签约的商户);另一个是商家账号(表示未签约商户)。资金账户是财务域定义的,而商家账号是账号域定义的,两个概念都不是营销域原生概念。此时,营销域需通过某种方式依赖外部概念,将外部概念映射到营销域,通过防腐层来对接外部服务来实现这种映射。领域驱动设计里定义九种上下游映射关系,这里不赘述:

下图是营销系统的整体上下文关系:

从用例分析,统一语言到子域拆分,初步完成战略设计,但这并非终局,战略设计是一个持续迭代的过程,迭代的来源主要有3个:

  1. 用例精化:在探讨需求的过程中,用例不断丰富。
  2. 需求变更:业务不断发展带来需求变化,进而影响用例及相关概念的内涵,概念模型亦随之调整和迭代。
  3. 方案选型:当产品,业务或技术发生较大变化时,可能需要采用另一种方式实现它,这时所采用的概念会有所不同。比如早期构建营销活动域时,通过参与规则来定义谁可以参加活动,将商户与参与规则进行匹配,符合就能参与。这种方式带来的问题是无法提供一个完整的活动人群列表,除非将所有商户(5000万+)匹配一遍。随着业务方越来越重视活动参与商户的分层,触达和转化,引入目标人群的概念,通过目标人群来保存所有可参加活动的商户。从参与规则到目标人群,概念发生了变化,底层模型也完全不一样(参与规则是一套规则体系,而目标人群由筛选服务提供),实现了战略设计上的迭代。

有了战略设计,构建了统一语言和概念模型后,如何验证概念模型呢?通常用两个方法:

  1. 场景走查:把模型代入到所有的场景确认一遍,确定所抽象出来的概念模型和统一语言能正确描述它。
  2. 业务预判:未来业务的变化会在哪里,当变化发生时,概念模型的内涵和外延是否方便扩展并支持到变化。

4 战术设计实践

战略设计得到了概念模型,战术设计则是将概念模型映射为代码模型,有很多编程范式,比如事务脚本、表模式、面向对象,函数式等,最好的方式是面向对象的实现。

从概念模型到对象模型:

  • 首先,概念是分层的,如营销活动是一个泛化概念,其下还有充值送活动、消费返活动,买赠活动等具体活动。构建对象模型时,通过派生/继承来实现概念分层。
  • 其次,概念关系映射成对象关系,比如营销活动包含了档位和库存,那在构建营销活动对象时,可通过组合实现这种包含关系(档位对象和库存对象成为营销活动对象的属性)。
  • 最后,概念的属性行为,可以直接变成对象的属性和行为;概念的状态机以及生命周期也会变成对象的状态机。

两类对象:实体和值对象,这两者的区别是是否有统一标识和自己的状态。

有了对象模型,还需通过聚合根完成封装,如何确定聚合根的粒度?营销活动包含活动、库存、档位、档位项、目标人群五个对象,如果采用小聚合根模式,一个对象对应一个聚合根,这样每个聚合根都很简单。但从业务角度看,库存或档位会影响活动的状态,如:修改了库存或档位,活动需要重新审批和上下线,这种业务上的耦合需要在技术上进行处理。此时,就得在小聚合根上构建领域服务来封装这些逻辑。

另外一种模式是大聚合根。围绕活动,把活动相关的概念(活动、库存、档位、档位项、目标人群)都封装起来,但聚合根比较复杂,影响活动加载(一些活动的目标人群上百万,懒加载可解决问题,但增加了复杂度)。

聚合根的设计要遵循一定的原则:

  1. 满足业务一致性、数据完整性、状态一致性。比如库存档位和活动状态要一致,在数据上也要完整,不存在没有档位的活动,也不存在没有库存的活动。
  2. 技术限制。有些实体会带来技术挑战,如数据量太大,可抽出来单独考虑。
  3. 业务逻辑不灭,在业务封装与适度的职责边界之间寻找平衡。不管是大聚合根还是小聚合根,业务逻辑永远都是存在的,就是看把它放在哪里。

如下图是营销系统的聚合根:

聚合根已经非常接近代码实现,落地代码时,大家还会纠结用贫血模型还是充血模型。Spring MVC通常运行在单例模式下,引入充血模型会增加理解成本和技术复杂度。另外,不适合放在聚合根里的领域逻辑,可以放在领域服务里,如:同时存在多个充值送活动时,用户只能参加优先级最高的一个,在充值送活动聚合根里会标识活动的优先级,但挑选优先级最高的活动并非聚合根的职责,但确实是领域逻辑的一部分,此时可通过领域服务实现。

从概念模型,类模型到代码实现,整个过程都要使用统一语言。在落地代码时,代码要体现出业务含义,比如下图的例子,要避免左边updateStatus()这样的方法,它没有体现业务含义(必须阅读代码实现,才知道这个方法做了什么);图中右边的submitCampaign(),approveCampaign(),cancelCampaign()则有明确的业务含义。

5 代码架构实践

完成战术设计后,如何组织代码架构?无论是六边形架构,整洁架构还是洋葱架构本质上都是围绕着领域模型展开,应用层、基础设施层和外部接口都依赖领域模型:

下图是我们团队的工程实践,与前面三个图本质上是一样的。领域层和应用层次放在中间(两者都属于领域逻辑),基础设施和用户接口依赖中间层:

6 总结

  • 我们做的大部分系统都不是全新系统,如CRM、HR或SCM等,已经有很多业界实践,可充分借鉴这些实践,没必要自己创造新概念。
  • 要重视统一语言。没有统一语言就不会有概念模型,没有概念模型就不可能有靠谱的代码模型,拿到需求后就开始设计代码模型是不靠谱的。
  • 领域驱动设计是团队工作。现实中没有一个是严格意义上的领域专家,所有参与到这项工作的人都可以是领域专家,整个工作可以由技术团队主导,但一定要落地到产品和业务。
  • 拥抱变化,持续迭代。模型是相对稳定的,但并非一成不变,业务理解的深度,抽象的角度与方式,业务的变化都会影响到领域模型,领域模型的建立是持续迭代的过程。

这里分享几个常见的误区:

  • 深陷领域驱动设计的概念体系。在代码里生搬硬套领域驱动设计里的概念,比如聚合根、值对象、实体等,掰扯概念之间的细微差异,设计复杂的领域事件等。这反而增加理解成本,让系统变得复杂。领域驱动的精髓在于从业务出发,抽象出业务领域知识,构建概念模型,一步一步将这些概念模型映射成系统。至于如何采用聚合根、领域服务、实体、值对象、领域事件等,可以灵活取舍。
  • 试图通过精心设计来获得领域模型。领域模型不是设计出来的,而是通过战略设计的几个步骤,从业务中抽象出来的,最重要是理解业务,对业务进行抽象。
  • 使用了DDD就一定会产生好的领域模型的想法也不可取,我们知道飞机怎么造,但我们不一定能够造出好飞机,但如果我们知道这个方法,可以少走弯路。 

在聊需求的那一刻,设计就开始了,统一语言就是设计的一部分。

解决方案域在模型维度分为四层:

  1. 功能模型:产品表达给我们业务的玩法,我们把它变成了用例,从用例里抽取出功能模型。
  2. 概念模型:对功能模型进一步抽象,统一语言,形成概念模型。
  3. 代码模型:将概念模型映射为代码模型。
  4. 数据模型:业务数据需要存储,需要设计对应的表结构。

这里有两个陷阱:

  1. 看到功能模型后,就开始设计数据模型,考虑数据该怎么创建、怎么更新、什么时候该删除,沦落为CRUD boy。
  2. 看到功能模型后,就开始考虑操作数据的流程是什么,陷入到事务脚本陷阱。(对于一些简单的功能,不排斥使用事务脚本,但对于复杂功能,事务脚本的维护成本非常大)

另外,领域至少可以分为两大类:一是学科型,比如财务、会计、图形学、动力学,这类系统的设计须先深入理解学科知识;二是实践型,如CRM、订单交易等,是业务经验的总结,这类系统的设计不妨参考前人的实践。当然,如果自己的业务具有独特性,那就只能靠自己摸索了。

7 参考资料

基于多模态信息抽取的菜品知识图谱构建

中国有句古话:“民以食为天”。对食物的分析和理解,特别是识别菜肴的食材,在健康管理、卡路里计算、烹饪艺术、食物搜索等领域具有重要意义。但是,算法技术尽管在目标检测[1]-[3]、通用场景理解[4][5]和跨模态检索[6]-[8]方面取得了很大进展,却没有在食物相关的场景中取得好的表现,尤其是对烹饪菜肴的相关场景。其核心原因是缺乏细粒度食材的基准,这已经成为该领域发展的瓶颈。

以往的研究主要集中在食物层面的表征学习,如Food2K上的食物识别[9]-[12],UNIMIB2016上的食物检测[13]-[15]。然而,这些方法忽视了菜肴中的食材组成,也不理解食材之间的上下文关系。相比之下,一系列的方法[16]-[18]运用Recipe1M的“食谱-图像”对,实现了跨模态的食谱检索[16]

然而,由于缺乏食材边界框的标注,这种类型的研究只能通过三元组建模出整个食物图像和食谱文本之间的关联[16],[19],[20]。这种限制导致图像区域与食物的一系列食材之间存在模糊的匹配关系,产生虚假相关性[21]。综上,目前迫切需要一个细粒度的食材级基准,促进复杂的食品场景理解算法的发展,并支持细粒度的任务,如食材检测和跨模态食材检索。

在本研究中提出对于中餐进行理解这一新任务,旨在捕捉中餐图像中食材之间的语义关系,并建立了有关中国菜品理解的新基准。我们大致设定了中餐理解的两个任务:食材检测和食材检索。对于食材检测,目标是确定图像中特定食材的存在并提供精确的定位。对于食材检索,目标是探索不同食材组合与食品图像之间的细粒度对应关系。对中餐的理解扩展了食品相关任务的范围,在食品领域开辟了更广泛的应用。同时,食材的多样外观和它们错综复杂的语境关系,对中餐的理解提出了一个更大的难题。

为了进行中餐理解这一新任务,我们需要构建一个包含食材粒度标注的数据集。然而,由于中餐种类繁多、风格独特,因此在食材标注上面临着巨大的挑战。构建含中餐食材的细粒度跨模态数据集主要有三个难点。

  • 首先,相同的食材有不同的名称。图1.1(a)说明了这种情况:“圣女果”和“小番茄”都是广泛使用的食材名称,它们是同一食材的不同名称,这样的情况使得我们需要花费更多的精力来清除数据集中的模糊标签以及其他噪声。
  • 其次,同一植物类食材之间的图像存在细微差异,如“青菜”和“油菜”,“香菇”和“冬菇”,如图1.1(b)所示。这些情况对标注人员来说是相当具有挑战性的,他们需要从文本部分获得一些提示。此外,对于下游任务来说,基于视觉特征来区分它们也是相当具有挑战性的。
  • 第三,由于烹饪方法的原因,中国菜肴的食材通常分散在图像中。如图1.1©所示,碎片化食材通常缺乏清晰的轮廓边界。此外,从图1.1(d)中可以看出,食品图像中的主要食材往往占据显著区域,这不可避免地削弱了辅助食材的语义信息。这使得在提取食材特征的同时,对辅助食材之间的上下文关系进行建模成为一个关键问题。

为了应对上述挑战并促进对中餐理解的研究,我们开发了一个名为CMIngre (Cross-Modal Ingredient-level Dataset) 的跨模态食材级数据集。该数据集旨在通过提供对食材及其关系的有价值的见解来增强对中国烹饪的理解。该数据集由来自三个不同来源的8,001张图像组成,即菜肴,食谱和用户生成内容(UGC)。该数据集包含429种不同的中国食材和95,290种食材边界框。

为了对广泛的食材进行全面的语义分类,我们根据中华人民共和国健康行业标准对食品食材数据表达的规定[23],将其划分为更高级的层次。这些层次关系也可以作为先验信息,以促进在后续研究中探索不同食材之间的上下文关系。此外,我们评估了传统的基于CNN的检测算法和基于Transformer的预训练模型在CMIngre上食材检测任务的性能。我们还提出了食材检索任务的基线方法,该方法捕获单个食材的语义信息以及各种食材组合之间的关系,并进一步采用pooling策略来研究跨模态图像-食材之间的匹配关系。在CMIngre数据集上进行的深入实验评估证实了我们提出的方法在提高食材检测和检索性能方面的有效性。

本文的贡献可以概括为以下几点:

  • 本文提出了一种新的基于“图像-文本”对的中餐理解任务,该任务扩展了细粒度对象检测和检索的范围,对中餐烹饪领域的理解提供进一步的帮助。
  • 为了支持对中餐理解的研究,我们建立了一个名为CMIngre的跨模态食材级别的数据集,该数据集由来自三个不同来源的8,001组图像食材组成,涵盖了429种不同的中国食材和95,290个边界框。
  • 我们评估了不同的目标检测算法在CMIngre数据集上的性能,并提出了跨模态食材检索任务的基线方法。
  • 我们在CMIngre上对两个食材级的食品理解任务进行了广泛的实验,以评估我们提出的方法的有效性。

图1.1 菜品中不同尺寸的食材

2. 数据集

在本节中,我们将讨论如何构造CMIngre数据集。我们将在第一部分中介绍我们如何收集和标注数据。在第二部分中,我们对数据进行了后处理,提升原始数据的质量。在第三部分中进行了CMIngre数据集的统计和分析。

2.1 数据收集和标注

数据收集:为了收集全面的食物图像,我们探索了三种类型的图像-文本对:

  • 菜肴图片:如图2.1第二行所示,这一类别包括与其名称配对的菜肴图像。与其他类型相比,这种类型的文本提供了最简洁的描述。
  • 菜谱图片:如图2.1第三行所示,这些数据由菜谱图像和详细的食谱文本组成。这些图像的质量更高,并且比其他两个类别的图像描述的信息更丰富。
  • 用户UGC图片:如图2.1的最后一行所示,这种类型数据主要包含用户拍摄的图像及其附带的评论。由于用户生成的内容缺乏约束限制,图像和文本描述经常包含与食物无关的元素,例如餐厅氛围或餐具。为了将该数据集细化为专注于食物,我们使用菜肴名称识别算法[45]来识别带有菜肴名称的文本。具体来说,我们会选择评论中包含三个以上菜名的照片,减少与食物无关的内容。

这三种类型的数据在线上平台很流行,并且提供了食品相关数据的多样化表示。我们总共收集了11,300个图像-文本对用于标注。

图2.1 不同数据来源的图像-文本对,其中UGC表示用户生成的内容

数据标注:这里将详细介绍收集到的“图像-文本”对的标注过程。我们首先雇佣了8名母语为中文的工作人员,分别对文字描述和图片进行标注。然后,使用另外两名工作人员进行双重检查过程。

  1. 文字描述标注:标注人员的任务是识别文本描述中提到的所有食材。该标注的结果如图2.1第三列所示。
  2. 图片标注:如图2.1最后一列所示,图像标注遵循两个关键原则:1)要求标注人员标注文本中提到的和图像中可见的食材。2)文本中没有提及但在图像中可以识别的食材也需要标注。在这个过程中,标注人员遇到了几个挑战:1)一个图像包含相同食材的多个实例。在这种情况下,标注人员需要用多个边界框标注所有实例。但是,如果同一食材的多个实例紧密聚集在一起,则可以将它们分组在一个边界框中。2)多种食材被其他食材覆盖。在这种情况下,标注人员需要标注出所有可识别的部分。本质上,食材中任何可以被辨别和识别的部分都应该被标注。

经过标注过程后,最终的数据集包含11,300个图像-文本对,用4,492个不同的食材标签和199,853个边界框进行了标注。

2.2 标注数据后处理

由于缺乏对标注人员关于每个图像的边界框的大小和数量的限制,最终的标注结果中存在边界框大小的显著变化和相当多的冗余边界框。为了解决这个问题,我们分别对图像和文本进行了进一步的后处理。

  • 图像标注清洗:为了提高数据集中边界框的质量,我们基于两个关键策略实现了清理过程:1)边界框融合:我们通过将相同标签(重叠,相互包含或临近)合并到单个边界框中来解决冗余边界框的问题。具体来说,融合是基于边界框的面积,计算每个边界框内的像素数。如果融合前后的面积比大于一个特定的阈值,我们将这些边界框整合成一个新的边界框。这个阈值的设置是一个关键问题。我们注意到,过高的阈值将使融合策略无效,而过低的阈值将导致可能包含多种食材的过大的边界框。因此,我们根据经验将其设置为0.6作为平衡。2)较小边界框移除:我们通过两个过程来移除数据集中的小边界框。首先,为了去除只有小框的图像,我们去除所有框的总面积小于整个图像面积3%的图像-文本对。其次,如果图像中有超过三个相同类别的边界框,我们只保留面积至少为该类别中最大边界框面积0.8倍的边界框。在这些清理步骤之后,我们的精细化数据集包含8,001个图像-文本对,共有95,290个边界框。
  • 文本标注清洗:为了改进数据集中的食材标注,我们实现了两个步骤:1)为了保留足够的数据用于训练和测试,我们删除出现在少于五张图像中的食材。由于原始数据集中存在显著的长尾问题,这一步使得食材标签总数减少到510。2)在这510种食材中,我们发现了不同名称指代同一种食材的情况,例如“松花蛋-皮蛋”。为了解决这个问题,我们利用中华人民共和国健康行业标准[23]中的食物成分数据表达规范,对目前510种食材进行比较和组合。具体而言,两个标注人员最初将510个食材中的每一个分类到分层本体的适当叶节点中。随后,另一个标注人员在同一父节点下审查并合并具有相同语义的食材。合并操作进一步将食材标签减少到429个。

综上所述,清理后的数据集包括8,001张图像,95,290个边界框和429个食材标签。

2.3 数据统计和分析

在CMIngre中,有1,719对来自菜肴的图像-文本,2,330对来自食谱,3,952对来自UGC。如2.1所述,UGC的图像质量比菜肴和食谱的图像质量差,这给我们在接下来的食物理解任务中处理低质量数据带来了更多的工作量,因为UGC覆盖了近一半的数据集。

数据集中每个食材上的图像数量如图2.2所示,少量食材在我们的数据集中出现了很多次。例如,“葱–scallion”在1,961张图片中出现次数最多,约占图片总数的24.51%。此外,有138种食材出现在不到10张图片中。例如,只有5张图片包含“西柚–grapefruit”,8张图片包含“桃–Peach”。图2.3显示了我们数据集中每个食材的边界框数量。如图2.3所示,每种食材对应的边界框数量分布与图2.2中包含该食材的图像数量分布大致相似,均为长尾。为了说明边界框尺寸的差异,图2.4给出了不同尺寸边界框的比例。我们观察到小尺寸的边界框(面积比在0.0025 ~ 0.01之间)的比例最大。同时,有超过50%的边界框的面积比小于0.01,说明数据集中有很多小物体。

表2.1显示了与食品相关数据集的统计比较。我们可以看到,现有的食品相关数据集主要集中在食品识别任务上,其目的是识别图像内的食品类别。很少有数据集为食物边界框提供标注,这是由于它们的目标是定位整个菜肴,而不是各种类型的食材。相比之下,Recipe 1M为每个食物图像提供食材标注。然而,由于缺乏对这些细粒度食材的位置标注,它们只能隐式地建模整个食物图像与相应食材之间的关联,从而限制了模型的性能。因此,我们引入了CMIngre,旨在通过食材检测和检索任务增强对中餐的理解。

表2.1 现有食品相关数据集之间的统计比较

最后,我们将CMIngre数据集与广泛使用的目标检测数据集COCO进行了比较分析。在图2.5中,横轴表示每张图像中标签种类的数量(在CMIngre中标签为食材,在COCO中标签为物体)纵轴表示每种图像的比例。很明显,CMIngre图像通常包含更多的对象(在我们的例子中是食材)。具体来说,CMIngre中包含三个以上标签的图像的占比高于MS COCO数据集。这一趋势在边界框的数量上也很明显。如图2.6所示,与MS COCO相比,我们的数据集中超过5个边界框的图像比例更大。综上所述,CMIngre中的图像比其他现有数据集具有更丰富的语义和更密集的边界框,这对图像理解提出了更艰巨的挑战。

3. 方法

在本研究中,我们引入了两项从食材层面理解中国菜食材的任务,即食材检测(任务1)和跨模态食材检索(任务2)。任务1的重点是识别食材并在图像中标注准确的位置信息,任务2旨在研究图像与食材组成之间的复杂关系。对于任务1,我们使用现有目标检测模型在CMIngre数据集上进行微调,构建有关中国菜品理解的新基准;对于任务2,我们在现有跨模态检索方法的基础上,提出了一些创新性的做法,填补了有关中国菜品食材粒度理解的空白。

3.1 食材检测

与传统的目标检测数据集相比,CMIngre数据集具有极其详细的食材分类和密集的边界框注释,因此直接利用现存的目标检测算法进行拟合是一件非常具有挑战的事情。直接对现有的大规模目标检测模型[1]在原始边界框注释上进行微调的效果并不让人满意,因此我们采用融合和过滤策略来缓解边界框密集和尺寸较小带来的问题。

具体而言,我们首先按照融合前后的边界框面积百分比 不低于阈值τ的规则,对同一类别的多个边界框进行融合,在实验中这个阈值被设置为0.6。接下来,我们对融合后的边界框进行排序,并将边界框的三个最大区域保留为真值。此外,我们将食材树层级结构的最低级标签都转换为第三级标签,例如“紫菜”和“海带”都融合为“藻类”,“冬笋”和“酸笋”都融合为“笋”,这样可以避免模型无法识别同一分支中高度相似的类别的问题。根据这种转换,类别总数从429减少到67个。在这种设置下,我们使用如下的两种不同的基线方法进行实验。

3.1.1 基于CNN的方法:Faster R-CNN[47]和YOLO v5[48]

Faster R-CNN是一种经典的基于卷积神经网络(CNN)的两阶段目标检测框架。在第一阶段,Faster R-CNN利用CNN提取输入图像的特征映射,然后利用区域提名网络(RPN)生成候选目标区域。在第二阶段,基于候选目标区域,利用图像区域边界框回归以及区域食材识别两个约束进行网络参数的整体更新。相比之下,YOLO(You Only Look Once)是一种单阶段目标检测算法,以其速度和效率而闻名。与Faster R-CNN不同,YOLO在一次评估中处理整个图像,同时预测多个对象的分类概率和边界框。

3.1.2 DINO[1]

DINO(DETR with Improved deNoising anchOr boxes)是一个融合对比降噪训练(contrastive way for denoising training),混合查询选择锚点初始化(mixed query selection method for anchr initialization),前向两次预测(look forward twice scheme for box prediction)的端到端Transformer框架。相比于Faster R-CNN,DINO是一个参数量更大且更高效的目标检测模型。

评估方案:使用平均精度(AP)来评估基线模型的检测性能。对于Faster R-CNN,YOLO和DINO,分别评估了不同IoU阈值(0.5、0.75和0.5:0.95)下的标准平均精度结果。

3.2 跨模态食材检索

图3.1 中餐理解框架

如图所示,使用两个独立的特征提取器提取图像特征和食材特征。然后,应用对比约束以端到端的方式来缩小匹配的图像和食材之间的嵌入距离。考虑到食材检测能够学习不同图像区域中食材的语义嵌入,我们进一步研究了两阶段的检索模型的有效性,该模型首先使用食材检测算法提取区域特征,然后使用区域特征和食材来训练一个联合嵌入模型。

3.2.1 方法1-端到端训练

在端到端设置中,我们首先将食品图像和食材组合投影到公共的嵌入空间中,然后使用对比损失来约束跨模态特征对齐。对于图像编码器,受视觉-语言Transformer在各种下游任务中取得成功的启发,我们采用预训练的[49]-[51]CLIP ViT B/16作为图像特征提取器对图像特征进行编码,然后利用线性全连接层将原始图像特征投影到公共的嵌入空间中:

3.2.2 方法2-二阶段训练

与图像编码器直接提取的全局图像特征相比,从食材检测模型中提取的局部特征包含了特定的食材语义信息,为跨模态食材检索提供了更有利的初始化状态。为了利用这一优势,我们首先使用食材检测模型提取$Z$个区域特征。然后,我们提出了一个自适应式池化策略来自动融合多区域特征和多食材特征。

4. 实验

4.1 算法实现细节

CMIngre数据集在本次实验中被随机划分为6,001个训练样本,1,000个验证样本和1,000个测试样本。所有的实验都使用了PyTorch框架,在2张NVIDIA GTX 3090 GPU上进行实验。

  • 食材检测:对于Faster R-CNN框架,与方法[47],[54]保持一致,利用ResNet-101作为特征提取器,设置batch size为2,学习率为0.001,并利用SGD优化器进行端到端检测优化。对于YOLO算法,遵循官方报告[48]使用yolov5x6进行检测实验。对于DINO框架,与官方设置[1]保持一致,然后选用Vision Transformer作为特征提取器fine-tune整个模型。
  • 跨模态食材检索:选用Adam优化器训练整个模型并且设置batch size为128,最终映射层维度为1024。对于双层自注意力编码机制,选用包含有2层、4个头部的Transformer作为每层编码器,并且设置隐藏层维度为512。对于图像食材区域特征预提取,在Faster R-CNN框架中提取36个维度为2048的区域特征,在DINO框架中提取128个维度为256的区域特征。为了增加模型泛化能力,随机消去20%的图像区域,并且设置位置编码向量维度$d_2$为32。

4.2 实验结果

4.2.1 食材检测

为了验证现有的检测框架在CMIngre食材数据集上的有效性,我们利用基于CNN以及基于Transformer的端到端框架。实验结果如表4.1所示,可以发现YOLO v5,Faster R-CNN和DINO在CMIngre数据集上性能一般。这一结果表明,目前的目标检测方法为明确的目标边界而设计,很难直接检测到自由形式的食材。这也表明,在食品相关领域开发更多细粒度食材理解算法仍有很大的性能提升空间。与Faster R-CNN相比,DINO在不同的IoU阈值下的检测性能更好,这说明大规模预训练模型在食物领域依然存在着较强的理解能力。

此外,为了验证微调目标检测模型实验的有效性,我们找到了CMIngre数据集和MS COCO数据集中的七个公共类别:蛋糕、西兰花、苹果、胡萝卜、橙子、香蕉、甜甜圈。接下来,我们选取CMIngre数据集中包含这七类食材的数据,对预训练模型和使用CMIngre中数据微调后的模型进行了对比验证。表4.2展示了Faster R-CNN和DINO在CMIngre数据集中公共7类食材上的检测结果。与Faster R-CNN相比,预训练的DINO和微调后的DINO都表现出了更优的性能,突出了大规模预训练模型的泛化能力。此外,在CMIngre数据集上对DINO进行微调后,模型对常见类别的检测性能有了很大的提高。具体而言,微调后的DINO在7个公共类别上AP50:95、AP50和AP75方面分别比预训练的DINO提高了18.3%、25.2%和21%,这证明了在CMIngre数据集上进行模型调优的有效性。

表4.1 CMIngre和MS COCO的检测结果(%),“()”表示检测方法在MS COCO和CMIngre上的性能差异

表4.2 Faster R-CNN和DINO在MS COCO和CMIngre的共有类别上的检测性能

4.2.2 跨模态食材检索

在这一节中,我们重新实现了几个图像backbone(ResNet-50, ViT B/16和CLIP ViT B/16)和食材backbone(分层Transformer和分层LSTM)进行性能对比。此外,还进行了两阶段实验设置,验证了食材对象和跨模态食材检索相结合的有效性。实验结果如表4.3所示,其中APS表示自适应池化策略。最后,在表4.4中,我们重新实现了两种最先进的跨模式食谱检索方法(TFood[19]和VLPCook[56]),来比较我们提出的CMIngre和Recipe 1M[32]

表4.3 CMIngre中跨模态食材检索性能

结果表明,ResNet+H-LSTM的性能并不令人满意。我们认为这是因为卷积神经网络的接受域有限,ResNet-50只能捕获整体图像的粗粒度语义,而忽略了细粒度的食材特征。这个结果突出了在跨模态食材检索中对于图像进行细粒度分析的重要性。通过利用Transformer中的自注意力机制对不同食材之间的语义关联进行建模,ResNet+H-Transformer增强了食材组合的表现力,从而提高了检索性能。

具体来说,在图像到食材的设置中,medR从62.0降低到40.0。当使用视觉Transformer[58]作为图像backbone时,检索性能显著提升。这证明了视觉Transformer通过利用不同图像区域之间的关系来提取细粒度食材表示的能力。受视觉-语言基础模型在各种下游任务中获得成功的启发,我们采用CLIP[49]作为图像backbone进行实验,与其他端到端设置相比,CLIP具有最佳的检索性能。这些实验结果表明,当采用更深和更先进的backbone时,检索性能得到了一致的改善。

除此之外,我们还探索了结合食材检测和跨模态食材检索的两阶段模型的检索性能。首先,我们使用Faster R-CNN和DINO提取固定长度的区域特征。然后,引入自适应池化策略(APS)来融合多区域特征。如表4.3所示,在所有的评估指标中,两阶段的方法明显优于端到端的方法,这表明当前的图像编码器很难直接从图像中提取细粒度食材的判别特征。

在这种情况下,更有效的方法是下训练一个专门针对食材图像的检测模型,然后使用经过训练的检测模型提取的细粒度食材特征进行检索任务。此外,可以观察到,与Faster R-CNN相比,使用DINO的区域特征可以进一步提高检索性能。这表明食材检索模型的性能提升可以同步体现在跨模态食材检索中。

表4.4 CMIngre和Recipe 1M的跨模态检索性能

为了进一步将所提出数据集与其他跨模态食品检索数据集的复杂性进行对比,我们在Recipe 1M中重新实现了两种最先进的方法[32],并对比了这些方法在CMIngre数据集上的检索性能。根据表4.4所示,CMIngre数据集上的检索效率大约是Recipe 1M上的一半,这一显著差异凸显了中国食材面临的更大挑战。具体来说,Recipe 1M提供了一套全面的食谱细节(包括配料、标题和说明),它丰富了图像和食谱之间的上下文关系,从而促进了跨模态检索。相比之下,CMIngre数据集仅局限于食材信息,这对有效的跨模态检索提出了更大的挑战。值得注意的是,我们的两阶段方法明显优于这些对比方法,这进一步凸显了两阶段方法的优势,即训练食材检测方法提取细粒度食材特征可以显著增强图像的表示能力。

4.3 可视化

我们从三种类型的数据(菜名,菜谱,用户生成内容)中随机采样一个查询样本,执行跨模态检索任务,并可视化该查询样本的Top-5检索结果。如图4.1所示,查询图像所对应的正确食材组合成功的以最高相似度出现在第一个检索结果中,验证了我们图像搜索食材的有效性。此外,我们观察到查询样本和Top-5检索结果有着一定程度上的关联,例如在菜谱(recipe)查询图像的检索结果中,Top-5的食材组合都包含有鸡蛋和蔬菜(油菜、蔬菜、西兰花),并且第一个检索结果和第二个检索结果仅仅是“蔬菜”和“油菜”的细微区别,这说明我们的方法可以有效挖掘到图像和食材间的匹配关系。

如图4.2所示,上述相同的现象也出现在三类查询食材的Top-5检索结果中。我们也在图4.3中可视化了一些最佳匹配失败的案例,发现当图像中所包含的食材不能被清晰认知时,模型会倾向于给出一个相似的具体食材。例如在菜品名称查询图像中,其中的一个绿色食材由于无法被清晰的辨识所以被标注为更高级的“蔬菜”标签。然而当模型执行跨模态检索时,会更倾向于将其认知为更细粒度“芥菜”和“秋葵”而不是“蔬菜”。另外一个观察是相比于最佳匹配案例,错误案例中Top-5检索结果的相似度往往倾向于更低且更平均,表示出了模型很难分辨菜品图像中模糊食材的具体分类。

图4.1 使用图像检索食材组合,三种不同来源查询图的top-5检索结果

图4.2 使用食材组合检索图像,三种不同来源查询食材组合的top-5检索结果

图4.3 三种不同来源查询图像最佳匹配失败示例

此外,按照[59]中描述的方法,我们可视化了单个食材的匹配下降分数(MDS)。具体来说,我们将单个食材的MDS定义为当从食材组合中删除特定食材时,图像与其相应食材组合之间的相似性变化。如图4.4所示,具有明显视觉特征的食材往往具有更高的MDS。例如,在第一张图像中,删除“米”导致了0.1216的相似度显著下降,这个下降明显高于土豆、胡萝卜、肉。另一个值得注意的是,具有模糊视觉外观的食材会对跨模态检索产生负面影响。例如,在第三张图中,由于煮熟的青菜缺乏鲜明的视觉特征,导致图像与缺乏青菜的食材组合匹配相似度增加。

图4.4 单个食材在CMIngre上的MDS。MDS最高的食材用红色表示,MDS为负的食材用蓝色表示

5. 业务应用

菜品作为餐饮业务的最基本单元,在供给策略运营、用户需求洞察、业务经营分析等场景都必要依赖。2020年至2021年,到餐研发团队基于业务菜品数据,进行了标准统一和知识融合,整体菜品知识准确率达到94.51%、覆盖率达到87.01%。但在局部视角,部分菜品知识属性受限于获取信源单一、挖掘技术难度大等原因导致知识覆盖不足,例如烧烤/火锅品类准确率仅63.6%,食材属性覆盖率67.5%,口味属性覆盖率11.9%,影响支持业务精细化、智能化的运营需求。

为了提升菜品知识的覆盖,我们提出一套构建多模态知识图谱的流程,分别从文本和图像两个模态获取菜品知识。

图5.1 多模态知识图谱构建流程

对于文本模态,使用命名实体识别提取文本中的食材、口味、口感、菜系、烹饪方法;对于图像模态,使用目标检测提取图像中的食材信息和对应区域对文本信息进行补充。在对单个图像-文本对构建多模态知识图谱对基础上,通过相同食材、口味等信息对不同的图像-文本对进行关联,进而构建完整的菜品多模态知识图谱,从而提升菜品知识覆盖率。

6. 结论

在本研究中,我们将重点放在中餐食材理解上,它扩展了细粒度对象检测和检索的范围,在中餐领域提供了更广泛的应用。为了支持新任务的研究,我们设计了第一个跨模态食材级数据集CMIngre,该数据集由来自菜肴、食谱和UGC三种不同来源的8,001对图像食材组成,涵盖了429种不同的中国食材和超过95,290个边界框。我们在CMIngre数据集上评估了不同目标检测算法的有效性,表明开发更高级的细粒度食材检测算法仍然有足够的性能提升空间。此外,在CMIngre上进行的广泛的跨模态食材检索实验验证了我们提出的基线的有效性。此外,我们希望这个基准可以激发更多新颖的细粒度食材理解算法的发展,从而促进食品相关领域的进步。

利用以上技术能力,在多模态数据集上建设菜品知识图谱。对比文本单模态 (知识准确率95%、覆盖率达到80%),通过在评测数据上进行验证,该项目提升菜品知识图谱的属性知识的质量,知识准确率96.52%、覆盖率达到87.01%。将菜品知识图谱的能力应用于相同商品识别的业务场景,通过提供商品理解的关键信息,识别的错误率从20.38%降低至2.3%,提升美团精细化运营的效率。

作者团队简介

到店研发平台

美团核心本地商业/到店研发平台是到店业务的技术服务团队,聚焦公司“零售+科技”战略,为美团到店餐饮、休闲娱乐、丽人医美、教育母婴、Life Event、酒店、民宿、门票度假等业务提供从客户线索、商户入驻、供给上单、交易履约、整合营销、会员评价、经营收益等全方位技术研究和能力建设,保障到店场景下多业务的高效发展,持续优化用户体验,提升商户数字化经营水平。

高校团队

刘安安教授团队为天津大学图像所(教育部批准设立),长期从事跨媒体计算和人工智能领域研究,目前拥有全职教授4人、副教授8人、讲师2人,在读博士和硕士百余人;先后承担和参与国家重点研发计划、863计划、国家自然科学基金、安全部专项等科研项目;获得天津市科技进步特等奖、国家安全部科技进步奖一等奖等;在IEEE/ACM汇刊、CCF-A类期刊/会议发表论文百余篇,获批发明专利百余项。

8. 招聘信息

美团核心本地商业/到店研发平台是到店业务的技术服务团队,聚焦公司“零售+科技”战略,为美团到店餐饮、休闲娱乐、丽人医美、教育母婴、Life Event、酒店、民宿、门票度假等业务提供从客户线索、商户入驻、供给上单、交易履约、整合营销、会员评价、经营收益等全方位技术研究和能力建设,保障到店场景下多业务的高效发展,持续优化用户体验,提升商户数字化经营水平。

到店研发平台下的数据智能部,长期招聘AIGC大模型、NLP等相关领域的算法工程师/专家,感兴趣的同学可以将简历发送至 [email protected]

9. 致谢

本课题是在到店研发平台和天津大学共同参与下完成。在课题推进过程中,感谢天津大学刘安安教授、王岚君研究员的悉心指导,以及天津大学张晨宇、张国楷、李秋静、杨博、胡明望等同学的积极参与,助力课题的顺利完成,并在美团餐饮美食场景带来实际的业务价值。本课题也获得了2023年度美团科研合作「卓越实践奖」。

10. 参考文献

  • [1] H. Zhang, F. Li, S. Liu, L. Zhang, H. Su, J. Zhu, L. M. Ni, and H.-Y. Shum, “Dino: Detr with improved denoising anchor boxes for end-to- end object detection,” arXiv preprint arXiv:2203.03605, 2022, doi:10. 48550/arXiv.2203.03605.
  • [2] Z. Liu, Y. Lin, Y. Cao, H. Hu, Y. Wei, Z. Zhang, S. Lin, and B. Guo, “Swin transformer: Hierarchical vision transformer using shifted windows,” in Proceedings of the IEEE/CVF international conference on computer vision, 2021, pp. 10 012–10 022, doi:10.1109/ICCV48922. 2021.00986.
  • [3] X. Dai, Y. Chen, B. Xiao, D. Chen, M. Liu, L. Yuan, and L. Zhang, “Dynamic head: Unifying object detection heads with attentions,” in Proceedings of the IEEE/CVF conference on computer vision and pattern recognition, 2021, pp. 7373–7382, doi:10.1109/CVPR46437. 2021.00729.
  • [4] A.-A. Liu, H. Tian, N. Xu, W. Nie, Y. Zhang, and M. Kankanhalli, “Toward region-aware attention learning for scene graph generation,” IEEE Transactions on Neural Networks and Learning Systems, vol. 33, no. 12, pp. 7655–7666, 2021, doi:10.1109/TNNLS.2021.3086066.
  • [5] J. Yang, J. Lu, S. Lee, D. Batra, and D. Parikh, “Graph r-cnn for scene graph generation,” in Proceedings of the European confer- ence on computer vision (ECCV), 2018, pp. 670–685, doi:10.1007/ 978- 3- 030- 01246- 5 41.
  • [6] C. Liu, Z. Mao, T. Zhang, H. Xie, B. Wang, and Y. Zhang, “Graph structured network for image-text matching,” in Proceedings of the IEEE/CVF conference on computer vision and pattern recognition, 2020, pp. 10 921–10 930, doi:10.1109/CVPR42600.2020.01093.
  • [7] H.Diao,Y.Zhang,L.Ma,andH.Lu,“Similarityreasoningandfiltration for image-text matching,” in Proceedings of the AAAI conference on artificial intelligence, vol. 35, no. 2, 2021, pp. 1218–1226, doi:10.1609/ aaai.v35i2.16209.
  • [8] Y. Wang, Y. Su, W. Li, J. Xiao, X. Li, and A.-A. Liu, “Dual-path rare content enhancement network for image and text matching,” IEEE Transactions on Circuits and Systems for Video Technology, 2023, doi:10.1109/TCSVT.2023.3254530.
  • [9] L. Bossard, M. Guillaumin, and L. Van Gool, “Food-101–mining discriminative components with random forests,” in Computer Vision– ECCV 2014: 13th European Conference, Zurich, Switzerland, September 6-12, 2014, Proceedings, Part VI 13. Springer, 2014, pp. 446–461, doi:10.1007978- 3- 319- 10599- 429.
  • [10] J.ChenandC.-W.Ngo,“Deep-based ingredient recognition for cooking recipe retrieval,” in Proceedings of the 24th ACM international confer-ence on Multimedia, 2016, pp. 32–41, doi:10.11452964284.2964315.
  • [11] W. Min, L. Liu, Z. Wang, Z. Luo, X. Wei, X. Wei, and S. Jiang, “Isia food-500: A dataset for large-scale food recognition via stacked global-local attention network,” in Proceedings of the 28th ACM International Conference on Multimedia, 2020, pp. 393–401, doi:10.11453394171. 3414031.
  • [12] W. Min, Z. Wang, Y. Liu, M. Luo, L. Kang, X. Wei, X. Wei, and S. Jiang, “Large scale visual food recognition,” IEEE Transactions on Pattern Analysis and Machine Intelligence, 2023, doi:10.1109/TPAMI.2023.3237871.
  • [13] E. Aguilar, B. Remeseiro, M. Bolan ̃os, and P. Radeva, “Grab, pay, and eat: Semantic food detection for smart restaurants,” IEEE Transactions on Multimedia, vol. 20, no. 12, pp. 3266–3275, 2018, doi:10.1109/TMM.2018.2831627.
  • [14] R. Morales, J. Quispe, and E. Aguilar, “Exploring multi-food detection using deep learning-based algorithms,” in 2023 IEEE 13th International Conference on Pattern Recognition Systems (ICPRS), 2023, pp. 1–7, doi:10.1109/ICPRS58416.2023.10179037.
  • [15] G. Ciocca, P. Napoletano, and R. Schettini, “Food recognition: a new dataset, experiments, and results,” IEEE journal of biomedical and health informatics, vol. 21, no. 3, pp. 588–598, 2016, doi:10.1109/JBHI. 2016.2636441.
  • [16] A. Salvador, N. Hynes, Y. Aytar, J. Marin, F. Ofli, I. Weber, and A. Tor-ralba, “Learning cross-modal embeddings for cooking recipes and food images,” in Proceedings of the IEEE conference on computer vision and pattern recognition, 2017, pp. 3020–3028, doi:10.1109/CVPR.2017.327.
  • [17] A. Salvador, E. Gundogdu, L. Bazzani, and M. Donoser, “Revamping cross-modal recipe retrieval with hierarchical transformers and self-supervised learning,” in Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition, 2021, pp. 15 475-15 484, do: 10.1109/CVPR46437.2021.01522.
  • [18] M. Carvalho, R. Cadène, D. Picard, L. Soulier, N. Thome, and M. Cord, “Cross-modal retrieval in the cooking context: Learning semantic text-image embeddings,” in The 41st International ACM SIGIR Conference on Research & Development in Information Retrieval, 2018, pp. 35-44, doi: 10.11453209978.3210036.
  • [19] M. Shukor, G. Couairon, A. Grechka, and M. Cord, *Transformer decoders with multimodal regularization for cross-modal food retrieval,” in Proceedings of the IEEE/CV Conference on Computer Vision and Pattern Recognition, 2022, pp. 4567-4578, doi: 10.1109/CVPRW56347.2022.00503.
  • [20] H. Wang, D. Sahoo, C. Liu, K. Shu, P. Achananuparp, E.-p. Lim, and S. C. Hoi, “Cross-modal food retrieval: learning a joint embedding of food images and recipes with semantic consistency and attention mechanism,” IEEE Transactions on Multimedia, vol. 24, pp. 2515-2525, 2021, doi: 10.1 109/TMM.2021.3083109.
  • [21] M. Li, P.-Y. Huang, X. Chang, J. Hu, Y. Yang, and A. Hauptmann, “Video pivoting unsupervised multi-modal machine translation,” IEEE Transactions on Pattern Analysis and Machine Intelligence, vol. 45, no. 3, pp. 3918-3932, 2023, doi: 10.1109/TPAMI.2022.3181116.
  • [22] Chinese cuisine culture, Last accessed on June 23, 2023.
  • [23] “Regulation of food composition data expression,” https://www.chinanutri.cn/fgbz/fgbzhybz/201707/P020170721479798369359.pdf,Last accessed on June 23, 2023.
  • [24] T. Joutou and K. Yanai, *A food image recognition system with multiple kernel learning,” in 2009 16th IEEE International Conference on Image Processing (ICIP). IEEE, 2009, pp. 285-288, doi: 10.1109/ICIP.2009.5413400.
  • [25] Y. Kawano and K. Yanai, “Food image recognition with deep con-volutional features,” in Proceedings of the 2014 ACM International Joint Conference on Pervasive and Ubiquitous Computing: Adjunct Publication, 2014, pp. 589-593, doi: 10.11452638728.2641339.
  • [26] K. Yanai and Y. Kawano, “Food image recognition using deep con-volutional network with pre-training and fine-tuning,” in 2015 IEEE International Conference on Multimedia & Expo Workshops (ICMEW). IEBE, 2015, p. 1-6, doi: 10.1109/ICMEW.2015.7169816.
  • [27] M. T. Turan and E. Erzin, “Domain adaptation for food intake classification with teacher/student learning,” IEEE Transactions on Multimedia, vol. 23, pp. 4220 4231, 2020, doi: 10.1109/TMM.2020.3038315.
  • [28] H. Liang, G. Wen, Y. Hu, M. Luo, P. Yang, and Y. Xu, “Mvanet: Multitask guided multi-view attention network for chinese food recognition,” IEEE Transactions on Multimedia, vol. 23, pp. 3551-3561, 2020, doi: 10.1109/TMM.2020.3028478.
  • [29] J. He, L. Lin, H. A. Eicher-Miller, and F. Zhu, “Long-tailed food clas-sification,” Nutrients, vol. 15, no. 12, 2023, doi: 10.3390/nu15122751.
  • [30] K. Aizawa, Y. Maruyama, H. Li, and C. Morikawa, “Food balance estimation by using personal dietary tendencies in a multimedia food log,” IEEE Transactions on multimedia, vol. 15, no. 8, pp. 2176-2185, 2013, doi: 10.1109/TMM.2013.2271474.
  • [31] J.-J. Chen, C.-W. Ngo, F.-L. Feng, and T.-S. Chua, “Deep understanding of cooking procedure for cross-modal recipe retrieval,” in Proceedings of the 26th ACM international conference on Multimedia, 2018, pp.1020-1028, do: 10.11453240508.3240627.
  • [32] Y.-C. Lien, H. Zamani, and W. B. Croft,“Recipe retrieval with visual query of ingredients,” in Proceedings of the 43rd International ACM SI-GIR Conference on Research and Development in Information Retrieval, 2020, pp. 1565-1568, do: 10.11453397271.3401244.
  • [33] W. Min, B.-K. Bao, S. Mei, Y. Zhu, Y. Rui, and S. Jiang. “You are what you eat: Exploring rich recipe information for cross-region food analysis,” IEEE Transactions on Multimedia, vol. 20, no. 4, pp. 950-964, 2017, doi: 10.1109/TMM.2017.2759499.
  • [34] G. Ciocca, P. Napoletano, and R. Schettini, *Learning cnn-based features for retrieval of food images,” in New Trends in Image Analysis and Processing-ICIAP 2017: ICIAP International Workshops, WBICV, SSPandBE, 3AS, RGBD, NIVAR, IWBAAS, and MADiMa 2017, Catania, Italy, September 11-15, 2017, Revised Selected Papers 19. Springer, 2017, pp. 426 434, doi: 10.1007978-3-319-70742-6_41.
  • [35] X. Chen, Y. Zhu, H. Zhou, L. Diao, and D. Wang, “Chinesefoodnet: A large-scale image dataset for chinese food recognition,” arXiv preprint arXiv: 1705.02743, 2017, doi: 10.48550/arXiv. 1705.02743.
  • [36] S. Hou, Y. Feng, and Z. Wang, “Vegfru: A domain-specific dataset for fine-grained visual categorization,” in Proceedings of the IEEE International Conference on Computer Vision, 2017, pp. 541-549, doi:10.1109/ICCV.2017.66.
  • [37] J. Qiu, F. P.-W. Lo, Y. Sun, S. Wang, and B. Lo, “Mining discriminative food regions for accurate food recognition,” arXiv preprint arXiv:2207.03692, 2022, doi: 10.48550/arXiv.2207.03692.
  • [38] J. Wang, X. Ding, and B. Guo, “High precision food detection method based on deep object detection network,” in 2021 IEEE Sth Information Technology, Networking, Electronic and Automation Control Conference (ITNEC), vol. 5. IEEE, 2021, pp. 646-650, doi: 10.1109/ITNEC52019. 2021.9587189.
  • [39] $. Akti, M. Qarage, and H. K. Ekenel, “A mobile food recognition system for dietary assessment,” in International Conference on Image Analysis and Processing. Springer, 2022, pp. 71-81, doi: 10.1007978-3-031-13321-3_7.
  • [40] Y. Matsuda, H. Hoashi, and K. Yanai, “Recognition of multiple-food images by detecting candidate regions,” in 2012 IEEE International Conference on Multimedia and Expo.IEEE, 2012, pp. 25-30, doi: 10.1109/ICME.2012.157.
  • [41] Y. Kawano and K. Yanai, “Foodcam-256: a large-scale real-time mobile food recognifionsystem employing high-dimensional features and compression of classifier weights,” in Proceedings of the 22nd ACM international conference on Multimedia, 2014, pp. 761-762, doi:10.11452647868.2654869.
  • [42] B. Muñoz, I. Chirino, and E. Aguilar, “Can deep learning models recognize chilean diet,” IEEE Latin America Transactions, vol. 20, no. 9, pp. 2131-2138, 2022, doi:10.1109 TLA.2022.9878168.
  • [43] Y. Kawano and K. Yanai, “Automatic expansion of a food image dataset leveraging existing categories with domain adaptation,” in Computer Vision - ECCV 2014 Workshops, 2015, pp. 3-17, doi:10.1007/ 978-3-319-16199-0_1.
  • [44] J. Chen, L. Pang, and C.-W. Ngo, “Cross-modal recipe retrieval: How to cook this dish?” in MultiMedia Modeling: 23rd International Conference, MMM 2017, Reykiavil, Iceland, January 4-6, 2017, Pro-ceedings, Part I 23.978-3-319-51811-4_48. Springer, 2017, pp. 588-600, doi: 10.1007/
  • [45] X. Li, J. Feng, Y. Meng, Q. Han, F. Wu, and J. Li, “A unified MRC framework for named entity recognition,” in Proceedings of the 58th Annual Meeting of the Association for Computational Linguistics, ACL 2020, Online, July 5-10, 2020, 2020, pp. 5849-5859, doi: 10.18653/V1/ 2020.ACL-MAIN.519.
  • [46] Y. Kawano and K. Yanai, “Automatic expansion of a food image dataset leveraging existing categories with domain adaptation,” in Computer Vision-ECCV 2014 Workshops: Zurich, Switzerland, September 6-7 and 12, 2014, Proceedings, Part III 13. Springer, 2015, pp. 3-17, doi: 10.1007978-3-319-16199-0_1.
  • [47] 1 S. Ren, K. He, R. Girshick, and J. Sun, “Faster I-cnn: Towards real-time object detection with region proposal networks, “Advances in neural information processing systems, vol. 28, 2015, do: 10.1109/TPAMI. 2016.2577031.
  • [48] G. Jocher, “Yolov5 by ultralytics,* 2020, doi: 10.5281/zenodo.3908559.[Online]. Available: https://github.com/ultralytics/yolov5
  • [49] A. Radford, J. W. Kim, C. Hallacy, A. Ramesh, G. Goh, S. Agarwal, G. Sastry, A. Askell, P. Mishkin, J. Clark et al., *Learning transferable visual models from natural language supervision,” in International conference on machine learning. PMLR, 2021, pp. 8748-8763, doi: 10.48550/arXiv.2103.00020.
  • [50] L. H. Li, P. Zhang, H. Zhang, J. Yang, C. Li, Y. Zhong, L. Wang,L. Yuan, L. Zhang, J.-N. Hwang et al., “Grounded language-image pre-training,” in Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition, 2022, pp. 10 965-10 975, doi: 10.48550/ arXiv.2112.03857.
  • [51] H. Zhang, P. Zhang, x. Hu, Y-C. Chen, L. Li, x. Dai, L. Wang, L. Yuan, J.-N. Hwang, and J. Gao, “Glipv2: Unifying localization and vision-language understanding,” Advances in Neural Information Processing Systems, vol. 35, pp. 36 067-36080, 2022, doi: 10.48550/arXiv.2206.05836.
  • [52] K.-H. Lee, X. Chen, G. Hua, H. Hu, and X. He, “Stacked cross attention for image-text matching,” in Proceedings of the European conference on computer vision (ECCV), 2018, pp. 201-216, doi: 10.1007/ 978-3-030-01225-0_13.
  • [53] J. Chen, H. Hu, H. Wu, Y. Jiang, and C. Wang, “Learning the best pooling strategy for visual semantic embedding,” in IEEE Conference on Computer Vision and Pattern Recognition (CVPR), 2021, doi: 10.1109/ CVPR46437.2021.01553.
  • [54] J. Yang, J. Lu, D. Batra, and D. Parikh, “A faster pytorch implementation of faster r-cnn. “https://github.com/jwyang/faster-renn.pytorch, 2017.
  • [55] P. Anderson, X. He, C. Buehler, D. Teney, M. Johnson, S. Gould, and L. Zhang,*Bottom-up and top-down attention for image captioning and visual question answering,” in CVPR, 2018, doi: 10.1109/CVPR.2018.00636.
  • [56] M. Shukor, N. Thome, and M. Cord, “Vision and structured-language pretraining for cross-modal food retrieval,” Available at SSRN 4511116, 2023, doi: 10.48550/arXiv.2212.04267
  • [57] T.-Y. Lin, M. Maire, S. Belongie, J. Hays, P. Perona, D. Ramanan et al., “Microsoft coco: Common objects in context,” in European conference on computer vision. Springer, 2014, pp.1740-755, doi: 10.1007978-3-319-10602-1_48.
  • [58] A. Dosovitskiy, L. Beyer, A. Kolesnikov, D. Weissenborn, X. Zhai, T. Unterthiner, M. Dehghani, M. Minderer, G. Heigold, S. Gelly et al., “An image is worth 16x16 words: Transformers for image recognition at scale,” arXiv preprint arXiv:2010.11929, 2020, doi:10.48550/arXiv.2010.11929.
  • [59] Z. Zheng, L. Zheng, M. Garrett, Y. Yang, M. Xu, and Y.-D. Shen, “Dual-path convolutional image-text embeddings with instance loss,” ACM Trans. Multimedia Comput. Commun. Appl., vol. 16, no. 2, 2020, doi: 10.11453383184.

DDD在大众点评交易系统演进中的应用

本文主要涉及境外出行、商场团购和内容商业化等三类交易业务场景。在大众点评App里,在境外城市站有美食、购物、商场、景点、门票、当地玩乐等频道入口,可以购买境外出行交易产品,在境内的逛街/商场频道可以找到商场团购优惠以及商场团购代金券。

此外,商家如果有推广需求可以在商家端App(开店宝App)“点星”入口购买达人的创作服务,最终达人交付的笔记,在点评App信息流里进行展示。具体来说,境外出行产品覆盖景点门票、餐厅订座和休闲娱乐;商场团购产品包含普通团单和秒杀团单,适用于商场的优惠活动;内容商业化产品则允许商家购买达人的图文或视频笔记,以此来推广自己的服务或产品。

2 领域驱动设计概述

2.1 什么是领域驱动设计

领域驱动设计是一种软件设计方法,它主要用于处理复杂业务需求。我们可以将其分解为“领域”、“驱动”和“设计”三个部分来理解。“领域”指的是特定的业务范围或问题域,如电商、医疗、保险等。确定领域后,我们就能明确核心的业务问题。例如,在电商中,核心问题可能涉及商品、库存、仓储和物流;在保险领域,则可能关注投保、承保和理赔等方面。

“设计”在DDD中通常指的是领域模型的设计,DDD强调领域模型是系统的核心,它反映了业务概念和业务规则。“驱动”有两层含义:一是业务问题域驱动领域建模的过程;二是领域模型驱动技术实现或代码开发的过程。确保领域模型的准确性是关键,因为它可以保证代码实现能够真实反映并解决业务的核心问题。

领域驱动设计是一种处理高度复杂领域的设计思想,它通过分离技术实现的复杂性,围绕业务概念构建领域模型来控制业务的复杂性,以解决软件难以理解、难以演化等问题。领域驱动设计是一种设计思想,首先体现了分离的思想,它分离了业务复杂性和技术复杂性,其次体现了分治的思想,它通过领域模型、限界上下文或子域进行分治。

2.2 领域驱动设计核心概念

领域驱动设计涉及到的核心概念非常多,我们重点强调一下“统一语言”和“限界上下文”。“统一语言”贯穿领域驱动设计从战略设计到战术设计到最后的代码实现全过程,对于需求分析、知识提炼和最后代码的实现,都是非常重要的。

“限界上下文”是连接问题空间和解决方案空间的桥梁,一方面我们在问题空间分析问题时,它是语言的边界和模型的边界;另一方面,在解决方案空间我们通过限界上下文来确定应用的边界和技术的边界,从而帮助我们确定整个系统及各个限界上下文的解决方案。

2.3 领域驱动设计的过程

首先,领域驱动设计需要业务、产品、研发以及QA共同来参与,应基于对问题域以及业务愿景的理解,并进行充分讨论而达成统一认知,在这过程中提炼领域知识,并建立统一语言。同时在领域知识基础上进一步提炼,分解问题域为核心子域、支撑子域和通用子域,再通过模型驱动设计思想,设计领域模型,通过领域模型连接业务和系统,并且在模型驱动设计过程中,会有新的认知迭代。通过这些认知迭代进一步丰富统一语言,因此领域知识是一个不断迭代、螺旋式推进的过程。

3 大众点评交易系统演进

点评交易系统的发展历程从业务视角和技术视角看,分别有三个阶段。从业务视角看:

  • 第一阶段是单业务线单业务形态阶段,这个阶段我们只支持了境外出行交易业务场景,包含了预订的业务形态;
  • 第二个阶段是单业务线多业务形态阶段,业务形态变得更加丰富;
  • 第三个阶段即多业务线多业务形态阶段。

而从技术视角看,主要是经历了包括简单架构、微服务架构和平台化架构等三个阶段的演进。

3.1 简单架构阶段

这个阶段是我们业务和系统起步的阶段,当时我们只支持了预订形态的一两个品类的交易,整体上相对比较简单,同时我们当时团队的规模也很小,为了快速支持业务上从0到1这个过程中不断的探索和试错,我们在技术系统建设的主要思路是按照业务环节对业务功能模块做了一些简单的划分,从而做到能够快速的迭代和交付。

在这个阶段,我们的系统架构也相对简单,根据业务进行了基础的拆分。具体来说,接入层分为商家B端、商品C端和订单C端,而服务层则划分为商家、商品和订单三个部分。整体上采用了传统的MVC分层架构。这种架构在项目初期确实展现出了其优势,即“简单”和“快”。

然而,随着业务需求的不断增加和变得复杂,系统开始暴露出一些问题,主要可以归纳为两个方面:

首先,我们采用的是数据驱动设计,通常是先建立数据库表,这导致模型无法直观地反映业务实际情况。其次,由于采用了传统分层架构,我们将数据库表映射为持久化对象(PO),然后在服务层通过CRUD操作进行过程式编程。这在多个场景下出现了功能相似但又有所不同的需求时,经常导致重复编写相似的代码,最终造成了逻辑上的分散,系统整体的内聚性不足。

以订单退款逻辑为例,我们面临着包括订单确认前/后退款、履约前/后退款等多种场景,以及需要考虑由用户(买家)、商家(卖家)、客服和系统等不同角色发起的退款。虽然这些不同场景和角色发起的退款业务逻辑在很大程度上是相似的,但它们之间也存在一些差异。

在传统的MVC架构模式下,由于缺乏对业务领域的深入理解和沉淀,服务间的调用往往缺乏清晰的结构,导致逻辑交织在一起。此外,研发团队在系统迭代过程中可能没有足够重视高内聚和低耦合的设计原则。因此,系统内部往往会出现多处重复且相似的订单退款代码逻辑,这不仅降低了系统的可读性,也给系统的可维护性带来了挑战。  

3.2 微服务化阶段

随着业务品类的增加和业务模式的多样化,我们的业务和系统复杂度迅速上升,团队规模也相应扩大。这种复杂性主要由三个因素造成:

首先,业务规模的扩张带来了系统规模和代码量的增加;其次,业务需求的累积导致了系统内部的重复代码、复杂的依赖关系,以及为了满足高可用性和高性能需求而引入的各种技术组件和并行、异步解决方案;最后,业务需求的频繁变动也增加了系统的复杂性。

为了应对这些挑战,我们的主要思路是:通过分治的方法来管理软件规模,利用系统分层和关注点分离的原则来优化系统结构,以及通过隔离变化来应对频繁的需求迭代。这些策略都是领域驱动设计(DDD)的核心理念,基于此,我们实施了微服务架构的拆分,以更好地管理和控制系统复杂性。

在领域驱动设计的落地方法上,我们参照行业实践内容并且结合自身的理解,我们将DDD的实施过程划分为以下四个阶段:

  1. 理解问题域:这个阶段的核心是深入分析业务价值、需求以及构建业务概念模型。产出统一语言和子域划分,确保团队在业务理解上达成共识。
  2. 识别限界上下文:在这一阶段,我们通过组织、业务和应用的边界来确定限界上下文,并且明确不同上下文间的关系和交互。
  3. 领域建模:包括领域分析、设计建模,以及模型的持续迭代。这个阶段的目标是构建能够反映业务核心概念和规则的模型。
  4. 模型实现:实现阶段主要依赖于应用分层架构、微服务架构和应用集成,确保领域模型能够在系统中得到有效实施。

理解问题域

业务价值分析有助于评估系统的复杂性,并且可以指导我们识别最为关键的业务领域。业务需求分析是一个关键的知识提炼过程,其中涉及多种方法和工具,例如事件风暴、四色建模以及用例分析等,我们采用的是相对轻量的用例分析法。

在进行用例分析之前,我们首先需要对业务流程进行细致的分析。这一步骤通过拆解业务流程和环节,帮助我们发现和识别具体的业务用例。这里简化了交易业务流程的分析,因为大多数人对电商类业务流程较为熟悉。对于不熟悉的业务领域,我们将需要进行更深入的业务流程和场景分析。

我们将交易业务流程分为四个主要部分:客户合作流程、商家上单流程、在线交易流程和资金结算流程。有了这些流程的分解,我们就可以进入到具体的用例分析阶段。

在用例分析阶段,我们以商家上单流程和在线交易流程为例来说明。在商家上单流程中,涉及到的主要角色包括商家和运营。商家负责创建新商品、商品上架、商品下架以及更新商品库存等操作。而运营人员则参与商品审核,包括审核通过、审核驳回、查看审核列表等关键用例。至于在线交易流程,其参与方主要是买家、商家以及客服。买家的行为包括购买商品、支付、申请退款和查看订单等,商家则处理订单确认、发送凭证、核销凭证和订单检索等关键用例。客服则参与售后服务,涉及订单退款、订单赔付等核心用例。

在完成业务流程和用例分析之后,我们可以根据相关性对问题进行初步分类,并划分为不同的子域,建立统一语言。为了更好地进行知识提炼,为识别限界上下文和建立领域模型提供必要的信息,我们需要深入分析每个用例,并制定用例规约来提取关键概念。

在实际操作中,我们没有严格制定用例规约,而是使用产品需求文档中的描述。在技术方案设计阶段,我们也会使用类似于时序图和接口描述的方法来详细阐述用例。无论采用哪种描述方式,关键在于坚持使用统一语言,这对于从描述中提炼出核心概念至关重要。这样做不仅有助于团队成员之间的沟通,也便于后续的设计和开发工作。 

在业务流程分析、用例分析以及用例规约的制定和编写之后,我们对交易业务的领域知识已经有了充分的了解,并构建了相应的概念模型。在这个模型中,销售签约商家,商家负责商品的创建,用户选择商品进行下单,下单购买过程中可能会使用优惠,在订单完成之后需要财务介入对商家进行结算。

对这些关键的概念进行归类之后,我们识别出了商家、商品、订单、优惠和结算等几个子域。这里或许会产生一个疑问:对于我们已经熟悉的领域,是否真的需要经过这样复杂的分析和提炼过程来划分子域?实际上,对于有经验的架构师而言,确实可以迅速地完成子域的识别和划分,这也展示了领域驱动设计过程中的一种艺术性。然而,这些系统性的分析步骤确保了即使是不熟悉领域的团队成员,也能够准确地理解业务并作出恰当的架构决策。

在问题域分析阶段主要的输出包括两大部分:一是统一语言,二是子域划分。

  1. 在统一语言上,通过用例分析我们提炼了商家、买家、商品等统一语言,通过用例规约的整理对统一语言进行了丰富,包括售卖规则、售卖单元、订单项等等,我们可以使用这些统一语言进行交流并且用于后面的模型设计和代码实现。
  2. 在子域划分上,我们最终识别出了如图所示的这样几个子域,结合我们在价值分析阶段得到的为用户提供一站式服务体验,以及为商家提供一体化售卖平台的这样的核心价值,我们将商品域和订单域作为核心域进行重点建设。

此外,对于子域的划分方法,可以分别按照业务和组织两个视角来看,从业务视角上可以按照业务环节或业务方向进行划分,我们使用的其实就是按照业务环节来划分的,将商家合作到商品上单再到交易和结算的整个业务流程进行阶段划分,按照划分出来的每个环节确定子领域。

当目标系统为客户提供多个业务方向的产品时,可以根据业务方向进行子领域划分,比如银行系统可以从储蓄、理财、外汇等几个方向来进行拆分;当目标系统用于企业的管理时,可以从组织视角按照业务职能部门进行划分。

识别限界上下文

在对问题域进行了充分的分析之后,我们进入了限界上下文识别的阶段。前面提到了限界上下文的重要性,它是连接问题空间与解决方案空间的重要桥梁。一方面我们在问题空间分析问题时,它是语言的边界和模型的边界,也就是业务的边界,另外在解决方案空间我们通过限界上下文来确定应用的边界。

所以我们在限界上下文识别的时候,也主要是从业务边界和应用边界两方面来进行。首先我们基于语义相关性和功能相关性对我们在问题域分析阶段所罗列的业务活动进行归类,优先考虑功能相关性,得到初步的限界上下文划分,在我们交易系统的分析过程中,这个结果与子域划分结果基本上是一致的。

那么限界上下文具体要到什么粒度呢,这里跟我们的业务复杂度、技术复杂度以及团队规模有一定的关系,结合我们的实际情况,我们对商品和订单这两个核心域的限界上下文做进一步的识别和划分。

仔细思考后我们发现,尽管商品和订单是贯穿整个业务流程的核心概念,但在业务流程的不同阶段涉及不同的参与方和关注点,对应到系统能力上的诉求也不尽相同。

以商品为例,其涉及到商品的创建、审核发布以及用户端的展示销售等环节。在商品创建阶段,商家关注录单效率和商品制作过程的管理;在审核阶段,运营关注审核需求和审核效率;而在展示销售阶段,用户关注商品信息、价格库存以及如何做出购买决策。订单的情形也类似,在购买、履约和售后各个阶段,关注点也有所不同。因此,我们对商品和订单的限界上下文进行了细分,以确保系统设计能够更精准地满足各阶段的业务需求。

限界上下文的识别过程虽然本质上仍然是对问题域拆分和求解的过程,但同时限界上下文也是应用的边界和技术的边界,所以我们也需要考虑一些质量需求和技术因素,不过需要注意的是我们仍然要遵循先业务后技术的原则,并且在考虑技术因素时,仍然要保证领域模型的完整性和一致性。

我们从质量属性、服务集成和功能复用三个方面对限界上下文做进一步的划分,以商品计算为例,商品计算量大、任务多、规则复杂,为了避免影响正常的商品展示和售卖,所以从展销上下文进行了拆解。此外,我们的商品和订单都涉及到要与很多第三方的系统进行对接,这里面将第三方服务的集成划分为单独的直连上下文,从而隔离三方系统差异对内部商品和订单相关系统带来的变化。在功能复用上,我们考虑对多个限界上下文都涉及的功能进行提炼,作为单独的一个上下文,比如商家权限上下文。

限界上下文封装了按照纵向切分的业务能力,那多个限界上下文如何协作来完成一个完整的业务场景呢,这就涉及到限界上下文的映射,按照通信集成模式和团队协作模式来划分,有多种映射关系,这里面我们用到最多的是通过防腐层、开放主机服务和发布语言三者联动来隔离上下游的变化、维护整个领域模型的稳定性。

领域建模

在领域建模阶段,我们整体上分为领域分析建模和领域设计建模。首先,主要是对用例以及用例规约和用户故事进行详细的分析,从中通过名词法和动词法寻找领域概念来构建我们的领域分析模型。在此基础上,我们基于DDD战术设计的元模型,识别出这些概念中的实体和值对象,并且根据业务规则的不变性设计聚合。

以订单为例,这里是我们简化之后的模型,包括订单、支付单、履约单、凭证以及退款单这样几个聚合,在存在状态变化时,聚合之间通过领域事件进行协作。

模型实现

在完成限界上下文的识别以及领域模型的设计之后,接下来进入到代码实现阶段,那我们如何将具体的业务流程或业务活动映射到我们的系统进行代码实现呢。这里我们首先是从业务视角对业务流程和业务活动进行分层结构化拆解,其实我们之前的用例分析和用例规约就是这个拆解过程,在拆解之后,我们按照一定的映射关系将其映射到用户接口、应用服务、领域服务、聚合和端口的实现上。

最后,我们按照限界上下文划分微服务,服务内部按照分层架构进行实现。整体上基于关注点分离和SOLID原则,分为接入层、应用层、领域层和基础设施层。最终需要维护领域层的稳定性,对上由接入层和应用层来隔离变化,对下由基础设施层通过依赖倒置的方式来隔离数据以及外部依赖的差异性和变化。

3.3 平台化阶段

随着业务的不断发展,出现了商场团购、内容商业化等更多的交易业务场景,在技术上可以通过平台化的思路将底层系统能力进行复用来提升各业务的支持效率。同时,DDD的战略模式也在重点关注组织上如何更好的管理大型业务系统,因此我们可以结合DDD来构建平台领域模型和业务扩展模型,从而更加高效地完成平台化改造。

我们主要以业务最为复杂的境外交易业务作为基础的主领域模型,并按照DDD领域建模过程对商场团购和商业化业务进行拆解得到的领域模型与主领域模型进行映射匹配,经过同类项识别、归并和重组,得到平台领域模型和各业务的扩展模型。在我们的实际落地过程中,为了实现在多业务之间进行最大化复用的目标,我们在平台领域模型的构建上做了进一步的提炼,将平台领域模型拆解为基础领域模型,以及预订业务模型、团购业务模型等按照业务形态划分的领域模型。

此外,为了提升业务BP和平台团队的协作效率,在平台领域模型和业务领域模型划分的基础上,我们采用了基于插件化的集成开发模式。通过扩展点的定义,由各业务线在各自的插件包里基于业务扩展模型进行业务定制化实现,再集成平台领域模型和业务扩展模型,最后实现完整的业务流程和业务场景。

4 总结和思考

DDD是一种开放的思想体系,其核心在于通过领域模型的建立来引导整个设计过程。

  • 第一,本文认为战略设计的重要性可能要高于战术设计,因为它涵盖了对业务流程和核心概念的理解和组织。
  • 第二,领域建模是一个动态的、迭代的过程,而非一成不变的瀑布式流程。这个过程类似于一个建模涡流,从战略设计到战术设计,不断迭代。在战术设计过程中,如果发现某些方面不合理,就需要对战略设计做出调整。同样,子域的划分和限界上下文的识别也是动态的,需要根据新的发现不断优化。
  • 第三,DDD不强迫采用特定的架构模式,它关注的是业务与技术复杂性是否得到了有效分离。无论是整洁架构、六边形架构还是传统的DDD分层架构,只要能够实现这一目标,它们都是可行的选择,即便是采用MVC分层架构,只要能够分离业务和技术复杂性,也同样适用。

最后,我们来简要强调一下工程师的思维模型,这些在领域驱动设计(DDD)的实施过程中也至关重要。一方面,工程师需要培养用户思维、业务思维和产品思维,这有助于深入理解业务和问题域。基于这样的理解,工程师可以运用结构化思维来分解问题,并通过抽象思维来提炼模型。另一方面,结合分层、分治和工程思维,工程师可以有效地将设计转化为实际的代码实现。

5 参考资料

  • [1]《解构领域驱动设计》– 张逸
  • [2]《领域驱动设计-软件核心复杂性应对之道》– Eric Evans
  • [3]《领域驱动设计模式、原理与实践》– Scott Millett, Nick Tune
  • [4]《Business Model Generation》– Alexander Osterwalder

美团外卖基于GPU的向量检索系统实践

随着大数据和人工智能时代的到来,向量检索的应用场景越来越广泛。在信息检索领域,向量检索可以用于检索系统、推荐系统、问答系统等,通过计算文档和查询向量之间的相似度,快速地找到与用户需求相关的信息。此外,在大语言模型和生成式AI场景,向量索引做为向量数据的底层存储,也得到了广泛的应用。

如下图所示,向量检索主要分为三个步骤:(1)将文本、图像、语音等原始数据经过特征抽取,模型预估,最终表征为向量集合;(2)对输入Query采用类似的方式表征为向量;(3)在向量索引中找到与查询向量最相似的K个结果。一种简单直接的检索方式是与向量集合进行逐一比较,找到与查询向量最相似的向量。这种方法也被称为暴力检索。在大数据量或者高维度场景中,暴力检索的耗时和计算资源消耗巨大,无法在现实场景中直接使用。

为了解决上述问题,业界提出ANN(Approximate Nearest Neighbor)近邻检索方案:通过构建有效索引,减少向量计算量,牺牲一定的召回精度以换取更高的检索速率。另一方面,研究如何通过GPU的并行计算能力,加速向量相似计算,也是一个比较热门的发展方向之一。Facebook开源的向量检索库Faiss在GPU上实现了多种索引方式,与CPU版性能相比,检索速率提升5到10倍。开源的向量检索引擎Milvus基于GPU加速的方案使得检索提高10+倍。

目前,向量检索已经广泛应用在美团外卖搜推业务各场景中。相较于其他业务场景,美团外卖业务特点具有较强的Location Based Service(LBS)依赖,即商家的配送范围,决定了用户所能点餐的商家列表。以商品向量检索场景为例:向量检索结果集需要经过“可配送商家列表”过滤。

此外,在不同的业务场景使用过程中,还需要根据商家商品的品类、标签等标量属性进行过滤。当前,美团外卖向量检索基于Elasticsearch+FAISS进行搭建,实现了10亿级别+高维向量集的标量+向量混合检索的能力。为了在保证业务高召回率的同时进一步减少检索时间,我们探索基于GPU的向量检索,并实现了一套通用的检索系统。

2 美团外卖向量索引的发展历程

在美团外卖向量检索系统的建设过程中,我们相继使用了HNSW(Hierarchical Navigable Small World),IVF(Inverted File),IVF-PQ(Inverted File with Product Quantization)以及IVF-PQ+Refine等算法,基于CPU实现了向量检索能力。在过去的几年间,我们对Elasticsearch进行定制,实现了相关的向量检索算法,在复用Elasticsearch检索能力的情况下支持了标量-向量混合检索。下面是这四种技术的简介及演进历程。

HNSW是一种用于大规模高维数据近似最近邻搜索的算法,它的基本思想是使用一种层次化的图结构,每一层都是一个导航小世界图,从而实现了在高维空间中的高效搜索。导航小世界图是一种有着特殊拓扑结构的图,它的特点是任意两点之间的路径长度都很短,而且可以快速找到。

在HNSW算法中,这种导航小世界图的层次结构使得搜索过程可以从图的高层开始,快速定位到目标点的大致位置,然后逐层向下精细化搜索,最终在底层找到最近邻,在通用检索场景上有显著的优势。然而该算法在高过滤比下性能会有折损,从而导致在到家搜推这种强LBS过滤场景下会暴露其性能的劣势。业界有较多相关的benchmark可以参考,以Yahoo的向量检索系统Vespa相关博客为例,性能与召回率的趋势如下:

2.2 IVF (Inverted File)

IVF是一种基于倒排索引的方法,它将高维向量空间分为多个簇(Cluster),每个簇对应一个倒排列表,存储了属于该簇的向量索引。这种方法大大减少了搜索时需要比较的向量数量,从而提高了检索速度。它的缺点是需要存储原始的向量数据,同时为了保证检索性能需要将其全量加载到内存中,从而占用了大量的内存空间,容易造成内存资源瓶颈。

2.3 IVF-PQ(Inverted File with Product Quantization)

在候选集数量巨大的场景下,比如商品向量检索场景下,IVF带来的内存空间大的问题很快就显现出来,为了解决内存空间的问题,开始尝试使用了IVF-PQ方法。该方法在IVF的基础上,使用了乘积量化(Product Quantization,PQ)的方法来压缩向量数据。PQ将高维向量分为多个子向量,然后对每个子向量进行量化,从而大大减少了对内存空间的需求。

然而,由于量化过程会引入误差,因此IVF-PQ的检索精度会低于IVF,从而导致召回率无法满足线上要求,对召回率要求相对较低的场景可以使用IVF-PQ,对召回率有一定要求的场景需要其他解决方案。

2.4 IVF-PQ+Refine

为了提高IVF-PQ的检索精度,进一步采用了IVF-PQ+Refine的方案,在IVF-PQ的基础上,在SSD磁盘上保存了未经压缩的原始向量数据。检索时,通过IVF-PQ召回数量更大的候选向量集合,然后获取对应的原始向量数据进行精确计算,从而提高检索精度。这种方法既保留了IVF-PQ的存储优势,解决了内存资源瓶颈,又保证了召回率,因此在实际应用中得到了广泛的使用。

2.5 基于地理位置的向量检索

美团外卖业务有一个区别于普通电商的明显特征——LBS特征,用户和商家的距离在很大程度上影响着用户的最终选择。因此可以考虑在向量检索过程中增加地理位置因素,使距离用户更近的商品可以优先被检索到。通过将经纬度编码为向量,优化具体做法是将用户或商家的经纬度以加权的方式加入查询Query和候选向量中,在计算Query和候选向量的相似度时,距离因素就可以在不同程度上影响最终的检索结果,从而达到让向量索引具备LBS属性的目标。在加入地理位置信息后,向量检索的召回率有较大提升。

除了以上几种检索方式,常见的向量检索方式还有Flat(即暴力计算),可以实现100%的召回率,但是由于计算量大,其性能较差,一般仅用于小规模的数据场景。

3 目标与挑战

3.1 目标

在以上几个方案落地后,向量+标量混合检索、前置过滤、支持海量数据检索几个挑战都得到了解决,但是检索性能及召回率与理想目标仍有一定差距,需要探索其他可能的解决方案。考虑到美团外卖的业务场景,目标方案应该满足以下要求:

  • 支持向量+标量混合检索:在向量检索的基础上,支持复杂的标量过滤条件。
  • 高过滤比:标量作为过滤条件,有较高的过滤比(大于99%),过滤后候选集大(以外卖商品为例,符合LBS过滤的商品向量候选集仍然超过百万)。
  • 高召回率:召回率需要在95%+水平。
  • 高性能:在满足高召回率的前提下,检索耗时Tp99控制在20ms以内。
  • 数据量:需要支持上亿级别的候选集规模。

在调研业界向量检索方案后,我们考虑利用GPU的强大算力来实现高性能检索的目标。当前业界大部分基于GPU的向量检索方案的目标都是为了追求极致的性能,使用GPU来加速向量检索,如Faiss、Raft、Milvus等,然而它们都是面向全库检索,不直接提供向量+标量混合检索的能力,需要在已有方案的基础上进行改造。

3.2 解决方案探索

实现向量+标量混合检索,一般有两种方式:前置过滤(pre-filter)和后置过滤(post-filter)。前置过滤指先对全体数据进行标量过滤,得到候选结果集,然后在候选结果集中进行向量检索,得到TopK结果。后置过滤指先进行向量检索,得到TopK*N个检索结果,再对这些结果进行标量过滤,得到最终的TopK结果。其中N为扩召回倍数,主要是为了缓解向量检索结果被标量检索条件过滤,导致最终结果数不足K个的问题。

业界已有较多的成熟的全库检索的方案,后置过滤方案可以尽量复用现有框架,开发量小、风险低,因此我们优先考虑后置过滤方案。我们基于GPU的后置过滤方案快速实现了一版向量检索引擎,并验证其召回率与检索性能。GPU中成熟的检索算法有Flat、IVFFlat和IVFPQ等,在不做扩召回的情况下,召回率偏低,因此我们在benchmark上选择了较大的扩召回倍数以提高召回率。

测试数据集选取了线上真实的商品数据,据统计,符合标量过滤条件的候选向量数量平均为250万,在单GPU上验证后置过滤检索性能与召回率如下:

测试结果表面,以上三种算法均无法同时满足我们对检索性能和召回率的需求。其中IVF与IVFPQ召回率较低,Flat算法虽然召回率较高,但是与全体候选集计算向量相似度导致其性能较差。

举个例子,候选向量数据规模为1000万,向量维度为D。

(1)Flat是纯暴力计算的算法,精度最高,但需要在全体候选集上计算相似度,单条查询向量的计算量为1000万*D次浮点运算。

(2)IVF在Flat的基础上通过IVF倒排索引,将候选集划分成多个簇(Cluster),然后选取部分离查询向量较近的簇计算相似度,这样可以按比例降低计算量,如果将候选集分成n_list=1024个簇,每次查询只选取n_probe=64个簇,则单条向量的计算量为Flat的1/16,即62.5万*D次浮点运算。

(3)IVFPQ对比IVF算法,使用了乘积量化,将D维向量切分成M组子向量,每组子向量训练出K个聚类中心,如果M=8,K=256,则单条查询的计算量为8*256*D次浮点计算+1000万*8次查表+1000万*8次加法运算。

在Flat算法的基础上,我们考虑通过向量子空间划分的方式,将全量候选集划分为多个向量子空间,每次检索时选取其中的一部分向量子空间,从而减少不必要的计算量,提高检索性能。

考虑到外卖搜索的强LBS属性,可以基于GeoHash来进行向量子空间划分。构建索引时,根据商家的地理位置(经纬度)计算GeoHash值,将全量商品数据划分为多个向量子空间。检索时,根据用户的地理位置信息计算其GeoHash值,并扩展至附近9个或25个GeoHash块,在这些GeoHash块内采用Flat算法进行向量检索,可以有效减少计算量。这种向量子空间划分方式有效地提高了检索性能,但是存在某些距离稍远的商家无法被召回的情况,最终测得的召回率只有80%左右,无法满足要求。

综上,后置过滤方案无法同时满足检索性能和召回率的需求,而GPU版本的Faiss无法实现前置过滤功能,考虑到美团外卖的业务场景,向量+标量混合检索能力是最基本的要求,因此我们决定自研GPU向量检索引擎。

4 GPU向量检索系统

4.1 前置过滤实现方案选择

基于GPU的向量检索,要想实现前置过滤,一般有三种实现方案:

  1. 所有原始数据都保存在GPU显存中,由GPU完成前置过滤,再进行向量计算。
  2. 所有原始数据都保存在CPU内存中,在CPU内存中完成前置过滤,将过滤后的原始向量数据传给GPU进行向量计算。
  3. 原始向量数据保存在GPU显存中,其他标量数据保存在CPU内存中,在CPU内存完成标量过滤后,将过滤结果的下标传给GPU,GPU根据下标从显存中获取向量数据进行计算。

由于GPU与CPU结构与功能上的差异性,使用GPU完成前置过滤,显存资源占用量更大,过滤性能较差,且无法充分利用过滤比大的业务特点,因此不考虑方案1。

方案2与方案3性能对比与各自的优点如下所示:

实验结果表明,方案2在数据拷贝阶段耗时严重,时延无法达到要求。因为在美团外卖的场景下,过滤后的数据集仍然很大,这对CPU到GPU之间的数据传输带宽(A30显卡带宽数据如下 CPU-GPU:PCIe Gen4: 64GB/s;GPU-GPU:933GB/s)提出了很高的要求,因此我们最终选择了方案3。

4.2 GPU向量检索引擎

4.2.1 数据结构

考虑到显存的价格远高于内存,因此我们在设计方案的过程中,尽可能将数据存储在内存当中,仅将需要GPU计算的数据存储在显存当中。

内存中保存了所有的标量数据,数据按列存储,通过位置索引可以快速找到某条数据的所有字段信息,数据按列存储具备较高的灵活性和可扩展性,同时也更容易进行数据压缩和计算加速。针对需要用于过滤的标量字段,在内存中构造了倒排索引,倒排链中保存了对应的原始数据位置索引信息,内存数据结构如下图所示:

显存中保存了所有的向量数据,数据位置索引与内存中的数据一一对应,可以通过位置索引快速获取某条数据的向量信息,如下图所示:

4.2.2 检索流程

Flat暴力检索

初始化阶段,在内存中构建用于标量过滤的倒排索引,同时,将向量数据从CPU内存拷贝到GPU显存,通过位置索引进行关联。

1. 标量过滤

标量过滤过程在CPU内存中进行,通过内存中的倒排索引,可以快速得到符合某个标量过滤条件的原始数据位置索引列表,通过倒排索引的求交、求并等逻辑,可以支持多个标量过滤条件的与、或关系组合,最终,得到所有符合条件的位置索引列表。

2. 相似度计算

相似度计算在GPU中进行,通过上一步标量过滤得到的位置索引列表,从GPU显存中读取符合条件的候选向量数据,然后使用常见的向量距离算法计算最相似的TopK个向量,将检索结果下表列表回传给CPU。

3. 检索结果生成

通过上一步的检索结果下表列表,在CPU内存中获取对应record记录并返回。

整体检索流程如下:

IVF近似检索

在某些场景下,我们对检索性能有更高的要求,同时对召回率的要求可以适当放宽,因此我们在GPU向量检索引擎上支持了IVF近似检索。

在初始化阶段,使用向量数据训练出P个聚类中心,并针对每个聚类中心构建局部的倒排索引,倒排索引结构与Flat方案类似,区别在于位置索引信息只保存在最近的聚类中心下。

1. 标量过滤

标量过滤过程在CPU内存中进行,先找到与query向量最近的N个聚类中心点,在这些聚类中心点下进行标量过滤,得到N个候选位置索引列表,再merge 成最终的候选位置索引列表。与Flat方案相比,IVF近似检索减少了计算量,因此能获得更好的检索性能。

2. 相似度计算

相似度计算阶段与Flat方案相同。

3. 检索结果生成

检索结果生成阶段也与Flat方案相同。

整体检索流程如下:

在单GPU上验证检索性能与召回率如下(测试数据集同后置过滤):

可见,无论是Flat还是IVF,在相同的召回率下,使用前置过滤的性能都要明显好于后置过滤。

4.2.3 性能优化

完成前置过滤的向量检索功能之后,我们对向量检索引擎做了一系列优化。

1. 单GPU性能优化

  • 高并发支持,通过Cuda Stream,GPU可以并行处理多个查询请求,高并发压测下,GPU利用率可以达到100%。
  • 通过GPU实现部分标量过滤功能,支持在GPU上实现部分标量过滤功能,向量计算与标量过滤同处一个Kernel,充分利用GPU并行计算能力(标量过滤本身是一个无状态操作,天然支持并行处理,CPU并发能力受限于CPU核数,但GPU可以支持上千个线程的并发,所以在性能上体现出明显优势)。
  • 资源管理优化,支持句柄机制,资源预先分配,重复利用。每个句柄持有一部分私有资源,包含保存向量检索中间计算结果的可读写内存、显存,以及单独的Cuda Stream执行流;共享一份全局只读公有资源。在初始化阶段,创建句柄对象池,可以通过控制句柄数量,来调整服务端并发能力,避免服务被打爆。在检索阶段,每次向量检索需从句柄对象池中申请一个空闲的句柄,然后进行后续的计算流程,并在执行完后释放响应的句柄,达到资源回收和重复利用的目的。

在单GPU上性能优化后的检索性能与召回率如下(测试数据集同后置过滤):

2. 多GPU并行检索

除了以上优化方案,还可以考虑将数据分片,通过多GPU并行检索,减少单卡计算量来提升检索性能;同时,多卡架构也能支持更大规模的向量数据检索。

相比多机多卡的分shard架构,单机多卡可以有效减少网络传输开销,并且具有较低的索引加载复杂度,因此我们最终选择了单机多卡的数据分片方案,单台服务器部署多张GPU,检索时并行从本地多张GPU中检索数据,在CPU内存中进行数据合并。

3. FP16精度支持

为了支持更大规模的向量数据检索,我们还在GPU检索引擎上支持了半精度计算,使用FP16替换原来的FP32进行计算,可以节省一半的GPU显存占用,经验证Flat召回率由100%下降到99.4%,依然满足需求。使用半精度之后,单机可以加载近10亿数据,足够支撑较长时间的业务数据增长。

4.3 向量检索系统工程实现

向量检索系统的工程化实现包括在线服务和离线数据流两部分,总体架构图如下:

GPU 检索系统上线后实际性能数据如下(数据量1亿+):

5 收益

到家搜索团队面向在线服务场景实现的GPU向量检索系统,目前已经应用于外卖商品向量检索,向量召回链路的检索性能、召回率均有显著的提升,满足策略对召回扩量和策略迭代的需求,具体提升如下:

1.向量索引召回率由85%提升至99.4%。

2.向量索引检索时延TP99降低89%,TP999降低88%。

6 展望

  • GPU向量检索系统目前只支持T+1全量构建索引,后续计划支持实时索引。
  • GPU向量检索当前支持FLAT和IVF检索算法,后续计划支持HNSW算法,在过滤比较低的场景下可提供更高的检索性能。
  • 除了GPU,后续还会在NPU等新硬件上做更多的尝试。

百亿大规模图在广告场景的应用

美团外卖在线服务正成为日常生活中必不可少的服务,其中召回作为外卖广告系统的第一个环节,主要承担着从海量商品中寻找优质候选的角色。相比于业界召回系统,外卖场景召回阶段存在LBS限制,因此外卖搜索广告[1]提出供给分层的自循环召回体系:无供给区域,实现流量运营联动提升流量召回上限;高供给区域,通过关键词、向量召回提升召回效率;弱供给区域,通过搜索推荐进行弱供给填充,提高候选效率。搜索推荐目标是解决用户搜索意图不明确、供给受限制的流量下,从满足用户需求的角度出发进行的用户->供给匹配,提高弱供给流量变现效率、用户搜索效率。

1.1 外卖广告搜索推荐业务及挑战介绍

用户进入外卖场景,整体浏览路径为推荐页、搜索页,进入搜索页之后整体浏览路径为搜索前导购渠道、搜索SUG渠道、主动搜索渠道、结果页、详情页,搜索推荐主要目标是解决搜索意图不明确、供给受限制的候选匹配问题,主要覆盖搜索前导购渠道(搜索发现)、搜索SUG渠道、结果页【POI+SPU】组合推荐、结果页相关填充等场景。

搜索推荐覆盖如上多个场景,具有场景多且场景输入交互和展现形态异构的特点,第一个挑战是如何统一建模异构多场景业务,提高弱供给匹配效率(多渠道) 。外卖用户需求变化多样,从用户行为中可以发现,用户有在不同场景之间比较,需求发生演化至逐渐收敛的特点,例如用户从推荐转搜索、搜索换Query、结果页反复对比、最终成单或者离开,第二个挑战是如何实时、准确捕捉用户需求的演变,完成用户与供给的高效匹配(即时化)。

针对搜索推荐业务多渠道、即时化特点,业界语义向量召回、个性化向量召回一般解决方案和问题是:

  • 针对输入交互和展现形态差异较大的多种异构业务,不同业务样本组织方式差异较大,由于向量召回以线性方式组织样本,导致异构业务样本难以统一,因此一般每个向量模型基于当前场景数据或者多场景数据进行单场景精细化建模,存在迭代效率低、小场景迁移能力弱的问题;
  • 通过长短期序列建模,精细化刻画在不同时段内用户需求变化关系。时间段划分的序列内,存在数据稀疏性高、兴趣圈封闭、兴趣演变刻画粒度粗的问题。

搜索推荐业务的多个场景输入交互和展现形态差异较大,难以应用传统的具有相同目标、相似特征的多场景个性化向量召回建模方法,图结构作为多维非规则立体结构,由多种异构类型节点和节点间关系组成,适合通过异构图统一搜索推荐多异构场景。

图技术具有异构节点关系关联能力、高阶关系聚合能力、稀疏节点高阶表征的特点,通过关系聚合、关联能力缓解小场景难以学好、稀疏节点难以表征好的问题,因此我们提出多场景异构大图统一建模解决搜索推荐渠道多带来的迭代效率低、异构场景难以统一、小场景难以学好的问题。用户需求具有不同场景间相互比较,需求演变至逐渐收敛的特点,这种即时性的变化特点,我们以多场景异构大图为基座提出异构动态图在线建模刻化需求演变关系,解决兴趣演变刻画粗、数据稀疏性高的问题。

1.2 图技术和引擎介绍

最近几年工业界和学术界在图领域研究取得了不错的进展,我们在这里对图深度学习的范式演进、主流研究方向、图引擎发展进行梳理[2][3][4][5][6][7]

图神经网络范式演进主要由基于图游走的无监督范式->基于聚合的消息传递范式->下一代范式,从浅层无监督深度学习到统一全场景图深度学习发展。在主流的基于聚合的消息传递范式下,主要研究方向分为消息传递函数设计、构图设计、图预训练、联合训练、动态图等主流方向。

图神经网络范式演进决定了未来走向图多任务统一方向,我们期望在范式演进路线上找到搜索推荐业务如何统一建模多场景异构业务;消息聚合范式下动态图、联合训练方向主要解决图新增节点、新增变化关系如何刻画,我们期望在动态图方向找到建模用户需求变化关系的方案。

相比传统深度学习引擎,图学习引擎需要具备图构建、图采样和图运算的能力。随着图技术发展越来越火热,图技术由学术界逐渐推广到工业界,引擎发展由支持图技术基本功能向更高效的支持大规模图方向发展。当前已有很多针对不同场景的开源图训练引擎[8][9]。图学习业务场景的图模型规模越来越大,训练时间也越来越长,因此训练引擎[8][9]需要同时支持较大的图规模端到端训练和较快的训练速度。

在当前开源的框架中,单机的训练引擎可以发挥GPU的计算优势,但是存储有限,无法支撑业务TB级别内存和模型参数的大规模图学习训练任务。分布式的训练引擎可以通过横向扩展来支持大规模的图学习任务,但是优化多机图采样之间需要进行密集的通信造成瓶颈,使得各台机器都无法发挥GPU的计算能力,导致训练速度难以满足工业界需求。因此我们联合美团机器学习平台建设了一套图学习训练引擎,能够同时满足速度和规模两方面的需求。

2 异构大图在搜索推荐业务的演进

我们提出多场景异构大图统一建模解决搜索推荐渠道多带来的迭代效率低、异构场景难以统一、小场景难以学好的问题。用户需求具有不同场景间相互比较,需求演变至逐渐收敛的特点,这种即时性的变化特点,我们以多场景异构大图为基座提出异构动态图在线建模刻化需求演变关系,如下阐述多场景异构大图和异构动态图在线建模的迭代演进。

2.1 外卖多场景异构大图

从业务逐步扩增、基建逐渐完善、技术逐渐发展的现状,我们多场景异构大图由单场景精细化图建模->多场景统一的大图预训练+下游任务微调->联合GPT增强式检索的大图预训练+下游任务Prompt微调进行迭代,最终构建外卖领域Graph模型。

随着迭代的发展及数据规模的变化,图引擎的技术能力需要由支持小规模图快速迭代,到支持百亿边图规模、全参数端到端训练,最终实现支持千亿边规模领域大图训练能力的跨越。落地于搜索前导购渠道(搜索发现)、结果页【POI+SPU】组合推荐、结果页相关填充等多个场景,取得了较为明显的业务效果;在学术层面,相关论文已被CIKM 2023收录。

2.1.1 单场景图建模

基于EM(Expectation Maximization)框架的单意图语言增强降噪图

背景:将之前的图神经网络直接应用于该异构图宽泛检索任务会遇到噪声交互。噪声交互主要来源于用户的随机误点(例如,在一个查询中共同点击“汉堡”和“沙拉”)以及全场景行为序列之间Session(用户在搜索引擎中从开始到结束的连续行为)点击(例如,“肯德基”和“海底捞”),以及由于消息传递方案更容易受到噪声的影响。

动作:之前工作主要聚焦于结构相似性或者基于规则的语义相似性降噪,不同层面存在稀疏表示和节点覆盖问题,因此我们提出基于变分EM框架进行LM和GNN联合训练,通过联合训练融合结构和语义信息进行图结构降噪。具体而言,在单意图去噪中,我们基于LMs((Language Models)估计每次图交互的可靠性程度,并基于可靠度为GNNs(Graph Neural Networks)设计了硬去噪和软去噪策略,如下公式所述,此外用变分EM框架将语言模型和图神经网络结合起来,以避免联合训练需要不可承受的计算成本,最终通过联合训练融合结构和语义信息进行降噪。

结果:EM联合训练和软硬降燥(对比只有硬降燥图)带来离线Recall +3.7%。

基于对比学习的多意图差异化建模

背景:将之前的图神经网络直接应用于该异构图宽泛检索任务会遇到意图不可区分性的问题。用户搜索词表达了多种多样的意图,对于同一个曝光卡片,具有不同意图的用户可能会关注不同部分(菜品、商家等),但是现有的图神经网络通常忽略意图之间的差异统一建模。

动作:我们提出多意图差异化建模,通过多意图对比学习方式解决之前忽视意图之间差异性问题。具体的我们在语言模型(LMs)中引入了意图感知节点,能够为同一个节点获得不同意图表示。GNNs中通过设计聚合函数让每个意图节点更多地关注来自具有相同意图的边的邻居节点(公式如下)。最后提出了一个多意图对比学习目标(公式如下),以明确而有效地指导图模型显示建模不同意图的差异性。详细信息可以去阅读我们的论文LEAD-ID[10]

结果:多意图对比学习带来离线Recall + 1.8%,多意图表征带来离线多业务平均Recall + 3.8%。

其中$w·$是参数,$𝑆 (𝑢, 𝑣)$表示边$(𝑢, 𝑣)$的意图;请注意,图神经网络(GNNs)只聚合相同意图的邻居的表示,即$\mathrm{h}_{u(s)}^{(k-1)}$ 。

2.1.2 WM多场景大图预训练

WM大图构建

我们以外卖全场景作为数据源进行异构类型构图,实现一个大图支持多场景多业务。如下图所示,我们以用户画像、用户全行为序列、搜索点击序列Session内序列等为数据源进行大图构图;商品作为多场景共性连接节点,自定义业Meta-path作为单场景子图构建方法,构建具有实际任务意义的搜索商品子图、搜索商户子图、用户商品子图等。

其中图节点包User、Item、POI、搜索词;边包括User点击、成Item,搜索词点击、成单、加购item、POI,用户序列Item、POI的Session内点击、成单等;大图整体规模亿节点、百亿边。

多场景统一大图预训练

背景:为了实现一个大图支持多场景多业务,提高迭代效率,我们在语义联合增强图降噪网络基础上进行统一多场景大图预训练。相比于上述单场景语言增强降噪图,大图预训练主要挑战为如何进行多场景的语言模型和图模型预训练。

动作:语言模型采用BERT为Base,采用底层多场Share-bottom共享,顶层异构节点差异化建模统一搜索推荐多个场景,获得多种类型节点表征。统一大图预训练阶段无差异性高阶聚合所有邻居节点必然带来噪声干扰,因此我们通过自定义场景Meta-path显示定义场景子图,多场景子图内进行高阶聚合、多场景子图间底层共享节点表征。模型以无监督链接预测任务作为目标,通过LMs和GNNs联合训练进行统一大图预训练任务。

结果:优化多任务样本混合比例离线多任务平均Recall + 4%。

2.1.3 生成式模型增强的大图预训练、Prompt微调

背景:上述统一多场景大图预训练+Finetune范式主要有几个问题,首先预训练任务和下游任务之间固有的训练目标有差距,导致预训练无法最大化发挥能力,其次此范式下每个任务都需要大量样本有监督训练,微调成本高且新任务泛化能力弱,在Prompt范式之前,多场景训练方法集中在模型框架结构优化,设计复杂且可迁移性弱,因此借鉴GPT新范式设计图领域统一多场景模型。

动作:生成式模型实现语义理解模型具有统一多场景任务设计简单、可迁移性强等优点,因此通过生成增强检索(GAR)方式进行搜索推荐多场景语义模型设计,然后通过GAR生成式检索模型和GNN联合训练进行统一大图预训练任务。具体而言,GAR通过底层共享基于开源模型领域微调后的模型为基座、以对比学习为目标设计双塔结构、多场景多样Prompt设计样本结构,以SFT方式进行多场景任务训练实现搜索推荐多场景语义模型;如上所述,大图预训练阶段通过自定义场Meta-path显示定义场景子图,多场景子图内进行高阶聚合、多场景子图间底层共享节点表征,模型以无监督链接预测任务作为目标,最后GAR和GNN联合训练实现统一大图预训练任务。下游设计多场景Soft-prompt进行SFT,具体Soft-prompt 初始化向量进行表示,通过融合预训练节点表征Soft-prompt表征作为最终节点表征,多场景以训练少量参数、小样本进行下游任务微调。

结果:相比于多任务BERT,GAR带来所有任务离线指标上涨,多任务平均Recall +1%;zero-shot评估下游任务,soft prompt 微调(对比不进行下游任务微调),下游多任务平均Recall +10%。

2.2 异构大图在线建模

由于用户需求变化关系有即时性、场景间相互比较逐渐收敛的特点,因此我们基于多场景异构大图建设图在线引擎,通过图在线建模完成用户与供给的高效匹配,提高流量使用和用户搜索效率,业务收益取得了较为明显的效果。

用户需求变化的动态图建模

背景:考虑用户需Session之间兴趣独立、Session内部用户在不同场景间相互比较,需求演化至逐渐收敛的特点,提出基于动态图的用户Sessionlevel建模刻化用户需求的变化关系。

动作:Sessionlevel建模加剧了序列的稀疏性、加大了表征难的问题,我们利用图的高阶聚合能力,沿用之前“软硬降噪”聚合函数,通过高阶聚合操作丰富序列中所有节点的表征能力。Sessionlevel分为Session内部建模和Session间建模,Session内部场景拆分为推荐、搜索中、搜索后,通过基于场景的时序Self-attention建模需求演化关系,Session间基于当前实时搜索意图、用户信息双重注意力动态聚合,整体建模用户需求。用户搜索场景下搜索词表达用户即时意图,因此我们在上述语言增强降噪预训练图的基础上,基于搜索词和候选商品关系、商品共现关系构建搜索商品子图,为用户召回精确候选;最终搜索子图表征和动态图表征进行融合,整体结构如下图所示:

结果:用户Sessionlevel建模离线Recall + 1%。

3 大规模图引擎GraphET工程建设

3.1 大规模图引擎训练框架建设

图学习业务场景的图模型规模越来越大,业务已经迭代到了几亿节点百亿边的规模,以10亿节点、100亿条边的图模型为例,图结构本身采用COO格式保存在内存中,要占约100GB的内存(10GB*4*2 + 1GB*8)。在采样过程中随机游走会用CSR、CSC两种格式保存中间结果,以及训练过程中的内存占用,内存占用已经有了300GB。

每个节点中还有用户定义的特征,以一个256维的节点特征为例,10亿个节点总共需要256* 4*1GB = 1TB。节点通常不会只有一类特征,边上也会有各种维度的边特征,这样的图规模常见集群中的1TB内存的无法保存。为了保证业务效果,节点和边的Sparse、Dense特征需要和模型参数进行端到端全量更新,TB级别参数GPU训练更新开源图学习框架不支持。

因此我们在开源的图学习训练框架DGL(Deep Graph Library)v0.7基础上,研发了一套大规模图神经网络的训练框架GraphET,服务于公司多个业务线。该框架支持亿级别节点、百亿级别边离线图训练流程高效pipline(图构建/采样/聚合/端到端建模)Pytorch Dgl Serving 在线向量计算,方便实现学术界任意复杂图模型工程在线化。

GraphET训练系统的架构如下图所示:

系统由负责模型训练的Worker进程和负责Hashtable保存的Parameter Server进程两部分构成。为了降低内存开销,将DGL图结构存到共享内存中,在多个Worker进程间共享同一份图结构。图中的节点和边上的特征保存在Parameter Server中,每次采样后会向Parameter Server发送需要查询的节点,将查询到的Embedding放入SHM。Mini-batch训练前将Embedding加载到GPU上,训练过程中用alltoall通信来获取节点/边特征,训练结束后将Embedding写回PS完成更新。系统支持显存/内存/SSD多级存储,根据特征的访问频次来将特征放置在合适的位置,在不影响系统吞吐的情况下,提高了DGL可以支撑的图的特征规模。

worker进程

在我们设计的架构下,模型训练过程中涉及Super-batch粒度的训练样本采样、样本特征查询、Mini-batch粒度的GPU训练和特征更新,不同阶段对硬件特点的需求是不同的,具体来说对为了充分发挥不同硬件的功能,最大化利用GPU的计算优势,提升模型整体训练速度,我们通过三级流水线来加速模型训练。

  • 训练样本采样是CPU密集型任务;
  • 样本特征查询是SSD IO密集型任务;
  • GPU训练是计算密集型任务。

在流水线中,每个Super-Batch都包括采样、获取特征、训练三个阶段。样本采样阶段是独立的,采样结果放入Queue中;获取特征阶段由PS Client向PS发送异步请求拉取特征参数放入SHM;训练阶段阶段将特征放到GPU上,训练后将新的Embedding写回SHM。多级流水线之间通过消息队列和共享内存通信。

worker进程对重复查询Embedding做了两方面优化:

  • 采样后,在查询特征前会对多GPU采样出的Key进行去重。由于Worker进程一个Super-batch采样多个Mini-batch,邻居较多的节点可能会被重复采样,去重后每个key在PS端仅查询一次;
  • 每个Mini-batch训练时,所有Key按照Key%Worker_num=i的方式存储在Worker i对应的显存中,GPU进程间alltoall通信前会对key去重以减少卡间通信。

PS进程

PS主要负责PS负责存储、查找和更新Embedding参数,支持两种存储方式:Full_memory和Ssd_kv_store。在Full_memory模式中所有的参数都是存在内存中,这相当于将参数存储在SHM中。在Ssd_kv_store模式中,所有的参数都存在SSD中,内存作为SSD的Cache仅存储部分参数,这种方式可以存储更多的参数,但需要考虑Cache命中率,避免内存中存储的参数太少,导致SSD读写速度成为性能瓶颈。

PS以KV形式存储Embedding参数,使得Embedding参数在PS和Worker进程中的PS Client之间共享。为了优化内存使用效率,将所有Hashtable的KV对统一存储在一块大的共享内存中,内存中的Hashtable中存储指向共享内存中对应Value的指针(Offset)。

我们在SSD引擎方面做了多方面的优化:

  • SSD聚合读优化。SSD上的Key查询是以Group为单位进行数据读取,而查询Key的分布很随机,导致读到PageCache的Group数据被频繁换入换出,影响查询性能。因此,我们将待查询的Key集合按照Group进行提前聚合,聚合后再进行SSD查询,一方面降低I/O读取次数,另一方面也能更好利用PageCache来提升查询性能。
  • 对象池优化。在Key查询过程中,需要频繁创建小对象(Cache结点、Block结点等),虽然底层已使用TCMalloc优化,但内存分配释放的开销仍不容小视。因此,我们引入定长对象池,在连续大内存上维护小对象的分配和释放操作,减少系统调用,提升服务性能。
  • 文件GC优化。由于Compaction操作,SSD文件可能包含很多无效Group数据,但只有文件中Group全部为无效状态时才会触发文件删除,导致有效Group占比很低的文件迟迟得不到删除,占用磁盘空间,对SSD读写性能也产生影响。因此,我们引入异步GC线程,定期合并有效Group占比低的文件,删除无效文件,降低磁盘占用。

3.2 图引擎在线框架建设

随着图训练引擎支持大规模图落地,图节点和边变化关系更新、实时新增图节点、实时预测图表征能力成为制约业务效果的瓶颈。因此基于图模型离线训练流程,建设图在线引擎。图在线引擎建设包括两部分内容:图采样和图推理,如下图所示:

  • 图采样:将图模型训练过程中用到的多跳图节点,进行整合拼装后写入KV Serving,提供高效图采样(后续会迁移至图数据库,实现实时采样);
  • 图推理:将图采样节点以及其它特征输入到图模型中,进行在线前向推理,输出向量Embedding用于后续的向量检索召回。下面也将重点介绍我们在图推理方面的相关建设工作。

图推理遇到的挑战

Python在线推理:图模型基于开源DGL框架进行训练和导出。虽然DGL框架支持Pytorch和Tensorflow两种backend,但Pytorch相比Tensorflow,无论是新功能特性的迭代效率方面,还是公司训练平台的支持方面都更加突出,因此在线推理部署的图模型是基于DGL+Pytorch的模式进行训练和导出。

Pytorch本身是支持将模型序列化成TorchScript格式,进行C++部署和推理加速,但DGL框架是基于Pytorch进行二次开发,无法序列化成TorchScript格式进行C++部署,只能通过Python部署的方式进行推理,这就需要在现有C++推理框架的基础上进行底层能力升级,支持Python部署模式的backend,这对框架的WorkFlow推理流程、模型管理模式、进程部署方式等方面都是不小的挑战。

单机显存瓶颈:Python由于全局解释器锁GIL的限制,导致单进程模式无法并行处理请求,一方面导致多核CPU/GPU无法被充分利用,资源被浪费,另一方面请求被串行积压,导致耗时上涨,这对于在线推理服务是不能接受的。

因此,为了避免GIL锁的影响,需要通过部署多进程的方式进行模型推理,支持在线请求的并发处理。但多进程部署方式,需要每个进程都加载一份模型数据,这无疑会受到单机显存的约束,模型越大,单机可部署的进程数就越少,进而限制处理请求的并发度,影响在线推理性能。因此,如何降低单进程可加载的模型数据量,提高并行部署的进程数量,是我们需要思考的问题和挑战。

图推理框架建设

针对上面梳理的问题和挑战,并结合业务现状和系统现状,我们进行了在线图推理框架的建设,系统架构如下图所示:

从上图可以看出,在线图推理框架由1个主进程+ N个子进程组成,主进程负责WorkFlow工作流的调度,包括在线请求接收、解析、特征/图节点Embedding数据准备以及与子进程间的数据交互,最终返回向量Embedding结果;子进程负责以Python的方式进行模型的加载和推理,并将推理结果返回给主进程。主进程每次会从子进程池中选取空闲子进程,并通过管道进行通信。

多进程架构:解决Python GIL锁造成的单进程CPU/GPU利用率低的问题

将Python执行逻辑部署在多个进程中,通过单进程内串行执行请求,可有效避免Python GIL锁带来的限制,通过进程间并行处理请求,可充分利用CPU/GPU多核资源,提升服务性能和吞吐。主进程和子进程池之间,交互流程类似于“生产者-消费者”模式,通过引入管道、epoll等机制,保证进程间通信高效执行。

模型拆分:解决模型过大造成的单机显存对子进程数量限制的问题

图模型包括亿级节点和几十亿条边,模型大小在几十G左右,默认全部加载到GPU中。考虑到模型加载后会出现膨胀现象,实际占用的GPU显存会更大,而GPU显存资源有限,加载单个模型都会存在显存溢出风险,很难支撑多进程加载多模型的模式。

经过分析,我们发现模型结构中存储了大量图节点Embedding数据,而图模型网络Dense参数只占百兆左右,同时发现单机内存大小要远大于GPU显存,且处于空闲状态。因此,我们在离线侧将图模型进行了拆分,将图节点Embedding部分加载到主进程内存中,且只需加载一次,而将模型Dense参数加载到GPU显存中,虽然每个子进程都需加载一份,但Dense参数体量较小,单个进程占用显存可控,可大幅提升子进程部署数量。

统一通信协议:解决不同策略模型的低成本快速迭代问题

不同策略模型对特征/采样Embedding的处理方式都有所不同,如果放在框架层进行适配,时间成本和人力成本都很高,影响模型的快速迭代。因此,我们制定了主进程->子进程->Python逻辑全流程的统一通信协议,通过标准化、规范化的通信数据格式,将特征/采样Embedding数据逐层传输到子进程Python逻辑中,而子进程Python逻辑中才会真正执行模型定制化逻辑,算法同学可以按需修改,并作为模型的一部分被子进程加载,从而保证在服务框架层面稳定不变的情况下,动态支持不同策略模型的快速迭代。

4 总结和展望

图神经网络作为图结构数据建模方法,在搜推广领域展现出巨大潜力,业界头部公司均结合各自业务特点自建图引擎和图技术落地应用。

本文主要介绍大规模图框架在外卖广告场景的落地。基于对外卖搜索广告场景分析,提出搜索推荐业务解决LBS场景下弱供给问题。搜索推荐业务面临着多渠道、即时化的挑战。我们提出多场景异构大图,通过单场景精细化建模->大图预训练+下游任务微调->大图预训练+下游任务Graph Soft Prompt解决多渠道问题,异构图在线建模通过基于Sessionlevel的动态图建模用户需求变化关系。

为了满足亿节点百亿边大规模图端到端训练、在线实时推理,基于开源DGL框架研发了一套大规模图神经网络的训练、推理框架GraphET,支持离线图训练流程Pipline(图构建/采样/聚合/端到端建模), DGL Serving在线推理,方便实现学术界任意复杂图模型工程在线化。

未来我们还将在以下方向继续进行探索:

  • 借鉴GPT思想,搜推广领域通用Graph模型建设及落地;
  • 构建领域大图,引擎需要支撑千亿边、复杂类型构图能力;
  • 图在线引擎加速及支撑更大规模图在线推理框架建设。

5 参考资料

  • [1] Daniel C Fain and Jan O Pedersen. 2006. Sponsored search: A brief history.Bulletin-American Society For Information Science And Technology 32, 2 (2006).
  • [2] Grover A, Leskovec J. node2vec: Scalable feature learning for networks[C]//Proceedings of the 22nd ACM SIGKDD international conference on Knowledge discovery and data mining. 2016: 855-864.
  • [3] Procopio L, Tripodi R, Navigli R. SGL: Speaking the graph languages of semantic parsing via multilingual translation[C]//Proceedings of the 2021 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies. 2021: 325-337.
  • [4] Lu Y, Jiang X, Fang Y, et al. Learning to pre-train graph neural networks[C]//Proceedings of the AAAI conference on artificial intelligence. 2021, 35(5): 4276-4284.
  • [5] Velickovic P, Cucurull G, Casanova A, et al. Graph attention networks[J]. stat, 2017, 1050(20): 10-48550.
  • [6] Han H, Zhang M, Hou M, et al. STGCN: a spatial-temporal aware graph learning method for POI recommendation[C]//2020 IEEE International Conference on Data Mining (ICDM). IEEE, 2020: 1052-1057.
  • [7] Sun X, Cheng H, Li J, et al. All in One: Multi-Task Prompting for Graph Neural Networks[J]. 2023.
  • [8] Wang M Y. Deep graph library: Towards efficient and scalable deep learning on graphs[C]//ICLR workshop on representation learning on graphs and manifolds. 2019.
  • [9] Lin Z, Li C, Miao Y, et al. Pagraph: Scaling gnn training on large graphs via computation-aware caching[C]//Proceedings of the 11th ACM Symposium on Cloud Computing. 2020: 401-415.
  • [10] Zhou X, Wang R, Li H, et al. LEAD-ID: Language-Enhanced Denoising and Intent Distinguishing Graph Neural Network for Sponsored Search Broad Retrievals[C]//Proceedings of the 32nd ACM International Conference on Information and Knowledge Management. 2023: 4460-4464.
  • [11] Sun X, Cheng H, Li J, et al. All in One: Multi-Task Prompting for Graph Neural Networks[J]. 2023.

大众点评内容搜索算法优化的探索与实践

美团在本地生活服务领域深耕多年,在帮助用户完成交易的同时,积累了丰富的图文视频内容供给。依托于这些内容供给,我们可以满足用户更丰富的需求类型,从交易环节扩展到交易前的种草、交易后的体验分享环节,将大众点评建设成为本地吃喝玩乐的社区。

在大众点评的用户中,有相当高比例会通过搜索来查找本地信息,而内容搜索是辅助用户决策、促进社区氛围的重要工具。例如当用户搜索“火锅”时,除了能看到火锅相关的商户和团单,还可以看到图文、视频、评价、笔记等多种形态和类型供给呈现;搜索“圣诞节活动”时,直接以双列内容形式呈现搜索结果,可以更加生动形象。

通过持续优化内容搜索体验,可以带来更多内容消费流量,进而吸引更多的用户转化为作者,激励创作出更多的内容,而有了更多的内容之后,又可以进一步带动体验提升,最终形成一个良性循环。从实际效果来看,内容搜索的价值也得到了用户的认可,如下图是用户访谈原声,可以看到通过内容搜索结果逐步拓展了用户对搜索功能的认知。

内容搜索与典型类型的搜索如网页搜索、电商搜索、商户搜索等相比,有如下差异点:

  • 在优化目标上,网页搜索更强调搜索满意度,电商搜索更看重商品交易总额,商户搜索更关注用户到店消费意向率,而内容搜索既要考虑搜索满意度,又要考虑点击和点击内容后的停留时长、点赞/收藏/转发/评论等交互行为。
  • 在地域约束上,网页搜索和电商搜索没有特别强的地域限制,而商户搜索和内容搜索却有非常强的LBS区域限制,因为用户在美团点评的搜索场景下更希望查找附近的商户和内容。
  • 在供给类型上,网页搜索、电商搜索、商户搜索结果类型较为单一,而内容搜索有非常多的类型,比如笔记、评价、旅游攻略、菜谱等。
  • 在结构化程度上,电商搜索和商户搜索相对较高,因为有商家和销售维护相应信息;网页搜索一般结构化程度比较低,可被检索的网页大部分信息是非结构化的;内容搜索的供给中既包括图片、视频、文本等非结构化信息,也有内容关联的作者、商户、关联话题等结构化信息,整体呈现半结构化的特点。
  • 在供给规模上,电商搜索和商户搜索供给量级相对可控,因为商品、商户的生产维护成本较高;而网页搜索和内容搜索的供给生产成本低,规模会相对更大一些。
  • 在更新频率上,一个商品从上线到下架、一家店从开业到关停,需要相当长的时间周期,而内容和网页生产和更新频率都更快一些。

从以上对比来看,内容搜索在各个维度上与典型的搜索类型存在很大区别,这就需要结合自身特点,进行相应的技术选型和方案设计。

我们对面临的困难挑战进行总结,主要包括以下四个方面:

  1. 多种类型供给并存,且供给中既有结构化的信息,又有非结构化的信息。
  2. 内容供给量级大且更新频繁,导致用户行为分散,单篇内容较难获取到足够的用户行为数据;在分发过程中又有较强地域限制,形成类似蜂窝状的消费规律,进一步加剧了用户行为稀疏的问题。
  3. 在优化过程中既要拉动内容消费指标,也要兼顾搜索满意度,在推进中需要综合平衡多个维度。
  4. 在最终搜索结果中,内容与商户、团单等以混排形式呈现,需要与其他类型搜索结果协同发挥价值,共同满足用户需求。

2 内容搜索优化实践

下面我们会从面临的问题和挑战出发,分享如何通过链路各环节,持续优化内容搜索的体验。

2.1 供给理解

面对用户持续创作生产的海量内容,我们需要对其进行充分理解,包括显式标签和隐式表征两部分工作。显式标签体系主要包括:

  • 类目标签:通过构建分发前台后台两套标签,可以实现前后台类目灵活映射。当需要进行前台类目体系调整时,可以通过调整映射层快速支持,减少对后台打标任务的影响。
  • 细粒度标签:类目标签个数有限,在推荐搜索等分发场景还需要更细粒度的刻画,为此构建主题标签、概念标签等,相互之间有一定的关联和组合关系。
  • 属性标签:前两类标签更多关注内容在讲什么,而属性标签更侧重于刻画内容本身是什么,比如是否涉政涉黄、是否重复、是否命中生态治理等。

除了显式标签,分发链路很多环节还需要更加泛化的隐式表征。结合实际场景特点,我们自研了多模态预训练模型,通过引入对比损失把图文表征对齐到统一特征空间,并结合自监督对比学习训练范式、掩码学习、图文匹配等优化,提升了跨模态交互效果。

2.2 召回环节

作为最前置环节,召回决定了一次搜索查询所能拿到的候选总集合,直接影响到后续环节的效果天花板。搜索场景的召回主要包括:

  • 语义召回:搜索召回需要首要保证结果相关,为此对语义召回进行了多维度的设计,包括不同颗粒度的语义单元召回、对用户需求进行细化和泛化处理。
  • 个性化召回:结合用户地理偏好、特定区域偏好与用户历史消费内容相似度等,设计召回通路满足个性化需求。
  • 策略召回:基于用户不同场景的实际需求设计对应策略,包括最新最热内容的召回、更符合种草需求的高价值攻略召回、定向搜索作者内容或特定类型如菜谱的召回等。

其中语义和个性化召回有很大部分通过隐式实现,语义召回更侧重搜索词自身信息的刻画,而个性化召回还融入了用户偏好、上下文等很多信息。

2.3 排序环节

排序包括粗排、精排、多目标融合排序、异构混排等多个环节,随着逐层筛选,打分量级依次减小,可以使用结构更复杂、规模更大的模型。

介于召回和精排之间的粗排环节,需要兼顾准确性和全面性、权衡打分能力和时延性能,发挥承上启下的作用。为此引入用户在全域的行为样本,达到系统层面的纠偏作用;我们通过表征蒸馏、分数蒸馏和顺序蒸馏等方法,提升模型表达能力;在常见Query-Doc双塔结构基础上,引入交叉塔(如交叉点击率、时长等),提高特征交互能力。

精排环节着重介绍在输入表征层、多目标建模层和输出层的相关工作。

首先是模型输入表征层,为了准确刻画Query、用户、Doc、上下文等多种维度、各种粒度、各种来源的输入信息,我们从以下几个方面进行表征。

  • Query语义表征:搜索场景下Query是用户需求的直接表达,借鉴向量检索的工作,对Query进行了不同粒度的刻画,通过多粒度语义网络进行搜索词表征。
  • 用户序列表征:引入用户全站行为序列,捕捉用户长短期个性化偏好。搜索场景需要兼顾个性化和相关性,但用户历史行为和当前搜索词不一定存在关联,为此在主流建模方案DIN基础上,引入零向量注意力机制来权衡个性化和相关性。具体来说,引入了Query语义表征,对长尾低频Item做过滤,帮助模型决策哪些历史行为和当次搜索词相关,且在历史行为和搜索词无关时不引入额外的噪声。
  • 多模态表征:图像、摘要等创意维度信息,对于用户决策至关重要,也是内容高效分发的基石。为此引入高维的多模态预训练向量,并结合场景进行端到端降维,既引入了丰富的多模态语义信息,又能够兼顾线上时延,对于刻画用户的多模偏好、提升新内容高效分发至关重要。
  • 特征重要度建模:通过动态权重的建模范式,捕捉样本粒度的动态表征,可以有效增强模型的表达能力。通过在EPNet、MaskNet等模型结构基础上,结合场景特点设计域感知的多门控网络、并联结构,实现了特征重要度的动态建模。

接下来是多目标建模层,由于点击、时长、交互等各个目标行为量级不同,导致优化过程中很容易出现跷跷板问题,为此在模型结构、优化方式等方面进行相关探索。

  • 模型结构:我们采用MMoE和PPNet融合的方案,为了防止Gate极化现象,对门控网络结构上进行dropout、设计skip connection等;在各个任务上会引入个性化因子,通过个性化网络PPNet建模,MMoE和PPNet的输出会拼接后传到预估输出层。
  • 优化方式:底层稀疏Embedding很容易受到各个多目标梯度反传的影响,造成梯度冲突,从而引起指标跷跷板问题。为此针对重要的表征增加参数量或新增任务特定表征,并对重要表征控制梯度反传,时长或交互目标不更新底层部分Embedding或更新时设置较小学习率。

最后是模型输出层,为促进新内容、长尾内容分发,并保证模型输出的预估分的稳定性和准确性,我们从探索结构和学习目标上进行了对应优化。

  • 探索结构:搜索场景消费内容个数比推荐少,马太效应问题也更加严重,对行为积累不够充足的新内容或长尾内容,预估不够准确。为此设计全链路冷启和探索通道,并基于不确定性预估范式,在模型中引入基于对抗梯度的探索网络,基于CTR预估的不确定性和对抗梯度在输入侧做扰动和探索。
  • 学习目标:之前搜索场景采用的学习目标是Listwise的Lambdaloss,在排序能力上优于Pointwise,但预估准确性上不足,会造成后续链路无法使用预估分。业界有不少研究关于Listwise损失如何做预估校准,例如KDD 2023中阿里巴巴校准工作JRC、CIKM2023中Google校准工作等。参考相关工作并结合场景特点,在原有的LambdaLoss基础上增加用于校准的Logloss,在梯度更新上控制校准Loss不影响底层的Embedding更新,只更新多目标建模层和输出塔的参数,提高预估分数的稳定性和准确性,方便后续融合、混排等环节使用。

2.4 满意度优化

除了优化内容消费指标如点击、交互、时长等,搜索场景还很重视满意度优化。用户对搜索结果是否满意,可以从结果是否相关、是否足够新鲜、是否是对应地域、内容质量高低等显式维度进行刻画。

相关性是搜索满意度中最基本、最重要的维度。大众点评的很多内容有关联商户,可以比较方便地获取很多明确的结构化信息,比如商户类目、区域等,可用于辅助判断相关性。但也可能由于内容误关联商户带来噪音,为此需要综合从图片、文本、商户信息进行关键信息抽取,作为相关性模型的输入。

除了相关性,搜索结果的时效性也很影响用户体感。比如迪士尼疯狂动物城园区开始对外开放,属于突发性热点,通过敏锐捕获到突发热点,在搜索“迪士尼”时优先呈现对应的结果,可以带给用户惊喜。另一类查询词如“平安夜”属于周期性时效性热点,每年到这个时间段都会有这样的热点。为了更好地对时效性进行建模,从多个来源挖掘建立了热点事件库,接入商家自己提报的新鲜事,建立独立召回通道进行承接,并结合线上点击反馈进行误识别纠正。

以上满意度的评测通常较为依赖人工标注,近期开始探索自动化标注,对比分析如下:

  • 在成本和效率上,人工标注需要准确理解搜索诉求,并对结果进行精确评判,从相关性、地域性、时效性、内容质量等维度进行刻画,成本非常高,通过自动化标注可以极大降低成本。
  • 在标注准确率上,虽然还没有完全达到人工标注的水平,但自动化标注也达到了可用标准。
  • 在标注维度上,自动化标注可以比较方便地对原有标注维度进行扩充,成本变化可控,比如在Prompt中提供用户的历史行为和偏好,就可以综合判断个性化需求是否得到了满足。
  • 在标注稳定性上,人工标注质量可能会受标注人员主观判断甚至心情影响,但自动化标注不会有这样的问题。

在具体实现上,我们通过分步推理来实现自动化标注,首先分析用户当前意图,再结合当次搜索Query、搜索意图、搜索结果等信息,从几个维度对搜索结果进行分析,最终综合判定当前搜索结果对需求的满足程度。

2.5 多目标融合

在得到内容点击、交互、时长、满意度等多维度的预估分数后,多目标融合层负责融合各个维度分数并排序。

  • 精准预估:多目标融合的前提是保证各个因子的打分稳定性和精准性,这也是前文提到做排序和校准联合建模的原因。
  • 融合搜参:通过AutoML方式进行自动搜参,寻找帕累托最优解,针对细分流量进行单独搜参,更加精准地刻画不同场景下对于各个目标之间的不同需求。
  • 分发调控:将生态或调控导向的因子引入融合公式,进行分发调控,比如对于新内容的扶持、更老内容的分发治理、近距离和特殊供给扶持等。

2.6 异构混排

前面各环节动作集中在内容搜索自身链路上,而最终内容是作为搜索结果的一部分和商户、团单等不同类型结果混排,追求整体搜索收益的最大化,为此需要进行多元异构混排。业界常见的混排建模方式包括端到端建模、价值融合公式、序列生成和评估等。

此外,本地生活领域流量分布有独有特点,在用户快决策和慢决策的场景下,对内容的需求存在差异,午餐和晚餐流量高峰期对内容的点击偏低,下午茶和夜宵等时段内容消费意愿更强。结合内容和商户峰谷差异,依托工程能力如流量价值预估、模型算力和服务稳定性监控等,进行算力动态适配,从而保证整体搜索结果更能满足用户需求。

3 总结与展望

综上所述,大众点评内容搜索通过优化用户体验持续提升渗透率,进入快速增长阶段。在商户体系之外构建了基于内容的搜索分发能力,同时针对站内需求和供给特点进行了专项建设。

在后续工作中,希望建立体验问题的自动发现机制,帮助产运促进供给生产,并推动大模型在各个环节扎实落地、提升全链路的时效与性能,让内容得到高效准确及时的分发,进而在本地生活信息领域形成体验优势,助力建设本地吃喝玩乐社区。

4 招聘信息

大众点评内容智能团队持续招聘中,如果你对大模型应用、搜索算法、内容理解等方面工作有经验有热情,欢迎联系 [email protected],期待你的加入!

5 参考文献

  • [1] Li S, Lv F, Jin T, et al. Embedding-based product retrieval in taobao search[C]. Proceedings of the 27th ACM SIGKDD Conference on Knowledge Discovery & Data Mining. 2021: 3181-3189.
  • [2] Ai Q, Hill D N, Vishwanathan S V N, et al. A zero attention model for personalized product search[C]. Proceedings of the 28th ACM International Conference on Information and Knowledge Management. 2019: 379-388.
  • [3] Chang J, Zhang C, Hui Y, et al. Pepnet: Parameter and embedding personalized network for infusing with personalized prior information[C]. Proceedings of the 29th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2023: 3795-3804.
  • [4] Wang Z, She Q, Zhang J. MaskNet: Introducing feature-wise multiplication to CTR ranking models by instance-guided mask[J]. arXiv:2102.07619, 2021.
  • [5] Chang J, Zhang C, Hui Y, et al. Pepnet: Parameter and embedding personalized network for infusing with personalized prior information[C]. Proceedings of the 29th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2023: 3795-3804.
  • [6] Burges C J C. From RankNet to LambdaRank to LambdaMART: An Overview; 2010[R]. MSR-TR-2010-82. Available from: https://www.microsoft.com/en-us/research/publication/from-ranknet-to-lambdarank-to-lambdamart-an-overview, 2010.
  • [7] Sheng X R, Gao J, Cheng Y, et al. Joint optimization of ranking and calibration with contextualized hybrid model[C]. Proceedings of the 29th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2023: 4813-4822.
  • [8] Bai A, Jagerman R, Qin Z, et al. Regression Compatible Listwise Objectives for Calibrated Ranking with Binary Relevance[C]. Proceedings of the 32nd ACM International Conference on Information and Knowledge Management. 2023: 4502-4508.

美团大规模KV存储挑战与架构实践

KV 存储作为美团一项重要的在线存储服务,承载了在线服务每天万亿级的请求量,并且保持着 99.995% 的服务可用性。在 DataFunSummit 2023 数据基础架构峰会上,我们分享了《美团大规模 KV 存储挑战与架构实践》,本文为演讲内容的整理。文章主要分为四个部分:第一部分介绍了美团 KV 存储发展历程;第二部分分享了内存 KV Squirrel 挑战和架构实践;第三部分阐述了持久化 KV Cellar 挑战和架构实践;最后一部分介绍了未来的发展规划。希望这些内容对大家有所帮助或启发。

1 美团 KV 存储发展历程

上图就是美团第一代的分布式 KV 存储的架构,可能很多公司都经历过这个阶段。在客户端内做一致性哈希,然后在后端部署上很多 Memcached 实例,这样就实现了最基本的 KV 存储分布式设计。但这样的设计存在很明显的问题:比如在宕机摘除节点会时丢失数据;此外,在缓存空间不够需要扩容时,一致性哈希也会丢失一些数据,这样会给业务的开发带来很大的困扰。

随着 Redis 项目的成熟,美团也引入了 Redis 来解决我们上面提到的问题,进而演进出来上图这样一个架构。可以看到,客户端还是一样,使用一致性哈希算法,在服务器端变成了 Redis 组成的主从结构。当任何一个节点宕机,我们可以通过 Redis 哨兵完成 failover,实现高可用。但有,还一个问题还是没有解决,如果扩缩容的话,一致性哈希仍然会丢失数据。

这时我们发现业界有一个比较成熟的开源 KV 存储:也就是阿里巴巴的 Tair 。2014年,我们把 Tair 引入到技术内部,去满足业务 KV 存储方面的需求。Tair 开源版本的架构主要是三部分:最下边的是存储节点,存储节点会上报心跳到它的中心节点,中心节点内部设有两个配置管理节点,会监控所有的存储节点。如果有任何存储节点宕机或者扩容之类的行为,它会做集群拓扑的重新构建。客户端启动的时候,它会直接从中心节点引入一个路由表,这个路由表简单来说就是一个集群的数据分布图,客户端根据路由表直接去存储节点读写。之前我们 KV 遇到的扩容丢数据问题,它也有数据迁移机制来保证数据的完整性。

但是在使用的过程中,我们还遇到了一些其他问题,比如:它的中心节点虽然是主备高可用的,但它没有分布式仲裁之类的机制,所以在网络分割的情况下,它是有可能发生“脑裂”的,这种情况也给我们的业务造成过比较大的影响。在容灾扩容的时候,遇到过数据迁移影响业务可用性的问题。

另外,我们之前用过 Redis ,业务会发现 Redis 的数据结构特别丰富,而 Tair 还不支持这些数据结构。虽然我们用 Tair 解决了一些问题,但是 Tair 同样也无法完全满足我们的业务需求。于是,我们认识到在美团这样一个业务规模大、复杂度高的场景下,很难有开源系统能很好满足我们的需求。所以,我们决定在已应用的开源系统之上进行自研。

时值 2015 年, Redis 社区正式发布了它的集群版本 Redis Cluster。所以,我们紧跟社区步伐,并结合内部需求做了很多自研功能,进而演进出本文要介绍的全内存、高吞吐、低延迟的 KV 存储 Squirrel。另外,我们基于 Tair,加入了很多美团自研的功能,演进出本文要介绍的持久化、大容量、数据高可靠的 KV 存储 Cellar 。

Redis 社区一直都很活跃,所以,Squirrel 的迭代是自研和社区并重,自研功能设计上也会尽量与社区架构兼容。Tair 开源版本已经多年没有更新,所以,Cellar 的迭代完全靠自研。后续内容上大家也能看到,因为这方面的不同,Cellar 和 Squirrel 在解决同样问题时可能会选取不同的方案。

这两个存储其实都是 KV 存储领域的解决方案。实际应用上,如果业务的数据量小,对延迟敏感,建议用 Squirrel ;如果数据量大,对延迟不是特别敏感,我们建议用成本更低的 Cellar 。

2 大规模 KV 存储的挑战

大规模 KV 存储的业务挑战主要有两点:

一个是扩展性。随着业务规模持续变大,业务会要求使用容量更大的集群。这个容量包括两方面,一方面是数据量,还有一方面是调用量。扩展容量,最常见的方法就是把集群水平扩展到更多的节点,但是当集群节点数达到一定规模后,再想扩展新节点也会遇到很多困难,这是扩展性上的第一个挑战。

还有一个问题是有些业务场景的调用容量是无法随着集群水平扩展而扩展的。比如,很多业务会使用 mget 进行批量读取。但随着集群节点数的增加,由于“木桶效应”,整个 mget 请求的长尾延迟会越来越高,进而导致服务的请求超时率持续上升。等集群达到一定规模之后,长尾延迟造成的可用性降低就超出业务的承受能力了。所以在水平扩展之外,我们还需要解决好节点垂直扩展上的挑战,来支持这种批量操作的业务场景。

另一个是可用性。随着集群规模变大,要保证可用性维持在与小规模集群同等的水平,其实是很困难的。但业务服务却不会因为集群规模变大而能接受可用性有所降低。所以,美团的挑战是如何保证集群可用性不会随着规模的变大而有所降低。

3 内存 KV Squirrel 挑战和架构实践

上图是美团的 Squirrel 架构。中间部分跟 Redis 社区集群是一致的。它有主从的结构,Redis 实例之间通过 Gossip 协议去通信。我们在右边添加了一个集群调度平台,包含调度服务、扩缩容服务和高可用服务等,它会去管理整个集群,把管理结果作为元数据更新到 ZooKeeper。我们的客户端会订阅 ZooKeeper 上的元数据变更,实时获取到集群的拓扑状态,直接对 Redis 集群节点进行读写操作。

3.1 Squirrel水平扩展的挑战

但是基于 Redis Cluster 架构的水平扩展,会有如下问题:

一个是 Gossip 的消息通信量是节点数的平方,随着集群节点数的增加,Gossip 通信的消息量会急剧膨胀。比如,我们实测对于一个 900 节点的集群,Gossip 消息的 CPU 消耗会高达12%,远高于小集群的 Gossip 资源消耗,这样会造成极大的资源浪费。

除了资源的浪费以外,Gossip 消息过多,也会更多抢占用户请求处理线程的资源,进而会导致用户请求经常被 Gossip 消息的处理所阻塞,再导致用户请求产生更多的超时,影响服务可用性。

3.2 Gossip优化

为了解决上述的扩展性问题,我们对社区的 Gossip 方案进行了优化。首先针对 Gossip 传输的消息,我们通过 Merkle Tree 对其做了一个摘要,把集群 Gossip 通信的数据量减少了90%以上。服务端节点仅需要对比 Hash 值即可判断元数据是否有更新,对于存在更新的情况也能快速判断出更新的部分,并仅对此部分元数据进行获取、更新,大幅降低了 Gossip 消息处理的资源消耗。同时,我们还增加了一个周期性的元数据全量同步功能,来解决可能因 Hash 冲突导致元数据无法更新的问题。

针对上述提到的 Gossip 消息处理影响业务请求的问题,我们把 Gossip 消息处理功能剥离到一个单独的心跳线程里,并且由心跳线程来更新集群拓扑的元数据。对于处理用户请求的工作线程,仅需要对元数据进行读操作,可以做到无锁读。这样的话,Gossip 请求处理就对业务请求完全没有影响了。

3.3 Squirrel 垂直扩展的挑战

对基于 Redis 研发的 Squirrel 来说,垂直扩展会存在如下问题:

首先是数据容量的问题。对一个内存存储来说,节点容量过大的话,很容易影响服务的可用性。例如,在主从节点要做数据同步时,Redis 节点需要通过 fork 产生子进程来生成全量数据的 RDB 快照。当一个 8GB 的节点做 fork 调用时,会由于页表项过多,造成进程出现 500 毫秒的阻塞。对于平均耗时只有几毫秒的 KV 请求来说,这 500 毫秒的阻塞会造成大量的超时。

还有就是处理量的扩展问题。虽然我们可以通过加从库去扩展集群的读能力上限,但主库的写处理能力却还是无力扩展的。而且,受限于主库的处理能力和机器带宽限制,加从库来扩展读能力也是有上限的。

3.4 forkless RDB

针对上述节点过大,fork 生成 RDB 会导致可用性降低的问题。我们实现了 forkless RDB 方案,这是一个不基于 fork,且不会中断服务的生成数据快照 RDB 的方案。

如上图所示,forkless RDB 的生成期间,它首先会停止哈希表的 rehash 过程,避免数据在哈希表之间的搬迁影响快照的一致性。然后,它会从头开始对整个哈希表的 key 做迭代,每迭代一个 key 就会把它 dump 一份出来放到复制队列里边。在迭代 key 的同时,它会对迭代的位置记录一个游标。

如果在迭代哈希表的过程中,里面的 KV 有变更的话,在这个游标之前的 KV 变更,也会把它放到复制队列里边,确保已经复制的 KV 能够持续获得后续的变更。如图所示,RDB 游标在 key 3,它会把之前已经迭代过的 key 1 更新、key 2 删除操作也插入到复制队列里边。在游标之后的 key,因为还没有做数据复制,所以等后续迭代到这个 key 时,把其最新值 dump 到复制队列就好。通过这样的方式,就实现了一个不需要 fork 就能获得一个一致性数据快照 RDB 的过程。

这个方案的优点很明显,生成 RDB 的过程不会阻塞服务请求处理,并且因为是实时的发送一个个 KV 数据,所以就不需要等 RDB 生成好就可以向从库复制数据了,大幅提升了数据同步的速度。但因为全量数据迭代、复制是在工作线程去做的,而不是在子进程内。所以,该方案会占用一部分工作线程的资源。另外,因为是以 KV 为粒度做复制的,所以,如果哈希表里面有大 KV 的话,可能会因为工作线程复制大 KV 耗时过长,造成用户请求等待耗时的上升。

3.5 工作多线程

对于处理量的扩展,社区有一个 IO 多线程的解决方案。但这个 IO 多线程只是把网络收发部分做了多线程处理,所以,其扩展能力是比较有限的。比如 4个 IO 线程下,它只能把整体的吞吐提升一倍,就到极限了。而且因为此时工作线程已经到瓶颈了,再往上去加 IO 线程,不仅无法提升性能,反而会消耗更多的 CPU 资源。对此,我们的解决方案是工作多线程,也就是说把请求处理的过程也多线程化。

如上图所示,在工作多线程方案下,每个线程都会去处理请求,并且每个线程会完成从收包到请求处理,然后到发包的整个过程,是一个 Run-to-Completion 线程模型。相比 IO 多线程,它会减少很多线程切换,节省很多的 CPU 资源。同时对于请求处理的过程,我们也通过细致的梳理,尽量缩小了临界区的范围,以保证大部分的请求处理过程是在临界区之外的,来提升处理并发度。

如果一个工作线程需要加锁的话,它会先 try lock。如果加锁成功就继续执行了,但如果加锁失败的话,这个工作线程也不会阻塞等锁。它会先去注册一个管道的通知消息,然后就继续处理网络的收发包,还有非临界区的请求了。等到锁被释放的时候,这个工作线程会通过 epoll 获得管道里面的锁释放通知,然后去拿到这把锁。这个时候它就可以去处理临界区的请求操作了。

这样的话,在整个加锁、解锁的过程中,工作线程没有任何阻塞,仍然可以继续做网络收发、非临界区请求的处理,获得最大限度的处理能力。另外,对于新建 socket、数据复制等工作,跟工作线程的耦合很低,我们将其放到了单独的线程去执行,以尽量降低工作线程的负载。

通过实测,工作多线程方案的吞吐比社区 IO 多线程提升了 70%,相对于社区单线程提升 3 倍多。

3.6 Squirrel可用性的挑战

基于 Redis Cluster 的大规模集群可用性挑战主要是维持机房容灾部署很困难。如上图所示,由于 Redis Cluster 是去中心化的架构,所以部署上要求至少是三机房分布,以此来保证任何一个机房挂掉的时候,剩余的两个机房仍然能有过半的节点来选出新的主节点。比如一个上千节点的集群要扩容的话,可能需要几百个分布在三个机房的节点,一时之间其实很难凑齐这么多机房的资源。而当业务大促容量需求很急时,我们有时候只能牺牲机房容灾能力来满足业务的容量需求。

还有在成本方面,对于一些数据可靠性要求较低的业务,只需要两副本冗余就够了,极端情况下丢一点数据也是可以接受的。但受限于容灾要求,这些业务也只能使用三机房三副本部署,从成本角度考量很不划算。

3.7 两机房容灾

受 Google Spanner 的见证者节点启发,我们在 Squirrel 集群也引入了见证者节点角色。同 Spanner 一样,Squirrel 见证者节点也不会存储数据,所以,它无法作为正常的主从库提供请求处理能力,也不能发起选主投票。但见证者节点可以在集群选主时参与投票,帮助存活的机房节点完成过半选主过程。

见证者节点还可以设置权重,这样只需要一个或几个高权重见证者节点,就能满足一个大规模集群的容灾部署需求了。由于见证者节点不存储数据,且节点数很少,虽然集群还是三机房部署,但实际几乎只需要两机房的资源就能满足机房容灾部署需求了,这样就大幅降低了集群维持容灾部署的难度,从而节省大量的机器成本。

3.8 跨地域容灾

Squirrel 跨地域容灾的架构如上图所示,它通过一个集群间同步服务在两个不同地域的集群之间做数据同步。这个同步服务首先伪装为上游集群节点的 slave 把它的 RDB 和增量 log 拉取过来,然后再把拉取到的数据转化成写请求发到下游的集群,从而实现了一个集群间的数据同步。通过这样的架构,我们解决了服务的跨地域容灾问题。并且,通过在集群间搭建正反两个方向的两个同步任务,就能实现集群间的双向同步。这样的话,用户服务就可以只在本地域写,但同时能读到两个地域分别写入的数据,解决了单向同步需要跨地域写的问题。

双向同步有两个经典问题需要解决:

一个是循环复制问题。我们为每个 Squirrel 集群标记了不同的 cluster id,并且记录了每个 KV 的初始写入 cluster id,同步服务会过滤掉与目标集群 cluster id 相同的数据,以避免发生循环复制。

还有一个是数据冲突问题。我们一开始是通过业务层面保证在每个地域写不同的 Key 来解决的。但是在双向同步的运行过程中,还是会有一些极端场景可能会出现两个地域并发写同一个 Key。比如像机房网络故障场景,业务会把故障机房的所有写入都切到正常机房。

但由于我们的集群间复制是异步的,可能故障机房有一些最新的 Key 变更还没有复制到正常机房的集群。而如果在业务将写切换到正常机房后,又写入了相同 Key 的不同变更,就会产生两个同步集群的数据冲突。在机房网络恢复之后,业务还是要把一部分流量切回到之前故障的集群上,恢复到跨地域容灾的架构。

但由于两个集群可能已经有数据冲突了,所以,在业务切回之前,就需要对数据做冲突校验和修复。但是对大数据量集群来说,数据校验和修复的耗时可能会长达数天。在这样长的时间内,只有一个单地域集群来支撑业务,无论是从容灾还是容量的角度来看,都是有较大风险的。

3.9 双向同步冲突自动解决

为了解决上述的双向同步数据冲突问题,我们实现了一个基于数据写入本地时间的 last write win 冲突自动解决功能。

如上图所示,在 T1 时刻 Key money 的值在 A、B 两个集群都是 100。T2 时刻,money 的值在 A 集群更新成了 120。但是在 A 集群的新值还没复制到 B 集群的时候,B 集群在 T3 时刻把 money 的值更新成了 130。这时候 A、B 集群会互相向对方复制各自写入的新值,A 集群收到 B 集群的值 130 后,会发现 B 集群 money 的更新时间大于自己(T3 > T2),它就会更新自己的 money 值为 130;B 集群也会收到 A 集群复制过来的 money 值 120,但它会发现这个值的更新时间小于自己本地值的更新时间(T2 < T3),就会忽略这个复制请求。通过这样一个基于更新时间的 last write win 策略,就可以达到最终一致性。

上述方案看起来简单,但是在复杂、大规模的业务场景下,还有很多问题要处理,所以,我们还做了以下的工作:

  • 保存最近更新的时间戳:当发生时钟回退时,我们会继续使用自己保存的时间戳,避免使用本地回退的时间导致数据也跟着发生了回退。(PS:对于时钟回退问题,我们调研过最新的 NTP 时钟同步不会像以前一样造成本地时钟的回退或跳变,现在它通过把时钟 tick 调快或调慢来完成类似的调整,所以,前述关于时钟回退的解决方案在最新的 NTP 同步机制下就不是必要的了。不过,为了保证我们的服务在任何系统下都能正常运行,我们最终还是实现了这个功能。)
  • 记录写入数据的集群 id:我们会为所有写入的 Key 保存写入的集群 id。当两个值的更新时间相同时,我们会比较集群 id,如果也相同,我们就知道是同一个集群先后写入但获取到相同本地时间的数据,会允许其写入;如果不同,我们仅会让集群 id 更大的值写入,来保证数据最终一致性。
  • 由复制操作改为复制变更后的数据:像 INCR 类接口,A 集群的 money T1 时刻通过 INCRBY money 20 变成了 120,然后 B 集群 T2 时刻通过 INCRBY money 30 变成了 130。A 集群收到 B 集群的复制时,因为时间戳比自己的本地值大,它会执行 INCRBY money 30 变成 150;然后 B 集群收到 A 集群的复制时,因为时间戳比自己的本地值小,它会把这个复制请求给忽略掉,就造成了数据冲突。针对这个问题,我们将所有操作的数据复制都改成了复制操作后的数据,而不是这个操作本身,来解决类似 INCRBY 这种接口的数据冲突问题。
  • 保存最近删除的 Key:像删除类接口,A 集群 T2 时刻写入了 money:120,然后 B 集群在 T3 时刻删除了 money 这个 Key。A 集群收到 B 集群的复制时,由于其时间戳比本地值大,A 会把数据删了;但 B 集群收到 A 集群的复制时,由于本地已经不存在 money 这个 Key 了,它就会把 money 当做一个新 Key 进行写入,就造成了数据最终不一致。针对这个问题,我们通过保存最近一段时间删除掉的 Key 及删除时间戳,以便在删除集群收到对端复制过来的旧 Key 时进行甄别。

4 持久化 KV Cellar 挑战和架构实践

上图是我们最新的 Cellar 架构图,它跟阿里开源的 Tair 主要有两个层面的不同。

第一个是 OB,第二个是 ZooKeeper。我们的 OB 跟 ZooKeeper 的 Observer 是类似的作用,提供 Cellar 中心节点元数据的查询服务。它实时的与中心节点的 Master 同步最新的路由表,客户端的路由表都是从 OB 去拿。这样做的好处主要有两点:第一,把大量的业务客户端跟集群的大脑 Master 做了隔离,防止路由表请求影响集群的管理;第二,因为 OB 只提供路由表查询服务,不参与集群的管理,所以它可以水平扩展,极大地提升了路由表的查询能力。

第二个是我们引入了 ZooKeeper 做分布式仲裁,解决了上述提到的 Master、Slave 在网络分割情况下的“脑裂”问题。并且通过把集群的元数据存储到 ZooKeeper,从而提升了元数据的可靠性。

4.1 Cellar垂直扩展的挑战

在 Cellar 架构下,不存在水平扩展的问题,但与 Squirrel 一样,它也有垂直扩展方面的挑战。而由于 Cellar 是持久存储,它也很少遇到单机数据容量的问题,而要解决的问题主要是处理容量的垂直扩展。而且,由于 Cellar 是持久化引擎、多线程模型,它要解决的处理容量扩展问题也是不一样的,具体如下:

  • 引擎读写能力的不均衡性:Cellar 是基于 LSM-Tree 引擎模型的持久化存储,这种引擎的多 Level compaction 会导致写放大问题,进而会造成其写处理能力比读低很多。所以,在一些写相对较多的场景,机器资源虽然还有空闲,但写处理能力却已经到瓶颈了。
  • 线程间同步的开销:想要提升处理容量,就需要增加线程数。而随着线程数的增加,线程间同步的开销在整个服务的 CPU 使用占比也会越来越高。所以,如果解决不好线程间同步的问题,想单纯地增加线程数来提升处理容量行不通。

4.2 Bulkload 数据导入

对于上述提到引擎写压力达到瓶颈的集群,我们调研后发现其在线的实时写入一般都是比较少的,高写入量主要是用户从离线批量写数据到线上 Cellar 集群带来的。基于此,我们开发了 Bulkload 数据导入能力来解决这个问题。

Bulkload 整体架构如上图所示,它在普通写入流涉及的客户端和存储节点之外,还引入了 S3 对象存储来做导入数据的中转。下面我们看下 Bulkload 具体的写入流程:Bulkload 首先会在客户端进程内生成分片内有序的数据文件并写到本地硬盘上。等客户端的数据文件写好之后,它会上传到对象存储,利用对象存储做数据文件的中转,解决了客户端与服务端之间直传大文件容易失败的问题。

分片 1 的数据文件写入到对象存储之后,客户端会将数据文件的存储地址告诉分片 1 的主所在的存储节点 DS1。然后 DS1 就会从对象存储下载分片 1 的数据文件,并把它直接插入到 LSM-Tree 引擎里面。因为这是一个完整的文件插入,所以,它可以消除引擎在普通写入时的内存排序和刷盘压力。同时,因为这个文件的数据是分片内有序的,所以,它在参与 Level 间 Compaction 时会与其他的引擎文件交叉很少,可以大幅减少多 Level compaction 的压力。

然后 DS1 会把分片 1 数据文件的对象存储地址复制发送到分片 1 的从所在的存储节点 DS2 。因为存储节点的复制只是传输数据文件的地址,所以复制速度是特别快的,也节省了很多传输的带宽。DS2 收到了分片 1 的地址后同样会从对象存储下载数据文件,并插入到引擎里面。

通过 Bulkload 解决方案,我们整体把数据离线导入的性能提升到旧版的 5 倍。比如我们的一个存储广告特征的客户使用 KV 方式从离线导数据到在线需要 14 小时,受限于在线高峰期无法导数据,如果需要继续增加特征数据,就需要扩容集群了。而扩容集群一方面会因为“木桶效应”导致请求长尾延迟问题,另一方面 Cellar 成本的上升也会抵消一部分广告收益。而在 Bulkload 功能加持下,该客户导入相同规模数据仅需不到 3 小时,它可以在不增加 Cellar 资源的情况下,将广告特征规模增加数倍,大幅提升了广告的效果。

4.3 线程调度模型优化

我们最初的线程模型与开源版 Tair 一样,网络线程池做收发包,收到的包经过一个队列转出到一个大的工作线程池做请求处理。这样的线程模型,很容易发生请求间的互相影响。比如用户有离线数据导入到 Cellar 的时候,就很容易导致在线读请求的超时。又比如当有大 Value 读写的时候,工作线程处理会比较慢、占用线程的时间会很长,导致正常 Value 读写的快请求只能在队列等待,进而导致大量超时。

所以,为了隔离在离线请求、快慢请求的处理,让服务资源优先保证核心流量的处理,我们后来把线程模型改造成如上图所示的 4 个队列 + 4 个线程池的结构,将请求分成 4 类(读快、读慢、写快、写慢)分别放到不同的队列和线程池去处理,进而来提升服务核心流量的可用性。

但是,工作线程池按照请求类型分离之后带来一个问题,就是不同业务场景、甚至同一业务的不同时段,不同类型请求量的占比是不一样的。所以,给每个线程池分配多少线程是一个很棘手的问题。

针对这个问题,我们增加了一个线程动态调度的逻辑:每个线程池都有一部分线程被设定为可共享线程,如果线程池比较空闲,共享线程就会去轮询其他的队列,处理一些繁忙线程池的请求,这样就达到了自适应调整各线程池资源的效果。但是在这样的架构下,虽然解决好了请求隔离性和不同请求类型线程资源的动态分配问题,但我们发现随着节点流量的上涨,共享线程对于其他队列的轮询会消耗越来越多的 CPU 资源,而且集群业务的负载分布与默认的线程数设置差异越大,这个消耗的占比也会越高。

为了解决上述线程池资源自适应调度带来的 CPU 消耗问题,我们对分离后的线程、队列模型做出了如上图的改造。改进后的线程模型最主要的特点是引入了一个调度线程和一个空闲线程池,这个调度线程会实时统计每个线程池的负载,来评估每个线程池是否需要增加或减少线程并做出调度动作,空闲线程池用来存放当前空闲的可用于调配的线程资源。

当调度线程评估后决定做线程资源调配时,它就会发送调度指令到相应队列中,当线程池里的线程获取并执行了这个指令后,就实现了线程资源的调配。比如,它想给读快线程池增加线程,就会给空闲线程池的队列发送一个调度指令,空闲线程池的线程取到这个指令后,就会将自己加入到读快队列的线程池里面,去处理读快队列的请求。

当调度线程想对读慢线程池调减线程时,它会向读慢队列发送一个调度指令,读慢队列的线程获取到这个指令后,就会离开读慢线程池加入到空闲线程池。通过调度线程准实时的毫秒级负载统计、调度,我们实现了线程池资源的快速动态分配。对于每一个线程池的共享线程,也不再需要去轮询其他线程池的队列了,只需要专心处理自己队列的请求即可,大幅降低了线程池资源调度的 CPU 消耗。

通过上述的线程队列模型优化,服务在高负载场景下可以提高 30% 以上的吞吐量。

4.4 线程RTC模型改造

上图左侧画的是我们服务请求的 IO 处理路径:一个请求的处理流程会经过网络线程、请求队列、工作线程、内存和硬盘引擎。这个设计的问题是,请求在不同线程之间流转会造成大量的 CPU 切换以及 CPU 高速缓存的 Cache Miss,进而造成大量的 CPU 资源消耗。在大流量场景下,这样的 CPU 消耗也是很可观的一笔资源。

针对这个问题,我们对线程队列模型又做了如上图右侧所示的改造。新的模型下,我们让网络线程直接去做读请求的处理,对于能够命中内存引擎的读请求,其处理模型就是一个 RTC(Run-to-Completion)模型。

具体来讲,当网络线程收到一个请求之后,会先判断是否为一个读请求,如果是,就会直接去读内存引擎。我们服务的内存引擎会缓存硬盘引擎上的热点数据,如果内存引擎命中的话,网络线程就可以直接返回结果给客户端。这样在网络线程内就实现了请求的闭环处理,相比原来的模型可以去除所有因请求流转造成的 CPU 资源消耗。而对于写和读未命中内存引擎的请求,仍然需要经过原来的请求处理路径,去硬盘引擎读或者写数据。

新的线程模型,经实测在 80% 内存引擎命中率场景下,服务读吞吐可以提升 30%+。虽然新的线程队列模型只实现了读缓存命中请求的 RTC,但其实在线流量大多都是读多写少且热点数据明显、内存引擎命中率比较高的场景,所以,新模型上线后在大多数的业务集群都取得了明显的性能提升。

4.5 内存引擎无锁化

当单机请求量达到了一定规模之后,我们发现服务内的锁操作会占用很多的 CPU 资源。经分析发现,大多数的锁操作都发生在上节内容提到的内存缓存引擎上。如上节所述,所有请求都会经过内存引擎,且大部分请求都会在内存引擎命中并返回结果给客户端。所以,大部分请求都是纯内存处理,这个过程中的锁操作就很容易成为瓶颈。针对这个问题,我们对内存引擎做了无锁化改造,其改造后的结构如下图所示:

整体改造主要跟上图的 HashMap 和 SlabManager 两个数据结构有关(其他数据结构在图中已略掉)。HashMap 是存储 KV 数据的核心结构,它把 Key 通过 Hash 算法散列到不同的 Slot 槽位上,并利用链表处理 Hash 冲突;SlabManager管理不同尺寸内存页的申请和释放,它利用链表把相同尺寸的内存页放到一起管理。

对于 HashMap,我们做了单写多读的无锁链表改造。同时,通过引入 RCU 机制实现了异步的内存回收,解决了读请求与写请求内存释放操作的冲突,实现了读请求处理全程的无锁化。写请求虽仍需要加锁,但我们对写做了锁粒度的优化,可以大幅提升并发度。比如我们把 SlabManager 的访问由一把大锁改成每个内存尺寸的管理链表单独一把锁,这样在分配和释放不同尺寸内存页的时候就可以实现并发。同时 RCU 机制下的内存异步回收,也解决了写线程回收内存时可能被阻塞的问题,进一步提升了写性能。

内存引擎通过无锁化加 RCU 技术的改造,读处理能力提升了 30% 以上。

4.6 Cellar可用性的挑战

同 Squirrel 一样,Cellar 也通过建设集群间数据同步能力,实现了跨地域的容灾架构。不同的是,Cellar 因为是自研,无需考虑与社区版本的兼容性,同时为了简化部署结构、降低运维成本,它把集群间数据同步功能做到了存储节点内部。如上图示例的北京集群 A 节点、上海集群 H 节点,在接收到写入之后,除了要做集群内的数据同步以外,还需要把写入数据同步到跨地域的另一个集群上。

Cellar 也可以通过配置两个方向的跨集群数据同步链路,实现完全的本地域读写。Cellar 由于采用了存储节点内建的方案,它的集群间复制通过使用定制的复制包来甄别客户写入和复制写入,并只为客户写入生成复制 log 来避免循环复制,相对Squirrel 会简单一点。但同样的,这种架构也会遇到极端情况下,双向同步导致的数据冲突问题。

4.7 双向同步冲突自动解决

如上图所示,Cellar 也实现了类似 Squirrel 的基于数据写入本地时间的 last write win 冲突自动解决方案。但 Cellar 的方案有一点区别是,它没有通过在每条数据记录 cluster id 的方式解决时钟回退、两次变更写入的本地时间相同的问题,而是引入了 HLC(Hybrid Logic Clock)时钟来解决这个问题。

因为 HLC 可以保证每个集群写入数据的时钟是单调递增的。所以,接收端是不用担心对端复制过来的数据有时间戳相同的问题。而对于两个集群分别写入,时间戳相同且 HLC 的逻辑时钟刚好也相同的情况,可以通过比较集群配置的 cluster id(不会存储到每条 KV 数据内)来决定最终哪个数据可以写入。

5 发展规划和业界趋势

未来,根据技术栈自上而下来看,我们的规划主要覆盖服务、系统、硬件三个层次。

首先,在服务层主要包括三点:

  • 第一,Squirrel && Cellar 去 ZK 依赖。如前所述,Squirrel 集群变更到客户端的通知是依赖 ZK 来实现的,Cellar 的中心节点选主和元数据存储也是依赖 ZK 实现的。但 ZK 在大规模变更、通知场景下,它的处理能力是无法满足我们的需求的,很容易引发故障。所以,Squirrel 会去掉对 ZK 的依赖,改为使用公司内的配置管理、通知组件来实现集群变更到客户端的通知。Cellar 会通过在中心节点间使用 Raft 协议组成 Raft 组,来实现选主和元数据多副本强一致存储。(注:本文整理自 DatafunSummit 2023 演讲,此工作当前已完成开发,处于灰度落地阶段。)
  • 第二,向量引擎。大模型训练、推理场景有很多向量数据存储和检索需求,业界很多 NoSQL、SQL 数据库都支持了向量引擎能力。KV 存储作为高性能的存储服务,如果支持了向量引擎,可大幅提升大模型训练、推理的效率。
  • 第三,云原生。当前美团的 KV 服务规模很大,相应的运维成本也比较高。所以,我们计划做一些服务云原生部署、调度方面的探索,向更高运维自动化水平迈进。

其次是系统层,计划对 Kernel Bypass 技术做一些探索和研发落地,比如新版内核支持的 io_uring、英特尔的 DPDK、SPDK 技术等。由于 KV 存储是典型的高吞吐服务,它的网络 IO、硬盘 IO 压力都很大,Kernel Bypass 技术可以大幅提升服务的 IO 能力,降低访问延迟和成本。

最后是硬件层,计划对计算型硬件的应用做一些探索,比如配备了压缩卡的 SSD,可以将服务引擎层使用 CPU 做的数据压缩工作卸载到压缩卡上,释放出 CPU 资源做更高价值的计算工作。KV 服务是典型的低延迟、高网络负载的服务。所以,我们也计划对 RDMA 网络做一些探索,以期进一步降低服务访问延迟、提升网络处理能力。

基于接口数据变异的App健壮性测试实践

在维基百科的定义中,健壮性(Robustness)是指一个计算机系统在执行过程中处理错误,以及算法在遭遇输入、运算等异常时继续正常运行的能力。IEEE中将健壮性定义为系统或组件在存在无效输入或压力环境条件下可以正常运行的程度。早在1989年,Barton Miller首次提出了模糊测试的概念,通过向目标应用抛出随机字符串的方式来测试UNIX应用程序的健壮性;而在1996年的Ballista项目中,研究人员探索根据API定义的数据类型,对操作系统或软件接口进行自动化测试方法。两个项目均以“无应用程序崩溃或挂起”作为测试验证通过的标准。

在移动端App领域,健壮性可以理解为App运行时遭遇环境异常或者输入异常时客户端能够继续正常运行的能力。

其中,环境异常主要分为操作系统异常、外部环境异常、硬件环境异常三大类。比如内存不足、CPU负载过高、线程池满载、内存分配失败、网络连接失败等。输入异常主要分为系统输入和用户输入。比如网络接口返回的数据异常、应用内缓存、数据库文件读写异常,这类的异常属于在系统输入异常;在电话号码输入框场景,用户输入的空格、富文本则属于用户输入异常。

对于这些风险,如果App没有处理,理论上都可能会产生展示异常、交互异常、性能、安全等问题,导致用户无法继续使用或在使用过程中产生不好的体验。比如用户操作App下单过程中,API请求出现故障未返回状态码为200的响应,App由于没有获取到预期接口响应的信息而发生崩溃,就会中断用户的使用流程。

02 基于接口数据变异的App健壮性测试方案设计

在实际的客户端测试执行过程中,测试人员会考虑测试异常输入的场景,但由于成本无法做到无穷尽的测试,同时还存在人工执行遗漏的风险。

从美团App平台业务的历史故障分析中,我们发现:网络请求返回的数据与实现预期不符引发的Crash或核心功能缺失问题导致的故障占比最高,且影响面较广。比如接口返回非预期数据时,客户端处理数据类型转换异常导致闪退,即使5分钟内操作降级仍影响了百万量级的用户。因此美团平台业务App的健壮性测试探索优先从发现网络请求返回数据导致的异常开始。

针对于发现请求接口返回客户端非预期数据导致的Crash,或者核心模块缺失问题这个诉求,我们调研后发现方案的基本原理都是相似的,即以网络请求的原始响应为基础,根据规则进行变异构造,使用代理工具改写响应体返回给客户端,在端上设备做异常检测。但是都存在一些问题不能满足诉求,比如测试变异数据是根据预置或者自定义规则随机生成组合,随机性过大,不能有效拦截健壮性问题;但如果不做随机,产生的用例组合量过大,测试不能在合理时间范围内结束;另外在检测能力方面,不具备发现业务异常或功能模块异常的能力。

因此,我们结合通用方案做了一些自定义改造,整体检测方案包含静态检测和动态检测两部分。

  • 静态检测,主要是指静态代码扫描,将典型代码编写规范问题转化为自定义静态代码扫描规则,管控增量代码,同时长期治理存量风险。比如自定义了PrimitiveParseDetector、ColorParseDetector,管控业务必须使用健壮性测试通过的工具类。

  • 动态检测是指结合触发时机,构造并注入变异数据后,识别App运行时是否出现崩溃、挂起或业务功能模块异常。比如在集成事件/回归事件触发自动化测试运行,构造触发异常的数据进行动态测试,然后监测是否出现了异常。核心动作包含构造变异数据和完成检测两部分。比如将接口响应体中表示颜色含义的Key对应的Value值构造成非色值,然后检测客户端请求处理接口数据时是否出现崩溃或挂起。

下文重点介绍端到端的动态检测方案。

03 变异数据的构造和异常检测

对于美团App来说,首页有多种形态,对于某种特定形态,除了控制请求数据外还需要控制实验、策略等一系列因素,才能保证测试对象的唯一性。一个页面中包含多个异步请求,因此请求的构造也需要和页面路径关联。这些都是采集变异所需的基础数据时需要关注和控制的。

响应体由基本类型数据和复合类型数据组成,相同基本类型的数据可能具备不同的业务语义,需要根据语义的类型做变异规则的区分对待,才能保障业务场景覆盖。

因此,如何保障变异数据构造的全面性和准确性,是我们面临的首要挑战。

要解决数据构造全面性问题,首先要解决页面描述方案,这样才能控制获取基础数据的唯一性。在解决方案中,我们构建了页面描述的特征规则,解决用户视角的页面标识问题。需要的信息包含端信息、页面路由信息、实验策略账号信息、页面标识模块合集等。通过页面请求数据自动录制的方式,自动更新迭代请求数据和页面之间的绑定关系,使得基础数据能够随需求迭代更新,从而通过变异规则构造生成的用例也能够自动更新。

在用例变异生成构造上,对于响应体里的Value设置了语义匹配规则,比如字符串的语义可能代表颜色、页面跳转路由、动静态资源链接(即图片资源数据/视频文件/GIF文件),需要区分特征分别按语义构造异常数据。比如在图片的变异数据构造里,除了需要构造非图片链接情况外,还要考虑不同图片格式、非图片格式以及非合法的图片剪裁格式拼接等场景。

我们对接口返回数据使用脚本做了初步的语义分析,人工二次校正后建立了基本数据类型和语义的映射集合,结合基本数据类型边界值和语义定义了初始的变异规则。然后对历史的线上健壮性问题和线下测试发现的健壮性Bug的变异数据进行整理,作为增补的变异规则。

在自动化测试执行过程中,我们基于App可测性改造提供的能力,对测试场景进行了控制,同时基于布局视图的解析SDK、App异常上报SDK提供的能力,完成了对App异常的通用检测。

04 变异数据的精简方案

伴随着变异规则的丰富,自动生成的数据量级是巨大的,数据的变异组合如果按照全覆盖方式来生成组合数量就是指数级增长。比如对于1种有7种变异取值的变量,如果存在n个此类型变量,就会产生7^n种数据组合,并且在实际业务场景中很多组合情况是没有意义的。

如何在保障用例构造全面性的情况下精简变异构造的用例数,是我们面临的第二个挑战。解决方案包含2个策略:1)数组元素结构一致时,删减构造的用例数;2)结构不完全一致的数组元素,引入编辑距离和并查集算法判断节点相似性,节点不相似,可以在一次数据生成里做合并构造。

我们可以把请求响应的JSON理解成树,第一个解决思路是判断树中节点、路径的相似度,相似节点删减构造。

如果路径、节点相似,可以推测路径即业务逻辑也是一致的,比如页面上的一些列表元素,可能是数据结构对象完全一致数组,如果对每个数组对象中的每个元素进行全用例构造,生成的变异数据量极大,且对业务场景或代码逻辑的增量覆盖有限,因此我们决定将构造逻辑优化,进行删减构造。即假如数组中元素的结构完全一致,那么同含义的字段可以为他们分配不同的变异构造值,然后删减掉无效的构造情况。应用这种方法可以有效降低28%左右的用例构造数量。

如图数组的3个元素中均存在“resourceName”键值对,假如每个键值对有3种变异取值,按照全排列方式进行用例构造将会生成有9份变异数据,在删减构造情况下,可以分别为它们构造一个特定的变异值,这样变异生成用例数量可以从9减少为1。

在对业务接口返回数据的数据结构进行分析后,我们发现在层级越深的场景下,距离根节点越近的两个节点,业务逻辑耦合和结构相似程度越低,它可以进行合并构造,相互逻辑之间不会产生影响,比如有两个键值对,每个键值对的Value有3种变异取值,在合并构造情况下,可以从排列组合的6份数据减少到3份数据。

基于这个个思路,我们在实践中引入了编辑距离和并查集算法,以节点路径为参照,对树的每一层的每两个节点计算编辑距离,生成一个n*n矩阵;同时以树的高度减去节点位于的层数作为权重,修正编辑距离。基于这样的计算,会产生多个编辑距离矩阵。

为了尝试最大化合并构造用例效果,我们把编辑距离做了0,1矩阵转化。其中,由于编辑距离为1的两个节点可能存在业务逻辑耦合关系,必须放在同一个组里分别构造,所以我们把编辑距离大于1的情况转化成了0,最后得到了一个0,1的编辑距离矩阵。

在0,1矩阵情况下,我们使用了图的连通性概念,如果A和B连通,B和C连通,那我们认为A和C连通,转化到这里的概念就是A和B相似,B和C相似,那么A和C相似,它们应该被放在同一个组里分开进行构造,那么在同层元素构造时,我们会从每个分组里取到一个节点,对这些规则进行变异组合构造。

基于以上两个策略进行精简后生成的变异数据量较精简前降低了40%,同时代码覆盖率没有明显变化,并且保持不变的健壮性问题发现能力。

美团App和优选App都接入了这个工具,在新需求阶段可以人工触发运行,还可以结合客户端组件集成事件和回归事件做自动触发。至今应用一年时间内,发现了几十个问题。

05 总结及展望

在健壮性工具建设一期里,我们实现了App页面加载展示场景的健壮性问题检测,支持崩溃、卡死和部分功能异常这三类异常检测。另外,基于节点相似性优化变异数据生成策略能够在保持效果不变的情况下有效控制测试时长,但是否有更优的合并算法和推荐算法,还需要更多的尝试。

在后续工具的迭代还会继续围绕异常构造和异常检测这两个方向,支持更丰富的构造能力和检测能力,以及更高效的构造效率。短期建设上,我们将会从业务视角出发丰富自动化变异数据生成建模,完善客户端异常通用异常检测能力,完成通用前后端交互的数据构造类型(比如:长连接消息)的覆盖;长期建设上,需要支持更丰富的数据和环境构造能力,通过智能化用例生成,提升测试效率。

06 Q&A

Q1:节点相似的判断依据是什么?

A:从实际的response分析来说,两个节点的路径完全相似就是从根节点到最终的叶子节点上,它们的路径命名完全相似,数组里两个对象的结构完全一样。

Q2:用例的生成能举个例子吗?

A:比如颜色色值的格式是#+6位字符,通常运营配置会出现的情况是忘记添加#,或色值复制中少了一位。在这种情况下,我们会构造一个色值,比如没有返回#、色值位数不对、色值添加透明度,把这种场景作为构造情况,在配置里添加上,最后用代码生成。

Q3:健壮性平时执行的频率是什么样的?

A:第一个基于需求维度,需求维度需要人工触发;第二个基于变更维度,当组件发生变更时,可以关联到这段代码或者组件变更的页面,然后触发页面对应的健壮性测试,执行频率会受到组件变更频率的影响;第三个在回归测试时,App的回归测试两周一次,我们会把所有页面以及它关联的所有的用例都执行一次。

Q4:对于暴露给前端开发的接口,大部分是人为调用参数的变化,随机性相对比较高,对于必填和非必填参数如何确认用例的范围?

A:目前我们在实现的方案里,没有区分参数是必填参数还是非必填参数,所以对于整个数据接口返回里的所有结果都会进行构造,产生的问题是对于非必返回的参数可能产生的问题,到底是否是需要解决的问题,这部分目前通过运营手段做确认。

Q5:首页可能调用10个接口,然后针对每个字段都进行异常验证吗?

A:对于首页关联的接口,我们在接口请求、录制过程中和录制完数据后,会对接口进行确认到底有哪些接口是我们需要验证的,这是一次性的成本,录制完成后,会对每个字段都进行异常验证,当然会有一些黑白名单的设置。

Q6:对色号这种情况有一种生成规则嘛,这个规则是怎么制定?

A:刚刚我只是举了一个色号的例子,其实对于图片、请求的资源文件、配置文件、跳转链接,每一个对应到的业务语义,我们都有对应的用例生成规则,我们会根据参考依据,比如第一个是本身我们在通用的基础库里怎么处理这些问题,这里有一个基础的规则;第二个是我们积累了线上问题情况实际可能会产生的错误或者变异情况,生成第一版基础规则,在第一期工具里找相关研发达成共识,这样的话,数据变异是处于合理范围。

Q7:执行的时候,如何知道页面对应哪些规则提前配置?

A:执行时,在测试接入过程中有一个配置过程,它不是配置这个页面和接口的关联关系,而是配置我们要测试哪些页面,自动触发自动化录制过程,就是到这个页面时,会触发哪些接口请求,生成这个页面和这个接口请求的对应关系,给到对应的配置人做确认,保证哪些接口是真正可能想要构造的,哪些接口不需要构造,最后以这个为基准测试,基于录制过程,比如业务迭代里面产生了新接口,我们在录制中能够感知到它关联的接口发生了变化,在发生变化时发消息给对应的测试提交人/负责人,TA确认这条规则放到黑名单里还是更新到需要构造的接口里。

Q8:是否有做页面显示的一个校验?怎么做的?

A:目前我们在页面里的模块做了“是否展示”校验,基于当前集成到美团的可测性SDK,这个SDK会获取到当前页面是否渲染里是否展示了对应模块的信息,通过请求把对应模块描述传给SDK,通过返回来校验是否展示。

07 参考资料

美团技术年货 | 600+页电子书,前端、后端、算法、测试、运维系列大合集

新春将至,一年一度的美团技术年货也如期到来!

星海横流,岁月成碑。2023年,美团技术博客走过了整整十个春秋,累计发布了580多篇技术文章,感谢大家的一路相伴。

在龙年春节到来之际,我们精选过去一年公众号30多篇技术文章和科研论文,整理制作成一本600多页的电子书,作为新年礼物赠送给大家。

这本电子书内容覆盖算法、后端、前端、测试、运维等多个技术领域, 希望能对同学们的工作和学习有所帮助。也欢迎大家转给更多有相同兴趣、积极上进的同事和朋友们,一起切磋,共同成长。

面对未来,希望大家有「无惧前路雨潇潇」的勇气,也兼具「乘风破浪会有时」的魄力。

知不足而奋进,望远山而力行。祝愿大家在甲辰龙年,幸福平安,行稳致远。

如何获取?

温馨提示:

  1. 美团技术年货合集大小约为100M,下载需要一些时间;
  2. 打开电子书目录后,可直接点击感兴趣的标题进行阅读;
  3. 部分文章中的动态图片无法在电子书中进行完全的展示,大家可以移步美团技术团队官方博客 tech.meituan.com ,或在美团技术团队公众号历史文章中进行阅读,感谢理解。

往期技术年货下载

关注「美团技术团队」微信公众号。回复【2022年货】、【2021年货】、【2020年货】、【2019年货】、 【2018年货】、【2017年货】,即可获取往期年货下载链接。

分布式因果推断在美团履约平台的探索与实践

近年来,因果推断在商品定价、补贴、营销等领域得到广泛应用并取得了显著的业务效果提升,例如用户增长、活动营销等业务场景。这些领域的共性是需要“反事实推断能力”,传统机器学习算法更关注预测问题,而因果推断提供了更佳的反事实推断能力。以营销活动为例,我们不仅需要知道当前优惠券金额下,订单数是多少(预测问题),还要知道在改变金额的情况下,订单数会发生怎样的变化(反事实问题)。

常见的因果建模方法主要包含Meta-Learner、深度表征学习和Tree-Base算法三大类。其中以因果树为代表的Tree-Base算法泛化性强,适用于多种业务场景。相较于Meta-Learner,树模型建模流程简单;相较于深度表征学习,树模型特征处理和调参过程简单并且具备极强的可解释性。

开源社区涌现出了微软的EconML和DoWhy,Uber的CausalML,以及因果森林作者的grf-lab等等众多优秀开源项目,但这些项目均为单机实现,不能满足工业场景下亿级样本的模型训练、评估、解释分析。Meta-Learner和深度表征学习可以轻松借助XGBoost、LGBM、Spark MLlib、Tensorflow等开源工具支持海量数据,但是这些项目都不支持因果树相关的Tree-Base算法的分布式训练。

具体来说,XGBoost、LGBM、Spark Random Forest等树模型是为解决预测问题而提出的经典算法实现,而因果树算法引入了新的训练理论以及因果理论独有的干预变量、工具变量等概念。这意味着我们并不能通过对现有分布式树模型的简单改造,来实现因果理论下树模型的分布式训练,而是需要充分理解各类单机因果树算法的原理之后,选择合适的分布式编程范式高效地实现出来。

为了解决上述问题,美团履约平台技术部对开源项目进行了精细梳理,集各家之所长实现了一套高性能的分布式因果森林框架,在半小时内即可完成亿级样本100棵树的训练,突破了单机开源项目仅支持百万级样本的瓶颈。并经过复杂的抽象设计,最终实现通过自定义损失函数即可支持各类因果森林算法的能力,极大提升了框架的扩展性。

除此之外,美团履约平台技术部还在因果效应评估、观测数据去偏等方面建设了大量高效实用的分布式工具。本文将重点为大家分享如何设计实现一个分布式的因果森林算法,以及因果效应评估方面的经验技巧,将我们在分布式因果推断领域的一些探索和内部的实践经验分享给大家。

图1 美团履约因果推断工具包

2. 分布式因果森林框架

因果森林算法的提出引发了Tree-Base算法应用于因果建模的研究热潮,众多学者相继在因果森林的基础上提出了多种多样的改进算法。监督学习领域的树模型有众多优秀的开源分布式实现,例如Xgboost、LightGBM、Spark Random Forest等等。

但是开源的因果树模型分布式实现基本处于空白状态。因果树算法引入了新的训练理论(比如Honesty Tree)并且因果树的分裂还依赖于干预变量、工具变量,这导致我们无法通过对现有分布式树实现做简单来更改来实现。因此,我们立足于论文,充分调研并借鉴业内优秀的开源实现,最终设计实现了一套高性能的分布式框架,并能提供统一的Serving方案。

借助这套框架,新增因果森林类算法只需要专注于损失函数设计即可,完全不必考虑分布式的工程实现。截止到目前,我们已经实现了四种因果森林算法,能够灵活支持多维连续treatment和及工具变量,半小时内即可完成亿级样本100棵树的训练。下面我们将从技术选型与框架设计、性能优化、Serving实现这几个方面为大家介绍这套框架。

2.1 技术选型与框架设计

单机树模型的工程实现可以概括为:遍历所有潜在的切分点并计算分裂指标(损失函数),取指标指标最佳的分裂点分裂,不断分裂树节点直到满足退出条件。而分布式环境下每台机器只包含部分样本,分布式环境下任何全局指标计算都会带来极大的通讯成本,因此需要选择合适的分布式架构帮助我们计算分裂指标。

因此,对于分布式因果森林框架,我们关心三个问题:第一,如何计算因果树的分裂指标(损失函数);第二,如何求潜在分裂点;第三,选用何种分布式编程架构。在此基础上进一步抽象整合,就可以实现不同树模型共用一套分布式框架的目标。

从论文出发

为了深入了解因果森林类算法,我们仔细阅读了因果森林论文以及其作者Susan Athey的另一篇在因果领域有重要影响力的《Generalized Random Forests》论文。Susan Athey认为随机森林本质上是一种自适应的最近邻算法(KNN),也就是通过对样本空间的递归划分从而找到距离该样本点最近的K个点(落入同一个叶子节点)来表示该点的值。而因果森林算法本质上是随机森林算法在因果推断领域的一种特殊应用。

因果森林和传统分类、回归森林一样采用了二叉的CART树(Classification And Regression Tree)作为基模型。与分类和归回问题相同,特征值仅用于样本划分而不参与分裂指标的计算。不同之处在于,分类和回归问题仅研究预测观测值Y,而因果建模需要研究treatment、instrumental variable等变量与观测值Y之间的关联。此外,多维连续treatment是学界的热门研究方向。因此,相较于分类和回归问题,因果推断需要在样本表示上做出相应调整。

因果森林论文提出honestyTree的概念:将样本分成growSet和predictionSet两个部分,growSet用于树的生长,predictionSet用于prediction值的计算。在论文《Generalized Random Forests》中证明了最小化子节点评估值与真实值之间的误差等价于最大化左右节点间的异质性,并对CART树的生长过程做了更加广义的抽象,将其分解成labeling step和regression step两步。Susan Athey的单机C++开源项目grf-lab中将这两种观点融合在一起,把树的生长定义为relabeling/splitting/prediction三个步骤。

综上,我们可以得出一些指导方案设计的结论:

  1. 因果森林本质上是CART树Bagging算法在因果建模领域的特殊应用。因此CART树相关的论文和开源项目都可以广泛借鉴。
  2. 不同于CART树,因果树的样本表示需要做相应抽象,根据不同算法灵活支持单维treatment多维treatment和工具变量。
  3. 因果树的支持honestyTree,可以将树的生长拆分为relabeling/splitting/prediction三个步骤,根据不同算法灵活实现。

Pre-sorted Algorithm Or Histogram-based Algorithm ?

主流CART树模型求分裂点的实现有两种方式,以早期XGBoost为代表的预排序算法,以LightGBM和SparkRandomForest为代表的直方图算法(目前XGBoost也提供了直方图算法的实现)。

  1. 预排序算法:对每一个特征的所有取值排序,依次遍历这些值计算分裂指标,取指标最佳的分裂点将节点分裂为左右子节点。
  2. 直方图算法:直方图的主要思想是将连续特征离散化到最大k个桶中,同时构造一个宽度为k的直方图。在遍历样本时,以离散化值为索引在直方图中累积统计量。遍历每个特征的每个分桶计算分裂指标,取指标最佳的分裂点将节点分裂为左右子节点。

图2 离散化分桶

图3 直方图作差

相较于预排序的实现,直方图算法的时间复杂度由$O(data*features)$降低为$O(bin*features)$,同时离散化后的特征内存占用更低,并且可以通过直方图作差的方式(父节点直方图减去左节点直方图)进一步降低计算量。受限于篇幅,预排序算法与直方图算法的差异这里不再赘述。最终我们选择了直方图算法方案,这也意味着需要在框架中采样计算直方图和特征离散化的环节。

AllReduce Or MapReduce ?

工业界主流的分布式机器学习架构有AllReduce、ParameterServer、MapReduce三种,其中AllReduce性能最高(ParameterServer架构也可以和AllReduce结合,为了方便讨论,这里不再细究)。

架构实现性能代表框架
AllReduceC++最优XGBoost、微软LightGBM、谷歌Tensorflow
ParameterServerC++居中谷歌Tensorflow (PS模式)、Tencent Angel,主要应用在深度学习领域
MapReduceJava/Scala一般Spark MLlib、H2O (Uplift Random Forest)

因为XGBoost内建了一个AllReduce框架RABIT可以直接复用,因此我们迅速拟定了两个调研方向——复用XGBoost的AllReduce高性能实现和Spark MapReduce实现。

方案架构明细性能技术栈开发难度测试难度支持的样本量级
方案1AllReduceXGB RABIT + SparkC++和Scala百亿
方案2MapReduceSpark一般Scala/Java较高较高十亿

由于履约使用的样本量在几千万级别,综合考虑开发测试成本和训练性能后,我们最终选择了MapReduce方案。

框架设计

综合上文的分析,我们为分布式因果森林框架设计了4个模块:

图4 分布式因果森林框架

  1. 训练入口与参数模块:抽象出Abstract CFEstimator用来整合树模型的通用参数,新增算法继承此类后添加专属参数即可作为对应算法的训练入口。
  2. 样本转换模块:负责采样构建直方图与特征离散化,上文中单维treatment多维treatment、工具变量、观测值y的转换也封装在此模块中。
  3. 森林生长模块:框架的核心模块,使用MapReduce实现。包含随机森林需要的树采样、特征采样,同时实现honesty。抽象出relabeling/splitting/predcition这几个接口,不同的算法按需实现树的生长逻辑,并以此为基石抽象损失函数接口。
  4. 模型保存和serving模块:抽象出统一的树模型保存和加载方案。

2.2 性能优化

在选定MapReduce+直方图的方案后,我们迅速将目光锁定在同样使用直方图算法的Spark RandomForest算法上(以下简称SparkRF)。我们在SparkRF上快速实现了一版分布式因果森林框架,并进一步实现了Generalized Causal Forests算法。

但是测试过程中我们发现,随着总节点数的增加,跨节点通信量(也就是Shuffle)剧增,同时还非常容易溢出。为了支持更大规模的模型训练,我们从跨节点通信、内存占用、计算复杂度、剪枝以及CPU缓存命中优化等多个方面优化了整个框架。为了讲清楚我们优化逻辑,大家先来看看SparkRF是如何实现的。

SparkRF的实现

SparkRF整个实现过程可以概括为如下几个步骤:

(1)将全量样本离散化并cache到内存,这一步包含三部分:

  • 采样collect到driver为每个特征等距分桶,得到潜在切分点split。
  • 使用潜在切分点split,将每个样本的特征离散化,此时特征值从double被转换成int。
  • 根据树采样比例,为每条样本生成标记数组(由int数组实现),标记这条样本用于哪棵树的生长。

(2)树的生长

  • 将整个森林看做一张图,采用深度优先搜索待分裂的节点,一次迭代一组节点,由maxMemoryInMB参数控制节点数。
  • 根据样本的标记数组,计算每个样本在每个节点的每个split下的直方图(统计信息)。
  • 通过reduceByKey算子,将同一个待分裂节点的所有split下的直方图汇总到同一个worker中。
  • 将待分裂节点的每个切分点直方图积分,例如feature0有3个切分点[a,b,c],积分后为[a, a+b, a+b+c],使用直方图作差,计算左右子节点增益,获取最佳切分点。
  • 将待分裂节点的最佳切分点collect回driver,完成森林的生长。
  • 使用rdd cache记录样本所属节点id(由useNodeIdCache参数控制)或广播模型。
  • 持续迭代直到达成退出条件。

可以看到,Spark的实现除了直方图,还有不少精妙的地方。例如在每次可训练的总结点数有限的情况下,深度优先搜索相较于广度优先搜索更倾向于快速完成单棵树的训练,从而减少后续训练需要广播的树模型。篇幅所限,下面将主要为大家介绍分布式因果森林框架在内存占用方面的优化。

减少Cache体积

从上文可以看出,SparkRF使用int来表示最大分桶个数,而lightGBM使用无符号byte来存储,支持最多256个分桶。我们认为128个分桶足以支撑因果森林的业务需要,所以使用了有符号byte来表示分桶,相比int内存占用减少至1/4。

前文中提到,SparkRF为每个样本创建了一个标记数组。例如训练一个2棵树的森林,这个标记数组为[4,0],这表示此样本在tree0有放回采样4次,在tree1未被使用。此外,框架需要支持honestyTree,也就意味着需要另一个标记数组记录样本在growSet还是predictionSet。考虑到无放回采样足以覆盖绝大部分场景,并且为了不引入第二个标记数组,我们最终选择了BitSet实现。每棵树最多使用2个bit,1个bit表示是否是该树的样本,1个bit表示是否是honesty样本。当关闭honesty或者不使用下采样时,每棵树只需要1个bit,内存占用最多减少至1/32。

支持更大模型广播

上文中提到,SparkRF每一轮迭代调用reduceByKey之前都需要计算出哪些样本属于待分裂的节点,Spark通过useNodeIdCache参数提供了两种策略:

  • 策略一:每次迭代将树模型跟随闭包广播到各个worker节点通过predict获取节点id。
  • 策略二:使用RDD[Array[Int]]类型来缓存当前样本隶属于每棵树的哪个节点(例如训练100棵树,则创建长度为100的int数组,每一个元素记录了此条样本在对应下标的树模型中的叶节点编号)。

从源代码中我们发现,策略二每一轮迭代都会卸载上一轮持久化的nodeIdCache,再创建一个新的nodeIdCache持久化到内存。以1亿条样本100棵树的森林举例,每一轮迭代就是1亿个长度为100的int数组的创建与垃圾回收。实际测试中我们也发现策略二的效率不如方案一高。那么策略一又如何呢?

SparkRF在每一轮迭代中能够训练的最大节点数由maxMemoryInMB控制,我们希望通过增大这个参数来减少迭代次数。但随着树或树深的增加,往往陷入增大该参数就导致树模型广播到worker溢出的尴尬境地。经过对SparkRF源码分析,我们发现每个LearningNode都会存储当前节点、左子节点、右子节点的直方图,最终实现在一套通用框架下计算出每个节点的增益、纯度、预测值等等属性,但这导致了3倍的内存占用。

考虑到因果森林honestyTree原则,叶节点prediction值的计算使用predictionSet,因此生长过程中每个节点全都带着growSet的直方图是完全没有意义的。因此我们优化了树的生长逻辑,每个节点仅保留自身的直方图,对于已分裂的节点则清除直方图。以二叉满树为例,叶节点约占整棵树节点的1/2,结合直方图从3倍冗余到1倍存储,这一优化使树模型直方图的内存占用下降到原本的1/6,极大降低了模型体积。

BenchMark

经过一系列优化,最终实现了百棵树亿级样本小时级训练的目标。

样本量特征数量树棵树最大树深资源配置Generalized Causal Forest算法Continuous Causal Forest算法
1亿1271008400*(7core16g)29min17min

备注:不同森林算法的复杂度不同,跨节点通讯量不同,总耗时会存在明显的差异。

2.3 Serving实现

因果森林本质上是随机森林算法的变种,由一棵棵彼此独立的二叉因果树构成,每棵树由innerNode和leafNode构成。其prediction的逻辑非常简单,每棵因果树单独predict获取leafOutput向量,森林中所有树预估的leafOutput向量求均值即可得到森林的输出值。因此,整个树模型的结构其实非常清晰,innerNode存储特征split信息,leafNode存储输出向量。除此之外还包含gain、impurity、count等属性用于计算特征重要性。

模型serving除了性能还需要考虑模型离线存储体积、模型的内存占用、模型字段的扩展性。结合因果树的特点,就需要特别注意leafOutput向量的实现。以下表中的场景为例,使用float数组大约就需要500*409640 4 byte / 1024/ 1024 = 312.5mb,而List则需要约4倍内存,正因如此我们快速放弃了简单快捷的Protobuff方案。

树深满树节点数满树叶节点数叶节点统计指标长度
500128191409640(例如ccf算法20维treatment下的输出)

为什么要重视模型字段的扩展性呢?这是因为离线模型训练追求快速迭代而在线Serving追求稳定性。模型的扩展性好,不仅可以轻松做到新版本服务向下兼容老模型,还可以做到在不使用新特性的情况下,老版本服务向上兼容新模型,从而减少在线服务更新发版的次数。综合考虑以上因素以及对Spark的兼容性和对java serving生态的兼容性,我们设计了如下方案。

  1. 使用parquet文件格式存储模型文件。
    • 字段扩展性:好,读取类似KV,模型文件可以随意扩展而不影响线上服务
    • 模型内存体积:好,相较于protobuf,可以逐行读取转换为float数组而非Float List
    • 模型存储体积:好,采用snappy算法压缩
  2. 字段平铺的方式存储树模型。相较于SparkRF的采用tree-node嵌套的方式,更利于字段扩展。虽然会带treeId等个别字段的冗余存储,但是列存储的压缩效率非常高,影响很小。
  3. 提供独立jar包cos-serving实现模型加载和prediction的功能,实现了离线模型训练升级而在线服务可以不升级的目标。

我们将离线模型的保存和加载逻辑抽象封装到了因果森林框架中,进一步增强了因果森林框架的扩展性,开发新森林算法时专注于将论文中树的生长逻辑实现即可。

3. 分布式因果效应评估

业内常见的因果效应评估手段主要评估ITE的序关系,例如qini score和auuc。但是存在如下三方面不足:

  1. 缺乏对数据和模型无偏性的校验
  2. 缺乏因果效应量级关系的评估,qini-score和auuc只能反应弹性的序关系
  3. 开源因果评估工具都是单机实现,仅支持百万级样本的计算

下文将为大家一一进行说明。

3.1 无偏性校验

无偏性校验分为数据无偏性和模型无偏性。

数据无偏性校验可以通过X⊥T验证。首先可以训练一个X->T的倾向性得分模型,如果倾向性得分模型的auc在0.5附近则说明X无法正确地预测T,也就是说X⊥T,此时数据无偏。例如,使用了post-treatmen特征会导致特征穿越,最终导致数据是有偏的,这时候使用X⊥T的校验工具可以快速帮我们排查出这一类问题。

模型无偏性校验使用ITE⊥T验证。首先用训练好的弹性模型在随机实验数据上预测ITE,接着对样本按照ITE升序排列后等频分桶,计算每个ITE分桶下实验组样本占比(下图的trtRatio曲线)。理想情况下,每个ITE分桶中实验组样本占比应该和随机试验中实验组样本占比一致,此时ITE正交于treatment。比如,随机实验中实验组比对照组为1比1,那么trtRatio就应该在1/2附近浮动。如果trtRatio比例不符合预期,我们就可以进一步去排查模型结构的问题。这项工具更是作为标准测试组件融入到分布式因果森林早期的开发过程中。

图5 模型偏差大

图6 模型偏差小

3.2 因果效应量级关系评估

因果效应的序关系和量级关系同样重要,只是将弹性的序关系学习准确而没有将弹性的量级关系学习准确,决策者无法预估该treatment对用户的影响程度。例如,将量级错误的弹性应用到运筹优化决策中,可能会导致无法满足重要约束从而无法求得可行解。针对弹性量级无法评估的问题,我们在原有的qini_curve基础上增加了qini_pred_curve_counterfactual和qini_pred_curve。

qini_curve及其扩展

qini_pred_curve_counterfactual:将每个样本按照模型预测的ITE降序排列,按照如下公式依次计算前t个样本的反事实qini_pred即可得到曲线。

  • $pred_ite_t$ 代表前t个的样本ITE累加。
  • $N_{t}^{T}$ 代表前t个样本中treatment组样本数量。
  • $N_{t}^{C}$ 代表前t个样本中control组样本数量。

通过比较qini_pred_curve_counterfactual和qini_curve这两条曲线的重合程度和右端点纵坐标,我们可以观察出ITE的预估量级和真实量级是否一致。

qini_pred_curve:每个样本按照模型预测的ITE降序排列,按照如下公式依次计算前t个样本的qini_pred即可得到曲线。

  • $pred_{t}^{T}$ 代表前t个的样本中treatment组样本预估outcome的累加。
  • $pred_{t}^{C}$ 代表前t个样本中control组样本预估outcome的累加。

qini_pred_curve和qini_pred_curve_counterfactual差异越大,模型偏差越大,也就是ITE与T不正交。我们以下图的案例来说明这三条曲线。

图7 模型偏差大

图8 模型偏差小

根据这些曲线的形状、覆盖面积、重合程度,我们可以得到如下的判断:

  1. 如果数据无偏,那么qini_pred_curve_counterfactual会和qini_pred_curve重合,反之则表示数据有偏,即ITE不独立于T。
  2. qini_pred_curve_counterfactual和qini_curve的右端点纵轴的差距,代表了弹性预估的量级和弹性真实的量级存的差距。
  3. label曲线的qini score>0.5,也就是label曲线有明显向下的趋势时,存在过拟合现象,即学到了负弹性。
  4. 如果弹性模型对于弹性序关系和弹性量级关系学习得非常准确,那么三条曲线会几乎重合在一起。

avgITE和ATE的对比

上文中提到的三项指标都是累计因果效应的评估,我们还想更有针对性地观察每个弹性分桶下预估因果效应和真实因果效应量级的差异,所以开发了avgITE和CATE的对比工具。

同样将样本按照模型预测的ITE降序排列,然后等频分桶,统计每个分桶内预估ITE的均值(下图的avgITE曲线)和CATE值(下图的cate曲线)。对比avgITE和CATE,可以评估出真实因果效应和预估因果效应量级的差异。

$$ avgITE = E(pred_y(X_i,T_i=1) - pred_y(X_i,T_i=0)) $$

$$ CATE = E(Y_i|T=1) - E(Y_i|T=0) $$

图9 预测与真实ITE量级偏差大

3.3 分布式评估体系

早期我们也使用了pandas实现的单机评估算法,当样本量增加到400w条以上时遇到了严重的单机瓶颈。为此,我们对上述评估指标全部做了分布式改造。排序类指标的实现有分桶积分和逐条积分两种实现思路。考虑到逐条积分会有更高的精度,最终选择了分布式环境下逐条积分的方案。

不仅如此,我们还使用Spark实现了带权重的分布式的因果效应评估,能够支持十亿样本的评估。此外我们还融入了评估预估y与观测值Y之间的差异的指标,包括mae/mse/rmse,并将这些指标封装到二元因果效应评估组件中。由于我们实现的部分因果森林算法能够输出多元treatment下预估的y,因此我们还进一步封装了多元因果效应(拆分成多个二元因果效应)评估功能。

图10 Causal On Spark

4. 总结

经过两年持续迭代,我们实现的分布式因果推断工具包已经发展成集模型训练、评估、去偏、Serving于一身的综合型因果工具包。我们内部为这个项目命名为Causal On Spark,简称COS。目前这个项目也已经全部集成到图灵机器学习平台中。将来有机会我们会再次为大家分享美团履约技术团队在分布式因果推断领域的探索和实践经验。

5. 本文作者

立煌、子青、郑宸、琦帆、兆军,均来自美团到家事业群/履约平台技术部。

6. 参考资料

  • [1] Wager S, Athey S. Estimation and inference of heterogeneous treatment effects using random forests[J]. Journal of the American Statistical Association, 2018, 113(523): 1228-1242.
  • [2] Athey S, Tibshirani J, Wager S. Generalized random forests[J]. The Annals of Statistics, 2019, 47(2): 1148-1178.
  • [3] Li, G., Chen, Q., & Usunier, N. (2017). LightGBM: A Highly Efficient Gradient Boosting Decision Tree. Proceedings of the 31st International Conference on Neural Information Processing Systems (NIPS 2017), 3146-3154.
  • [4] Chen, T., & Guestrin, C. (2016). XGBoost: A Scalable Tree Boosting System. Proceedings of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (KDD ‘16), 785-794.
  • [5] 微软亚洲研究院:《开源 | LightGBM:三天内收获GitHub 1000 星》.
  • [6] https://grf-labs.github.io/grf/index.html.
  • [7] https://github.com/uber/causalml.
  • [8] https://github.com/apache/spark.

美团RASP大规模研发部署实践总结

RASP是Runtime Application Self-Protection(运行时应用自我保护)的缩写,是一种应用程序安全技术。RASP 技术能够在应用程序运行时检测并阻止应用级别的攻击。随着云计算和大数据的发展,应用程序安全越来越受到重视。RASP 技术作为一种新型的安全防护手段,正在逐渐被业界接受并广泛应用。其中Java RASP 是一种针对 Java 应用程序的 RASP 技术。通过在 Java 虚拟机(JVM)级别进行监控和防护,能够有效防止对 Java 应用程序的攻击。

RASP建设挑战

在业界,RASP的部署形式一般有agentmainpremain两种方式,二者各有优劣。适合不同的业务场景,以及安全需求。

  1. agentmain:业务无需改动,无需重启,热插拔,动态升级。有性能抖动,业务有感知。
  2. premain:需要改动,需要重启,前置注入,升级需要重启。无性能抖动,业务无感知。

美团的RASP建设时,大部分业务都已经在线上运营,而且有多个发布平台,没有提供一个统一的方式来更改启动参数,也就是说无法通过premain方式是实现快速部署。为了抓住主要矛盾,快速解决大部分风险问题,我们选择了agentmain方式。

业务场景复杂

技术方案的设计,依赖于业务形态。美团内部的业务服务中,Java语言占比80%以上,是主要的风险所在。2010年至今,有特别复杂的业务部署形态、业务依赖环境、繁多的JDK等等,这些都是RASP技术方案的挑战。

  1. 业务部署方式:物理机、宿主机、富容器、轻容器等;
  2. 发布环境:由于历史原因公司已知的发布系统至少有3个;
  3. Web中间件:Spring Boot、Jetty、Tomcat、WebLogic、自研框架等;
  4. JDK版本:Oracle、OpenJDK、 MJDK、Kona、Dragonwell、毕昇等;
  5. 进程数量:单个主机上进程数量和生命周期差异大,有的几千个进程,生命周期有分钟级、年级等;

问题的拆解思路依旧是抓住主要矛盾,以JDK版本为例,各个版本JDK的主机占比如下图1所示:

图1 公司JDK版本分布占比

业务目标确定后,解决方案同样具体到某一类的JDK上。同样,在发布环境、Web中间件的差异上,对RASP也有了更多的兼容要求。

对业务性能影响大

agentmain的动态注入机制,对JVM的影响是不可规避的。影响大小可以从与其他安全防护产品的部署位置看出,下图2是常见的基础安全防护产品:WAF、HIDS和RASP,他们与业务的隔离方式有以下几类:

  1. 主机隔离
  2. 进程(容器)隔离
  3. 无隔离(或者类加载器隔离)

图2 主机安全防护产品与业务的隔离等级

与其他的安全产品相比,如网络应用防火墙(WAF)和主机入侵检测系统(HIDS),RASP与业务部署在同一Java虚拟机(JVM),其隔离级别是最低的。这就意味着,当RASP自身出现BUG或者与业务不兼容时,对业务造成直接影响。RASP 一旦出现故障那至少是S4级别(核心功能受影如资损、客诉,且预判5分钟无法恢复)。从业务指标上分为cpu和执行耗时,执行耗时方面主要是对服务的TP9999影响较大,而CPU方面出现cpu.busy指标抖动情况。对于业务的指标影响,有以下几种。

运行时注入cpu.busy指标突增

下图3为特殊情况下运行时注入cpu.busy指标抖动情况,在RASP注入时间内(CPU分钟级别采样),Java 进程的CPU从0%飙升到50%,然后又恢复。如果RASP注入之前Java进程的CPU已经很高了,注入时CPU会直接打满(注入前后10分钟)。

图3 运行时注入cpu.busy指标抖动情况

运行时注入TP9999指标

下图4为运行时注入TP9999指标抖动情况。单机维度,注入时TP99995ms飙升到1000ms,大幅度增加,TP9999出现明显的尖刺,对响应时间敏感的服务影响特别大。

图4 运行时注入TP9999指标抖动情况

启动时性能差与检测逻辑执行耗时长

在RASP启动时,大量请求进入到检测流程中,此时RASP检测代码没有完成预热,检测方法处于字节码解释运行模式,执行效率低,从而导致启动时TP线高。如果正常的请求检测耗时过长,将严重影响业务的TP线,甚至导致请求超时。 在RASP运行过程中,因为检测引擎执行耗时长也会导致业务超时。

升级变更难

由于原生Java Agent的限制问题,JVM一旦加载了Agent,就无法进行更新,只能等待JVM重启。

图5 运行时Java Agent的实现原理与升级过程

图5左边的图展示了一个典型的运行时Java Agent的实现原理。在这个过程中,守护进程(这里指主动发起Attach的进程RASP Daemon)会attach到目标JVM上,然后RASP Agent的jar包会被JVM的AppClassLoader加载,接着Agent就会初始化并开始运行。然而,由于JVM类加载机制的限制,同一个类(Agent入口类)无法被AppClassLoader加载器加载两次。使用新的Agent jar包重新attach,即使attach成功,也不会加载新的类。因此想要增加新的功能或者进行bug修复,就必须等待业务进程重启后才能实现。

这也就是说,RASP功能的升级完全依赖于业务进程的重启时机。然而,我们发现线上有些业务,如大数据服务的核心节点,其重启时间可能长达半年甚至更长时间,这就使得RASP的功能升级过程变得异常漫长。由于服务长期未重启,RASP版本无法进行更新。影响主要有2个方面,一方面长期未重启服务的RASP版本低于最新版本,RASP Daemon需要兼容多种RASP Agent版本,这无疑提升了代码工程向下兼容的工作量和稳定性;另一方面,未重启的服务最新的hook点无法生效,也带来一定的安全风险。

热更新是强诉求

在美团内部,安全部门需要不对业务有过多打扰的前提下保障业务安全运行。大规模重启服务风险高,不具备可实施性。如果遇到紧急漏洞或者重大bug时,这种升级难的问题尤为突出。升级难的问题是RASP在部署中遇到的第一个重大问题。

监控难

当JVM加载Java Agent后,由于其运行在业务的同一层面,必然会对业务产生一定的影响。这些影响可能包括CPU使用率飙高、TP9999线的波动,甚至可能出现故障如内存泄漏、磁盘打满、核心转储(Core Dump)、触发JDK Bug、线程死锁、GC时间变长等等各种问题。业务反馈的线上各类问题的占比如下图6所示:

图6 RASP各类故障占比

由于RASP接入对用户无感知,一旦出现这些问题,业务方定位问题的源头往往耗费大量时间。业务需要对业务状态日志、GC日志、系统变更日志等进行详细的排查,以确定问题的根因。在实际的运行过程中,往往是业务最先反馈RASP影响,而RASP不能做到对故障及时感知与处理。

RASP架构介绍

美团 RASP 利用 Java agent 和instrumentation技术,通过 ASM 修改类字节码,实时分析检测命令执行、文件访问、反序列化、JNDI、SQL注入等入侵行为。它最初是从开源项目btrace 演化而来,后使用Golang重写了btrace的进程注入的功能,即架构中的 RASP Daemon 部分,在 Java Agent 端也参考了一些开源项目和公司内部的性能诊断工具。经过多年的迭代,RASP 逐渐形成目前的架构。

通过RASP管理端进行主机维度的配置下发,将最新配置更新应用到 RASP Daemon。日志收集和jar包下载使用公司基础组件,通过这些组件的协同工作,实现对 RASP 部署过程的管理,包括支持灰度发布、配置回滚、降级和一键关闭操作。下图7为 RASP 的配置分发流程。

图7 RASP的配置分发流程

解决方案

灰度部署方式和复杂场景的兼容

RASP 启动方式

传统的RASP直接修改JVM启动参数增加RASP的Java Agent参数,即premain方式。而美团的RASP在最初只支持运行注入agentmain方式,不支持premain。原因主要是下面的2个方面:

  1. 在RASP项目建立时,公司的机器节点数量已经有几十万规模了,业务先行,安全补位。已经面临风险,需要尽快实现安全能力覆盖。
  2. 早期公司内部服务发布平台不完善,有多个发布系统,并且每个业务线的发布脚本不统一,统一控制的力度弱。

综合业务现状与安全诉求,比较符合技术选型的是agentmain机制。无需业务改动,也不依赖统一的代码发布平台,做到安全部门可控的能力覆盖。

经过多年部署,RASP已经覆盖大部分业务,具备相应安全能力。但也逐步遇到业务抱怨RASP注入带来的性能抖动问题。随着公司基础组件建设,也逐步统一了代码发布系统,在JAVA类服务的管控上有了统一的控制入口。同时,IDC内服务形态逐渐从VM虚拟机演化到容器,RASP的服务环境也与以往不一样。

当下主要矛盾发生变化,业务形态发生变化,支持premain的技术方案迫在眉睫。RASP联合服务发布与镜像团队在拉起服务之前将RASP的Java Agent以环境变量的方式设置到服务启动脚本的上下文中。下面为部署脚本中关于RASP环境变量的设置片段。

// 前置检查...

// 增加环境变量
if [[ $RASP_SWITCH=="ON" ]];then 
	JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -javaagent:rasp-premain.jar" && export JAVA_TOOL_OPTIONS
fi

// 启动Java进程...

配置分发方案

在 RASP 升级新版本时,为尽可能地提高稳定性,需要按照一定策略进行灰度升级。

  1. 公司内部分为测试和生产等多种环境,并且测试环境服务器数量为万级别,RASP 需先在线下环境稳定运行足够时候后再开始线上环境灰度;
  2. 服务按照重要性(或者环境复杂性)从低到高划分为普通服务、重要服务、高优服务3个类别,依次进行。
  3. 每一个服务需要再按照主机数量的百分比进行灰度,一个服务下的主机不能同时进行 RASP 的升级,需按照 10%、30%、50%、100% 的比例灰度。

Web/JDK版本识别与准入

RASP Daemon(golang语言)通过内核进程事件,感知新进程。再识别进程的cmdLine、JDK、Tomcat、Jetty、Spring Boot等的关键jar包,解析出JDK版本、Web类型和版本。对于已经兼容的服务可以开启注入,对于无法识别或者与RASP不兼容的服务关闭注入(ES、Jetty等个别版本),最大程度的减少对业务的影响。

组件的兼容性

JDK兼容性:美团RASP除了使用ASM包之外基本上不使用第三方组件,降低供应链攻击,同时减少对不同版本JDK的专有特性依赖,对于JDK的代码也尽可能的本地化到RASP工程中,屏蔽JDK的版本差异性。

Java Agent兼容性:公司有多种Java Agent 包括性能诊断,安全扫描、动态调试、流量录制、热部署、链路追踪等约十多种,这些工具实现原理都是基于Instrument。 冲突主要在还是在字节码修改上,例如RASP与jdwp的兼容上,最初版本的RASP在业务类中增加方法数量,当用户开启远程debug时,本地代码的方法数量与远程不一样,导致JVM崩溃。Java Agent应该遵循的规范:

字节码的修改应该遵循下面的基本原则:不允许新增、修改和删除成员变量 ;不允许新增和删除方法 ;不允许修改方法签名(来源于:Java 字节码规范);

Java Agent的jar包应该采用自定义类加载加载,依赖包名称前缀替换等方式,避免与其他Java Agent和业务依赖的冲突;

与其他Java Agent约定,在类查找遍历修改时排除其他的Java Agent的包名称,避免相互引用;

对于热部署等Java Agent,由于它不遵循字节码修改的基本规范,很遗憾,目前无法兼容,只能排除关闭注入;

2.2 RASP的运行时注入与更新

运行时注入方式解决了RASP的首次注入不依赖业务重启服务的问题,但是随着部署场景的增加,不可避免的要对RASP进行更新迭代,如何升级成为一个让人头疼的问题。于是更新也不依赖业务重启,成为一个需要解决的最大问题。

插件热更新是一项具有挑战性的技术,也是RASP建设初期要求具备的核心特征之一。由于美团拥有上百万个Java服务节点,一般的Java Agent安装和升级都需要重启Java进程,对于如此庞大规模的服务来说,这并非易事。在超大规模下,如果依赖业务重新发布的方式来使RASP生效,需要等待所有的服务重启一遍。RASP项目没有权限重启业务。因此,对于RASP来说,插件热更新是至关重要的。

在最初的版本中,当RASP注入到业务中后,如果需要更新功能(如修改策略或hook点),仍然需要重新启动Java进程。如果业务不重启,之前版本的RASP会残留在进程中无法卸载,而新版本需要兼容这些无法卸载的部分。这导致线上存在多个不同版本的RASP,不同版本之间的兼容性几乎无法实现,这种方式是行不通的。

因此,RASP借鉴了Tomcat的类加载器架构,将功能分为两类:第一类是需要频繁迭代的功能,如hook点、资源监控、检测引擎、通信等;第二类是几乎不需要改动的部分,如插件加载和初始化部分。将第一类功能抽取出来,形成一个单独的插件包(RASP Plugin),插件包由自定义类加载器加载,使得这部分具备运行时更新的能力。而RASP Agent引导包仅保留几个类,负责初始化插件jar包。下图8展示了拆分前后的对比:

图8 mt-rasp jar包拆分前后对比

对于拆分后的架构,首次注入 RASP Agent 加载V1.0的插件,在需要对插件进行更新时,清除RASP PluginV1.0对象的引用和PluginClassLoader对象,然后创建新的PluginClassLoader实例重新加载并初始化V1.1版本插件,从而实现插件的卸载与热更新。上面拆分方案实现依靠自定义RASP类加载器,RASP的类加载器层次结构(agentmain)如下图9所示:

图9 RASP的类加载器层次结构

从顶层类加载器开始依次说明RASP包的功能和所属的类加载器。

  • rasp-boot.jar:定义全局变量,能够被所有类访问到,使用BootstrapClasLoader加载;
  • rasp-agent.jar :标准的Java Agent 入口类,定义了agentmain/premain 等Agent初始方法、加载plugin并初始化,使用AppClassLoader加载;
  • rasp-plugin.jar :RASP核心实现,包括hook点、检测逻辑、资源监控等功能,使用自定义类加载器RaspClassLoader加载;
  • Script.class :定义检测逻辑,父加载器为RaspClassLoader,使得脚本类能够访问rasp-plugin.jar中的类,使用自定义类加载器ScriptClassLoader加载,并且脚本在磁盘加密在运行时解密。

premain & agentmain 两种方式兼顾

agentmainpremain方是Java Agent的两种启动方式,agentmain在Java进程启动后加载,而premain在Java进程启动前加载。由于启动时机不一样,带来的差异主要有agentmain 更新加载更加灵活,但是字节码修改时存在性能问题,特别是对性能比较敏感的服务;而premain需要将javaagent参数加入到JVM启动命令行中,完全依赖业务启动,不太灵活,但是性能上比较稳定。美团RASP采用agentmainpremain结合方式,平衡灵活性与性能。原则上premain逻辑尽可能的简单,避免频繁的迭代与升级。

premain 一期方案

RASP在加载时,Java进程的CPU会短暂的升高甚至打满,并且CPU核数越少,升高越明显持续时间越长。根因是Java Agent首次加载时会触发JVM中的code cache区域清零机制(可以认为是JDK的bug),大量热点代码的编译导致JIT编译线程将CPU打满,并且这种现象在CPU核数低于4核时表现尤为明显。

Manifest-Version: 1.0
Premain-Class: com.meituan.rasp.agent.RaspAgent
Agent-Class: com.meituan.rasp.agent.RaspAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: true

为了解决运行时CPU飙高问题,我们引入空的premain包(premain v1.0)(仅开启上面的字节码转换的开关Can-Redefine-Classes,无任何逻辑,也不修改字节码),在应用启动前加载,该方案取得较大优化效果。因为无任何代码,代码兼容性风险极小(并不是没有),因此能快速上线解决CPU飙高问题。以某个业务的主机为例子,在优化前后的cpu.busy指标如下图10所示(注入前后10分钟)。

图10 cpu.busy指标优化前后对比

图10中红色为优化前的cpu.busy指标,优化前即使注入前系统负载很低(4核8G,cpu.busy <2%),注入瞬间CPU依然飙升很高(50%);蓝色为优化后的cpu.busy指标,优化后cpu.busy曲线较平滑,无明显尖刺。

premain 二期方案

采用premain一期方案的原因是代码足够简单,几乎没有兼容性问题,因此能够快速大规模部署解决棘手的cpu抖动问题,上线效果较好。但是大部分服务虽然CPU不飙高了,但是还有少部分的服务TP9999指标依然影响较大。

修改字节码TP9999线升高分析

premain一期方案主要用来快速解决cpu.busy抖动问题,但是对于性能比较敏感(如tp9999 < 50ms)的业务,运行时字节码修改不可避免的造成STW,从而导致TP9999 线升高。因为字节码修改时需要进入JVM的safepoint,执行字节码转换的方法VM_RedefineClasses::doit 会导致应用短暂地暂停响应,这部分代码执行慢就会影响应用TP9999 。大批量修改字节码测得火焰图如下图11所示:

图11 批量字节码转换时的Java进程火焰图

从火焰图11看出,RedefineClasses 耗时主要在VM_RedefineClasses::AdjustCpoolCacheAndVtable::do_klass ,hotspot JDK官方也有类似的issue 。转换一个类的主要耗时在redefine_single_class方法,初步测试耗时占比在40%~80% 之间,并且一次转换类越多,占STW总耗时越大。

类转换STW时间与服务负载(QPS)关系

在同一服务(硬件配置8核8G)测试了修改类的个数、服务负载和STW时间的关系如下图12所示:

图12 类转换STW时间与服务QPS、转换类数量的关系

从上面数据可以对比看出:

  1. 业务请求QPS为0时,STW时间并不为0;
  2. QPS为20时,一次转换的类越多,单个类的STW耗时越长;
  3. 转换相同数量的类,随着QPS的增加,STW时间有增加,但是不明显;

从上面的分析可以看出,修改字节码无法避免的产生STW(当然,优化这部分JDK代码理论上是可以实现的,但是技术难度较高,短期内无法解决),因此只能从规避的角度出发来解决。原则上只要保证字节码修改时没有请求即可。

一种可行的方案是将字节码转换的逻辑前移到JVM启动前(即业务没有流量或者主动摘除流量),并且尽量避免有请求时大批量的回滚/修改字节码,能够在一定程度上避开或者缓解STW影响业务请求响应时间。

premain修改字节码

对于高频率调用的方法如http body参数读取、sql.execute等,使用premain修改字节码插入RASP检测逻辑,premain agent做到轻量级。对RASP的架构做出相应修改,新增rasp-premain.jar,让服务启动前进行加载并初始化,将字节码的转换逻辑前置到启动时,如下图13所示,蓝色jar包为新增的rasp-premain.jar

图13 RASP类加载器增加premain agent

为了最大程度复用之前的系统架构,premain加载后虽然字节码已经被转换,但 RASP的功能在逻辑上是关闭的,需要等到 agentmain 注入之后打开检测开关。premain 只做字节码转换,没有日志和通信等功能,不能单独工作。如图14所示,优化前后TP9999指标有较明显改善。

图14 优化前后注入时TP9999指标

运行时性能优化与整体指标

在RASP的流量控制层增加对流量的计数,RASP初次接入流量时控制接入流量的比例(如1%),使得业务业务流量能够预热RASP检测逻辑,预热时间或者次数达到设定的阈值后,再开启100%的流量检测。

业务负载较高的场景(CPU飙高、hook逻辑执行严重超时等),为了避免RASP检测逻辑加剧性能恶化,RASP采样软降级措施,关闭对应hook类的逻辑开关,使得部分流量不执行检测逻辑。如果性能进一步恶化,RASP运行模式降级为观察上报模式,待系统资源检恢复正常过后,资源监测通过后自动恢复到检测阻断模式。

RASP更新插件代码时,需要将plugin的全部对象置空,否则会有内存泄漏问题,特别是元空间的内存泄漏,将导致业务将运行越来越慢,直到停止运行。从前面的STW时间结论来看,运行时的字节码回滚(和修改机制相同)也会产生STW,因此RASP将hook代码的逻辑开关关闭后,字节码依然留在业务类中,在清理完各种对象引用关系后,依然能够卸载plugin插件。

监控体系建设

全局维度的监控指标:

  • 主机注入覆盖率大盘;
  • coredump总数;
  • 高峰期字节码的修改数量;
  • 熔断超时数量和比例;

单机维度指标:从业务层面到系统层面如下(列举部分)

  • 业务层面:检测引擎执行耗时、TP9999、请求出错率等
  • JVM层面: 堆内存、元空间/永久代、线程死锁、插件加载次数限制、GC、STW耗时、字节码转换等
  • 进程层面:Java进程CPU、内存、coredump、守护进程状态等
  • 系统维度:系统CPU、系统内存、系统磁盘空间、网络等

图15 RASP监控的指标分布

系统指标和进程指标对于Golang来说很容易获取,相关api较多。这里仅以JVM指标元空间使用率(MetaSpace)的检测为例子说明。RASP Daemon 执行attach获取目前JVM的最大元空间(MaxMetaSpaceSize)指标,然后读取 /tmp/hsperfdata_${user}/pid 文件解析元空间的占用(usedMetaspaceSize参数在jvm里面是sun.gc.metaspace.used),计算出元空间的占用比例和剩余空间,当剩余空间不足时,禁止RASP Agent注入,防止RASP成为压垮业务的最后一根稻草。

性能影响

测试配置: 8核/8G/150G

压力:QPS梯度100,持续120s,稳定施压 120s

表1 注入前后的cpu.busy指标

基准数据(不加载RASP)注入数据(加载RASP)CPU指标增量值
QPS=203.47%4.18%0.71%
QPS=10011.70%11.76%0.06%
QPS=20020.95%21.05%0.15%
QPS=30032.12%32.78%0.66%
QPS=40041.23%44.2%2.97%
QPS=50052.78%56.5%3.73%
最大QPS620587.8-
拟合方程拟合方程:y = 0.103x + 1.203 拟合度:0.999拟合方程:y = 0.109x + 0.827拟合度:0.998

cpu.busy 绝对值增加: 0.06%~3.73%,整体性能与开源的RASP相当

QPS超过350时系统cpu达到35%,触发弹性扩容,QPS压测到350可以测出最大内存损耗。

表2 注入前后的内存增加值

最小值平均值最大值
基准(QPS=350)422.38MB638.34MB3.10GB
注入(QPS=350)457.69MB821.59MB3.30GB
差值32MB183MB0.2GB

注入前后对比,压测到系统弹性扩容的最大QPS,最大堆内存增加约200M,整体性能与开源的RASP相当

元空间/永久代增加2MB,优于开源RASP产品

当前请求耗时控制在5ms内,优于开源RASP产品

漏洞检测

支持的漏洞类型

经过近多年的研发迭代,目前具备的漏洞检测类型如下,基本覆盖常见漏洞(部分):命令执行 (支持Native方法)、SQL注入、文件访问、反序列化攻击、JNDI、表达式等等。

实时检测与阻断

不同语言实现的脚本性能比较

开源方案中采用了JavaScript引擎作为实现方式,JS脚本可以被Java、PHP和C++等各种语言兼容,具备较强的通用性。但是经过测试,与原生Java相比,这些方案在性能上存在较大的差距。尽管JavaScript引擎具有不同语言通用性的大优势,但在执行性能方面并不满足高性能场景下RASP的需求。在美团,相比于性能,检测引擎的语言通用性并不是最重要的考虑因素。下面简单对比一下JavaScript和Java实现的检测引擎的性能。因为检测脚本主要涉及字符串的各种操作,我们选择了字符串累加的for循环作为测试场景。

// java
c+='c'
// javascript
c=c+'c'

经过测试,我们发现Java在执行这种字符串操作的性能方面表现更好。Java作为一种编译型语言,具有较高的执行效率和优化能力。它可以通过使用StringBuilder等高效的字符串操作类来提高性能。相比之下,JavaScript作为一种解释型语言,执行效率相对较低。因此,在高性能场景下,使用Java实现的检测引擎往往能够更好地满足需求。尽管JavaScript引擎具有通用性,但在性能要求较高的场景下,选择使用Java实现的原生检测引擎更为合适

表3 10万量级的for循环中跑出结果如下(单位ms)

JavaScriptJava
平均执行耗时5856.5

可以看出Java语言实现的检测引擎,性能上具备优越性。美团RASP使用Java语言构建检测引擎,能够满足性能上的需求。

检测脚本的实现

在RASP Plugin中定义了检测脚本需要实现的接口,脚本的实现类由RASP Daemon下载到磁盘上;RASP Agent定时检测脚本文件是否更新,如果脚本更新,使用新的类加载器加载磁盘上的class文件,并创建实例。

阻断与热修复

在RASP中,通常会在hook方法的执行之前(before)、返回(return)和抛出异常处(throw)增加检测逻辑。RASP通过使用ASM字节码框架,在方法的before、return和throw处织入检测逻辑的字节码(下图16黄色框)。

图16 RASP阻断热修复控制流程

这里以在方法返回之前增加hook逻辑为例子说明阻断/热修复的流程:

  1. 字节码插桩:使用ASM工具识别方法中的返回指令如(return、areturn等),在返回指令之前插入RASP的检测方法的字节码,使用instrumentrestransform api将修改后的字节码替换原来的字节码。
  2. 运行时检测:当检测引擎返回阻断异常对象时,方法的异常处理抛出阻断异常,终止方法的执行(上图16红色箭头的流程);当检测引擎返回对象时,提前返回指定的对象,修改返回的返回值(上图16中蓝色箭头的流程);返回Null,表示既不阻断也不返回对象(上图16绿色箭头的流程),不改变当前方法的执行流程和返回对象。

热修复与阻断的区别在于热修复返回的是一个对象,这个对象是修复后的正确的对象。

成果

美团RASP经过多年的建设,在覆盖对象、部署方式、性能优化、兼容性和安全策略等多个方面逐步迭代,现在已覆盖绝大多数Java服务,支持众多web容器部署,基本覆盖常见的安全漏洞,整体覆盖率上达到了较高水位,并且多次检测出海量的漏洞攻击,成为美团IDC基础安全纵深防御体系中最重要的安全能力。

总结

本文主要介绍了美团RASP在研发过程中遇到的问题和解决方案。首先介绍了RASP的痛点问题,包括业务场景复杂、升级变更难、对业务性能影响大和缺少监控等。对于RASP的升级问题,引入了插件热更新的技术,可以在不重启Java进程的情况下,即时地更新RASP的功能。

为了降低对业务性能的影响,介绍了采取的优化措施,包括低峰期注入、启动时流量预热、软降级与逻辑开关以及插件卸载时不回滚字节码等关键技术。然后介绍了RASP的监控体系建设,包括监控指标的定义和收集。最后介绍了RASP的性能与灰度策略,通过对性能损耗的测试和分析,可以看出RASP对CPU和QPS的影响较小。在灰度策略方面,RASP结合了业务形态,特性影响等,选择合适的验证机制和测试方法。

后续规划:

  • 新型容器形态支持:美团IDC形态中,逐步从VM、富容器过度到轻容器,未来轻容器会越来越多,RASP的管控机制、容器隔离机制,都是未来RASP的挑战;
  • 低打扰无感接入:宿主业务的低打扰,注入性能影响,小众场景的覆盖,依旧是RASP的核心重点,让业务无感、自动、默认接入RASP,提升整体IDC防御水位;
  • 管控、监控自动化:管控端的配置下发依赖链路较多、流程较长,配置变更成本风险高,优化为更高效、更实时、更准确的机制;

本文作者

许乐、孙绥 、东华、陈驰、丛祥、世宇等,均来自于美团信息安全部。

2023 | 美团技术团队热门技术文章汇总

新年好!时光飞逝,我们告别了难忘的2023,迎来了充满希望的2024。再次感谢大家的一路相伴~~

今天,我们整理了2023年公众号阅读量靠前的10篇技术文章,欢迎大家品阅。祝愿大家在新的一年里,幸福平安,行稳致远。

01 Code:美团代码托管平台的演进与实践

作者:潘陶、费翔、丹丹、毛强

美团代码托管平台经过长期的打磨,完成了分布式架构的改造落地,托管数以万计的仓库,日均Git相关请求达到千万级别。本文主要介绍了美团代码托管平台在迭代演进过程中面临的挑战及解决思路。阅读全文

02 《美团开放平台SDK自动生成技术与实践》

作者:飞宏、照东、宇豪、王鸿

美团开放平台为整个美团提供了20+业务场景的开放API,为了使开发者能够快速且安全的接入美团开放平台,美团开放平台提供了多种语言的SDK来提高开发者的接入效率。本文介绍了美团开放平台如何自动生成SDK代码的相关技术实现方案。阅读全文

03 代码变更风险可视化系统建设与实践

作者:桂来

文章整理自美团技术沙龙第77期《美团亿级流量系统的质量风险防控和稳定性治理实践》。第一部分介绍了软件系统风险与变更;第二部分介绍了代码变更风险可视化系统的能力建设;第三部分介绍了整个系统在美团内部实践落地的情况;最后是对未来的规划和展望。阅读全文

04 《一次「找回」TraceId的问题分析与过程思考》

作者:李祯

用好中间件是每一个开发人员的基本功,一个专业的开发人员,追求的不仅是中间件的日常使用,还要探究这背后的设计初衷和底层逻辑,进而保证我们的系统运行更加稳定,让开发工作更加高效。

结合这一主题,本文从一次线上告警问题出发,通过第一时间定位问题的根本原因,进而引出了分布式链路追踪系统的设计思想和实现途径,再回到问题本质深入@Async的源码分析底层的异步逻辑和实现特点,并给出MTrace跨线程传递失效的原因和解决方案,最后梳理目前主流的分布式跟踪系统的现状,并结合开发人员日常使用中间件的场景提出一些思考和总结。阅读全文

05 《如何提供一个可信的AB测试解决方案》

作者:王鹏、永斌、中锋

本文以履约场景下的具体实践为背景,介绍如何提供一个可信赖的AB测试解决方案。一方面从实验方法的角度论述实验过程中容易被忽视的统计陷阱,给出具体的解决方案,一方面从平台建设角度论述针对业务场景和对应约束制定实验方案提供给用户,而不只是功能和方法由用户自由选择,因为实验方法差之毫厘,结果可能是失之千里。阅读全文

06 《KDD 2023 | 美团技术团队精选论文解读》

作者:美团技术团队

本文精选了美团技术团队被KDD 2023收录的7篇论文进行解读,论文覆盖了Feed流推荐、多模态数据、实例分割、用户意图预测等多个方向。这些论文也是美团技术团队与国内多所高校、科研机构合作的成果。阅读全文

07 《基于UI交互意图理解的异常检测方法》

作者:诗雨、张雨、永祥

美团到店平台技术部/质量工程部与复旦大学周扬帆教授团队开展了科研合作,基于业务实际场景,自主研发了多模态UI交互意图识别模型以及配套的UI交互框架。

本文从大前端质量保障领域的痛点出发,介绍了UI交互意图识别的方法设计与实现。基于UI交互意图编写的测试用例在实际业务中展现出了可以跨端、跨App的泛化能力。阅读全文

08 《MJDK 如何实现压缩速率的 5 倍提升?》

作者:艳梅

MJDK是基于OpenJDK构建的美团JDK发行版。本文主要介绍MJDK是如何在保障java.util.zip.* API及压缩格式兼容性的前提下,实现压缩/解压缩速率提升5-10 倍的效果。阅读全文

09 《超大规模数据库集群保稳:高可用系统》

作者:张洪、李军、运洋

本文整理自主题分享《美团数据库的高可用系统》(点击查看视频),系超大规模数据库集群保稳系列的第一篇文章。对数据库而言,非常核心的就是如何保证其高可用性。本文围绕4个方面的内容展开,包括高可用简介、高可用部署、重点模块的设计思考以及对未来思考。阅读全文

10 《交互式推荐在外卖场景的探索与应用》

作者:姬晨、亚成、王炜、成龙、姜飞、王聪、北海

外卖场景的用户停留时长低于传统电商,对用户实时需求的理解和反馈有更高的要求。针对业务问题,外卖推荐团队从2021年起开始持续投入,最终摸索出了一套适用于外卖场景的交互式推荐架构和策略,并取得了较好的收益。本文详细介绍外卖首页Feed在搭建交互式推荐时遇到的挑战和解决思路。阅读全文

写在后面

新年已至,一切都是新的开始。愿2024伙伴们幸福、平安、健康,事业有成。祝大家新年快乐!

| &#x672C;文系美团技术团队出品,著作权归属美团。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者使用。任何商用行为,请发送邮件至[email protected]申请授权。

美团到店终端从标准化到数字化的演进之路

在我们日常工作中,要实现需求的快速和高质量交付,关键在于提升效率和质量。然而,在实践中很难找到一个通用策略,能同时针对效率和质量进行优化。这是因为在不同的场景下,对效率和质量会产生不同的解读。例如,在需求研发阶段,研发团队可能更注重提升工程效率和代码质量;而测试团队可能会更关注平台的稳定性和需求的质量;产品团队则可能聚焦于缩短交付周期和提高资源的有效使用。

在深入探讨具体的研发场景时,我们遇到了若干挑战。首先,一个完整的交付流程涵盖了从需求开发到产品上线的各个阶段,涉及产品、研发、测试等关键角色,每个人的职责各有侧重,且整个过程依赖于持续的沟通与协作。在这个环节中,我们常会遇到这样的问题:尽管每个人都忙碌,但整体效率却并没有明显的提升;即便对单一环节进行了优化,但优化效果并没有直接让整个需求的时间缩短,有时甚至会导致资源等待时间变长。

对于第一个问题,这通常不是个人能力的问题,而是因为我们在产研协同流程或资源管理工具上还有改进的空间。而第二个问题,可以举个例子这样理解:假设一个研发任务预计需要3天完成,一个高效的研发人员可能在1天内就完成了开发工作。然而,在实际的工作流程中,这样的个人优化并不会直接把5天的需求变为3天。因为开发后还需要进行多个步骤,如前/后端联调、需求验收、测试用例编写等,因此个人的单点优化很难对整个项目产生显著的影响。

因此,面向全流程优化的关键在于采取一个全面的视角,关注整个团队和协作流程的改进。只有当我们从整体出发,全面优化各个团队的工作流程和协同机制,我们才能真正实现缩短交付周期、提高效率和质量的目标。

产研阶段不同应对的问题不同

在进行分阶段优化时,各个企业在需求、研发和运维这些关键阶段通常都已经拥有一些成熟的解决方案或产品。例如,在需求阶段,企业通常会利用需求管理和排期管理工具;而在研发阶段,则广泛采用各种成熟的CI/CD工具(这个阶段进一步细分为多个子阶段,每个子阶段都有其特定的目标和潜在的提升点)。为了有效地进行优化,首先需要建立一个清晰的流程规范,明确各个阶段的具体任务和目标。随后,将这些明确的规范转化为线上化的操作,通过人力资源的有效配置,以自动化的方式实现效率和质量的双重提升。

不同业务形态和基础环境面对的问题不同

总结起来,优化方向主要有两个:首先是全面提升产研协同的效率,其次是通过建立标准化、线上化,进而实现自动化的过程进行优化。然而,根据不同公司和业务形态的具体情况,这两方面的具体感受和需求也会有所不同。

以美团的业务技术形态为例,早期时,技术栈主要是Native和Web独立运营,各个团队“各自为政”,效率和质量的提升很大程度上取决于团队的规范执行情况和个人的编码习惯。因此,这一阶段最主要的痛点在于缺乏统一的标准和最佳实践。

进入中期,开始逐渐采用动态化技术,一些需求也开始具备跨iOS和Android的能力。发版模式从原先的Native“火车发版”转变为动态发版,产研交付的频次大幅增加,从两周一版变成了一周甚至一天一版。这一变化极大地考验了团队的协同能力和快速发版的能力。因此,这个阶段的挑战在于如何通过工具化的方式有效整合资源,以及如何适应快速变化和发展的节奏。

目前阶段,美团的业务已全面拥抱动态化,并在多个场景下实现了一码多端的能力。从之前的iOS、Android到现在的多端对齐,研发的差异性和管理成本、质量风险都有所增加。因此,当前阶段的核心问题是如何通过一套统一且通用的自动化方案,来实现整体效率的提升和质量的保障。

演进策略概览

带着这些挑战,我们来看下技术演进的概览。在初始阶段,我们通过标准化解决缺乏统一标准和最佳实践的问题的策略。进入中期,我们重点实施了工具化和线上化策略,以应对产研协同过程中出现的挑战。当前阶段,我们正专注于利用综合的大前端DevOps方法,旨在统一跨技术栈的流程并显著提升效率。

2 标准化

什么是标准化?为什么要做标准化?为什么大家都是共同实践出来的经验,A说的就是标准,B说的就不标准?带着这些问题我们这里先展开标准化的建设过程。

标准化背景

如图中所示,前端标准化实施之前,从业务规范到技术选型,差异显著,且难以判断各种方案的优劣,每个团队均根据自身的经验实施各异的实践。

在此背景下,我们面临了所谓的“三高”问题:

  • 操作成本高:缺乏统一流程标准,相同的工作流在不同团队中执行方式各异。例如,在处理故障时,快速实现跨团队的止损方式和优先级判定共识极为困难。
  • 学习成本高:由于工程结构、技术选型和周边能力的差异,新成员或跨团队协作时的学习成本高,适配难度大。
  • 维护成本高:工具的重复建设,同一环节可能存在多套保障工具,质量参差不齐,且问题碎片化严重,导致维护成本激增。

面对这些挑战,我们亟需制定一套全面的标准化落地流程。

标准化生产过程

首先,从制定标准的生产过程入手,建立规范的生产标准和讨论流程,明确谁将负责制定和决策标准。接着,通过实施分层次的规范,从基础设施和基本规范到业务层规范,逐步实施。最终,当规范在业务侧得以实施后,应通过监控工具和运营大盘指标来确保这些标准得到有效执行。

基础设施支撑规范落地

基础设施、研发规范和研发流程三方面的标准化统一。

研发规范落地

业务规范和技术选型的统一。

3 产研协同

协同成本增加

在动态化改造的背景下,我们从之前各技术栈独立运维转向了合并至一个统一的开发和发布流程。这一转变带来了人员和团队角色的显著变化。随着多端技术的整合,原先专注于单一平台的研发和测试人员现在需要同时关注多个平台,人和团队的职责都被放大,同时复杂度大幅增加了。

终端场景能力增加

随着终端的不断建设,能力逐渐丰富,场景也越来越复杂。这些终端能力在不同App、iOS、Android上的实现也都各异。高门槛及一系列复杂操作让终端研发和测试头痛不已。

举几个研发中常遇到的情况:1. 前端问后端的接口怎么出问题了,是不是你们又变更了部署?后端又反问前端,你切环境了吗,泳道是不是正确?2. 测试问研发这个Bug怎么没有修?研发开始反确认,容器锁包锁了没有,环境对不对,AB策略切了没有,灰度链路是否正确?3. 团队来了新同学,这些问题差异又就会被无限放大,在某APP/某端上,到底怎么切灰度链路,怎么设置环境,怎么锁包这一系列的问题。

这些问题十分阻碍整个需求的研发,但却频繁的在日常研发中遇到。

如何基于终端特点解决这些问题?

好的功能往往需要用最简单的方式呈现,比如移动支付仅是通过“扫一扫”的方式(几乎没有操作成本,而且零学习成本)解决复杂场景,“扫一扫”的背后可能会解决像入网支付、支付安全性等一系列复杂问题,但呈现给用户是简单的“扫一扫”操作。

因为终端本身和服务端、Web不一样,它有一些原生的能力,那我们是否也可以把这种复杂的方式,用最简单的方式呈现?

用简单无门槛的操作解决全流程痛点

终端扫码配置方案挑战点

在方案设计过程中,我们面临了几项技术挑战。首先,推动基础设施的统一是一项艰巨任务。鉴于公司内有众多基础设施团队,每个团队负责独立的应用、平台或功能,要统一这些分散的基建到一个共同标准,并推动大规模的改造,既困难且成本高昂。其次,即使推动了基础设施能力的标准化,由于终端技术不断进化且新功能持续增加,如果依赖单一团队来维护所有扩展,不仅成本高昂,还容易导致团队过度劳累。

针对这些技术挑战,我们采用了结合原生特性的方法。例如,引入了动态化能力来解决跨端一致性问题,使用原生技术解除了双向依赖,并通过按需加载机制来避免对业务逻辑的影响。最终,实现了两大插件化能力:一是框架插件化,允许无依赖地集成至各个应用中,提供运行时检查和挂载能力。二是功能插件化,允许业务团队在自己的业务组件和仓库中,按照标准接口实现和快速低成本地新增功能插件。

工具化解决流程管理难点

最终,我们落地了这个低成本、高易用性的方案,即一码全流程方案,有效解决了过去依赖文档或大量口头沟通才能实现的产研协同问题。现在,通过一个二维码就可以串联整个终端研发流程,无需反复确认操作步骤,也不用关心具体哪个端、哪个App需要怎样操作,所有复杂的操作都隐含在“扫一扫”的背后。

同时,由于配置的自动化和可控性,方案还顺利整合了美团的自动化测试工具体系,促进了自动化的精准控制。结合正在建设的DevOps平台,我们实现了从产研信息的自动化采集到二维码的自动生成;此外,还可以通过二维码反解出当时各个验收环境的具体配置,实现了可追溯性和便于管理。

4 持续交付体系

在流程和规范之上是团队基础设施,有句老话是“基础设施承载团队的流程规范”,所以不同阶段、不同规范需要依托不同基础设施来落地。持续交付基础设施作为我们需求交付过程中的核心环节或者硬卡控平台(硬卡控是我们在发布过程中,如果某一个环节不符合规范,那就作为卡控形式拦截线上风险),持续交付平台也成为整个流程和规范落地过程中的核心抓手。接下来,会主要分享持续交付基础设施从线上化到数字化的演进过程。

早期:发布流程线上化

前文提到,在早期阶段,团队完成了对前端发布流程的标准化,但团队前端项目数量不断扩张,多样性的工程化与规范定制诉求导致了我们脚手架的碎片化程度高、重复工程化建设、上线质量不可控、约束流程不健全等问题。所以我们在这个阶段做了线上化、可定制的交付流程解决这个问题,也进一步成为了团队前端的持续交付平台。

如下图可以看到,除了底层的基础依赖,核心支撑团队规范的是一个可编排流水线,在它基础上提供了不同场景下的卡控能力,比如源码拉取、审批、代码检查、解禁、发布、周知等,承载了团队标准化所定义的一些基础规范。此外,加上项目管理和发布报告,组成了团队早期持续交付的基础能力。同时可以对团队不同场景提供能力定制,比如有不同类型项目的发布配置、在前端有不同发布类型自定义、团队流程的自定义。在这些能力之上,支撑了早期团队不同类型的交付场景,比如Web、动态化、小程序、NPM等。

用团队实际场景来举例,从团队标准流水线来看,我们将团队的标准化规范内置到默认流水线中,例如在默认流水线里提供了Lint规范检查、单测检查、工程依赖收集、标准化检查等。

在这个基础上,团队可以定义扩展自己的流程规范,比如在线上发布的过程提供域名安全检查/是否包含不合规域名、ES高级语法检查以及上线后的数据监控,既满足了标准规范的卡控,又满足了团队能力定制扩展的诉求。

除了存量项目通过标准规范进行流程卡控,对于增量项目我们也期望同样保持标准不劣化,所以在持续交付的解决方案里,我们也内置了一键初始化基础能力,做到项目创建即标准,除了10分钟完成项目从创建到发布外,其本身也默认符合团队的标准化规范。

为了把这套能力更好的提供给业务团队,平台在基础能力之上提供了不同场景差异性的解决方案,为不同场景、不同阶段的团队对于规范的诉求差异性提供不同的基础能力。

  • 探索期团队的诉求是想快速验证业务可行性,团队内的规范定义相对比较少,我们会提供开箱即用的最佳实践给团队,使用默认的这套规范快速交付。
  • 成长期的团队逐渐有了对于业务场景的定义或特定场景下的规范,我们会提供可定制的持续交付能力,例如“期望在特定阶段完成自己业务项的检查”或者“要求团队所有发布需 TL 审批”等这种定制性的规范,可以低成本扩展在持续交付过程中。
  • 成熟的团队一般有自己完整的标准化规范,我们就需要将持续交付基础能力拆解为相对细粒度的API,给成熟团队拼装自己完整的定制化交付流程。

第一个阶段,我们将标准化结合持续交付平台完成发布流程的线上化落地,通过灵活的定制化能力支撑不同业务场景,累计支撑了美团上千个前端团队的项目发布流程升级。

中期:动态化交付复杂度增加

动态化场景的引入是一把双刃剑,其本身会提升业务交付价值效率,但也也增加了研发流程复杂度。无论在开发、测试还是发布阶段,我们从原来的单一关注App以及App测试和发布过程,变成了需要同时关注App和多个动态化Bundle的交付全流程,增加了研发交付的复杂度。比如原来开发只需关注某个App单仓库单分支流程,但如果在动态化场景,除了要关注App,也需要关注 Bundle可能分散在不同的仓库,研发的流程可能就是多仓库多分支、对应不同Bundle的测试线上灰度流程和以及线上指标。

面对这种复杂度增加的情况,如何支撑团队定义的产研流程/流程规范的落地?我们的解决方案是动态化DevOps,即将全流程通过线上托管的形式降低研发同学在交付过程中的复杂度。

如图是在动态化DevOps所提供的解决方案,除了两侧DevOps的常规能力即项目管理&配置和线上技术运营基础能力外,平台对团队流程规范内置支持,例如“产研协同流程”、“多人协作场景下的规范”、“研发流程规范”、“交付流程规范”等,平台的核心能力是工作流,通过平台定义的工作流将交付过程中的开发集成、构建测试和部署发布全流程托管起来,辅助研发提效。

还是以团队实际场景举例,平台工作流覆盖了研发和发布过程,同时定义了研发和发布的具体阶段。研发可以按流程或规范分为开发、集成、构建/部署、测试这4个阶段,在不同阶段,平台以工作流形式将标准动作或流程规范内置在工作流节点中,比如可以在平台上创建符合规范的分支,开发完成后,自动进行测试包构建、部署以及自动化测试和PM、QA的验收。研发过程中,除了将标准流程内置,也将产研协同的流程内置到这套工作流里,辅助规范落地的同时支撑产研协同效率。

同样,在发布阶段,我们将发布流程进行标准化定义并线上化,让工作流自动执行,辅助交付流程符合标准/研发协作规范。

总之,动态化DevOps完成了动态化场景全流程线上化,通过平台能力降低了交付的复杂度,同时我们将协同流程和研发规范进行平台内置,守住了质量底线。

大前端背景下的新问题

随着我们业务的演进,越来越多业务交付到多种前端场景,比如某些业务既涉及到Web/H5交付也需要动态化交付,甚至C端业务可能既有H5又有小程序同时又有动态化,业务形态决定了需要交付的场景,以交付流程来审视,它是多个维度的复杂问题。

除了持续交付领域流程,即从需求到发布再到度量,还有大前端不同交付端的维度,比如一个项目既要交付H5,又要有小程序、动态化,同时需要Native的配合。还有第三个维度就是不同团队对交付的技术栈以及不同阶段的定义规范不一样。

虽然之前解决了部分场景下比如解决了H5/小程序的发布流程、线上化的问题,还解决了动态化场景下的定制和线上化诉求,但是大前端、多团队视角下还是存在流程/规范落地的瓶颈。

那这些瓶颈都有哪些?我们总结了一下,主要包括以下三点:

  1. 研发周期涉及平台多,老人靠经验、新人靠询问。
  2. 各技术栈研发流程平台的差异性,导致团队学习成本和能力重复建设问题。
  3. 各团队基于标准规范扩展业务规范,规范落地分散,落地效果参差不齐。

那么在这种背景下,我们的解决方案是大前端DevOps,提供给美团内部前端团队的研发工作台,分为四个能力:

  • 第一,它涵盖了产研全流程,即从开始到需求、关联设计稿、自动创建分支,到最后上线、发布、周知等全流程所涉及到的能力;
  • 第二,它支持团队定制,即允许在标准流程基础之上,各个团队有自己的场景定制;
  • 第三,我们需要在这个流程中整合公司内部的基建,辅助在这个过程中因为平台多或者各技术栈场景不一样导致学习成本高问题;
  • 第四,它一定要支持团队不同的大前端技术场景。

下图是我们对大前端DevOps平台的架构分层。

在底层,我们对于公司的前端基础设施进行了深度整合,并对大前端标准研发流程内置。

核心能力层通过工作流的编排以及定制化节点市场提供前端不同团队的场景定制,通过团队自研的工作流引擎支撑多样化的工作流执行。

工作流本身也是研发同学在日常工作中大绝大多数的工作场景,工作流可以内置团队研发规范,比如分支规范、卡控规范和部署规范,这些规范节点组成了工作流最小原子,不同技术栈、不同团队共享一个规范配置池,这样就解决了对于不同团队、技术栈对于同质化能力的重复建设问题。除此之外,工作流本身基于产研全流程,我们在全流程埋点提供精确的研发过程数字化能力,输出团队研发度量数据。

如图流程所示,通过标准工作流承载了的团队规范和产研流程,比如在流程里提供了标准分支创建、Lint检查、产研协作动作、安全检查、灰度规范以及上线后的标准动作。

同时我们会在工作流内置研发前需求方案的一键创建、智能评估能力,研发中灰度异常指标自动采集、自动监控告警,研发后需求数据整理归档的一整套辅助能力,做到团队新人“开箱即用”。

在标准流程中可以增加团队的自定义规范,在节点市场选择的基础能力,无缝整合公司基础能力,例如可以在特定阶段创建分支、在某个阶段查看代码信息、在上线阶段触发某个端的侧/正式包构建,通过节点能力,整合基础设施,一站式闭环研发同学工作,避免这些研发过程来回跳转造成的效率损耗。

将不同场景、不同阶段的流程,定义成团队标准工作流模板,支撑不同场景下的标准交付动作,当然也可以针对一些新场景或者不同阶段定义自己的研发工作流。

大前端DevOps做到全流程线上化,所以可以在需求过程中关联需求信息、仓库信息,在不同阶段提供准确的研发数据,进行全周期数据统计,在全流程数字化基础之上,实现团队的效能度量,效能度量从宏观角度看到研发过程中所存在的共性问题,再反过来推动研发流程修正可能存在的问题,形成一个正向循环过程。

在这个阶段,通过大前端DevOps支撑了团队所有前端场景的研发交付,承载团队规范、产研协作流程,是研发开箱即用的一站式工作台。

团队的流程规范的演进从原来的不规范到研发规范标准化,再到产研协同过程的升级(即从产研协同配置化),再到产研流程自动化,为了支撑规范的落地,在持续交付基础设施上,需要完成交付线上化和全流程线上化,最终在线上化基础上,支撑全场景线上化、自动化和数字化能力。未来整合人工智能相关能力,完成流程规范上信息触达智能化、规范执行智能化,在这个过程中,团队流程规范和基础设施相辅相成、互相落地。

5 Q&A

Q1:多人、多分支的代码管理是否有好的经验可以分享?

A:分支管理有两个维度,一是质量相关,二是高频操作和效率相关。根据维度不同,有不同的推进策略。

首先质量相关方面,由于是多人协同,首先要解决的问题是大家在流程上很难达成一致,比如开发完成后,自己拉出一个分支,直接在Master分支上稳定分支上合,其他同学不知道TA正在Release分支上发布,这样的冲突很难通过线下方式主动发现,所以这种质量相关的东西,我们基本上会通过流程达成统一和增加卡控这两个方面做成,比如所有分支通过自动创建,人工不用关心应该从哪里检查出什么问题,它会直接告诉我们结果,即应该在哪里进行操作,一方面通过自动化方式解决流程不统一,一方面通过代码检查强卡控漏代码等场景。

其次效率相关方面,通过高频操作,分析哪些操作频繁,并可以提供像自动TPR或者智能推荐分析在哪个分支进行什么操作,智能推荐GIT命令、快捷命令等这方式。

Q2:团队代码审查有什么最佳实践吗?怎么平衡开发和审查任务之间的平衡?

A:这是我们持续交付过程中的标准环节,首先是定义规范,比如在这个环节有团队的共识规范,根据单个PR大小、Approve的数量以及每个同学所提建议的数量定义规范,这个规范和不同团队/项目有关系,通过不同场景定义规范,再落到交付卡控过程中,比如我们会检查它是否达到阈值,如果没有达到,就不允许合并,或者检查不通过,没有办法进行下一步,以这种方式实现了代码审查规范的落地。

6 本文作者

彬彬,自强,均来自部门美团到店事业群/平台技术部。

AIOps在美团的探索与实践——事件管理篇

文中所提及的事件并不仅限于故障,还包括运维工作中的告警、异常等。

“An incident is an unplanned interruption to an IT Service or a reduction in the Quality of an IT Service.” Source: Incident Management -ITIL

1 背景

《AIOps在美团的探索与实践——故障发现篇》一文中,我们探讨了AIOps在异常检测的实践。Horae(美团AIOps平台)在单时序异常检测方面已有较多积累,智能告警功能作为底层能力支撑了监控系统和异常检测场景。服务运维团队在此基础上开展AIOps在事件管理领域的相关工作,本文主要分享过去两年的探索与实践,希望能对大家有所帮助或启发。

事件管理的复杂性体现在两个方面:

  1. 数据繁多:

    • 数据多样化:运维工作需要各种类型的数据来识别、诊断、处理问题,包括告警、链路、指标、日志、变更(含发版)等。
    • 数据实时性强、关系复杂:运维数据通常需要实时采集和处理。这些数据之间的关系错综复杂,如链路数据与告警数据、指标数据与日志数据等可能存在密切的关联,需要精细的统一处理。
    • 领域知识强:运维领域涉及的知识广泛,包括网络、硬件、系统、数据库、应用等多个层面,业务运维更需要不同的领域知识,这对运维人员和运维工具提出了较高的要求。
  2. 流程复杂:

    • 事件管理的时间线如下,每个环节都需要提效才能达成事件管理的效率提升。

图1 事件管理时间线

面对上述挑战,美团运维团队在过去几年建设了丰富的工具体系,基于专家经验、规则配置、流程管控等方式进行事件管理。本文聚焦的AIOps实践,是对上述工作的赋能,可拆解为四个模块:

  • 风险预防——变更风险智能检测:以用户和实体为对象,结合规则以及机器学习模型,对用户行为进行分析和异常检测。
  • 故障发现——智能识别指标异常:基于统计算法和机器学习算法识别指标的异常模式,帮助用户快速发现故障。
  • 事件处理——诊断和预案推荐:通过多模态数据和算法规则引擎来帮助用户快速定位故障,推荐止损预案。
  • 事件运营——相似故障推荐:基于NLP技术推荐相似故障复盘,挖掘共性问题。

2 事件管理中AI能力总览

AIOps在事件管理领域中的能力框架如下:

图2 AIOps事件管理领域能力框架

3 AIOps之事件管理场景

3.1 事前预防

3.1.1 风险识别

变更检测分成前、中、后三个阶段。变更前风险预警的收益相对较高,因为它能够拦截异常的发生。但由于变更动作尚未发生,变更前检查所能获取到的参考信息少,检测难度比较大。变更中、后检测可以参考灰度组的变化情况以及是否有异常指标的出现,检测的参考信息更多,准确度更高。我们和MCM-线上变更管理平台(美团变更管控系统,后文简称MCM)合作,共同探索了对变更前、变更中和变更后的一些异常进行检测与识别。

变更前

配置变更风险的检测和识别。当用户进行配置变更时,我们会进行配置项变更风险检查。我们根据该配置项的历史合法变更数据挖掘出该配置项的约束规则,对当前变更值进行风险检测。约束项包括结构文本合法性、分隔符合法性、前后结构一致性等风险规则。

变更中/后

当灰度变更时部分系统指标会变化,比如集群中灰度机器的QPS、4XX、5XX指标可能会因为变更发生变化。系统需要识别出因为错误变更而导致的异常,屏蔽灰度变更影响导致的异常。

我们需要注意,如果直接采用全量未变更分组进行参考组对待检测组进行异常识别,会有一些干扰和噪声。同一个集群的机器会由于自身配置、承载流量任务等差异,其机器指标会产生出不同的分布和聚类情况。将实际差异较大的机器指标作为参考组,会干扰异常检测的结果。因此,我们需要筛选出和待检测指标相近的数据作为参考组,再在类内距离较近的多指标数据中识别出该指标是否符合正常模式。

以灰度变更组的数据为待检测数据点,以未变更组时序数据、变更历史时序数据作为参考组进行异常识别。算法思路如下:

  1. 剔除参考数据的离群序列:找到和检测数据相近的参考组,再做异常检测。我们使用优化后的自适应DBSCAN[1]进行聚类,排除参考组的离群时序序列。
  2. 检测待检测数据是否异常:识别待检测的时序数据在参考组中是否存在异常情况,包括点异常、上下文异常、子序列模式异常等异常特征。

表1 异常集群变更 vs 正常集群变更

该功能已经上线到MCM,用于某核心平台系统集群变化后的变更复检。检测效果效果如下:

图3 多指标复检效果图

当用户进行集群变更时,会触发集群维度和机器维度的变更检查,识别核心指标(QPS、4XX、5XX)是否有一些异常。当指标中存在异常时,指标数据详细展示区会有额外展示:

  • 异常点标记线:检测异常的时间点上会有红色的竖线标记。
  • 异常项详细展示区:详细展示出检测到异常的服务器主机名、时间点、指标值、与比对基线的偏离情况。
  • 标记误报按钮:如果发现异常为误报,可以点击按钮进行标记,便于后期算法复盘优化。

3.2 事中快恢

当故障事件发生后,需要尽可能降低服务的异常对其他用户的影响,提升服务的可用性。可以从MTTD(平均检测时间)、MTTT(最短定位时间)、MTTR(平均修复时间)这三个指标入手。

图4 事件处理手段

3.2.1 异常发现

故障发现需要快速、准确。为避免误报,服务运维团队开发了一种基于历史上邻近的点分布相似(时序特征相似)思想的智能异常检测算法。如果当前待检测点相较其他历史参考点相对异常(存在点异常或者模式异常),检测流程会将异常点识别出来,并告知用户待测指标出现异常现象。

图5 异常发现能力流程图

在进行实时检测流程中,待检测点会先进入预检测流程。预检测组件会拦截绝大多数正常点,而当预检测异常时,才会执行特征提取阶段,进入模型异常分类;同时分类结果通过反馈机制可以增加到样本集,提高模型泛化能力和精召率。整个算法流程训练、检测、反馈闭环。

该项能力为美团监控系统提供无阈值的时序检测能力。目前检测流程中的分类器在真实线上样本的精确率和召回率均在98%以上。团队会每周定时抽样核心指标并对检测结果进行复盘,核心指标的异常检出准确率在90%左右。

3.2.2 根因诊断

在事件处理阶段,事件根因的自动定位可以大幅度降低定位时间(MTTT),帮助用户快速处理事件。由于美团现有系统规模极其庞大且复杂,因此不能用一种简单的定位方式来涵盖所有的错误根因。我们从多个方面来定位故障根因,包括链路异常、日志堆栈异常、服务异常等故障场景。这里我们将从两个方面来探讨根因定位的探索和实践。

异常链路拓展

雷达系统是美团的统一的事件管理平台,用于高效处理告警、事件和故障,同时也提供对公司内微服务系统的根因定位能力(后文简称为雷达)。识别微服务系统中的故障链路是重要且具有挑战性的工作,根据服务的调用情况构建服务调用图,并通过异常检测进行扩展和剪枝,以获得准确的异常链路图。拓扑图过大或过小都不利于根因定位。链路异常检测工作有如下要求:

  1. 实时性高:由于服务调用实时变化,异常计算工作不能过于耗时,过长的滞后结果将会导致拓扑图更新的延迟,异常检测没有价值。
  2. 计算量大:美团每分钟产生几十万级别的链路数据,并且每一种链路数据都包含调用次数、TP耗时等关键指标。
  3. 精召率高:我们需要准确识别出当前链路是否存在异常,精准识别可以防止拓扑爆炸或者根因节点缺失。

为了解决以上问题,我们和数据库平台研发组合作,研发了一套基于预训练的大容量异常检测服务来进行链路异常检测,其具有大容量,低时延,准确率高的特点。算法使用了历史长时序来挖掘时序特征参数,并在实时检测中参考临近120分钟进行了波动过滤,可以在较短的时间内快速识别指标的异常程度,实现了每分钟百万级别的异常检测。整体检测的平均流程耗时在1.5-3ms,检测的异常点精确率在81%,异常点召回率在82%,F1值为81%。

图6 百万级别异常检测能力框架图

我们使用编排好的训练流程对单指标进行单模型参数建模,存放到离线模型数据库中。在实时检测过程中从数据库中加载对应的预训练参数,根据检测流程进行实时监测。

该工作的核心思想是:将大量的复杂的计算异步化,在实时流检测的过程中,大幅度降低实时浮点计算量,提高整体的计算容量。该项工作对接了雷达链路中的流量和TP线的识别,支持雷达在故障诊断的过程中,获取异常节点与邻接节点之间的调用量、耗时的波动,来获取准确的故障全景图,并获取节点间调用异常的准确情况。

图7 链路拓扑异常计算

异常拓扑图效果如下图所示,雷达链路的拓展根据流量异常、失败率上涨、耗时等多个方面进行拓展,可以有效地找到核心故障链路图:

图8 雷达异常链路

指标多维度根因定位

大盘有一部分是多维度的指标,由于其业务特性比较强,这些指标波动很难用通用系统指标进行异常定位。我们目前探索了从指标自身维度的异常特征来进行异常维度定位的工作。

总的KPI指标异常,需要处理人员人工下钻到不同维度分析。如果指标的维度较多,人工分析成本巨大。该项工作的困难和挑战有以下两个方面。 - 首先,不同的组合的KPI是相互依赖和影响的,真正的根因元素的KPI异常,可导致其他维度的KPI也发生变化,很难对KPI指标的根因做一个量化的判断。 - 其次,由于KPI拥有多维度的属性,因此随着维度的增加或粒度的细化,元素的数目往往呈现指数级增长的趋势,可能需要从成千上万的多维属性空间进行搜索;此外,对如此多的维度快速做预测也是一个挑战。

在算法的应用场景中,我们需要考虑算法的可落地性。什么时候触发、如何提升结果的准确度、有效性是我们需要解决的问题。

图9 指标异常维度定位流程图

上图是我们执行异常维度定位的简易流程,我们在Squeeze2的基础上针对美团业务特性做了优化, 其具有以下特点:

  1. 自动化框定检测时间范围:使用变点检测等算法自动框定时间区间,用户无需人工框定所需要的检测时间区间即可自动化的进行异常维度定位操作,并且该项工作提高了下钻的性能和准确度。
  2. 多时间戳下钻,定位结果汇总:为了减少因为单点抖动而产生的错误下钻,提高算法的精确度,通过多时间戳的方式并行下钻分析,然后根据各个指标的不同特征,区分汇总结果,提高结果的可用性。
  3. 裁剪非关键根因,减少干扰维度的影响:对于最后的根因维度,会计算每一个子维度的整体重要占比,裁剪非重要根因,减少无意义维度带来的干扰。将下钻的根因编码解析,提高定位结果的可读性。

结果展示

当核心大盘指标出现异常时,系统就会自动触发下钻分析,将异常维度的分析结果推送到群里,帮助用户快速定位该指标的异常维度是什么。

表2 告警与异常维度定位

3.2.3 相似事件推荐

雷达系统中经常出现相似事件,它们往往有着相似的根因,如同一个业务的促销活动、某个中间件故障等。如果我们能够根据当前事件的异常现象,找到历史上最相似的一些Case推荐给处理人,则能为事件的定位和处理提供参考,提高处理效率。我们实现了一套相似事件推荐算法,通过NLP技术和规则过滤,找到每个事件的Top历史相似事件并推荐给处理人。算法的整体流程如下:

图10 相似事件算法流程

整体而言,算法在离线阶段使用NLP技术,将每个历史雷达事件进行向量化并存储;在实时推荐时,算法将新的雷达事件进行向量化后,通过向量相似度搜索到最接近的历史事件,并通过一些规则计算的特征进行排序和过滤,得到最终可推荐给用户的Top相似事件。下面对一些实现细节进行介绍。

离线训练阶段

1)数据类型区分

一个雷达事件中包含的数据可以分为两种类型:结构化数据和文本数据。它们主要的区别如下:

表3 事件数据结构

如上表所示,这两类数据的特点和作用有着很大的不同,且它们的可重复度也不一样。如果我们把两类数据都放到一个语料库中去训练一个向量化模型,会导致在后续的实时推荐阶段中,对于文本数据较多的事件产生“不公平”的现象:由于用户生成的文本数据的可重复度低,它们之间的相似度“天花板”会远低于结构化类型的数据,这就意味着一个事件的群聊越丰富,就越不容易找到相似的事件,这不符合我们的预期。

所以我们针对这两类数据分别构建语料库,通过文本向量化算法分别得到两个向量集,以便后续做更精细化的控制。

图11 事件建模过程

2)分词&向量化

分词(tokenization)是将文档分解为以字词为单位的基本要素,方便后续处理。对于雷达事件中的文本类型数据,需要采用分词器进行分词,并去除停用词后得到tokens(即分词后的词语列表);对于结构化字段,则直接提取属性值或通过一些规则处理得到tokens。

为提升文本分词效果,预先加载了IT领域词库、公司Appkey列表、公共服务名称作为分词器的词库。经过分词后,对事件进行词频统计,再通过Tfidf算法计算各词语的权重。Tfidf算法得到的向量长度等于词库的大小,向量第i个位置上的值表示词库中第i个词在事件中的权重,计算方式会综合考虑词语在当前事件的出现次数和在历史所有事件中的出现次数:

$$ 词i权重\approx\frac{词i在当前事件中的出现次数}{词i在多少个历史事件中出现} $$

一个词语在当前事件中出现得越多,且在越少的历史事件中出现过,说明它是一个比较关键的词语,Tfidf算法将给予它比较大的权重。

注意,以上公式只是概念化表示,不是Tfidf算法的实际公式,对于Tfidf算法的具体实现感兴趣的读者可自行搜索了解。我们对事件的结构化数据和文本数据分别经过以上步骤进行处理,每个事件将被用两个Tfidf向量表征并存储起来,用于后续在实时推荐阶段计算事件相似度。

实时推荐阶段

1)基于向量相似度召回候选事件

在实时推荐阶段,我们同样将新的雷达事件分为结构化和文本类型数据,分别进行分词并向量化。然后,我们计算它们与历史事件向量的相似度,得到结构化和文本数据与所有历史事件的相似度。我们设定不同的阈值来召回候选的历史相似事件,在实践中,设定的文本数据相似度阈值要低于结构化数据的相似度。两类数据召回的相似事件取并集,作为候选的相似事件列表,它们是与当前事件具有一定相似度的历史事件,量级在1000个以下。

2)基于规则计算特征进行排序

一个历史事件是否值得推荐给用户,除了与当前事件的相似度之外,还需要考虑更多的维度,例如事件本身是否具有足够的文本内容可以参考,以及距离当前事件的时间是否比较接近。为了使值得推荐的事件被展现给用户,我们对每个候选事件通过规则计算一系列特征,用于后续的排序:

  • 文本丰富度:衡量历史事件的内容质量,其中群聊、通告、反馈等文本数据较丰富的,能够提供更多关于处理、定位过程的信息,为当前事件的处理提供更多参考。
  • 时效性:衡量历史事件发生时间与当前事件的距离,越临近当前事件发生时间的历史事件,参考价值越大。
  • 根因匹配度:判断历史事件诊断根因是否与当前事件诊断根因一致,如果一致那么很有可能背后是同一个原因导致的问题。
  • 告警匹配度:衡量两个事件告警列表的相似程度,计算过程中会降低通用兜底类告警的权重,提高具有明确业务含义告警的权重。

以上特征,再加上召回阶段已经计算好的结构化数据、文本数据相似度,进行加权求和,得到最终的推荐得分,用于对候选事件进行排序。我们还需要对其进行过滤,去除不希望推荐给用户的历史事件。过滤的依据主要包括:对于系统发现事件,告警匹配度需要足够大,对于人为发现事件,推荐的历史事件的文本内容需要足够丰富。

3)关键信息提取&用户展示

为了使用户能够更快速地从历史事件中获取有效信息输入,我们从每个待推荐的历史事件提取出关键信息,包括由用户反馈的历史事件的根因、解决方案等信息,把这些附在推荐项上,在事件处理过程中推送到群里,辅助用户提高事件处理效率。同时,在Web端页面也会展示Top相似事件列表,以供用户参考。

案例展示:

表4 相似事件能力结果展示

该功能上线后,有相似事件的Case覆盖率为70%左右,对于系统发现且有推荐历史相似事件的Case,其平均故障处理时长比无相似事件的Case缩短28%。为了评估推荐的准确率,我们对有推荐相似事件处理经验且用户反馈了真实根因的Case进行了人工复盘,若推荐的历史事件与当前事件有类似的根因或解决方案,则标记为推荐准确,由此统计得到的推荐准确率约为76%。

在复盘过程中,我们发现,推荐的准确率很大程度上取决于用户所配置的告警质量,对于粒度较粗的通用兜底类告警(如域名5xx告警)所产生的事件,推荐准确率会低于细粒度的、具有明确业务含义的告警所产生的事件。原因也是显然的:粗粒度的告警背后的根因可能千差万别,所以即使一个历史事件有着同样的告警,也会有很大可能不是同一个根因导致的故障;而细粒度的告警则与之相反。

在后续的优化中,对于相似事件的排序策略,我们可能需要根据事件中告警的粒度,来调整不同告警类型对于相似度的贡献大小,而告警的粒度如何衡量,则需要结合公司具体的现状和我们的经验判断。总而言之,在数据驱动的算法之外,需要结合一些专家知识来弥补数据的不足,从而使算法的输出更好地服务于用户。

3.3 事后运营

在故障事后,用户对于故障的运营和复盘有利于经验沉淀,避免相同的问题再次发生,对于稳定性的长期提升有着重要作用。

COE(Correction Of Error)是美团的故障复盘系统,以文本形式记录了公司大量的历史故障复盘内容。我们在COE系统中基于主题分析等NLP技术,实现了故障复盘的主题展示和相似推荐能力,旨在帮助用户找到更多相似的故障,挖掘出共性问题。目前该功能处于初期建设阶段,正在迭代和探索的过程中。

4 总结和未来展望

本文简单介绍了团队在AIOps在事件管理这一领域的探索和实践。我们从三个关键的运维阶段——事前预防、事中处理以及事后运营,深入探讨AIOps在这些场景中的具体应用和优势。

之后我们也会从多个方面进一步探索AIOps在美团场景下的可能性。这里我们简单列举了几个可能且有价值的发展方向。

智能日志检测

图12 日志检测

日志异常检测通常由四个模块组成,包括日志收集上报、日志解析、特征提取以及异常检测器。我们期望通过提取日志的计数、序列、语义等特征,来动态识别当前服务的日志是否存在异常情况,

我们计划从两个方面进行探索:

  • 日志模版时序异常:通过Drain3等日志模版挖掘技术,将服务日志转化成日志模版时序。基于时序异常检测算法,可以识别日志模版徒增、异常日志出现等风险点。
  • 日志模版语义异常:通过解析日志模版的语义特征,结合机器学习和深度学习算法,识别当前日志是否存在语义异常。

智能化变更识别

图13 配置变更挖掘与识别

对于配置类型的变更(如:分布式内存配置变更、营销活动配置变更等),一旦变更人员疏忽填写错误或遗漏,会导致线上问题。同时,这类配置可能数量极其庞大,我们需要去动态分析识别每一配置对应的模型信息。

对于历史上正常完结没有回滚的配置变更,根据其变更值进行特征提取,学习该配置的Key、Value变化规律,从统计学、数据建模等多个角度构造每个配置值的特征组。当新变更到来时,就转化成了特征相似匹配,从而发现人工填写错误的问题。

5 本文作者

政东、迎港、张霖、俊峰等,均来自美团基础研发平台。

6 参考文献

  • [1] Ester M, Kriegel H P, Sander J, et al. A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise. AAAI Press. 1996.
  • [2] Li Z, Pei D, Luo C, et al. Generic and Robust Localization of Multi-dimensional Root Causes. 2019 IEEE 30th International Symposium on Software Reliability Engineering (ISSRE), IEEE. 2019.
  • [3] He P, Zhu J, Zheng Z, et al. Drain: An Online Log Parsing Approach with Fixed Depth Tree. 2017 IEEE International Conference on Web Services (ICWS), IEEE. 2017.
  • [4] M Du, F Li. Spell: Streaming Parsing of System Event Logs. 2016 IEEE 16th International Conference on Data Mining (ICDM). 2016.
  • [5] IBM. Drain3. https://github.com/IBM/Drain3
  • [6] Akiko A. An information-theoretic perspective of tf–idf measures. Information Processing and Management. 2003.
  • [7] David B, Andrew Ng, Michael J. Latent Dirichlet Allocation. Journal of Machine Learning Research. 2003.
  • [8] 曹臻, 威远. 基于AI算法的数据库异常监测系统的设计与实现. https://tech.meituan.com/2022/09/01/database-monitoring-based-on-ai.html

7 招聘信息

基础技术部-服务运维部-故障管理开发组主要负责故障发现、故障定位、故障恢复、故障运营、告警中心、风险管理、数据仓库等工作。目前团队诚招高级工程师、技术专家。欢迎有兴趣的同学投送简历至 [email protected]。(邮件主题注明:运维工具)

美团技术博客十周年,感谢一路相伴

2013年12月4日,美团技术博客发布了第一篇技术文章。

2014年9月30日,「美团技术团队」微信公众号同步更新技术博客的内容。

10年的时间,3600多个日夜,我们共发布了570多篇技术文章。

感谢大家的一路相伴。

美团技术博客/公众号能够走到今天,

很重要的一个原因是美团技术团队一直有着爱学习、爱分享的工程师文化。

我们也愿意把美团经过验证的、比较好的技术实践经验,分享给更多爱学习的小伙伴,跟大家一起学习、共同成长。

我们没有涨粉的「套路」,

也没有运营的「绝学」,

美团技术博客/公众号像树一样缓缓地成长,

枝繁叶茂靠的是美团技术同学用心撰写的一篇篇文章。

我们没有浮夸绚丽的「辞藻」,

也没有空洞乏味的「鸡汤」,

美团技术博客/公众号有着树一般的平凡模样,

求真务实、用心分享技术实践经验才是我们最坚定的信仰。

期待未来,跟大家一起学习,共同成长。

上周,我们征集了大家跟技术博客的一些故事,看到大家的留言后,我们非常感动。

正是因为有大家鼓励和认可,才让我们一直坚持走到了今天。

美团技术博客/公众号,感谢大家的一路相伴。 相信坚持的力量,期待下一个十年!

基于UI交互意图理解的异常检测方法

近年来,随着美团多种业务线的扩充和迭代,UI测试的任务愈发繁重。针对UI测试中人工成本过高的问题,美团到店测试团队开发了视觉自动化工具以进行UI界面的静态回归检查。然而,对于UI交互功能逻辑的检验仍强依赖于脚本测试,其无法满足对于进一步效率、覆盖面提升的强烈需求。主要难点体现在两方面:

  • 前端技术栈多样,不同页面的实现方式各异,这导致不同页面中功能相似的UI模块的组件树差异很多,基于规则的测试脚本也就很难具备泛化能力,生产、维护的成本非常高。
  • UI样式繁多,同样的功能模块可能在视觉上有很大差异,这为基于CV方法实现自动化驱动带来了困难。

考虑上述两个难点,美团到店平台技术部/质量工程部与复旦大学计算机科学技术学院周扬帆教授团队展开了“基于UI交互理解的智能化异常检测方法”的科研合作,利用多模态模型对用户可见文本、视觉图像内容和UI组件树中的属性进行融合,实现了对于UI交互意图[1]的准确识别。该工作对于大前端UI的质量保障等多个领域都具有可借鉴的意义,介绍该工作的学术论文[2]已经被 ESEC/FSE 2023 (软件领域CCF A类推荐会议)接收,并将于12月6日在其工业届轨(Industry track)公开发布、推介。

2. UI交互介绍

2.1 UI模块与交互意图

移动应用由“页面”组成,不同页面中的不同“模块”为用户提供着不同的功能。用户在浏览页面时,根据以往使用经验以及当前页面中的图像、文字、页面结构等信息,可快速理解页面当中不同【模块】所想要提供的【功能】,以及通过该功能用户能够达到的【目的】。这些被用户认为能够提供特定功能并达到预期目的的页面模块,我们将其命名为一个【交互意图簇】。

以下图中的页面为例,不同模块通常对应不同的交互意图类型划分。比如商品详情区域,我们可以得知此模块主要是向我们展示当前商品最主要的信息,起展示作用;而顾客信息区域,需要用户进行点击或输入个人信息,用以补全整个订单所需要的信息;同时页面当中也会存在各类功能按钮,通过按钮的 位置文本信息、图标等信息,用户也可以大致推断出操作后会得到怎样的结果。由此,我们可以将UI交互意图定义为「用户通过当前UI展示推断出来的不同模块的概念及交互功能」。

图1 模块的概念及交互功能样例

2.2 当下痛点与启示

对于复杂的UI交互场景,如提交订单页,测试人员需要对不同模块制定较复杂的测试流程、测试规则,同时编写及维护复杂的自动化测试逻辑。以美团内的App测试场景为例,许多不同的页面有着相似的功能模块,这些功能模块尽管表象不同,但对于一般用户来说,交互意图明确且相似,没有理解困难。如:

图2 相似的功能模块

2.3 研究目标

本课题期望结合多种机器学习方法,通过机器获取与人工认知一致的“交互意图”,从而利用该信息模拟测试人员对客户端产品进行“理解-操作-检查”的测试验证流程。如人工操作一般,我们希望该能力能够以一个与一般用户类似的逻辑来操作、检查相似的功能,同时兼容不同的技术栈、App、业务领域,无需特定适配。就像一个用户在美团上能够订酒店,在没有使用过的点评或者美团小程序上也同样能完成预订酒店的流程。

从能力目标视角来看,UI交互意图识别的定位是完成一般用户的交互概念到页面实体的映射。由人直接进该映射的准确率最高、泛化性最好,典型的场景就是手工测试,即人观测页面后操作、检查。人能在不同的设计、程序实现形式下实现下找到目标操作实体(例如各种各样的提交按钮、商品卡片)。当前的自动化脚本测试提高了效率,但由于映射的泛化性较差,往往需为每个页面做单独的适配。

此外,业内尝试了诸如CV页面目标检测等方法,但在鲁棒性、泛化性、使用成本等方面上仍不太令人满意。本研究旨在利用深度学习和多模态信息,通过少量标注数据,尽可能提升交互意图识别的映射能力,使其接近人的识别、认知水平。

图3 UI交互意图理解的能力目标

2.4 效果预期

本研究提供一种UI交互意图理解的通用能力,能够在测试核心流程“理解-操作-检查”各个环节应用

  • 识别页面模块交互意图:通过页面UI交互意图识别来模拟测试人员的认知
  • 测试行为的注入:利用UI交互意图识别结果信息,将操作逻辑程序化
  • 测试结果检查:利用UI交互意图识别结果信息进行页面状态通用化校验

图4 核心流程“理解-操作-检查”

图5 预期效果示例

3. 架构设计

3.1 技术思路

考虑到UI交互意图理解是一种页面理解的通用能力,需要结合业务场景产生实际效果,我们决定首先将其应用于智能化UI交互,探索交互意图理解的能力范畴以及落地效果验证。后续会将该能力扩展到智能化测试逻辑检查、智能化遍历测试、测试知识标准化管理以及推荐等其它大前端测试领域应用。

为了验证技术方向的可行性,本课题先限定在某个垂直业务领域(订单/表单)进行探索,确认实际使用效果,再将方法推广泛化到其他领域。

具体来说,本项目的技术方案分为两个部分:

  • UI交互意图理解:基于深度学习方法对交易流程中表单/订单场景进行目标UI交互意图簇识别划分。
  • 智能化测试用例驱动:定义测试用例目标,基于表单/订单等场景中的UI交互意图簇编写交互逻辑,在跨App、跨技术栈、跨业务的场景下尝试复用执行。

3.2 当前进展与效果Demo

本项目目前已经实现了一套通用UI交互意图理解方法,利用UI交互意图在一些场景下编写的智能化测试用例可以在不同UI页面、不同技术栈,甚至不同App之间复用。下面是一个使用UI交互意图编写的“下单首个商品”测试用例的交互意图和其泛化能力的效果展示:

交互流程:识别第一个商品、点击购买进入提交订单页、填写顾客信息、提交订单。

App效果展示

【视频】美团App下单购买列表内首个商品

3.3 实现难点

图6 测试过程中存在的挑战

如何让机器学习到一般用户的认知概念,自动分析获取到预先定义的UI交互意图是本课题中最大的难点。

4 实现方式探索

针对上述难点,本课题从真实的业务需求出发,首先梳理了需要识别的交互意图类别。随后,对交互意图类别进行了分析,先后进行了多种方法的尝试,通过定量实验对不同设计进行了效果比对,最终选取了先分类,再聚类的落地方案:先以渲染树元素为最小单位进行交互意图类型的分类,然后在不同的交互意图维度进行元素聚类,生成对应的交互意图簇。

4.1 交互意图识别需求

深入挖掘业务需求后,我们发现:UI交互意图并不是一维的,在不同场景、不同需求下会有不同的分类标准。具体来说,如果关注所属业务类别,可以将交互意图簇分为:商品信息、评价和发票等;当关注用户的操作方式时,又需要将可交互的组件分为:点击、键入、长按三类。例如,对于“点击进入第一个商品详情页”这样一个交互意图:模型需要从业务层找到“商品信息”,在商品信息簇中找到操作层的可“点击”的UI组件(“商品信息”和“点击”的交集),然后执行点击操作。

此外,由于本工作的初步实验场景为具有大量计算逻辑和信息输入的表单页,因此我们又增加了计算层表单层两个特有的维度。例如,对于“购买最便宜的商品”这样的交互意图,其细分为“找出最便宜的商品”和“订单填写”两个串行子意图。具体来说,模型需要首先在商品列表页找出业务层“商品信息”和计算层“金额统计信息”的交集并排序,随后点击最便宜的商品进入提交订单页。在提交订单页中,模型需要在业务层的“顾客信息”和表单层的“信息输入”中找出共有的元素,并根据这些元素生成对应的文本输入信息,从而完成“订单填写”的子意图。

由此,我们利用上述四个维度将新的分类标准确定为16个不互斥的类别。

维度类别
1. 业务层顾客信息、商品信息、优惠、评价、备注、发票、提交按钮
2. 表单层功能按钮、信息输入、标题、说明
3. 计算层数量、金额统计信息
4. 操作层点击、键入、长按

四个维度的预期分类结果分别如下图所示:

图7 多层次预期识别结果

4.2 模型的输入

为了实现UI交互意图理解这个目标,我们推测,与一般用户的理解方式类似,基于多种信息的综合考虑能够提升整体效果,因此选择了三种模态的页面信息:图像信息(来源于页面截图)、渲染树信息文本信息

例如,针对元素 【“普通支付”按钮】,可获得的关键信息有以下三种:

图8 多模态信息示例

4.3 双阶段UI交互意图理解

分析输入数据可知:三种信息输入中只有“渲染树”带有明确的边界,但其与“交互意图”概念在粒度上有显著差异。因此,本研究考虑采用先分类,再聚类的思路:先以渲染树元素为最小单位进行交互意图类型的分类,然后在不同的交互意图维度进行元素聚类,生成对应的交互意图簇。

具体来说:

  • 在分类时,利用自注意力机制进行特征提取,实现在判断当前元素类别时会参考其它元素信息的目标。
  • 在元素聚类的过程中,利用有监督的聚类方法将分类后的渲染树元素在不同交互意图维度进行聚合,得到簇划分结果。

4.3.1 UI组件分类模型

由于渲染树反映的是最细粒度的UI组件,因此对渲染树中组件进行分类的最大难点是信息缺失:订单页中的数字有可能是金额、商品数量、顾客人数,此类情况仅依据当前渲染树节点无法区分。因此,本研究借鉴了NLP领域的经验,利用自注意力机制进行特征提取,实现在判断当前元素类别时会参考其它元素的信息的目标。

分类模型结构如下图所示,我们利用Vision Transformer预训练模型提取图像特征,使用中文bert预训练模型提取文本特征,同时把渲染树元素属性进行特征提取后输入模型,综合判断元素类别:

图9 模型结构

为了探究三种关键信息(渲染树、视觉图像信息、用户可见文本)的有效性以及三者之间的关系(是否相互补充),我们将不同关键信息作为模型的输入类型进行了消融实验,训练了7种不同的自注意力分类模型。此外,考虑到在UI领域很多实践使用CV目标检测能力实现类似工作,为了对比此类目标检测模型和自注意力模型在当前问题上的效果差异,本研究以YOLOv7模型为代表,定量评估了其在UI组件分类上的效果。

实验时,本研究从美团App上的四种业务线(酒店、KTV、密室、门票)中随机截取了158个提交订单页面,进行人工标注后,将其中的123个页面作为训练集,其余为测试集。在测试集上,各个维度的F1 Score[3]如下:

模型平均F1 Score顾客信息商品信息优惠提交信息输入数量金额统计
YOLOv70.5510.4770.3290.5700.6140.5260.8530.547
仅渲染树0.7790.8160.7940.7000.8630.7840.7850.686
仅图像0.7200.7290.7380.6490.9000.6960.6810.646
仅文本0.7800.9050.7980.7330.7340.7890.7370.823
图像+渲染树0.8130.8330.7830.7400.9260.7950.8390.754
文本+渲染树0.8370.9200.8130.7580.8650.7970.9220.815
图像+文本0.8300.9160.7880.7880.9240.7760.8260.829
多模态0.8610.8940.8230.7420.9960.8030.8690.850

从上表可以看出,多模态自注意力深度学习的 UI 交互意图理解方案在相同数据集下具有最好的表现。分析原因主要有两个:首先,随着模态的增多模型的效果会变好,可见三种模态的信息互为补充,让模型能够通过多个维度更准确的进行拟合;此外,自注意力机制的引入使得节点的分类能够考虑到其周围的相关信息,提升了特征提取的效果,让UI组件分类更准确。因此,我们的后续研究基于此多模态自注意力模型展开。

该多模态模型的UI组件的多维度分类结果示例如下:

图10 不同页面下的分类效果(不同颜色框代表不同类别)

4.3.2 交互意图簇生成:UI组件聚类

当前多模态多分类模型针对的识别对象是一个个渲染树节点。一般来说多个渲染树节点才能组合成一个完整的交互意图簇,所以我们考虑将属于同一个意图的节点聚类在一起,这样就能够给下游任务提供更多可用信息。

我们首先尝试了基于规则的无监督聚类方式:将一个表单页上被分类模型判为同类型的连续节点聚为一个交互意图簇。但由于其在处理连续但独立的同类交互意图簇时效果很差,并不适用于当下复杂场景。

深入分析可知,聚类任务有两个难点:

  • 情形1:如果渲染树节点不连续但是属于同一个簇,仍希望对其成功聚类。
  • 情形2:连续的渲染树节点可能被分类模型判定为同一个交互意图类别,但希望对齐一般用户的理解将其聚类为多个独立交互意图簇。(如:连续的几个表单填写框我们希望能够将其分割开,如下图中出现的三个连续的【信息输入】交互意图簇)。

图11 三个连续的信息输入交互意图簇

在聚类的实现方式上,我们考察了多种常用聚类手段:

  1. 最简单的规则聚类(连续同标签的渲染树节点为同簇)并不能处理前述连续多个同类簇情形。
  2. k-means[4]为代表的无监督聚类方法在本研究所涉及的多维度聚类问题上也表现不佳,我们对其超参数进行了广范尝试也无法得到理想的聚类效果。

经过广泛尝试,我们最终选取了一种有监督聚类方案:每个节点依次与其它节点计算是否属于同一个簇,将被判断属于同一个簇的聚合起来。通过分类模型判断是否属于同一个簇,而模型的ground truth(真值)是我们人工标注出的每个类别页面中的所有渲染树元素簇。聚类模型结构与训练流程如下:

图12 聚类模型结构

我们采用与分类模型相同的页面标注数据生成训练集和测试集。训练时,我们首先为每个UI界面上生成所有可能的两两组合,其中在任意类别属于同一簇的组合是模型输入正例,其余为反例。预测时,我们将分类模型的结果送入聚类模型,由聚类模型输出最终的交互意图簇。

在UI组件聚类的评测指标上,我们采用标准聚类量化评估参数兰德系数[5]衡量聚类模型的效果。William M. Rand 通过将聚类问题转换为任意两个元素的组合(N(N-1) 个)是否在同一簇的决策问题,由此定义了在聚类问题上的混淆矩阵(TP,TN,FP,FN)。因此本研究用Precision来衡量聚类结果的准确性,用Recall表示聚类结果的全面性,而两者的F1 Score就是兰德系数。

由于本研究采用的是多层次平行聚类算法,所以兰德系数的数值整体偏低(如下表所示)。但从聚类效果图(图13)可知当前有监督聚类模型对交互簇有良好的聚类效果。

顾客信息商品信息优惠备注提交功能按钮信息输入标题说明数量金额统计
Precision1.0000.9640.9541.0001.0001.0000.9971.0000.9241.0000.866
Recall0.8710.9650.4021.0000.7110.8730.5170.9830.6240.7340.296
F10.9310.9640.5651.0000.8310.9320.6810.9910.7450.8460.441

图13 选用的有监督聚类效果示例(不同颜色框代表不同类别)

4.4 实验结论

综上,我们得到了以下结论:

本研究提出的基于多模态自注意力深度学习的UI交互意图理解方案在准确性、泛化性上具有一定优势,且其对数据标注和训练轻量化的需求贴合业界的真实测试场景。

5 实际落地探索

基于UI交互意图理解的智能化测试用例驱动

UI交互意图识别模型在订单页领域已经具备了一定的交互意图簇识别能力,我们期望利用UI交互意图识别模型进行智能化测试用例驱动:在交互意图层面进行大前端测试用例的编写,希望测试用例在不需要任何适配的情况下实现跨端、跨App、跨技术栈的执行。 我们在美团App的酒店详情页场景的安卓端利用交互意图簇识别能力完成以下测试用例的驱动编写:

下面是测试用例伪代码以及部分输入集合定义:

图14 测试用例伪代码与执行流程

图中显示了选择 “首个酒店” 与 “最便宜酒店” 的伪代码流程。

  • 首先在酒店详情页,我们会在 BuyFirstItem 与 BuyCheapestItem 这两个函数中实现主要逻辑 。其中在 BuyFirstItem 会寻找到首个被模型识别为 “商品信息” 的交互意图,并从中找到在这个交互意图中的“购买按钮”意图,进行点击后跳转到填单页。在 BuyCheapestItem 中我们会获取页面上所有的 “商品信息” 的交互意图,并从每个商品信息意图中识别“价格信息” 交互意图,得到每个商品的价格进行比较,找到最便宜的商品后点击其“购买按钮”意图进入填单页。
  • 进入填单页后,通过模型识别“信息填写” 交互意图。如图中所示,首先识别到两个“信息填写” 意图,通过其提示文字 【住客姓名、联系手机 】匹配输入集合中的 姓名、电话,从而选择出信息填写到对应的输入框中。在此之后,利用模型识别页面中的“提交订单”交互意图,点击进行订单提交从而完成整个下单流程。

【视频】美团App下单购买列表内首个商品

【视频】美团App下单购买列表内最便宜的商品

此外,我们在训练集以外的五种App上定量研究了智能化测试用例的可用性和泛化性。100个不同的页面中,基于UI交互意图理解的智能化测试用例在89个页面正确执行通过。该实验证明:基于UI交互意图理解的智能化测试用例具备良好的鲁棒性和泛化性。

目前,我们正在推进UI交互意图在实际自动化测试用例编写中的落地工作,即用UI交互意图代替基于规则的测试驱动脚本。由于业内的测试场景往往涉及不同技术栈、不同业务之间的大量相似的页面,这种泛化能力强的测试用例可以在相似页面复用,因此可以减少开发成本。此外,与现有的基于规则的测试脚本不同,该方法对UI页面的小规模变化不敏感,不会出现需要频繁维护Selector[6]的情况,可一定程度上减少自动化Case维护所耗费的精力。

未来,我们将通过收集更为广泛的UI数据来训练一个通用的UI交互意图理解模型以覆盖常见页面中的UI交互意图识别,业务质量保障人员可以直接利用这种通用的识别能力开发泛化性、鲁棒性更好的智能化测试用例。对于那些暂时处于模型能力范围之外的页面或者新上线的业务,我们将提供模型的微调接口,经少量的标注数据微调即可使其在相关页面展现出识别效果。

6 总结

本文介绍了利用页面多模态信息在UI测试领域的探索与实践经验。针对意图信息识别问题,我们利用图像+文本+渲染布局属性信息探索出了一种交互意图簇识别模型,验证了基于自注意力的多模态方向可行性。此模型可以识别出渲染树元素多维度的意图属性信息,同时利用聚类算法将节点聚成交互意图簇,可以为后续的任务提供结构化决策信息。在标注数据较少的情况下仍体现了较好的准确率以及泛化能力。后续计划通过扩大数据集、加强预训练等方式继续提升模型识别的精度。

回顾整个UI交互意图理解探索历程,先后经历了“无监督/无类别的区域划分”,“有监督针对UI节点分类”, “分类后聚类”, “利用识别结果进行测试用例编写、执行”四个阶段。目前在UI交互意图提取上我们已探索出了较为合适的方案,正进行实际业务落地,让UI交互意图识别能力融入当前大前端测试能力,在智能测试用例驱动、智能检查等方向上取得实际应用收益。

7 展望

下面是几个基于UI交互意图理解能力开展的业务落地工作。

1. 智能探索性测试

当前App功能复杂,有大量可以操作的组件,无意识的探索效率太低,期望利用意图识别结果,对当前测试场景的一些通用可操作组件执行有意义操作的自动化测试,并进行逻辑问题校验。

图15 探索性测试大体流程

2. 跨分辨率UI Diff及归因

不同分辨率/设备下布局存在差异,像素级比对无法识别不同分辨率下的UI Diff。使用交互意图簇Diff 可以大大削弱像素位置的差异造成的干扰,支持跨分辨率的比较,凸显Diff所需要关注的文本/图像变化,并可利用意图信息对结果进行结构化归因。

3. 节点匹配选择

利用意图识别预训练模型,支持节点匹配任务,实现泛化性较强的跨分辨率、跨技术栈、跨App的节点查找能力,与现有的基于XPath、Selector等的线性条件节点选择模式形成互补。

在中长期来看,我们期望将UI交互意图识别作为大前端结构化信息提取的通用能力,在不同的业务领域进行如智能测试bot、终端测试标准化知识组织与覆盖率评估、智能辅助测试用例编写与生成等方向上持续探索、落地。

图16 相关下游任务

附录

大模型时代下UI交互意图理解能力的意义

目前业界内大模型主要存在两种类型:大语言模型[7](LLM:仅支持文本模态输入输出)以及多模态大语言模型[8](MLLM:可以同时处理多种模态信息)。目前大语言模型具备比较良好的通用化逻辑理解能力,而多模态大语言模型能够同时基于文本、图像等模态信息完成理解、判断,但整体的逻辑能力水平相比大语言模型有一定差距,在一些多模态任务上判断、分析的精度尚不够令人满意。

基于这两种大模型,在实际任务解决上有两种相对应的主要模式:【LLM as Controller】【MLLM cognize Everything】,UI交互意图理解作为一种垂直领域能力,在两种模式中都具有相应的应用潜力。

LLM as Controller

该模式的核心思路是将垂直能力作为工具,LLM作为总控,利用其逻辑推理能力,通过自然语言理解目标,然后进行决策,编排、调用工具,完成任务。这种范式下的典型实例有HuggingGPT[9]等。在这种范式下,LLM能够与垂直能力优势互补,更好的完成多领域任务。

图17 相关下游任务

以HuggingGPT项目为例,其主要思想是将LLM作为总控,将HuggingFace平台上众多的垂直能力模型作为工具集,用户可提出需求,LLM根据需求调用垂直能力。最后LLM根据垂直能力返回的结果生成满足用户需求的多模态内容。

图18 HuggingGPT工作流程

可以看到在这个模式下,与其它的垂直工具能力类似,UI交互意图理解能力可以作为工具能力供LLM调用,更好的完成UI交互相关的任务。

MLLM Cognize Everything

多模态大语言模型出现了之后,让我们看到了多模态任务通用化解决方案的曙光。具体到UI交互意图识别任务中,我们尝试使用多种MLLM直接进行UI交互意图识别,总体来看MLLM已经具备不错的识别能力,但是在具体的坐标、内容分析方面上仍有偏差。 UI交互意图识别模型可以通过以下两种方式帮助MLLM在意图识别任务上进行性能提升:

  • 将UI交互意图识别模型作为页面多模态信息Encoder,通过微调的方式提升意图识别任务中的准确率。

这里以MiniGPT为例,介绍Encoder模式。

图19 MiniGPT模型结构

由上图可知,多模态大语言模型中一般由每个模态对应的模块来进行模态信息处理,如上图中VIT[10]&Q-Former[11]为图像模态处理部分,Vicuna[12]是一种开源的LLM。UI交互意图理解模型可以替换图中VIT&Q-Former的位置,作为交互意图信息的处理预训练Encoder与LLM结合进行多模态整合训练,产出页面分析来辅助多模态大语言模型在大前端质量保障中的应用。

图20 基于UI意图理解能力的多模态大语言模型结构

  • 将UI交互意图识别模型做为信息提取工具,将识别出的结构化信息加入Prompt中,帮助MLLM更精确的进行意图识别。

总体而言,UI交互意图识别是一种简单轻量但效果不错的垂直领域能力,只需简单少量的训练数据,即可实现在诸如跨App、跨技术栈、跨业务等复杂场景下准确识别多种交互意图的能力。大模型领域日新月异,我们也将持续探索UI交互意图识别能力与该领域技术的结合方式,发挥其最佳效果。

本文作者

诗雨,张雨,永祥等,均来自美团到店事业群/平台技术部/质量工程部。

参考文献

招聘信息

美团到店平台技术部-质量工程部是到店事业群的技术质量保障团队,负责到店综合、到店餐饮、酒旅业务以及到店多个平台(业务平台、技术平台以及数据智能领域)的质量保障,通过建设基础工程能力,从研发全过程建设质量,保障到店业务与技术系统规模化快速迭代。我们注重技术创新,不断地探索技术边界,用技术驱动业务,团队技术氛围浓厚。目前正在招聘:

  • 测试开发工程师-服务端/视频领域/数据智能/移动端
  • 开发工程师-业务稳定性保障

&#x6B22;迎感兴趣的同学发简历到:[email protected],邮件标题格式:姓名-岗位名称-社招。

❌
❌