阅读视图

发现新文章,点击刷新页面。

0基础带你精通Java对象序列化--以Hessian为例|得物技术

一、概述

在高级编程语言的世界中,开发者始终与 【object/struct】 这类高度抽象的数据结构打交道。然而在分布式架构下,任何服务进程都不是数据孤岛——跨进程数据交换是必然需求。

以Java为例,业务逻辑的输入输出都是 【object】 。但在RPC场景中,这些对象必须经由网络传输。这里出现了一个根本性矛盾:网络介质(网线/光纤)对面向对象编程(OOP)一无所知,它们只会用光和电忠实地传输扁平化的字节流(byte[] )。

软件工程经典的分层理论驱使我们去添加一个转换层

我们需要有个工具或者组件来协助进行 【object】【byte[]】 之间的双向转换。这个过程包含两个对称的流程:

  1. object】->【byte[] 】:业界一般称为序列化/serialize,但是那个单词念起来很拗口,本文我们都叫它【编码/encode】好了。
  2. byte[] 】->【object】:业界一般称为反序列化/deserialize,但是那个单词念起来很拗口,本文我们都叫它【解码/decode】好了。

Hessian作为Java生态中久经考验的对象编解码器,相较于同类产品具有以下两大核心优势:

  1. 深度Java生态适配:与JSON、Protobuf等语言中立的通用协议不同,Hessian专为Java深度优化,对泛型、多态等Java特有语言特性提供原生支持
  2. 高效二进制协议:相较JSON等文本协议,Hessian采用精心设计的二进制编码方案,在编解码效率和数据压缩率方面表现更优。

需要强调的是,软件工程没有银弹——业务场景的差异决定了编解码器的选择必然需要权衡取舍。但就Java RPC而言,Hessian应该是经过广泛实践验证的稳健选择。

本文将系统解析Hessian的编码流程,重点揭示其实现【object】->【byte[] 】转换的核心机制。

二、基础编码原理

对象编码过程主要包含如下两大核心:

  • 对象图遍历:遍历高级数据结构
    • 通过反射或元编程技术遍历对象图(Object Graph)。
    • 是同类产品的通用逻辑,不管jackson、fastjson、hessian都需要用不同的方式做类似的事情。
  • 编码格式:将高级数据结构按协议拍平放到byte[]
    • 同类产品百家争鸣,各有各的思路。
    • 是同类产品的竞技场,各个产品在这里体现差异化的竞争力
    • 设计权衡包括:
      • 二进制效率 vs 可读性(如Hessian二进制 vs JSON文本)
      • 编码紧凑性 vs 扩展灵活性
      • 跨语言支持 vs 语言特性深度优化

对象图遍历决定了编码能力的下限(能否正确处理对象结构),而编码格式决定了编码能力的上限(传输效率、兼容性等)。

对象图遍历

对象图遍历的本质是按深度优先进行对象属性导航

举个例子:

宏观来看,A类型的对象其实是一棵树(或图) ,如果脑补不出来的话,我给你画个图:

可以看到这棵树的叶子结点都一定是Java内置的基本数据类型。换句话说,Java的8种基础数据类型和他们的数组变体,支撑了Java丰富的预定义/自定义数据结构。

八股文:Java的8种基础数据类型是哪些?String算不算基础数据类型?

编码的本质就是深度优先的遍历这棵树,拍平它,然后放到byte[] 里。

我举个例子吧。

伪代码

为降低伪代码复杂度,我们假设Java只有1种基础数据类型int,也就是说Java里只有int和只包含int字段的自定义POJO。

我们定义POJO指的是用于传输、存储使用的简单Java Bean或者常说的DTO。

从某种意义上来说,Integer也是基于int封装的自定义POJO。

字节流抽象

我们使用标准库里的java.io.DataOutput来进行伪代码说理,这个类提供了一些语义化的编码function。

java.io.DataOutput

对象图遍历

字节流布局

最终呈现出来的字节流层面的数据布局会是这样:

看起来没毛病,唯一的问题就是不好解码。

当解码端收到一个16字节的字节流以后,它分不清哪块数据是A对象的,哪块数据是B对象的。甚至都分不清这到底是4个int32还是2个int64。

这个问题需要编码格式来解决。

编码格式

上面遗留的问题,聪明的你肯定想到了答案。

就是因为编码产物太太太简陋了,整个过程中只是一股脑的把树拍平,把叶子节点的值写入字节流,缺少结构元数据

最最最重要的结构元数据就是数据块的边界,上述4个数据块,最起码应该添加3个边界标识。

我们先用我们耳熟能详的JSON格式来理解下编码格式这个事情。

伪代码

JSON是这样解决这个问题的:

JSON协议在嵌套的POJO上用 {} 来作为边界,POJO内部的字段键值用 , 来做边界, : 拆分字段键值。

字节流布局

结果就变成这样:

这样在解码的时候,可以通过 {},:token来切割JSON字符串,判定数据块边界并恢复出对象图

三、Hessian编码格式

接下来我们可以开始介绍Hessian的编码魔法了。

需要强调的是:Hessian跟JSON不同,Hessian是二进制格式。如果一个字节流直接按字符集解码不能得到一个完整的、有意义的字符串,那它就是二进制编码数据。

Hessian在编码时,按数据块类型为每一个数据块添加一个前缀字节(byte) 作为结构元数据,这些元数据数据块一起,交给解码端使用。

数据块

对象图里的每一个节点,都是一个数据块。

如上图所示,以A对象为根的对象图,一共有6个数据块

数据块标签(tag)

Hessain在编码每一个数据块时,都会根据数据块的类型在字节流中写入一个前缀字节(0-255),这个字节说明了数据块的语义和结构

int32为例,其最基础的编码格式如下:

除该基础编码格式外,int32的编码还有其他变体。

上述 I 就是整数类型的tag。解码端读取tag后,按tag值来解码数据。

com.alibaba.com.caucho.hessian.io.Hessian2Input#readObject(java.util.List<java.lang.Class<?>>)

由此延伸、拓展,其他的数据类型都是类似的模式。常见数据类型及其对应的tag值如下:

值得注意的是,NFT三个tag是自解释的,和固定值映射、绑定。

POJO编码

POJO是一种特殊的数据块,Hessian将POJO的结构拆开,分别编码。

POJO结构编码

POJO结构的tag为C,对照int32的编码格式,POJO结构的编码格式如下:

举个例子:

编码POJO时,Hessain会将POJO的类名、字段名列表写入字节流,供解码端使用。后续编码POJO字段值时,需要按照字段名列表(如上述bbcc)的顺序来编码字段值。

POJO字段值编码

POJO字段值的tag为O,对照int32的编码格式,POJO字段值的编码格式如下:

举个例子:

可以看到,编码POJO字段值的时候,在tag后面有一个POJO结构序号

这是Hessian的一个数据复用的小技巧。

POJO结构复用

JSON协议有一个缺点,那就是重复数据带来的存储/传输开销。举个例子:

如上图,B类型的字段名(ddee)在编码产物中重复出现

Hessian希望解决这个问题,同一类型的多个POJO对象在序列化时,只需要在第一次的时候编码类名、字段名等元数据,后续可以被重复的引用、使用,无需重复编码。

如果用Hessian来编码,结果会是这样:

数据布局

数据布局详解

如上图,APojo、BPojo的字段名只会编码一次。多个BPojo对象在编码时会通过结构引用序号(1) 来引用它。相对JSON,Hessian避免了多次编码BPojo字段名的开销。

为什么APojo的序号是1、BPojo的序号是2?

Hessain在编码过程中,每次遇到一个新的、没有处理过的新POJO类型时,会给它分配一个从0开始、单调递增的序号。

遥相呼应的,解码侧每次解码一个tag为C的POJO结构数据块时,也会按解码顺序维护好其索引序号。

四、Hessian编码细节

到现在,我们已经对Hessian编码有了一个的概括性的认识,接下来我们来看看一些值得注意的细节。

重复对象复用

A对象里有两个字段(d、e)指向同一个对象B。如果不做处理,会因为重复编码而带来不必要的开销。

相同的一个B对象,因为被两个字段重复引用,导致2次编码、产生2份数据空间占用!

如果只是有额外的开销,没有可用性问题那都还好。关键是在循环引用场景下,会因为引用成环导致递归进行对象图遍历时触发方法栈溢出!

循环引用是重复引用的特例,只要将重复引用处理掉,循环引用也就没问题了。

Hessian通过对象引用来解决这个问题。在对象图遍历过程中,遇到一个之前没有遇到过、处理过的POJO对象时,会给它分配一个从0开始、单调递增的序号。

后续再次需要序列化相同的对象时,直接跳过编码流程,将这个对象的序号写入字节流。

解码时,解码侧按相同的顺序来恢复出引用序号表,解码后续的对象引用。

小整数内联(direct)

很多编码类型,都需要在tag后再维护一个整数类型的字段。比如:

  • POJO的编码tag O需要一个整数来引用POJO结构引用序号

  • 类似String的变长类型需要一个整数来标识变长数据的长度

当字符串很短,就比如 "hi" 吧,短字符串编码格式的长度字段可能比实际字符数据还大(用4字节存储长度2),效率低下。

tag分段

Hessian将一些tag值的语义富化,让它既体现数据类型,也体现小数值。

因为tag是一个byte(int8) ,取值范围是0-255,每个tag标识一种特定的数据类型(int、boolean等),但是这些数据类型最多几十种,取值范围内还有很大的数值区间没有被使用,其实比较浪费。那我们就可以把这些空闲的tag值,挪作他用,提升tag数值空间利用率。

我举个例子,注意这个是参考Hessian思路的一个简单示意,具体的Tag值和Hessian无关

长度内联

对于长度≤31的字符串,Hessian用tag同时编码类型和长度

  1. 当0 <= tag <= 31 时,标识后续的数据块为字符串。
  2. tag的数值即为后续数据块的长度。

示例如下:

序号内联

当结构引用序号<=16时,Hessain用tag同时编码类型和序号

  1. 当0x60 <= tag <= 0x70 时,标识后续的数据块为POJO字段值。

  2. tag - 0x60的值,即为POJO结构(类名+字段名)引用序号。

示例如下:

相关源码如下:

com.alibaba.com.caucho.hessian.io.Hessian2Output#writeObjectBegin

字符串编码

Hessian编码字符串的关键流程是:字符串分段+不同长度的子串使用不同的tag。

  • 分段原则

字符串会被分割为若干块,每块最大长度为32768(0x8000)。前N-1块均为完整长度的子串(32768字节),使用固定tag R标识;最后一块为剩余部分,长度范围为0-32768字节,根据实际长度选择动态tag。

  • 尾段tag的选择基于尾块的长度决定
    • 长度≤31(0x1F):使用单字节tag 0x00-0x1F直接内联长度值。
    • 32≤长度≤1023(0x3FF):使用tag 0后跟1字节长度(大端序),10bit的计数空间由tag字节和长度字节共同提供。这个地方有点绕,看下代码吧。
    • 长度≥1024:使用tag S后跟2字节长度(大端序)。
  • 相关源码

com.alibaba.com.caucho.hessian.io.Hessian2Output#writeString(java.lang.String)

这种设计通过减少长字符串的冗余长度标记,在保持兼容性的同时显著提升了编码效率。

整数压缩

基础编码

整数(int32)的的取值范围很大(-23^31 - 2^31),保守的编码格式会用4个byte来编码整数。

但是日常使用中,我们会大量使用小整数,比如1、31。这时候如果还用4字节编码就很不划算啦~

变长编码

Hessian根据整数的值范围,动态的选择不同的编码方式,且不同的编码方式有不同的tag

  • 单字节整数编码类似【长度压缩】,tag中直接内联数值

适用范围:-16 到 47(共64个值)

编码方式:使用单字节,值为 value + 0x90(144)

例如:0 编码为 0x90,-1 编码为 0x8f,47 编码为 0xbf

  • 双字节整数编码

适用范围:-2048 到 2047

编码方式:首字节为 0xc8 + (value >> 8),后跟一个字节存储value剩下的bit。

这种编码可以表示12bit有符号整数

  • 三字节整数编码

适用范围:-262144 到 262143

编码方式:首字节为 0xd4 + (value >> 16),后跟两个字节存储 value 的高8位和低8位。

这种编码可以表示19bit有符号整数。

  • 五字节整数编码

适用范围:超出上述范围的所有32位整数

编码方式:以 'I' (0x49)开头,后跟4个字节表示完整的32位整数值。

  • 相关源码

com.alibaba.com.caucho.hessian.io.Hessian2Output#writeInt

收益

  • 小整数(如 0、-1)仅需 1字节 ,而传统 int32 固定4字节。
  • 大整数动态扩展,避免固定长度浪费(如 1000 仅需2字节)。

其他的数值类型比如int64也有类似的机制。

五、总结

Hessian专为Java优化,采用高效二进制协议,通过对象图遍历和编码协议实现对象与字节流的转换,利用数据块标签、重复对象复用、数据压缩等机制,提升编解码效率和数据压缩率。

本文没有去展开Hessian的代码细节,而是尽可能深入浅出的介绍了Hessain的核心编码原理,以帮助读者建立对Hessian的宏观认知,从而可以更好的去理解和使用它。

尽管不同语言/生态的序列化框架选型让人眼花缭乱,但是各自需要解决的问题和解决问题的思路都大同小异;我们对Hessain原理的认识可以迁移到其他序列化框架,甚至自己写一个领域特定的序列化框架。

相关内容均为笔者走读源码整理而来,如有疏漏,欢迎指正。

参考:

往期回顾

  1. 前端日志回捞系统的性能优化实践|得物技术

  2. 得物灵犀搜索推荐词分发平台演进3.0

  3. R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

  4. 可扩展系统设计的黄金法则与Go语言实践|得物技术

  5. 营销会场预览直通车实践|得物技术

文 / 羊羽

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

前端日志回捞系统的性能优化实践|得物技术

一、前言

在现代前端应用中,日志回捞系统是排查线上问题的重要工具。然而,传统的日志系统往往面临着包体积过大、存储无限膨胀、性能影响用户体验等问题。本文将深入分析我们在@dw/log和@dw/log-upload两个库中实施的关键性能优化,以及改造过程中遇到的技术难点和解决方案。

核心优化策略概览:

我们的优化策略主要围绕三个核心问题:

  • 存储膨胀问题 - 通过智能清理策略控制本地存储大小
  • 包体积问题 - 通过异步模块加载实现按需引入
  • 性能影响问题 - 通过队列机制和节流策略提升用户体验

二、核心性能优化

优化一:智能化数据库清理机制

问题背景

传统日志系统的一个重大痛点是本地存储无限膨胀。用户长期使用后,IndexedDB 可能积累数万条日志记录,不仅占用大量存储空间,更拖慢了所有数据库查询和写入操作。

解决方案:双重清理策略

我们实现了一个智能清理机制,它结合了两种策略,并只在浏览器空闲时执行,避免影响正常业务。

  • 双重清理
    • 按时间清理: 删除N天前的所有日志。
    • 按数量清理: 当日志总数超过阈值时,删除最旧的日志,直到数量达标。
/**
 * 综合清理日志(同时处理过期和数量限制)
 * @param retentionDays 保留天数
 * @param maxLogCount 最大日志条数
 */
async cleanupLogs(retentionDays?: number, maxLogCount?: number): Promise<void> {
  if (!this.db) {
    throw new Error('Database not initialized')
  }
  
  try {
    // 先清理过期日志
    if (retentionDays && retentionDays > 0) {
      await this.clearExpiredLogs(retentionDays)
    }
    
    // 再清理超出数量限制的日志
    if (maxLogCount && maxLogCount > 0) {
      await this.clearExcessLogs(maxLogCount)
    }
  } catch (error) {
    // 日志清理失败不应该影响主流程
    console.warn('日志清理失败:', error)
  }
}
  • 智能调度
    • 节流: 保证清理操作在短时间内(如5分钟)最多执行一次。
    • 空闲执行: 将清理任务调度到浏览器主线程空闲时执行,确保不与用户交互或页面渲染争抢资源。
/**
 * 检查并执行清理(节流版本,避免频繁清理)
 */
private checkAndCleanup = (() => {
  let lastCleanup = 0
  const CLEANUP_INTERVAL = 5 * 60 * 1000 // 5分钟最多清理一次
  
  return () => {
    const now = Date.now()
    if (now - lastCleanup > CLEANUP_INTERVAL) {
      lastCleanup = now
      executeWhenIdle(() => {
        this.performCleanup()
      }, 1000)
    }
  }
})()

优化二:上传模块的异步加载架构

问题背景

日志上传功能涉及 OSS 上传、文件压缩等重型依赖,如果全部打包到主库中,会显著增加包体积。更重要的是,大部分用户可能永远不会触发日志上传功能。

解决方案:动态模块加载

189KB 的包体积是不可接受的。分析发现,包含文件压缩(JSZip)和OSS上传的 @dw/log-upload模块是体积元凶,但99%的用户在正常浏览时根本用不到它。

我们采取了“核心功能+插件化”的设计思路,将非核心的上传功能彻底分离。

  • 上传模块分离: 将上传逻辑拆分为独立的@dw/log-upload库,并通过CDN托管。
  • 动态加载实现: 仅在用户手动触发“上传日志”时,才通过动态创建script标签的方式,从CDN异步加载上传模块。我们设计了一个单例加载器确保模块只被请求一次。
/**
 * OSS 上传模块的远程 URL
 */
const OSS_UPLOADER_URL = 'https://cdn-jumper.dewu.com/sdk-linker/dw-log-upload.js'


/**
 * 动态加载远程模块
 * 使用单例模式确保模块只加载一次
 */
const loadRemoteModule = async (): Promise<LogUploadModule> => {
  if (!moduleLoadPromise) {
    moduleLoadPromise = (async () => {
      try {
        await loadScript(OSS_UPLOADER_URL)
        return window.DWLogUpload
      } catch (error) {
        moduleLoadPromise = null
        throw error
      }
    })()
  }
  return moduleLoadPromise
}


/**
 * 上传文件到 OSS
 */
export const uploadToOss = async (file: File, curEnv?: string, appId?: string): Promise<string> => {
  try {
    // 懒加载上传函数
    if (!ossUploader) {
      const module = await loadRemoteModule()
      ossUploader = module.uploadToOss
    }
    
    const result = await ossUploader(file, curEnv, appId)
    return result
  } catch (error) {
    console.info('Failed to upload file to OSS:', error)
    return ''
  }
}

优化三:JSZip库的动态引入

我们避免将 JSZip 打包到主库中,从主包中移除,改为在上传模块内部动态引入,优先使用业务侧可能已加载的全局window.JSZip。

/**
 * 获取 JSZip 实例
 */
export const getJSZip = async (): Promise<JSZip | null> => {
  try {
    if (!JSZipCreator) {
      const module = await loadRemoteModule()
      JSZipCreator = module.JSZipCreator
    }
    
    zipInstance = new window.JSZip()
    return zipInstance
  } catch (error) {
    console.info('Failed to create JSZip instance:', error)
    return null
  }
}


// 在上传模块中实现灵活的 JSZip 加载
export const JSZipCreator = async () => {
  // 优先使用全局 JSZip(如果页面已经加载了)
  if (window.JSZip) {
    return window.JSZip
  }
  return JSZip
}

优化四:日志队列与性能优化

在某些异常场景下,日志会短时间内高频触发(如循环错误),密集的IndexedDB.put()操作会阻塞主线程,导致页面卡顿。

我们引入了一个日志队列,将所有日志写入请求“缓冲”起来,再由队列控制器进行优化处理。

  • 限流: 设置每秒最多处理的日志条数(如50条),超出部分直接丢弃。错误(Error)级别的日志拥有最高优先级,不受此限制,确保关键信息不丢失。
  • 批处理与空闲执行: 将队列中的日志打包成批次,利用requestIdleCallback在浏览器空闲时一次性写入数据库,极大减少了 I/O 次数和对主线程的占用。
export class LogQueue {
  private readonly MAX_LOGS_PER_SECOND = 50
  
  /**
   * 检查限流逻辑
   */
  private checkRateLimit(entry: LogEntry): boolean {
    // 错误日志总是被接受
    if (entry.level === 'error') {
      return true
    }
    
    const now = Date.now()
    if (now - this.lastResetTime > 1000) {
      this.logCount = 0
      this.lastResetTime = now
    }
    
    if (this.logCount >= this.MAX_LOGS_PER_SECOND) {
      return false
    }
    
    this.logCount++
    return true
  }
}

空闲时间处理机制:

export function executeWhenIdle(callback: () => void, timeout: number = 2000): void {
  if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
    window.requestIdleCallback(() => {
      callback()
    }, { timeout })
  } else {
    setTimeout(callback, 50)
  }
}

三、打包构建中的技术难点与解决方案

在改造过程中,我们遇到了许多与打包构建相关的技术难题。这些问题往往隐藏较深,但一旦出现就会阻塞整个开发流程。以下是我们遇到的主要问题和解决方案:

难点一:异步加载 import()

打包失败问题

问题描述

await import('./module')语法在 Rollup 打包为 UMD 格式时会直接报错,因为 UMD 规范本身不支持代码分割。

// 这样的代码会导致 UMD 打包失败
const loadModule = async () => {
  const module = await import('./upload-module')
  return module
}

错误信息:

Error: Dynamic imports are not supported in UMD builds
[!] (plugin commonjs) RollupError: "import" is not exported by "empty.js"

解决方案:inlineDynamicImports 配置

通过在 Rollup 配置中设置inlineDynamicImports: true来解决这个问题:

// rollup.config.js
export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'dist/umd/dw-log.js',
      format: 'umd',
      name: 'DwLog',
      // 关键配置:内联动态导入
      inlineDynamicImports: true,
    },
    {
      file: 'dist/cjs/index.js',
      format: 'cjs',
      // CJS 格式也需要这个配置
      inlineDynamicImports: true,
    }
  ],
  plugins: [
    typescript(),
    resolve({ browser: true }),
    commonjs(),
  ]
}

配置说明

  • inlineDynamicImports: true会将所有动态导入的模块内联到主包中
  • 这解决了 UMD 格式不支持动态导入的问题

难点二:process对象未定义问题

问题描述

打包后的代码在浏览器环境中运行时出现process is not defined错误:

ReferenceError: process is not defined
    at Object.<anonymous> (dw-log.umd.js:1234:56)

这通常是因为某些 Node.js 模块或工具库在代码中引用了process对象,而浏览器环境中并不存在。

解决方案:插件注入 process 对象

我们使用@rollup/plugin-inject插件,在打包时向代码中注入一个模拟的process 对象,以满足这些库的运行时需求。

  • 创建process-shim.js文件提供浏览器端的process实现。
  • 在rollup.config.js中配置插件:
// rollup.config.js
import inject from '@rollup/plugin-inject'
import path from 'path'


export default {
  // ... 其他配置
  plugins: [
    // 注入 process 对象
    inject({
      // 使用文件导入方式注入 process 对象
      process: path.join(__dirname, 'process-shim.js'),
    }),
    typescript(),
    resolve({ browser: true }),
    commonjs(),
  ]
}

创建 process-shim.js 文件:

// process-shim.js
// 为浏览器环境提供 process 对象的基本实现
export default {
  env: {
    NODE_ENV: 'production'
  },
  browser: true,
  version: '',
  versions: {},
  platform: 'browser',
  argv: [],
  cwd: function() { return '/' },
  nextTick: function(fn) {
    setTimeout(fn, 0)
  }
}

高级解决方案:条件注入

为了更精确地控制注入,我们还可以使用条件注入:

inject({
  // 只在需要的地方注入 process
  process: {
    id: path.join(__dirname, 'process-shim.js'),
    // 可以添加条件,只在特定模块中注入
    include: ['**/node_modules/**', '**/src/utils/**']
  },
  // 同时处理 global 对象
  global: 'globalThis',
  // 处理 Buffer 对象
  Buffer: ['buffer', 'Buffer'],
})

难点三:第三方依赖的

ESM/CJS兼容性问题

问题描述

某些第三方库(如 JSZip、@poizon/upload)在不同模块系统下的导入方式不同,导致打包后出现导入错误:

TypeError: Cannot read property 'default' of undefined

解决方案:混合导入处理

// 处理 JSZip 的兼容性导入
let JSZipModule: any
try {
  // 尝试 ESM 导入
  JSZipModule = await import('jszip')
  // 检查是否有 default 导出
  JSZipModule = JSZipModule.default || JSZipModule
} catch {
  // 降级到全局变量
  JSZipModule = (window as any).JSZip || require('jszip')
}


// 处理 @poizon/upload 的导入
import PoizonUploadClass from '@poizon/upload'


// 兼容不同的导出格式
const PoizonUpload = PoizonUploadClass.default || PoizonUploadClass

在 Rollup 配置中加强兼容性处理:

export default {
  plugins: [
    resolve({
      browser: true,
      preferBuiltins: false,
      // 解决模块导入问题
      exportConditions: ['browser', 'import', 'module', 'default']
    }),
    commonjs({
      // 处理混合模块
      dynamicRequireTargets: [
        'node_modules/jszip/**/*.js',
        'node_modules/@poizon/upload/**/*.js'
      ],
      // 转换默认导出
      defaultIsModuleExports: 'auto'
    }),
  ]
}

四、性能测试与效果对比

打包优化效果对比:

五、总结

通过解决这些打包构建中的技术难点,我们不仅成功完成了日志系统的性能优化,还积累了工程化经验。这些实践不仅带来了日志系统本身的轻量化与高效化,其经验对于任何追求高性能和稳定性的前端项目都有部分参考价值。

往期回顾

  1. 得物灵犀搜索推荐词分发平台演进3.0

  2. R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

  3. 可扩展系统设计的黄金法则与Go语言实践|得物技术

  4. 营销会场预览直通车实践|得物技术

  5. 基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

文 / 沸腾

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物灵犀搜索推荐词分发平台演进3.0

一、背景

导购是指在购物过程中为消费者提供指引和帮助的人或系统,旨在协助用户做出更优的购买决策。在电商平台中,导购通过推荐热卖商品、促销活动或个性化内容,显著提升用户的购物体验,同时推动销售额的增长。其核心目标是通过精准的引导,满足用户需求并促进商业价值最大化。

词分发:导购的重要组成部分

在电商导购体系中,词分发作为关键环节,主要聚焦于与关键词推荐相关的功能。这些功能包括但不限于下拉词、底纹词、热搜榜单、锦囊词以及风向标等。这些推荐词能够帮助用户快速定位感兴趣的商品或服务,降低搜索门槛,提高购物效率。例如,下拉词可以在用户输入搜索内容时提供智能提示,而热搜榜单则能引导用户关注平台上的热门趋势。

词分发平台的价值与功能

为了进一步优化词推荐的效率与一致性,词分发平台应运而生。该平台致力于打造一个通用、高效的词推荐生态系统,通过集成多种算法、工具和通用服务接口,为公司内不同业务域提供灵活的词推荐支持。其主要优势包括以下几点:

  • 统一开发,降低成本:词分发平台通过提供标准化的服务和接口,避免了各业务域重复开发和维护词推荐功能的成本。不同团队无需从零开始构建推荐系统,只需调用平台提供的接口即可快速实现定制化的词推荐功能,大幅节省开发时间和资源。
  • 高灵活性,适应多场景:平台的模块化设计使其能够根据不同业务场景和需求进行快速调整。例如,针对促销活动、节假日特辑或特定品类推荐,平台可以动态调整推荐算法和词库,确保推荐内容的精准性和时效性。
  • 支持业务扩展,提升效率:通过统一的词分发平台,各业务域能够更专注于核心业务逻辑的开发,而无需过多关注底层推荐系统的技术细节。这不仅提升了运营效率,还为业务的快速扩展提供了技术保障。
  • 优化用户体验: 词分发平台通过整合先进的推荐算法和数据分析能力,能够为用户提供更精准、更个性化的搜索建议。例如,基于用户历史行为和实时趋势生成的推荐词,可以帮助用户更快找到目标商品,从而提升整体购物体验。

二、已支持场景

已支持社区、交易、营销30+导购场景。

个别场景示例

三、整体架构

业务架构

平台架构

整体平台架构

平台+脚本化架构

脚本热部署功能在词分发搜索推荐引擎中发挥了重要作用,其主要目标是通过动态加载机制处理策略频繁变更的链路。实现这一功能的核心在于定义统一的抽象方法(具备相同出入参),将具体逻辑下放到 SDK 中,并通过后台打包、配置和推送流程,在线服务通过反射机制快速加载实现代码,再结合 AB 配置选择适用脚本。这种方法显著提升了策略调整的灵活性,同时减少了服务器重启的成本和时间。

在具体实施中,首先需要设计并实现统一的抽象方法,确保接口标准一致。随后,将具体的实现逻辑封装到 SDK 中,方便服务器端动态接收和加载。后台则负责提供打包、配置和推送功能,将实现代码整理为统一的包形式。当链路策略需要更新时,开发人员只需将新的实现代码上传至后台,完成打包、配置和推送操作。

在线服务在检测到新推送后,利用反射机制加载具体实现,并根据 AB 配置选择适用的脚本运行。这种动态加载方式无需重启服务,即可实现策略的即时切换和优化。整体而言,这一方法不仅提高了系统对策略变更的响应速度,还降低了维护成本,同时增强了系统的可靠性和稳定性,为词分发搜索推荐引擎的持续优化提供了有力支持。

主工程底座和脚本工程

在业务迭代的代码编写中,通常分为两种类型:主工程底座和脚本工程。

  • 主工程底座主要负责实现抽象和通用层的代码逻辑,注重提供稳定的基础框架和通用功能,确保系统的整体架构和扩展性。
  • 相比之下,脚本工程更贴近具体业务需求和定制化场景,专注于实现与业务逻辑密切相关的功能模块。通过这种分工,主工程提供通用的技术支持,而脚本工程则灵活应对多样化的业务需求,从而实现开发效率与业务适配性的平衡。

脚本热部署架构的存在原因

脚本热部署架构的存在主要出于以下原因:

  • 灵活应对策略变更:通过动态加载脚本,系统能快速适应频繁更新的业务需求,无需重启服务。
  • 降低维护成本:统一抽象方法和 SDK 实现减少重复开发,后台打包推送简化更新流程。
  • 提升效率:反射机制和 AB 配置实现即时脚本切换,节省时间并优化资源使用。
  • 增强稳定性:动态调整策略而不中断服务,确保系统持续稳定运行。

四、架构演进3.0之图化

串行架构

之前词分发业务一般都可以抽象为“预处理->召回->融合->粗排->精排->结果封装”等固定的几个阶段,每个阶段通常是有不同的算法或工程同学进行开发和维护。为了提升迭代效率,通过对推荐流程的抽象,将各阶段的逻辑抽象为“组件"+"配置”,整体的流程同样是一个配置,统一由“编排引擎”进行调度,同时提供统一的埋点/日志等。让工程或算法同学可以关注在自己的业务模块和对应的逻辑,而框架侧也可以做统一的优化和升级。

图化引擎架构演进

那为什么要去做“图化”/“DAG”呢?其实要真正要回答的是:  如何应对上面看到的挑战?如何解决目前发展碰到的问题?

从业界搜推领域可以看到不约而同地在推进“图化”/“DAG”。 从TensorFlow广泛采用之后,我们已经习惯把计算和数据通过采用算子(Operation)和数据(Tensor)的方式来表达,可以很好的表达搜索推荐的“召回/融合/粗排/精排/过滤”等逻辑,图化使得大家可以使用一套“模型”语言去描述业务逻辑。DAG引擎也可以在不同的系统有具体不同的实现,处理业务定制支持或者性能优化等。

通过图(DAG)来描述我们的业务逻辑,也带来这些好处:为算法的开发提供统一的接口,采用算子级别的复用,减少相似算子的重复开发;通过图化的架构,达到流程的灵活定制;算子执行的并行化和异步化可降低RT,提升性能。

图化是一种将业务逻辑抽象为有向无环图(DAG)的技术,其中节点代表算子,边表示数据流。不同的算子可以组合成子图,起到逻辑更高层封装的作用,子图的输出可供其他子图或算子引用。通过图化,策略同学的开发任务得以简化,转变为开发算子并抽象业务数据模型,而无需关注“并行化”或“异步化”等复杂逻辑,这些由 DAG 引擎负责调度。算子设计要求以较小粒度支持,通过数据流定义节点间的依赖关系。

图化引入了全新的业务编排框架,为策略同学提供了“新的开发模式”,可分为三部分:一是定义算子、图和子图的标准接口与协议,策略同学通过实现这些接口来构建业务逻辑图;二是 DAG 引擎,负责解析逻辑图、调度算子,确保系统的性能和稳定性;三是产品化支持,包括 DAG Debug 助手协助算子、图和子图的开发与调试,以及后台提供的可视化管理功能,用于管理算子、子图和图。整体架构可参考相关设计图。

图化核心设计和协议

节点‘算子’抽象封装——面向框架测

算子接口定义IDagTaskNodeExecutor

/**
 * dag 主节点注解
 */
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DagNodeMetaProcessor {
    
    /**
     * 算子名字
     * @return
     */
    String name();
    
    /**
     * 算子描述
     * @return
     */
    String desc() default "";
}
/**
 * 主工程节点任务-执行器
 *
 * @param <T>
 */
public interface IDagTaskNodeExecutor<T> {
    
    execute(DagStrategyContext dagStrategyContext);

图配置文件——面向框架测(使用者无需关心)

图分为图图,一个场景可以有多个图,可按实验制定不同的图;图定位为业务逻辑模版,可以将若干个独立算子组装为具有特定业务含义的“图”,图和算子一样可在场景大“图”中进行配置,即运行时可有多个“实例”,实现逻辑的复用和配置化。

面向业务使用者—如何配置

  • 节点自动注册:面向使用者无需关心JSON复杂的配置化,完全可视化操作。节点有俩种类型分主节点和脚本节点(可视化区分),节点注册完成框架测实现。
  • 业务关心编排关系:业务只需要关心节点之间编排关系即可。编排关系也是完全可视化拖拽实现。
  • 线程池隔离:一个服务内,不同场景线程池是隔离的,一个场景内,不同并行节点线程池也可以做到隔离,来区分强弱依赖关系。
  • 关联实验:一个场景有基础场景图,和实验图,实验图可以基于某个实验发布不同于场景的复杂实验图。

五、配套工具利器

脚本化开发&灰度发布CICD

自迭代流程图

去脚本化后台执行配置,首先选择对应环境的对应集群服务 (先预发验证,验证没问题,提merge给工程cr,合并后操作线上集群)。

脚本配置

如果是新加的脚步,选择配置,然后在配置页面对应类型的脚步后面选择新增,然后添加对应脚本类型的配置(一定要按类型添加,否则加载会失败),然后点击添加。

脚本构建

  • 配置完成后,选择cicd,进入cicd页面,首先选择新增cicd,然后会弹框,在弹框中选择你开发的分支,然后选择构建,这个时候构建记录会是打包中状态,然后等1到3分钟,刷新当前页面,查看状态是否为打包成功,如果为打包失败需要检查代码问题,如果是打包成功,操作栏会有同步操作;
  • 此次新增:在构建页面新增了构建日志和操作人两列信息。

    • 构建日志:点击详情会跳转到gitlab cicd日志详情(此次新增功能)
    • 操作人:会记录此次操作的具体人员,有问题及时联系相应同学(此次新增功能)

脚本发布

一次性全量发布(已有能力)

  • 当打包成功后,操作栏会有同步操作,点击同步,将当前打包的版本同步到集群。
  • cicd同步成功后,回到集群管理页面,这时点击操作里的发布操作,发布成功后,发布会变成同步,然后点击同步,同步成功后,这是集群中就已经加载到集群中,这就需要去ab实验配置具体的脚本然后验证。

灰度发布

  1. 通过cicd页面,构建完jar包后,点击右侧【灰度发布】按钮。

  1. 跳转到灰度发布页面
  • 基本信息如图显示,看图。
  • 发布间隔:第一批次5%,二批次30%,三批次60%,四批次100%;  当前流量xxx%(白名单验证)
  • 发布时,可以填写第一批次灰度IP机器,可选。
  • 当发布到第几批的时候,页面显示高亮
  • 系统一共默认四批次,首次点击发布是第一批,默认第一批暂停,再次点击发布,后面三批自动发布(间隔30s)
  • 如果发现异常变多或者RT变高,可马上回滚,点击回滚即可回滚上个版本
  • 如果一切正常,第四批就是全部推全操作,灰度jar包覆盖基础jar包。
  • 发布过程中,灰度的流量可以进行观察相应的QPS、RT、ERROR、和各个阶段召回、排序、打散等核心模块的性能和调用量。

  1. 灰度中的jar包,列表表格状态显示灰度流量

  1. 在集群维度,有俩个jar,一个是灰度中的jar, 另外一个是基础base的jar。 表格显示如下:

DIFF评估平台

社区搜索评测平台是面向于内部算法、产品、研发同学使用的评测系统,主要用于建设完善得物社区搜索badcase评估标准体系,致力于提升用户搜索体验和搜索算法问题发现及优化两方面,提供完善的评测解决方案。

核心功能包含:query数据抽取、快照数据抓取、评测数据导出和评测标注结果效果统计分析。

干预平台

搜索底纹词、猜搜词、下拉词在搜索链路的前置环节出现,在用户没有明确的搜索需求时,对激发用户搜索需求有较大的作用,因此,这些场景既是资源位也需要严格把控出词质量。本需求计划在上述场景支持干预能力,支持在高热事件时干预强插,也支持干预下线某些不合适的词。

召回配置平台

在现代的搜索引擎系统中,多路召回是一个非常重要的组件,其决定了搜索引擎的性能和准确性。因此,多路召回的配置和管理,对于搜索引擎系统的性能、稳定性和可维护性来说是至关重要的。

在以前的词分发系统中,多路召回的配置是以JSON字符串的形式存在的。每次修改配置都需要对这个JSON进行手动的编辑,该过程非常耗费时间,随着召回路的增多,配置效率也会越来越低,而且这种方式容易出错。因此,我们需要一种更加高效、可视化的方法来管理和配置多路召回。

为了提高多路召回的配置效率和准确性,我们需要一种可视化的后台工具来替代手动修改JSON字符串的方式。这样的后台工具可以将多路召回的配置以更加直观和可视化的方式展示出来,让配置人员能够直接在页面上进行配置和修改,从而减少手动编辑JSON字符串的错误和繁琐性。

通过使用可视化的后台工具,我们可以方便地管理和配置各种算法和策略,从而大大提高搜索引擎系统的性能和可维护性。可视化的后台工具对于提高搜索引擎的性能和可维护性非常重要,它可以大大简化配置人员的操作难度和减少错误,进一步提高搜索引擎系统的效率、可靠性和灵活性。

单路配置

多路配置

当然还有其他基建和配套工具和基建服务支撑,这里不一一展开了。

六、未来规划

词分发平台作为搜索引擎系统中的核心组成部分,负责管理和分配搜索词汇的处理与召回流程。其架构以灵活性和扩展性为核心,参考图示所示,平台通过模块化设计(如 Java 框架 Spring 容器、词分发平台主工程、依赖注入 Spring 容器、日志调试能力等)支持高效运行。为了适应市场需求的不断变化,未来词分发平台需从以下几个方面持续优化:

  • 平台建设:进一步完善灵犀平台功能,包括继承监控大盘,监控维度扩展,召回配置和脚本cicd建设,发布流水线接入等等。
  • 基座框架代码和工具完善:脚本框架改造2.0,无缝对接spring容器;构建可维护完善算字库。通过优化现有流程和算法,加速词汇处理与召回的速度,确保平台性能的持续提升。
  • 扩展场景:快速接入更多新场景,如商详触达,小蓝词等等。

此外,未来平台将联合算法团队,打破词圈品与品圈词之间的数据孤岛,打通相关链路,从而全面提升词分发平台的智能化与功能性。这一战略将推动平台更好地服务多样化业务需求,为用户提供更精准、高效的搜索体验。

往期回顾

1. R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

2. 可扩展系统设计的黄金法则与Go语言实践|得物技术 

3. 得物新商品审核链路建设分享

4. 营销会场预览直通车实践|得物技术

5. 基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

文 / 子房

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

一、背景

R8作为谷歌官方的编译优化工具,在编译阶段会对字节码进行大规模修改,以追求包体优化和性能提升。但是Android应用开发者数量太过庞大,无论测试流程多么完善,终究难以避免在一些特定场景下出现问题。

近期我们在升级项目的AGP,遇到了一个指向系统SurfaceTexture类的native崩溃问题。经反编译分析发现问题最终指向了smali字节码中多余的一行new-instance指令。

该指令创建了一个SurfaceTexture对象,但是并未调用其方法,这意味着构造方法没有执行,但是这个类重写了finalize方法,后续被gc回收时会调用其中的nativeFinalize这个JNI方法,最终在native层执行析构函数时触发了SIGNALL 11的内存访问错误.

二、复现问题

我们注意到多出来的new-instance指令下面紧接着的是对a0.e 类中的静态方法 i() 的调用,其内部实现就是SurfaceTexture的构造方法。这是典型的代码外联操作,即一段相同的代码在工程中多次出现,则会被抽出来单独作为一个静态函数,原先的调用点则替换成该函数的调用,这样可以减小代码体积,是常见的编码思路。

例如:

class Activity{
    void onCreate(){
        // ...
        String a = xx.xxx();
        String b = xx.xxx();
        Log.e("log",a+b);
        //...
    }


    void onReusme(){
        // ...
        String a = xx.xxx();
        String b = xx.xxx();
        Log.e("log",a+b);
        //...
    }


}
class Activity{
    void onCreate(){
        // ...
        Activity$Outline.log();
        //...
    }


    void onReusme(){
        // ...
        Activity$Outline.log();
        //...
    }
}
//外联生成的类
class Activity$Outline{
    public static void log(){
        String a = xx.xxx();
        String b = xx.xxx();
        Log.e("log",a+b);
    }
}

我们根据这个生成类的类名可以知道是R8中ApiModelOutline功能生成了这个类。

我们进到R8工程中检索下相关的关键字,再加上demo多次尝试,可以确认满足以下条件能够必现该问题:

  1. 使用了高于当前minSdkVersion的系统函数/变量(仅限系统类,自己写的无效)
  2. 用synchronized或者try语句块包裹了该调用,或者给该函数传参时有任何计算行为(除了传局部变量)。例如:
    1. new SurfaceTexture( getParmas() )
    2. new SurfaceTexture( if(enable) 1 : 2)
    3. new SurfaceTexture ( (boolean) enable )

三、问题分析

在确认复现条件之后,我们带着几个问题来逐个分析。

ApiModel外联是什么?

R8中的优化大多数跟包体优化有关,代码外联也是其中一种,但是外联的前提是代码重复的次数满足一定阈值,但是ApiModel会对所有调用了高版本系统API的代码做外联,包括只调用一次的场景。

ApiModel并非为了包体优化,我们通过R8工程的issueTracker issuetracker.google.com/issues/3334… 检索到了相关的信息:

译:AGP新增的ApiModel功能是为了防止在低版本设备上不可能执行的代码引起类验证错误,从而降低App启动耗时。

从这篇介绍ART虚拟机类验证的文档 chromium.googlesource.com/chromium/sr… 就能够理解上面这句话的含义:

ART虚拟机会在APK安装之后立刻执行 AOT class verification,即对dex文件中所有的类进行验证,如果验证成功则后续运行时将不需要再进行验证,反之若失败,则该class会被ART打上RetryVerificationAtRuntime的标记,后续运行时还得重新执行类验证。

同时这些失败的类也将无法被dex2oat优化成oat格式的优化字节码(oat字节码的加载和执行速度更快)。

如果是在MainActivity,启动任务中使用了这些高版本API,那么在低版本设备App启动时就必须额外执行一次类验证(比较耗时,有的类能到8ms issues.chromium.org/issues/4057…*

//安装apk后验证失败,运行时验证失败,但是能正常执行
class MainActivity{
    void onCreate(){
        if(android.sdk > 26){
            new SurfaceTexture(false);
        }
    }
}

ApiModel后

class MainActivity{
    void onCreate(){
        if(android.sdk > 26){
            a0.b(); //这样类验证就能成功
        }
    }
}
//生成的外联类,类验证会失败,但是运行时不可能走到,不影响
class a0{
    public static void b(){
        new SurfaceTexture(false);
    }
}

更多关于ApiModel的详细介绍,见这篇文章:medium.com/androiddeve…

为什么会多生成一个

new-instance指令?

介绍完ApiModel之后,我们已经知道了为什么方法的调用被替换成了一个生成函数的调用,接下来我们再分析下导致崩溃的罪魁祸首 new-instance 指令是如何出现的。

我们先来了解下java文件在编译过程中的格式转换过程,因为ApiModel是基于IRCode格式(R8自定义的格式)来做外联。

文件转换

javac

javac将java文件编译成class文件

值得一提的是sychronized语句块在javac编译之后会为其内部代码生成try-catch,这是为了确保在语句块抛异常时能够正常释放锁,因此和问题有关的是try-catch语句块,和synchronized无关。

D8

目前R8已经整合D8,因此输入class文件之后就会先通过D8转为dex格式,并持有在内存中。

转换之后的指令基本和class字节码基本类似。

IRcode

为了做进一步的优化,会将dex格式的代码转化成R8自定义的IRcode格式,其特点是代码分块。

案例:

问题根因

在R8工程里检索ApiModel关键字,最终定位到针对构造函数生成外联函数和指令替换的代码:

InstanceInitializerOutliner->rewriteCode

执行此方法之前的指令如下:

java:
new SurfaceTexture(false);
dex:
: -1: NewInstance          v1 <-  android.graphics.SurfaceTexture
: -1: ConstNumber          v2(0) <-  0 (INT)
: -1: Invoke-Direct        v1, v2(0); method: void android.graphics.SurfaceTexture.<init>(boolean)
  • 对整个方法中所有的指令从上往下进行遍历,第一次遍历主要是:
    • 检索 方法调用的指令
    • 判断该方法的androidApiLevel是否高于minSDK
    • 生成包含完整构造函数指令的外联函数,并替换函数调用为外联函数调用。
    • 执行完替换逻辑,就记录信息到map中,key是对应的new-instance指令,value是前一步中替换的新指令。

经过这一步,字节码会变成这样:

具体替换逻辑如下(可以参考注释理解):

  • 第二次遍历则是对new-instance指令的处理:
    • 找到new-instance指令
    • 查询map,确认方法已完成替换
    • 根据canSkipClInit方法返回的结果分为两种场景:
      • 无类初始化逻辑:直接移除new-instance指令,不影响原代码的语义。

      • 有类初始化逻辑:生成外联函数,只包含该new-instance指令,和前一次遍历一样进行指令替换。

具体替换逻辑:

  • 问题重点就在于canSkipClInit这个函数的实现。

它会检查 new-intance指令和invoke 指令之间是否存在任何局部变量声明以外的指令,如果存在,他会认为这些指令是这个类初始化的逻辑,因此为了保留源代码的执行顺序,这种情况下就是需要额外执行一次new-instance指令来触发类初始化。

但是实际上,如果在调用这个构造函数传参时执行了任何运算(和类加载无关),都会生成相关的指令插在中间,例如:

java写法 new-intance和invoke 指令之间的指令
new SurfaceTexture( getParmas() ) invoke-virtual   v2 <-; method: void xx.xx.xx
new SurfaceTexture( if(enable) 1 : 2) StaticGet            v3 <- ; field: boolean  xxx.xxx.xx
new SurfaceTexture ( (boolean) enable ) : -1: CheckCast            v5 <- v3; java.lang.Boolean: -1: Invoke-Virtual       v6 <- v5; method: boolean java.lang.Boolean.booleanValue()

从作者留下的todo也能看出,后续准备扩展这个方法,实现对这些夹在中间的指令的判断,如果是对类初始化无影响的入参计算逻辑,则也将正常移除new-intance指令。

值得一提的是,我们最终APK里 new-intance指令并没有被外联,这是因为SurfaceTexture这个类本身在安卓21之前的版本就已经存在,只是入参为bool类型的构造方法是在安卓26新增的,所以他其实是被外联之后又被内联回到了调用处,因此看起来像是没有被外联。

小结

至此,我们就明白了多出来一个看似无用的new-intance指令,实际上是为了保全源代码的语义,触发类加载用的,但是作者没有考虑到这些被优化的类可能重写了finalize方法来释放一些本就不存在的资源。

而且不局限于调用native函数,只要是重写了finalize,并在里面访问一些在构造函数中初始化的成员变量,一样可能造成NPE等崩溃。

R8是如何计算出API的版本?

R83.3版本开始,它编译时会下载一个.ser格式的数据库文件,里面记录了所有系统API、变量与安卓版本号的映射信息,在运行时通过行号和偏移量来寻找各自的版本号。

为什么try-catch

也会导致该问题?

前面解释了在构造函数入参中添加函数调用等写法导致的字节码异常原因,但是实际上这次我们遇到的崩溃场景是在sychronized里new了一个SurfaceTexture。

前文中已经解释过,sychronized在编译成class后会生成try-catch语句块,这段代码改成用try-catch语句块包裹,一样会复现崩溃,因此我们跟踪try-catch在文件转换过程中对字节码的影响即可。

回到class文件转dex文件的阶段,我们发现try语句块中的每一行指令,都会在其后生成一条FALLTHROUGH指令。

dex格式:

FALLTHROUGH是什么指令,他是做什么的?

FALLTHROUGH指令表示指令自然流转,没有实际含义,它主要是为了帮助优化器识别哪些指令是可达的。

例如下面这种写法,case1没有写break,这样会接着执行case2的代码:

switch (value) {
            case 1:
                System.out.println("One");
                // 故意不写break
            case 2:
                System.out.println("Two");
                break;
            case 3:
                System.out.println("Three");
                break;
        }

其字节码如下:

正常有break的话,会对应一条GOTO 指令跳转到switch语句块最后一行,但是没写break的话,就会出现:

在12行执行 goto 13 跳转到13行的指令,这种指令毫无意义,且运行时会消耗性能,因此可以替换成FALLTHROUGH指令,这样最终在生成dex文件时会被移除掉,从而避免浪费性能。

public static void switchWithFallthrough(int);
  Code:
    stack=2, locals=1, args_size=1


    // 加载参数
    0: iload_0


    // 检查case 1
    1: iconst_1
    2: if_icmpne 13    // 如果不等于1,跳转到case 2
    5: getstatic #2    // Field java/lang/System.out:Ljava/io/PrintStream;
    8: ldc #3          // String One
    10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    12: goto 13


    // case 2 (fallthrough目标)
    13: iconst_2
    14: if_icmpne 28   // 如果不等于2,跳转到case 3
    17: getstatic #2   // Field java/lang/System.out:Ljava/io/PrintStream;
    20: ldc #5         // String Two
    22: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    25: goto 40        // 跳转到switch结束


    // case 3
    28: iconst_3
    29: if_icmpne 40   // 如果不等于3,跳转到结束
    32: getstatic #2   // Field java/lang/System.out:Ljava/io/PrintStream;
    35: ldc #6         // String Three
    37: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V


    // switch结束
    40: return

既然没用为什么还要加这个指令?

class文件是通过Exception table来指定异常处理的指令范围,而dex文件则是通过为每一行可能产生throwable的指令后面添加FALLTHROUGH指令来实现try-catch。

这里会把每一行可能崩溃的指令都链接到catch指令所在的block中,确保任意位置的崩溃都能正常走到catch中。

问题根因

在R8 4.0.26版本,IRCode翻译器新增了对FALLTHROUGH指令的处理,即新建一个block并生成一条GOTO指令指向新的block。

根据前文的结论,GOTO指令一样会被认为是类初始化相关的逻辑,因此try-catch语句块一样会导致最终多出来一个new-instance字节码。

为什么只升级AGP会导致

R8功能出问题?

我们在数个版本之前就已经单独升级了R8,正好涵盖了ApiModel这个变更,但是直到近期才升级了AGP。

可以看到从AGP7.3-beta版本开始,才默认打开ApiModel功能,这就解释了为什么升级AGP之后才出现此崩溃。

四、解决方案

禁用ApiModel

ApiModel通过牺牲些微包体,换来启动阶段类验证耗时,但是从他覆盖的类范围来看,对启动速度的收益微乎其微,因此可以直接通过配置开关关闭整个功能。

System.setProperty("com.android.tools.r8.disableApiModeling""1")

虽说这是个实验中的功能,且逻辑相对独立,但是考虑到后续还有内联优化等操作,贸然关闭整个功能无法评估影响面,潜在的稳定性风险较高。

官方修复

该问题反馈给R8团队后,官方提供了临时规避的方案,即确保高版本API在单独的函数中调用。

issuetracker.google.com/issues/4411…

随后不久就提了MR针对SurfaceTexture这个类禁用了ApiModel,并未彻底解决此问题。r8-review.googlesource.com/c/r8/+/1090…

官方的修复方案比较权威,且影响面较小,但是并未彻底解决问题。

自行修复

如果要修复此问题,关键是要将多余的new-instance指令替换成一个合适的触发类加载的指令,根据java官方文档里的介绍,只有new对象,访问静态的成员变量或者函数的指令才能安全的触发类加载,比较理想的方案是改成访问静态变量,但是很多类并没有静态变量,比如SurfaceTexture就没有。

docs.oracle.com/javase/spec…

因此我们可以考虑结合getStatic指令和扫描finalize的方式来解决该问题:

虽说可以通过打印日志来约束此改动的影响面,但毕竟要自行修改并编译R8的jar包,且需要自行长期维护,整体影响面还是偏大,对稳定性要求高的App不建议采用该方案。

业务改造(推荐)

在前文中提到的外联函数生成处打印日志,即可感知到工程中有哪些类受ApiModel影响,如果数量不多,分别让业务改造其相关的写法,确保传参时是局部变量且无try-catch/synchronized语句块即可。

考虑到App整体的稳定性,最终我们采用了业务改造的方式绕过了此问题,并在R8异常代码处添加了日志告警来预防后续增量问题,并仿照官方MR中的写法补充了类的黑名单,用于应对无法编辑的三方库引入此问题的场景。

五、总结

在Android开发中,即使是AGP、R8这样的官方工具链升级,也要保持足够的警惕。毕竟Android生态太过复杂,再加上开发者们千奇百怪的代码写法,不论多么完善的测试流程都无法规避这类特定场景的bug。

这次的ApiModel外联优化问题就是一个很好的例子——它只在特定条件下才会暴露,但一旦出现就是必现的native崩溃。所以对于这种影响面无法评估的重大升级,还是需要经过足够长时间的独立灰度验证,才能合入主干分支。

往期回顾

1. 可扩展系统设计的黄金法则与Go语言实践|得物技术

2. 得物新商品审核链路建设分享

3. 营销会场预览直通车实践|得物技术

4. 基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

5. AI质量专项报告自动分析生成|得物技术

文 / 永乐

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

可扩展系统设计的黄金法则与Go语言实践|得物技术

一、 引言:为什么需要可扩展的系统?

在软件开发领域,需求变更如同家常便饭。一个缺乏扩展性的系统,往往在面对新功能需求或业务调整时,陷入“改一行代码,崩整个系统”的困境。可扩展性设计的核心目标是:让系统能够以最小的修改成本,适应未来的变化。对于Go语言开发者而言,利用其接口、并发、组合等特性,可以高效构建出适应业务演进的系统。

本文将从架构设计原则、编码实践、架构实现模式、验证指标到演进路线,系统讲解如何设计一个“生长型”系统。

二、可扩展系统的核心设计原则

2.1  开闭原则: 对扩展开放,对修改关闭

理论补充:

开闭原则是面向对象设计的基石之一。它要求系统中的模块、类或函数,应该对扩展新功能保持开放,而对修改现有代码保持关闭。这意味着,当需求变更时,我们应通过添加新代码(如新增实现类)来满足需求,而不是修改已有的代码逻辑。

Go语言的实现方式:

Go语言通过接口(Interface)和组合(Composition)特性,天然支持开闭原则。接口定义了稳定的契约,具体实现可以独立变化;组合则允许通过“搭积木”的方式扩展功能,而无需修改原有结构。

示例:数据源扩展

假设我们需要支持从不同数据源(如MySQL、S3)读取数据,核心逻辑是“读取数据”,而具体数据源的实现可能频繁变化。此时,我们可以通过接口定义稳定的读取契约:

// DataSource 定义数据读取的稳定接口(契约)
type DataSource interface {
    Read(p []byte) (n int, err error)  // 读取数据到缓冲区
    Close() error                      // 关闭数据源
}


// MySQLDataSource 具体实现:MySQL数据源
type MySQLDataSource struct {
    db *sql.DB  // 依赖MySQL连接
}


func (m *MySQLDataSource) Read(p []byte) (interror) {
    // 实现MySQL数据读取逻辑(如执行查询、填充缓冲区)
    return m.db.QueryRow("SELECT data FROM table").Scan(&p)
}


func (m *MySQLDataSource) Close() error {
    return m.db.Close()  // 关闭数据库连接
}


// S3DataSource 新增实现:S3数据源(无需修改原有代码)
type S3DataSource struct {
    client *s3.Client  // 依赖AWS S3客户端
    bucket string      // S3存储桶名
}


func (s *S3DataSource) Read(p []byte) (interror) {
    // 实现S3数据读取逻辑(如下载对象到缓冲区)
    obj, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{
        Bucket: aws.String(s.bucket),
        Key:    aws.String("data.txt"),
    })
    if err != nil {
        return 0, err
    }
    defer obj.Body.Close()
    return obj.Body.Read(p)  // 读取数据到缓冲区
}


func (s *S3DataSource) Close() error {
    // S3客户端通常无需显式关闭,可根据需要实现
    return nil
}

设计说明:

  • DataSource接口定义了所有数据源必须实现的方法(Read和 Close),这是系统的“稳定契约”。
  • 当需要新增数据源(如S3)时,只需实现该接口,无需修改现有的MySQL数据源或其他依赖DataSource的代码。
  • 这一设计符合开闭原则:系统对扩展(新增S3数据源)开放,对修改(无需改动现有代码)关闭。

2.2 模块化设计:低耦合、高内聚

理论补充:

模块化设计的核心是将系统拆分为独立的功能模块,模块之间通过明确的接口交互。衡量模块化质量的关键指标是:

  • 耦合度:模块之间的依赖程度(越低越好)。
  • 内聚度:模块内部功能的相关性(越高越好)。

理想情况下,模块应满足“高内聚、低耦合”:模块内部功能高度相关(如订单处理模块仅处理订单相关逻辑),模块之间通过接口通信(如订单模块通过接口调用支付模块,而非直接依赖支付模块的实现)。

Go语言的实现方式:

Go语言通过包(Package)管理模块边界,通过接口隔离依赖。开发者可以通过以下方式提升模块化质量:

  • 单一职责原则:每个模块/包仅负责单一功能(如order包处理订单逻辑,payment包处理支付逻辑)。
  • 接口隔离:模块间通过小而精的接口交互,避免暴露内部实现细节。

示例:订单模块的模块化设计

// order/order.go:订单核心逻辑(高内聚)
package order


// Order 表示一个订单(核心数据结构)
type Order struct {
    ID     string
    Items  []Item
    Status OrderStatus
}


// Item 表示订单中的商品项
type Item struct {
    ProductID string
    Quantity  int
    Price     float64
}


// OrderStatus 订单状态枚举
type OrderStatus string


const (
    OrderStatusCreated  OrderStatus = "created"
    OrderStatusPaid     OrderStatus = "paid"
    OrderStatusShipped  OrderStatus = "shipped"
)


// CalculateTotal 计算订单总金额(核心业务逻辑,无外部依赖)
func (o *Order) CalculateTotal() float64 {
    total := 0.0
    for _, item := range o.Items {
        total += item.Price * float64(item.Quantity)
    }
    return total
}


// payment/payment.go:支付模块(独立模块)
package payment


// PaymentService 定义支付接口(与订单模块解耦)
type PaymentService interface {
    Charge(orderID string, amount float64error  // 支付操作
}


// AlipayService 支付宝支付实现
type AlipayService struct {
    client *alipay.Client  // 支付宝SDK客户端
}


func (a *AlipayService) Charge(orderID string, amount float64error {
    // 调用支付宝API完成支付
    return a.client.TradeAppPay(orderID, amount)
}

设计说明:

  • order包专注于订单的核心逻辑(如计算总金额),不依赖任何外部支付实现。
  • payment包定义支付接口,具体实现(如支付宝、微信支付)独立存在。
  • 订单模块通过PaymentService接口调用支付功能,与具体支付实现解耦。当需要更换支付方式时,只需新增支付实现(如WechatPayService),无需修改订单模块。

三、Go语言的扩展性编码实践

3.1 策略模式:动态切换算法

理论补充:

策略模式(Strategy Pattern)属于行为型设计模式,用于定义一系列算法(策略),并将每个算法封装起来,使它们可以相互替换。策略模式让算法的变化独立于使用它的客户端。

Go语言的实现方式:

Go语言通过接口实现策略的抽象,通过上下文(Context)管理策略的切换。这种模式适用于需要动态选择不同算法的场景(如缓存策略、路由策略)。

示例:缓存策略的动态切换

假设系统需要支持多种缓存(Redis、Memcached),且可以根据业务场景动态切换。通过策略模式,可以将缓存的Get和Set操作抽象为接口,具体实现由不同缓存提供。

// cache/cache.go:缓存策略接口
package cache


// CacheStrategy 定义缓存操作的接口
type CacheStrategy interface {
    Get(key string) (interface{}, error)       // 从缓存获取数据
    Set(key string, value interface{}, ttl time.Duration) error  // 向缓存写入数据
}
// redis_cache.go:Redis缓存实现


type RedisCache struct {
    client *redis.Client  // Redis客户端
    ttl    time.Duration  // 默认过期时间
}


func NewRedisCache(client *redis.Client, ttl time.Duration) *RedisCache {
    return &RedisCache{client: client, ttl: ttl}
}


func (r *RedisCache) Get(key string) (interface{}, error) {
    return r.client.Get(context.Background(), key).Result()
}


func (r *RedisCache) Set(key string, value interface{}, ttl time.Duration) error {
    return r.client.Set(context.Background(), key, value, ttl).Err()
}


// memcached_cache.go:Memcached缓存实现
type MemcachedCache struct {
    client *memcache.Client  // Memcached客户端
}


func NewMemcachedCache(client *memcache.Client) *MemcachedCache {
    return &MemcachedCache{client: client}
}


func (m *MemcachedCache) Get(key string) (interface{}, error) {
    item, err := m.client.Get(key)
    if err != nil {
        return nil, err
    }
    var value interface{}
    if err := json.Unmarshal(item.Value, &value); err != nil {
        return nil, err
    }
    return value, nil
}


func (m *MemcachedCache) Set(key string, value interface{}, ttl time.Duration) error {
    data, err := json.Marshal(value)
    if err != nil {
        return err
    }
    return m.client.Set(&memcache.Item{
        Key:        key,
        Value:      data,
        Expiration: int32(ttl.Seconds()),
    }).Err()
}


// cache_context.go:缓存上下文(管理策略切换)
type CacheContext struct {
    strategy CacheStrategy  // 当前使用的缓存策略
}


func NewCacheContext(strategy CacheStrategy) *CacheContext {
    return &CacheContext{strategy: strategy}
}


// SwitchStrategy 动态切换缓存策略
func (c *CacheContext) SwitchStrategy(strategy CacheStrategy) {
    c.strategy = strategy
}


// Get 使用当前策略获取缓存
func (c *CacheContext) Get(key string) (interface{}, error) {
    return c.strategy.Get(key)
}


// Set 使用当前策略写入缓存
func (c *CacheContext) Set(key string, value interface{}, ttl time.Duration) error {
    return c.strategy.Set(key, value, ttl)
}

设计说明:

  • CacheStrategy接口定义了缓存的核心操作(Get和Set),所有具体缓存实现必须实现该接口。
  • RedisCache和MemcachedCache是具体的策略实现,分别封装了Redis和Memcached的底层逻辑。
  • CacheContext作为上下文,持有当前使用的缓存策略,并提供SwitchStrategy方法动态切换策略。客户端只需与CacheContext交互,无需关心具体使用的是哪种缓存。

优势: 当需要新增缓存类型(如本地内存缓存)时,只需实现CacheStrategy接口,无需修改现有代码;切换缓存策略时,只需调用SwitchStrategy方法,客户端无感知。

3.2 中间件链:可插拔的请求处理流程

理论补充:

中间件(Middleware)是位于请求处理链中的组件,用于实现横切关注点(如日志记录、限流、鉴权)。中间件链模式允许将多个中间件按顺序组合,形成处理流水线,每个中间件可以处理请求、传递请求或终止请求。

Go语言的实现方式:

Go语言通过函数类型(func(http.HandlerFunc) http.HandlerFunc)定义中间件,通过组合多个中间件形成处理链。这种模式灵活且易于扩展,适用于HTTP服务的请求处理。

示例:HTTP中间件链的实现

假设需要为Web服务添加日志记录、限流和鉴权功能,通过中间件链可以将这些功能解耦,按需组合。

// middleware/middleware.go:中间件定义
package middleware


import (
    "net/http"
    "time"
    "golang.org/x/time/rate"
)


// Middleware 定义中间件类型:接收http.HandlerFunc,返回新的http.HandlerFunc
type Middleware func(http.HandlerFunc) http.HandlerFunc


// LoggingMiddleware 日志中间件:记录请求信息
func LoggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        // 记录请求方法和路径
        println("Request received:", r.Method, r.URL.Path)
        // 调用下一个中间件或处理函数
        next(w, r)
        // 记录请求耗时
        println("Request completed in:", time.Since(start))
    }
}


// RateLimitMiddleware 限流中间件:限制请求频率
func RateLimitMiddleware(next http.HandlerFunc, limiter *rate.Limiter) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
            return
        }
        next(w, r)
    }
}


// AuthMiddleware 鉴权中间件:验证请求令牌
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token != "valid-token" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next(w, r)
    }
}


// chain.go:中间件链组合
func Chain(middlewares ...Middleware) Middleware {
    return func(final http.HandlerFunc) http.HandlerFunc {
        // 反向组合中间件(确保执行顺序正确)
        for i := len(middlewares) - 1; i >= 0; i-- {
            final = middlewares[i](final)
        }
        return final
    }
}

使用示例:

// main.go:Web服务入口
package main


import (
    "net/http"
    "middleware"
    "golang.org/x/time/rate"
)


func main() {
    // 创建限流器:每秒允许100个请求,突发10个
    limiter := rate.NewLimiter(10010)
    
    // 定义业务处理函数
    handleRequest := func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World"))
    }
    
    // 组合中间件链:日志 → 限流 → 鉴权
    middlewareChain := middleware.Chain(
        middleware.LoggingMiddleware,
        middleware.RateLimitMiddlewareWithLimiter(limiter),
        middleware.AuthMiddleware,
    )
    
    // 应用中间件链到处理函数
    http.HandleFunc("/", middlewareChain(handleRequest))
    
    // 启动服务
    http.ListenAndServe(":8080"nil)
}

设计说明:

  • 每个中间件(如LoggingMiddleware、RateLimitMiddleware)专注于单一功能,通过Middleware类型定义,确保接口统一。
  • Chain函数将多个中间件按顺序组合,形成一个处理链。请求会依次经过日志记录、限流、鉴权,最后到达业务处理函数。
  • 新增中间件(如CORS跨域中间件)时,只需实现Middleware类型,即可通过Chain函数轻松加入处理链,无需修改现有中间件或业务逻辑。

四、可扩展架构的实现模式

4.1 插件化架构:热插拔的功能扩展

理论补充:

插件化架构允许系统在运行时动态加载、卸载插件,从而实现功能的灵活扩展。这种架构适用于需要支持第三方扩展或多租户定制的场景(如IDE插件、电商平台应用市场)。

Go语言的实现方式:

Go语言通过plugin包支持动态库加载,结合接口定义插件契约,可以实现安全的插件化架构。插件需实现统一的接口,主程序通过接口调用插件功能。

示例:插件化系统的实现

假设需要开发一个支持插件的数据处理系统,主程序可以动态加载处理数据的插件(如csv_parser、json_parser)。

// plugin/interface.go:插件接口定义(主程序与插件共享)
package plugin


// DataProcessor 定义数据处理插件的接口
type DataProcessor interface {
    Name() string                      // 插件名称(如"csv_parser")
    Process(input []byte) (output []byte, err error)  // 处理数据
}


// plugin/csv_parser/csv_processor.go:CSV处理插件(动态库)
package main


import (
    "encoding/csv"
    "io"
    "os"
    "plugin"
)


// CSVProcessor 实现DataProcessor接口
type CSVProcessor struct{}


func (c *CSVProcessor) Name() string {
    return "csv_parser"
}


func (c *CSVProcessor) Process(input []byte) ([]byteerror) {
    // 解析CSV数据
    r := csv.NewReader(bytes.NewReader(input))
    records, err := r.ReadAll()
    if err != nil {
        return nil, err
    }
    // 转换为JSON格式输出
    var result []map[string]string
    for _, record := range records {
        row := make(map[string]string)
        for i, field := range record {
            row[fmt.Sprintf("col_%d", i)] = field
        }
        result = append(result, row)
    }
    jsonData, err := json.Marshal(result)
    if err != nil {
        return nil, err
    }
    return jsonData, nil
}


// 插件的入口函数(必须命名为"Plugin",主程序通过此函数获取插件实例)
var Plugin plugin.DataProcessor = &CSVProcessor{}
// main.go:主程序(加载插件并调用)
package main


import (
    "fmt"
    "plugin"
    "path/filepath"
)


func main() {
    // 插件路径(假设编译为so文件)
    pluginPath := filepath.Join("plugins""csv_parser.so")
    
    // 加载插件
    p, err := plugin.Open(pluginPath)
    if err != nil {
        panic(err)
    }


        // 获取插件实例(通过接口类型断言)
    sym, err := p.Lookup("Plugin")
    if err != nil {
        panic(err)
    }
    processor, ok := sym.(plugin.DataProcessor)
    if !ok {
        panic("插件未实现DataProcessor接口")
    }


        // 使用插件处理数据
    inputData := []byte("name,age
张三,20
李四,25")
    output, err := processor.Process(inputData)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(output))  // 输出JSON格式数据
}

设计说明:

  • 接口定义:主程序定义DataProcessor接口,规定插件必须实现的方法(Name和Process)。
  • 插件实现:插件(如csv_parser)实现DataProcessor接口,并导出名为Plugin的全局变量(主程序通过此变量获取插件实例)。
  • 动态加载:主程序通过plugin.Open加载插件,通过Lookup获取插件实例,并转换为DataProcessor接口调用。

优势:

  • 主程序与插件解耦,插件的添加、删除或升级不影响主程序运行。
  • 支持热插拔:插件可以在运行时动态加载(需注意Go插件的局限性,如版本兼容性)。

4.2 配置驱动架构:外部化的灵活配置

理论补充:

配置驱动架构(Configuration-Driven Architecture)通过将系统行为参数化,使系统可以通过修改配置(而非代码)来适应不同的运行环境或业务需求。这种架构适用于需要支持多环境(开发、测试、生产)、多租户定制或多场景适配的系统。

Go语言的实现方式:

Go语言通过encoding/json、encoding/yaml等包支持配置文件的解析,结合viper等第三方库可以实现更复杂的配置管理(如环境变量覆盖、热更新)。

示例:配置驱动的数据库连接

假设系统需要支持不同环境(开发、生产)的数据库配置,通过配置文件动态加载数据库连接参数。

// config/config.go:配置结构体定义
package config


// DBConfig 数据库配置
type DBConfig struct {
    DSN         string `json:"dsn"`          // 数据库连接字符串
    MaxOpenConn int    `json:"max_open_conn"` // 最大打开连接数
    MaxIdleConn int    `json:"max_idle_conn"` // 最大空闲连接数
    ConnTimeout int    `json:"conn_timeout"`  // 连接超时时间(秒)
}


// AppConfig 应用全局配置
type AppConfig struct {
    Env  string   `json:"env"`   // 环境(dev/test/prod)
    DB   DBConfig `json:"db"`    // 数据库配置
    Log  LogConfig `json:"log"`   // 日志配置
}


// LogConfig 日志配置
type LogConfig struct {
    Level string `json:"level"` // 日志级别(debug/info/warn/error)
    Path  string `json:"path"`  // 日志文件路径
}
// config/loader.go:配置加载器(支持热更新)
package config


import (
    "encoding/json"
    "os"
    "path/filepath"
    "time"


        "github.com/fsnotify/fsnotify"
)


// LoadConfig 加载配置文件
func LoadConfig(path string) (*AppConfig, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close()
    
    var cfg AppConfig
    decoder := json.NewDecoder(file)
    if err := decoder.Decode(&cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}


// WatchConfig 监听配置文件变化(热更新)
func WatchConfig(path string, callback func(*AppConfig)error {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        return err
    }
    defer watcher.Close()
    
    // 监听配置文件所在目录
    dir := filepath.Dir(path)
    if err := watcher.Add(dir); err != nil {
        return err
    }
    
    go func() {
        for {
            select {
            case event, ok := <-watcher.Events:
                if !ok {
                    return
                }
                // 仅处理写事件
                if event.Op&fsnotify.Write == fsnotify.Write {
                    // 重新加载配置
                    newCfg, err := LoadConfig(path)
                    if err != nil {
                        println("加载配置失败:", err.Error())
                        continue
                    }
                    // 触发回调(通知其他模块配置已更新)
                    callback(newCfg)
                }
            case err, ok := <-watcher.Errors:
                if !ok {
                    return
                }
                println("配置监听错误:", err.Error())
            }
        }
    }()
    
    // 保持程序运行
    select {}
}
// main.go:使用配置驱动的数据库连接
package main


import (
    "database/sql"
    "fmt"
    "config"
    _ "github.com/go-sql-driver/mysql"
)


func main() {
    // 加载初始配置
    cfg, err := config.LoadConfig("config.json")
    if err != nil {
        panic(err)
    }
    
    // 初始化数据库连接
    db, err := sql.Open("mysql", cfg.DB.DSN)
    if err != nil {
        panic(err)
    }
    defer db.Close()
    
    // 设置连接池参数(从配置中读取)
    db.SetMaxOpenConns(cfg.DB.MaxOpenConn)
    db.SetMaxIdleConns(cfg.DB.MaxIdleConn)
    db.SetConnMaxLifetime(time.Duration(cfg.DB.ConnTimeout) * time.Second)
    
    // 启动配置监听(热更新)
    go func() {
        err := config.WatchConfig("config.json"func(newCfg *config.AppConfig) {
            // 配置更新时,重新设置数据库连接池参数
            db.SetMaxOpenConns(newCfg.DB.MaxOpenConn)
            db.SetMaxIdleConns(newCfg.DB.MaxIdleConn)
            db.SetConnMaxLifetime(time.Duration(newCfg.DB.ConnTimeout) * time.Second)
            fmt.Println("配置已更新,数据库连接池参数调整")
        })
        if err != nil {
            panic(err)
        }
    }()
    
    // 业务逻辑...
}

设计说明:

  • 配置结构化:通过AppConfig、DBConfig等结构体定义配置的层次结构,确保配置的清晰性和可维护性。
  • 热更新支持:通过fsnotify监听配置文件变化,触发回调函数重新加载配置,并更新系统状态(如数据库连接池参数)。
  • 多环境适配:通过不同的配置文件(如config-dev.json、config-prod.json)或环境变量覆盖,实现不同环境的配置隔离。

优势:

  • 系统行为的调整无需修改代码,只需修改配置文件,降低了维护成本。
  • 支持动态调整关键参数(如数据库连接池大小、日志级别),提升了系统的灵活性和可观测性。

五、可扩展性的验证与演进

5.1 扩展性验证指标

为了确保系统具备良好的扩展性,需要从多个维度进行验证。以下是关键指标及测量方法:

指标 测量方法 目标值
新功能开发周期 统计新增一个中等复杂度功能所需的时间(包括设计、编码、测试) < 2人日
修改影响范围 统计修改一个功能时,需要修改的模块数量和代码行数 < 5个模块,< 500行代码
配置生效延迟 测量配置变更到系统完全应用新配置的时间 < 100ms
并发扩展能力 测量系统在增加CPU核数时,吞吐量的增长比例(理想为线性增长) 吞吐量增长 ≥ 核数增长 × 80%
插件加载时间 测量动态加载一个插件的时间 < 1秒

5.2 扩展性演进路线

系统的扩展性不是一蹴而就的,需要随着业务的发展逐步演进。以下是一个典型的演进路线:

graph TD
    A[单体架构] -->|垂直拆分| B[核心服务+支撑服务]
    B -->|接口抽象| C[模块化架构]
    C -->|策略模式/中间件| D[可扩展的分布式架构]
    D -->|插件化/配置驱动| E[云原生可扩展架构]
  • 阶段1单体架构:初期业务简单,系统以单体形式存在。此时应注重代码的可读性和可维护性,为后续扩展打下基础。
  • 阶段2核心服务+支撑服务:随着业务增长,将核心功能(如订单、用户)与非核心功能(如日志、监控)拆分,降低耦合。
  • 阶段3模块化架构:通过接口抽象和依赖倒置,将系统拆分为高内聚、低耦合的模块,支持独立开发和部署。
  • 阶段4可扩展的分布式架构:引入策略模式、中间件链等模式,支持动态切换算法和处理流程,适应多样化的业务需求。
  • 阶段5云原生可扩展架构:结合容器化(Docker)、编排(Kubernetes)和Serverless技术,实现资源的弹性扩展和自动伸缩。

六、结 语

可扩展性设计是软件系统的“生命力”所在。通过遵循开闭原则、模块化设计等核心原则,结合策略模式、中间件链、插件化架构等Go语言友好的编码模式,开发者可以构建出适应业务变化的“生长型”系统。

需要注意的是,扩展性设计并非追求“过度设计”,而是在当前需求和未来变化之间找到平衡。建议定期进行架构评审,通过压力测试和代码分析(如go mod graph查看模块依赖)评估系统的扩展性健康度,及时调整设计策略。

最后,记住:优秀的系统不是完美的,而是能够持续进化的。保持开放的心态,拥抱变化,才能在快速发展的技术领域中立于不败之地。

往期回顾

1. 得物新商品审核链路建设分享

2. 营销会场预览直通车实践|得物技术

3. 基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

4. AI质量专项报告自动分析生成|得物技术

5. Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术

文 / 悟

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物新商品审核链路建设分享

一、 前言

得物近年来发展迅猛,平台商品类目覆盖越来越广,商品量级越来越大。而以往得物的上新动作更多依赖于传统方式,效率较低,无法满足现有的上新诉求。那么如何能实现更加快速的上新、更加高效的上新,就成为了一个至关重要的命题。

近两年AI大模型技术的发展,使得发布和审核逐渐向AI驱动的方式转变成为可能。因此,我们可以探索利用算法能力和大模型能力,结合业务自身规则,构建更加全面和精准的规则审核点,以实现更高效的工作流程,最终达到我们的目标。

本文围绕AI审核,介绍机审链路建设思想、规则审核点实现快速接入等核心逻辑。

二、如何实现高效审核

对于高效审核的理解,主要可以拆解成“高质量”、“高效率”。目前对于“高质量”的动作包括,基于不同的类目建设对应的机审规则、机审能力,再通过人工抽查、问题Case分析的方式,优化算法能力,逐步推进“高质量”的效果。

而“高效率”,核心又可以分成业务高效与技术高效。

业务高效

  • 逐步通过机器审核能力优化审核流程,以解决资源不足导致上新审核时出现进展阻碍的问题。
  • 通过建设机审配置业务,产品、业务可以直观的维护类目-机审规则-白名单配置,从而高效的调整机审策略。

技术高效

  • 通过建设动态配置能力,实现快速接入新的机审规则、调整机审规则等,无需代码发布,即配即生效。

Q2在搭建了动态配置能力之后,算法相关的机审规则接入效率提升了70%左右。

三、动态配置实现思路

建设新版机审链路前的调研中,我们对于老机审链路的规则以及新机审规则进行了分析,发现算法类机审规则占比超过70%以上,而算法类的机审规则接入的流程比较固化,核心分成三步:

  1. 与算法同学沟通定义好接口协议
  2. 基于商品信息构建请求参数,通过HTTP请求算法提供的URL,从而获取到算法结果。
  3. 解析算法返回的结果,与自身商品信息结合,输出最终的机审结果。

而算法协议所需要的信息通常都可以从商品中获取到,因此通过引入“反射机制”、“HTTP泛化调用”、“规则引擎”等能力,实现算法规则通过JSON配置即可实现算法接入。

四、商品审核方式演进介绍

商品审核方式的演进

人审

依赖商管、运营,对商品上架各字段是否符合得物上新标准进行人工核查。

机审

对于部分明确的业务规则,比如白底图、图片清晰度、是否重复品、是否同质品等,机审做前置校验并输出机审结果,辅助人工审核,降低审核成本,提升审核效率。

AI审核

通过丰富算法能力、强化AI大模型能力、雷达技术等,建设越来越多的商品审核点,并推动召回率、准确率的提升,达标的审核点可通过自动驳回、自动修改等action接管商品审核,降低人工审核的占比,降低人工成本。

五、现状问题分析

产品层面

  • 机审能力不足,部分字段没覆盖,部分规则不合理:
    • 机审字段覆盖度待提升
    • 机审规则采纳率不足
    • 部分机审规则不合理
  • 缺少产品配置化能力,配置黑盒化,需求迭代费力度较高:
    • 规则配置黑盒
    • 规则执行结果缺乏trace和透传
    • 调整规则依赖开发和发布
    • 缺少规则执行数据埋点

技术层面

  • 系统可扩展性不足,研发效率低:
    • 业务链路(AI发品、审核、预检等)不支持配置化和复用
    • 规则节点不支持配置化和复用

六、流程介绍

搭建机审配置后台,可以通过配置应用场景+业务身份+商品维度配置来确定所需执行的全量规则,规则可复用。

其中应用场景代表业务场景,如商品上新审核、商家发品预检、AI发品预检等;业务身份则表示不同业务场景下不同方式,如常规渠道商品上新的业务场景下,AI发布、常规商品上新(商家后台、交易后台等)、FSPU同款发布品等。

当商品变更,通过Binlog日志触发机审,根据当前的应用场景+业务身份+商品信息,构建对应的机审执行链(ProcessChain)完成机审执行,不同的机审规则不通过支持不同的action,如自动修正、自动驳回、自动通过等。

链路执行流程图如下:

七、详细设计

整体架构图

业务实体

ER图

含义解释

※ 业务场景

触发机审的应用场景,如新品发布、商家新品预检等。

※ 业务身份

对于某个应用场景,进一步区分业务场景,如新品发布的场景下,又有AI发品、常规发品、FSPU同款发品等。

※ 业务规则

各行业线对于商品的审核规则,如校验图片是否是白底图、结构化标题中的类目需与商品类目一致、发售日期不能超过60天等。同一个业务规则可以因为业务线不同,配置不同的机审规则。

※ 规则组

对规则的分类,通常是商品字段模块的名称,一个规则组下可以有多个业务规则,如商品轮播图作为规则组,可以有校验图片是否白底图、校验图片是否清晰、校验模特姿势是否合规等。

※ 机审规则

对商品某个商品字段模块的识别并给出审核结果,数据依赖机审能力以及spu本身

※ 机审能力

商品信息(一个或多个商品字段模块)的审核数据获取,通常需要调用外部接口,用于机审规则审核识别。

※ 业务&机审规则关联关系

描述业务规则和机审规则的关联关系,同一个业务规则可以根据不同业务线,给予不同的机审规则,如轮播图校验正背面,部分业务线要求校验全量轮播图,部分业务线只需要校验轮播图首图/规格首图。

机审执行流程框架

流程框架

通过责任链、策略模式等设计模式实现流程框架。

触发机审后会根据当前的业务场景、业务身份、商品信息等,获取到对应的业务身份执行链(不同业务身份绑定不同的执行节点,最终构建出来一个执行链)并启动机审流程执行。

由于机审规则中存在数据获取rt较长的情况,如部分依赖大模型的算法能力、雷达获取三方数据等,我们通过异步回调的方式解决这种场景,也因此衍生出了“异步结果更新机审触发”。

※ 完整机审触发

完整机审触发是指商品变更后,通过Binlog日志校验当前商品是否满足触发机审,命中的机审规则中如果依赖异步回调的能力,则会生成pendingId,并记录对应的机审结果为“pending”(其他规则不受该pending结果的影响),并监听对应的topic。

※ 异步结果更新机审触发

部分pending规则产出结果后发送消息到机审场景,通过pendingId以及对应的商品信息确认业务身份,获取异步结果更新责任链(与完整机审的责任链不同)再次执行机审执行责任链。

动态配置能力建设

调研

新机审链路建设不仅要支持机审规则复用,支持不同业务身份配置接入,还要支持新机审规则快速接入,降低开发投入的同时,还能快速响应业务的诉求。

经过分析,机审规则绝大部分下游为算法链路,并且算法的接入方式较为固化,即“构建请求参数” -> “发起请求” -> “结果解析”,并且数据模型通常较为简单。因此技术调研之后,通过HTTP泛化调用实现构建请求参数发起请求,利用规则引擎(规则表达式) 实现结果解析。

规则引擎技术选型

调研市面上的几种常用规则引擎,基于历史使用经验、上手难度、文档阅读难度、性能等方面综合考虑,最终决定选用QLExpress

HTTP泛化调用能力建设

※ 实现逻辑

  • 定义MachineAuditAbilityEnum统一的动态配置枚举,并基于MachineAuditAbilityProcess实现其实现类。
  • 统一入参为Map结构,通过反射机制、动态Function等方式,实现商品信息映射成算法请求参数;另外为了提升反射的效率,利用预编译缓存的方式,将字段转成MethodHandle,后续对同一个字段做反射时,可直接获取对应的MethodHandle,提升效率。
/**
 * 缓存类字段的MethodHandle(Key: Class+FieldName, Value: MethodHandle)
  */
private static final Map<StringMethodHandleFIELD_HANDLE_CACHE = new ConcurrentHashMap<>();


/**
 * 根据配置从对象中提取字段值到Map
 * @return 提取后的Map
 */
public Map<StringObjectfieldValueMapping(AutoMachineAlgoRequestConfig requestConfig, Object spuResDTO) {
    AutoMachineAlgoRequestConfig.RequestMappingConfig requestMappingConfig = requestConfig.getRequestMappingConfig();
    Map<StringObject> targetMap = Maps.newHashMap();
    //1.简单映射关系,直接将obj里的信息映射到resultMap当中


    //2.遍历复杂映射关系,value是基础类型
    //3.遍历复杂映射关系,value是对象


  
    return targetMap;
}


/**
 *  预编译FieldMapping
  */
private List<AutoMachineAlgoRequestConfig.FieldMappingcompileConfig(List<AutoMachineAlgoRequestConfig.FieldMapping> fieldMappingList, Object obj) {
 
    List<AutoMachineAlgoRequestConfig.FieldMapping> mappings = new ArrayList<>(fieldMappingList.size());
    //缓存反射mapping
    return mappings;
}


private Object getFieldValue(Object request, String fieldName) throws Throwable {
    String cacheKey = request.getClass().getName() + "#" + fieldName;
    MethodHandle handle = FIELD_HANDLE_CACHE.get(cacheKey);
    return handle != null ? handle.invoke(request) : null;
}
  • 基于实现@FeignClient注解,实现HTTP调用的执行器,其中@FeignClient中的URL表示域名,autoMachineAuditAlgo方法中的path表示具体的URL,requestBody是请求体,另外还包含headers,不同算法需要不同headers也可动态配置。
  • 返回结果均为String,而后解析成Map<String,Object>用于规则解析。
@FeignClient(
        name = "xxx",
        url = "${}"
)
public interface GenericAlgoFeignClient {


    @PostMapping(value = "/{path}")
    String autoMachineAuditAlgo(
            @PathVariable("path") String path,
            @RequestBody Object requestBody,
            @RequestHeader Map<String, String> headers
    );
   
    @GetMapping("/{path}")
    String autoMachineAuditAlgoGet(
            @PathVariable("path") String path,
            @RequestParam Map<String, Object> queryParams,
            @RequestHeader Map<String, String> headers
    );


}
  • 动态配置JSON。
{
    "url": "/ai-check/demo1",
    "requestMappingConfig": {
        "fieldMappingList": [
            {
                "sourceFieldName": "categoryId",
                "targetKey": "categoryId"
            },
            {
                "sourceFieldName": "brandId",
                "targetKey": "brandId"
            }
        ],
        "perItemMapping": {
            "mappingFunctionCode": "firstAndFirstGroundPic",
            "fieldMappingList": [
                {
                    "sourceFieldName": "imgId",
                    "targetKey": "imgId"
                },
                {
                    "sourceFieldName": "imgUrl",
                    "targetKey": "imgUrl"
                }
            ]
        }
    }
}

机审规则动态解析建设

※ 实现逻辑

  • 定义MachineAuditRuleEnum统一的动态配置枚举,并基于MachineAuditRuleProcess实现其统一实现类。
  • 搭建QLExpress规则引擎,为了提升QLExpress规则引擎的效率,同样引入了缓存机制,在机审规则配置表达式时,则触发loadRuleFromJson,将表达式转换成规则引擎并注入到缓存当中,真正机审流程执行时会直接从缓存里获取规则引擎并执行,效率上有很大提升。
// 规则引擎实例缓存
private static final Map<StringExpressRunner> runnerCache = new ConcurrentHashMap<>();


// 规则配置缓存
private static final Map<StringGenericEngineRule> ruleConfigCache = new ConcurrentHashMap<>();


// 规则版本信息
private static final Map<StringInteger> ruleVersionCache = new ConcurrentHashMap<>();


/**
 * 加载JSON规则配置
 * @param jsonConfig 规则JSON配置
 */
public GenericEngineRule loadRuleFromJson(String ruleCode, String jsonConfig) {


    //如果缓存里已经有并且是最新版本,则直接返回
    if(machineAuditCache.isSameRuleConfigVersion(ruleCode) && machineAuditCache.getRuleConfigCache(ruleCode) != null) {
        return machineAuditCache.getRuleConfigCache(ruleCode);
    }
    // 如果是可缓存的规则,预加载


  
    return rule;
}
  • 机审规则执行时,通过配置中的规则名称,获取对应的规则引擎进行执行。
/**
 * 根据规则名称执行规则
 * @param ruleCode 规则名称
 * @param context 上下文数据
 * @return 规则执行结果
 */
public MachineAuditRuleResult executeRuleByCode(String ruleCode, Map<StringObject> context, MachineAuditRuleProcessData ruleProcessData) {
    if (StringUtils.isBlank(ruleCode)) {
        throw new IllegalArgumentException("机审-通用协议-规则-规则名称不能为空");
    }


        //从缓存中获取规则引擎


    //基于规则引擎执行condition


    //统一日志
}

※ 配置demo

  • 动态配置JSON。
{
    "ruleCode": "demo1",
    "name": "规则demo1",
    "ruleType": 1,
    "priority": 100,
    "functions": [
    ],
    "conditions": [
        {
            "expression": "result.code == null || result.code != 0",
            "action": {
                "type": "NO_RESULT",
                "messageExpression": "'无结果'"
            }
        },
        {
            "expression": "result.data == 0",
            "action": {
                "type": "PASS",
                "messageExpression": "'机审通过"
            }
        },
        {
            "expression": "result.data == 1",
            "action": {
                "type": "REJECT",
                "messageExpression": "'异常结果1'",
                "suggestType": 2,
                "suggestKey": "imgId",
                "preAuditSuggestKey": "imgUrl"
            }
        },
        {
            "expression": "result.data == 2",
            "action": {
                "type": "REJECT",
                "messageExpression": "'异常结果2'",
                "suggestType": 2,
                "suggestKey": "imgId",
                "preAuditSuggestKey": "imgUrl"
            }
        }
    ],
    "defaultAction": {
        "type": "PASS"
    }
}

八、关于数据分析&指标提升

在经历了2-3个版本搭建完新机审链路 + 数据埋点之后,指标一直没有得到很好的提升,曾经一度只是维持在20%以内,甚至有部分时间降低到了10%以下;经过大量的数据分析之后,识别出了部分规则产品逻辑存在漏洞、算法存在误识别等情况,并较为有效的通过数据推动了产品优化逻辑、部分类目规则调整、算法迭代优化等,在一系列的动作做完之后,指标提升了50%+。

在持续了比较长的一段时间的50%+覆盖率之后,对数据进行了进一步的剖析,发现这50%+在那个时间点应该是到了瓶颈,原因是像“标题描述包含颜色相关字样”、“标题存在重复文案”以及部分轮播图规则,实际就是会存在不符合预期的情况,因此紧急与产品沟通,后续的非紧急需求停止,先考虑将这部分天然不符合预期的情况进行处理。

之后指标提升的动作主要围绕:

  • 算法侧产出各算法能力的召回率、准确率,达标的算法由产品与业务拉齐,是否配置自动驳回的能力。
  • 部分缺乏自动修改能力的机审规则,补充临时需求建设对应的能力。

经过产研业务各方的配合,以最快速度将这些动作进行落地,指标也得到了较大的提升。

往期回顾

1.营销会场预览直通车实践|得物技术

2.基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

3.AI质量专项报告自动分析生成|得物技术

4.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术

5.eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

文 / 沃克

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

营销会场预览直通车实践|得物技术

一、背景:活动会场的配置走查之痛

在电商营销中,会场是承载活动流量的核心阵地。得物的营销会场不仅覆盖520、七夕等活动节点,也支撑日常的"天天领券"、"疯狂周末"等高频运营场景。数据显示,会场的UV占比、GMV贡献、订单量均占平台重要比重。

然而,随着业务复杂度提升,会场配置面临三大挑战。

1.1 三大挑战

※ 多目标耦合

同一会场需同时满足不同运营GMV提升、拉新、促活等不同目标,导致配置策略叠加,复杂度激增。

※ 验证滞后性

传统方式需活动生效后才能验证效果,配置错误可能导致资损,修复成本高昂。

※ 跨团队协作低效

涉及搭建、招商、优惠、资产等6大系统,联调成本高,走查覆盖率仅60%。

1.2 会场的配置举例

二、  解决方案:全链路"痛点穿越"

2.1 痛点梳理

2.2 核心思路

通过模拟未来时间、指定用户人群、强制命中AB实验,实现**"上线未对外先验证"**,让运营和技术在配置完成后即可预览真实效果。

分层架构设计

方案选型

某一线电商大厂穿越  VS  得物-时间穿越 VS 其他。

成本范围可控性,以及业务特性使用效率考量;原理即定义预览模式,传参即为true来消费。

关键改造点

  • 搭建系统: 低成本高便捷自查和走查。
  • 投放系统 :新增travel_mode参数,透传至下游。
  • 招商系统 :各类型招商活动查询逻辑,支持未来时间过滤。
  • 优惠试算 :兼容"虚拟资产"参与计算,确保价格准确性。
  • 风险管控 :限制仅白名单用户可触发,禁止真实下单。

三、 落地效果

3.1 应用姿势

活动预演

模拟不同人群用户不同时间点的价格计算及会场效果及稳定性。

优惠叠加校验

验证"跨店满减+品类券+平台补贴+商家自建优惠+商家代金券"的组合逻辑。

人群定向测试

人群定向测试 :对比新老用户、成熟非成熟及特殊类目新等的价格分层效果。

3.2 效率提升

不需要重新复制相同活动模拟提前开始,加之商家自建活动和平台活动较多,模拟相同时间的各类活动成本较大,且不可能做到完全相同,使运营配合测试线上验证配置工作量下降50%(少配置一套)

 提前穿越预览可提前感知活动期间各类价格、价格标签及各类活动叠加的优惠试算,检查配置问题,让活动走查场景覆盖度从历史60%覆盖度提升到80%以上(历史走查只能走查商品流、活动开始后的价格、标签、资源位无法走查到,活动叠加类型不够全),也方便运营预览预期实际效果并时调整策略,同时减少配置风险。

一个账号即可实现所有人群、实验、组件会场的预览,资产与走查更高效

线上风险规避:避免如过往活动生效才能感知效果,风险前置;如有问题只能下线活动及资源位的止损;减少资损风险,避免多类型活动叠加破价M类事件。

快速check不同排期下不同人群、不同实验组用户在不同时间段的活动下的商品优惠价、营销标签以页面组件呈现。

3.3 落地效果分析

做得好的

我们的"穿越"方案通过轻量级改造,实现了全链路验证能力 ,为复杂营销系统的配置管理提供了标准化解法。其核心价值在于:

※ 风险前置化

将问题发现节点从"上线后"提前至"配置阶段"。

※ 效率最大化

一个二维码即可验证所有人群、实验、时间组合。

※ 成本最优

仅需接口参数改造,无需搭建完整灰度环境。

有待提升

  • 权益投放的咨询和领取暂未实现穿越。
  • 会场存在与商品详情页的价格试算、标签不一致问题。

四、 未来规划

扩展可应用的穿越场景:

  1. 频道穿越:承载产品化运营的频道同活动会场实现痛点穿越,提效自查走查。
  2. 商详页一致性 :建立价格版本号机制,解决会场与商详页价标不一致问题。
  3. 活动资源位:建立活动核心资源位排期可监听,可自动穿越预览。
  4. 权益投放 :在沙箱环境实现"领取→使用"全流程验证。

绿色部分是已经具备的基础能力,红色边框是未来规划去实现的业务线,如下方案非最终方案,基于改动范围和成本考量:

4.1频道

频道穿越概述:

  1. 痛点:较多频道偏产品线运营,每周末都会提前招商提前配置。
  2. 穿越实现方式:同会场,通过sence区分。
  3. 价值:频道实现后,可同理无成本拓展新品频道、补贴频道、打牌低价等。

App入口管控

测试包安装有名单管控,天然支持了白名单。

资源位

资源位穿越:

  1. 痛点:活动c端引流入口、重体验,对外前的配置走查费力。
  2. 范围:首页弹窗、活动tab、活动中通、购买feeds商卡、我的tab、穹顶。
  3. 价值:时间+人群+实验穿越减少运营流量计划重复配置,提前预览活动氛围和投放效果。

商详

商详穿越:

  1.  商详:商详价格与会场一致性、氛围、标签、导购自身商详样式实验等。
  2. 价值:时间+人群+实验穿越减少运营流量计划重复配置,提前预览活动氛围和投放效果。

五、  总结

穿越类型

  • 仅传时间:即业务处理上假定到了某一时间,uid由App自动获取,是否命中人群、实验,按真实查询星云、AB。
  • 仅传人群:即业务处理上按照当前时间处理,假定用户属于入参人群,去定位计划或招商活动。
  • 仅传实验:即业务处理上按照当前时间,用户实际人群,时间为入参实验value处理。
  • 都设定:即业务处理上按照目标时间、假定命中目标入参人群和目标AB实验value来处理业务。
  • 消费穿越入参方:严格按照接收什么,即命中什么,未接收的走实际业务查询来处理。

风险管控

  • App测试包的安装现有管控:加入测试白名单的得物账号才可以下载测试包,默认可安装测试包的机器都可穿越。
  • 穿越目的是检验个业务配置正确性、素材效果、全链路验证等,供咨询查询,避免写操作:比如创单支付、核销。

能力沉淀

  • 从客户端上developer工具的透传穿越(时间、人群、实验),基础能力沉淀后,各业务域拓展性强,对于新增业务穿越工作量大大降低,接入成本也相对较低。

往期回顾

1.基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

2.AI质量专项报告自动分析生成|得物技术

3.Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术

4.Java volatile 关键字到底是什么|得物技术

5.eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

文 / 东陌

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

一、 背景

客服知识库是一个集中管理和存储与客服相关的信息和资源的系统,在自研知识库上线之前,得物采用的承接工具为第三方知识库系统。伴随着业务的发展,知识的维护体量、下游系统的使用面临的问题愈发明显,而当前的第三方采购系统,已经较难满足内部系统间高效协作的诉求,基于以上业务诉求,我们自研了一套客服知识库。

二、富文本编辑器的选型

以下是经过调研后列出的多款富文本编辑器综合对比情况:

2.1 编辑器的选择

  • 自研知识库要求富文本编辑器具备表格的编辑能力,由于Quill不支持表格编辑能力(借助表格插件可以实现该能力,但经过实际验证,插件提供的表格编辑能力不够丰富,使用体验也较差),被首先被排除。
  • wangEditor体验过程中发现标题和列表(有序、无序)列表两个功能互斥,体验不太好,而这两个功能都是自研知识库刚需功能,也被排除。
  • Lexical是facebook推出的一款编辑器,虽功能很丰富,但相较于CKEditorTinyMCE,文档不够完善,社区活跃性较低,插件不成熟,故优先选择CKEditorTinyMCE

CKEditorTinyMCE经过对比,由于当前正在使用的第三方知识库采用的是TinyMCE编辑器,选择TinyMC在格式兼容上会更友好,对新老知识库的迁移上更有利。且TinyMCE在功能丰富度上略占优势,故最终选择TinyMCE作为本系统文档知识库的编辑器

2.2 TinyMce编辑器模式的选择

经典模式(默认模式)

基于表单,使用表单某字段填充内容,编辑器始终作为表单的一部分。内部采用了iframe沙箱隔离,将编辑内容与页面进行隔离。

※ 优势

样式隔离好。

※ 劣势

由于使用iframe,性能会差点,尤其对于多实例编辑器。

内联模式(沉浸模式)

将编辑视图与阅读视图合二为一,当其被点击后,元素才会被编辑器替换。而不是编辑器始终可见,不能作为表单项使用。内容会从它嵌入的页面继承CSS样式表。

※ 优势

性能相对较好,页面的编辑视图与阅读视图合二为一,提供了无缝的体验,实现了真正的所见即所得。

※ 劣势

样式容易受到页面样式的影响。

三、系统总览

3.1 知识创建链路

3.2 知识采编

结构化段落

为了对知识文档做更细颗粒度的解析,客服知识库采用了结构化段落的设计思想,每个段落都会有一个唯一标志 ,且支持对文档的每个段落单独设置标签,这样在后期的知识检索、分类时,便可以精确定位到知识文档的具体段落,如下图所示。

知识文档编辑页面

3.3 应用场景

客服知识库的主要应用场景如下:

知识检索

基于传统的ES检索能力,用于知识库的检索,检索要使用的知识,且可以直接在工作台打开对应的知识并浏览,并可以定位、滚动到具体的知识段落。同时还会高亮显示知识文档中匹配到的搜索关键字

智能问答(基于大模型能力和知识库底层数据的训练)

※ RAG出话

辅助客服了解用户的真实意图,可用于客服作业时的参考。

原理阐述: RAG是一种结合了检索和生成技术的人工智能系统。它是大型语言模型的一种,但特别强调检索和生成的结合。RAG的最主要的工作流程包括:

  • 检索阶段:系统会根据用户的查询,从客服知识库中检索出相关信息。这些信息可能包括知识库内容、订单信息和商品信息等
  • 生成阶段:RAG使用检索到的信息来增强其生成过程。这意味着,生成模型在生成文本时,会考虑到检索到的相关信息,以生成更准确、更相关的回答。你可以直接将搜索到的内容返回给用户也可以通过LLM模型结合后生成给用户。

※ 答案推荐

可以根据用户搜索内容、上下文场景(如订单信息、商品信息)辅助客服更高效的获取答案。

流程示意:

※ 联网搜索

当RAG出话由于拒识没有结果时,便尝试进行联网搜索给出结果,可作为RAG能力失效后的补充能力。

原理阐述: 底层使用了第三方提供的联网问答Agent服务。在进行联网搜索之前,会对用户的查询信息进行风控校验,风控校验通过后,再进行 【指定意图清单】过滤,仅对符合意图的查询才可以进行联网搜索。

四、问题和解决方案

4.1 解决图片迁移问题

背景

在新老知识迁移的过程中,由于老知识库中的图片链接的域名是老知识库的域名,必须要有老知识库的登录台信息,才能在新知识库中访问并渲染。为了解决这个问题,我们对用户粘贴的动作进行了监听,并对复制内容的图片链接进行了替换。

时序图

核心逻辑

/**
 * 替换编辑器中的图片URL
 * @param content
 * @param editor 编辑器实例
 * @returns 替换后的内容
 */
export const replaceImgUrlOfEditor = (content, editor) => {
  // 提取出老知识中的图片访问链接
  const oldImgUrls = extractImgSrc(content);
  // 调用接口获取替换后的图片访问链接
  const newImageUrls = await service.getNewImageUrl(oldImgUrls);
  // 将老知识库的图片链接替换成新的可访问的链接
  newContent = replaceImgSrc(newContent, replacedUrls.imgUrls);
  // 使用新的数据更新编辑器视图
  editor.updateView(newContent);
};

4.2 解决加载大量图片带来的页面卡顿问题

背景

知识库内含有大量的图片,当我们打开一篇知识时,系统往往因为在短时间内加载、渲染大量的图片而陷入卡顿当中,无法对页面进行其他操作。这个问题在老知识库中尤为严重,也是研发新知识库过程中我们需要重点解决的问题。

解决方案

我们对图片进行了懒加载处理:当打开一篇知识时,只加载和渲染可见视图以内的图片,剩余的图片只有滚动到可见视图内才开始加载、渲染。

由于我们要渲染的内容的原始数据是一段html字符串,一篇知识文档的最小可渲染单元是段落(结构化段落),而一个段落的内容大小事先是不知道的,因此传统的滚动加载方式在这里并不适用:比如当滚动到需要加载下一段落的位置时,如果该段落的内容特别大且包含较多图片时,依然会存在卡顿的现象。

我们采用正则匹配的方式,识别出知识文档的html中所有的  标签(将文档的html视作一段字符串),并给  标签插入 loading="lazy" 的属性,具备该属性的图片在到达可视视图内的时候才会加载图片资源并渲染,从而实现懒加载的效果,大大节省了知识文档初次渲染的性能开销。并且该过程处理的是渲染知识文档前的html字符串,而非真实的dom操作,所以不会带来重绘、重排等性能问题。

知识文档渲染的完整链路

4.3 模板缩略图

背景

在知识模板列表页或者在创建新知识选择模板时,需要展示模板内容的缩略图,由于每个模板内容都不一样,同时缩略图中需要可以看到该模板靠前的内容,以便用户除了依靠模板标题之外还可以依靠一部分的模板内容选择合适的模板。

解决方案

在保存知识模板前,通过截屏的方式保存一个模板的截图,上传截图到cdn并保存cdn链接,再对截图进行一定的缩放调整,即可作为模板的缩略图。

时序图

实际效果

模板列表中缩略图展示效果:

新建知识时缩略图展示效果:

4.4 全局查找/替换

背景

知识库采用了结构化段落的设计思想,技术实现上,每个段落都是一个独立的编辑器实例。这样实现带来一个弊端:使用编辑器的搜索和替换功能时,查找范围仅限于当前聚焦的编辑器,无法同时对所有编辑器进行查找和替换,增加了业务方的编辑费力度。

解决方案

调研、扩展编辑器的查找/替换插件的源码,调度和联动多编辑器的查找/替换API从而实现全局范围内的查找/替换。

※ 插件源码剖析

通过对插件源码的分析,我们发现插件的查找/替换功能是基于4个基本的API实现的: find 、 replace 、 next 、 prev 、 done 。

※ 设计思路

通过在多个编辑器中加入一个调度器来控制编辑器之间的接力从而实现全局的查找/替换。同时扩展插件的API辅助调度器在多编辑器之间进行调度

※ 插件源码API扩展

  1. hasMatched: 判断当前编辑器是否匹配到关键字。
  2. hasReachTop:判断当前编辑器是否已到达所查找关键字的最前一个。
  3. hasReachBottom:判断当前编辑器是否已到达所查找关键字的最后一个。
  4. current: 滚动到编辑器当前匹配到的关键字的位置。
  5. clearCurrentSelection: 对编辑器当前匹配到的关键字取消高亮效果。

UI替换

屏蔽插件自带的查找/替换的弹窗,实现一个支持全局操作的查找/替换的弹窗:使用了react-rnd组件库实现可拖拽弹窗,如下图所示:

「查找」

※ 期望效果

当用户输入关键字并点击查找时,需要在文档中(所有编辑器中)标记出(加上特定的背景色)所有匹配到该关键字的文本,并高亮显示出第一个匹配文本。

※ 流程图

「下一个」

※ 期望效果

当用户点击「下一个」时,需要高亮显示下一个匹配结果并滚动到该匹配结果的位置。

※ 流程图

五、总结

在新版客服知识库的研发和落地过程中,我们基于TinyMce富文本编辑器的基础上,进行了功能扩展和定制。这期间既有参考过同类产品(飞书文档、语雀)的方案,也有根据实际应用场景进行了创新。截止目前已完成1000+老知识库的顺利迁移,系统稳定运行。

自研过程中我们解决了老版知识库系统的卡顿和无法满足定制化需求的问题。并在这些基本需求得到满足的情况下,通过优化交互方式和知识文档的加载、渲染性能等方式进一步提升了使用体验

后续我们会结合用户的反馈和实际使用需求进一步优化和扩展客服知识库的功能,也欢迎有同样应用场景的同学一起交流想法和意见。

往期回顾

1.AI质量专项报告自动分析生成|得物技术

2.Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术

3.Valkey 单点性能比肩 Redis 集群了?Valkey8.0 新特性分析|得物技术

4.Java SPI机制初探|得物技术

5.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术



文 / 煜宸

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

AI质量专项报告自动分析生成|得物技术

一、背景

在日常工作中,常需要通过各项数据指标,确保驱动版本项目进展正常推进,并通过各种形式报表数据,日常总结日报、周会进展、季度进行总结输出归因,分析数据变化原因,做出对应决策变化,优化运营方式,目前在梳理整理校准分析数据需要大量的时间投入、结合整体目标及当前进展,分析问题优化的后续规划。

常见形式

人工收集

数据来源依赖于各系统平台页面,通过人工收集校准后填写再通过表格公式计算,或者可以通过多维表格工作流触发通知等功能。

图片

quickbi报表

通过ODPS搭建自定义报表,实现快速收集数据,复制报表到飞书文档内进行异动分析。

图片

平台能力开发

通过代码开发文档导出能力,根据固定模板生成数据分析,该能力开发人力成本较高,需要针对不同平台数据源定制化开发。

图片图片

AI Studio智能体平台

研发效能团队基于开源Dify项目社区部署,可以根据需求自定义sop,多模型的可选项,选择最适合业务的模型。每个工作流节点可自定义流程的判断分析,轻松上线可投产的AI Agents。

Dify是一个支持工作流编排的AI智能体平台,轻松构建并部署生产级 AI 应用。其核心功能包含:

  1. 以工作流的方式编排AI应用,在工作流中可以添加LLM、知识库、Agent工具、MCP服务等节点,工作流支持分支流转、节点循环、自定义节点等高级能力项。

  2. 支持在工作流中调用公司内部的Dubbo/gRPC服务。(插件实现)

  3. 知识库管理,通过构建私有知识库以增强 LLM 的上下文。

  4. 与内部平台集成,支持H5页面嵌入、API的方式与内部平台集成。

  5. 主流模型集成,支持使用多种主流模型如DeepSeek、OpenAI等,支持多模态模型。

对标的业界产品有:

✅ 多模型选择(适配不同业务场景)

✅ 可视化工作流搭建(支持自定义SOP)

✅ 全链路可观测性(实时调试优化)

综上本期实践利用AI工作流平台针对报告进行生成分析输出,让使用方回归到聚焦数据归因分析上,减少数据收集分析、文档编写成本。

图片

二、应用实践

实践效果

整体分析数据从哪来->需要输出什么样的格式->优化模型输出结果,三步骤针对输出结果进行调优。

图片

自动化成熟度分析工作流搭建案例

图片

运用效果

图片图片

报告效果

图片

飞书机器人通知归因分析

图片图片

数据处理

图片

LLM:通过用户输入分析获取数据源请求格式,配置好对应数据的映射关系模型自行获取对应数据。

提示词输入

图片

格式化输出配置

图片

http请求:通过用户输入分析后的参数构造请求参数,通过固定接口拉取数据,支持curl导入功能。

图片图片

代码执行:支持python、js代码对结果数据进行处理过滤,提升分析结果准确性。

图片

模型提示词

如文档整体分为不同模块可设定不同模型节点处理,每个模块增加特定提示词处理节点内容,模型并行分析处理,提升输出稳定性和输出效率,再通过LLM输出整合进行整体输出。

图片

在模型输入上下文及用户输入,通过获取的数据指定输出格式,设定提示词,提供AI结合模板输出对应形式。

图片

通过衔接上下节点返回内容最终整合报表输出结果,统一输出样式格式。

图片

优化输出

切换可用模型

遇到模型输出不稳定或者未达到预期效果,可切换可用模型,寻找适配模型。

图片

设定模型预载参数

设定模型预载参数,提升模型输出准确度。

图片

优化增加提示词

优化增加提示词提升输出形式稳定性:角色定义 ->  字段映射 -> 模板说明 -> 实际数据填充 -> 输出格式定义。

`## 角色定义 你是一位接口自动化测试专家以及报告生成专家,负责将接口返回的数据映射字段结合模板输出一份有效的自动化成熟度报告-稳定性部分。

接口返回数据字段映射关系:

基础字段: bu_name:业务域名称。 parent_bu_id:业务域。

稳定性指标字段: total_auto_stability_score:稳定性评分 iter_case_success_rate: 迭代自动化成功率 iter_case_success_rate_cpp: 迭代自动化成功率环比 auto_case_failed_rate: 自动化失败率 auto_case_failed_rate_cpp: 自动化失败率环比 case_aigc_avg_score: 用例健壮有效性评分 case_aigc_avg_score_cpp: 有效性评分环比

模板:

2.2 自动化稳定性 用表格展示自动化稳定性,表格内容包含所有一级业务域、二级业务域。 表头按照顺序输出: 1、业务域 2、自动化稳定性评分 3、迭代自动化成功率 4、迭代自动化成功率环比 5、自动化失败率 6、失败率环比 7、用例健壮有效性评分 8、有效性评分环比

重点关注项:xxx --仅分析二级业务域的稳定性性指标字段,列出需重点关注指标。

模板说明:

1、以html格式输出,增加内容丰富度,不输出任何多余内容。 2、表格说明:表格需要包含所有业务域数据。不要省略或者缺少任何业务域数据,将所有业务域展示在同一个表格内。 3、表格行排序:根据评分从高到低排序。 4、环比字段说明:指标环比下降环比字段标记红色,环比提升字段标记绿色,不标记背景色。

任务说明

1、用户将提供接口返回的JSON数据。 2、根据接口数据和匹配字段映射关系。 3、结合模板以及模板说明html形式输出,不输出任何多余内容。 请你根据以上内容,回复用户,不需要输出示例。`

模板转换

输出的表格形式通过模板转化固定输出html表格形式,提升模型输出稳定性。

图片

输出形式

以markdown形式或以html形式输出,复制到飞书文档上进行输出。

html最终效果

图片

markdown最终效果

图片

飞书机器人通知归因分析

图片

生成飞书文档

支持飞书应用直接新建飞书文档,markdown形式输出。

图片

对话返回生成后的飞书文档地址及分析:

图片

三、总结

在日常工作中如何有效利用数据指标驱动项目进展,现有数据收集和分析流程中面临的挑战。通过手动收集数据、生成报表、平台开发等传统方式,需要投入大量时间和人力资源,导致工作效率低下。

为此,引入了研发效能AI 智能体平台,AI工作流平台不仅改进了数据处理方式,还提升了报告生成的效率和准确性,从而增强了业务洞察力。进一步丰富工作流和知识库,提高对核心数据指标的分析能力,并针对异常数据指标进行细致剖析,为团队提供更深入的指导和支持。

此外,相似场景的处理也可以借助AI工作流进行优化,有望在多个业务领域推广应用。

四、后续规划

  • 丰富工作流:丰富结合知识库,针对每项核心数据指标提升建议以及业务域现状给予业务域具体指导建议。

  • 明细下钻分析:获取对应数据指标异常后,结合明细数据进行分析,具体到用例、人员级别。

  • 类似场景可通过AI工作流处理:固定模板数据源报告类、周会均可使用该方法减少人工投入成本。

往期回顾

1.Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术

2.Valkey 单点性能比肩 Redis 集群了?Valkey8.0 新特性分析|得物技术

3.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术

4.正品库拍照PWA应用的实现与性能优化|得物技术

5.得物社区活动:组件化的演进与实践

文 / 笠

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术

一、Profiling:揭示性能瓶颈的“照妖镜”

在过去的一年里,我们团队完成了一项壮举:将近万核的 Java 服务成功迁移到 Rust,并收获了令人瞩目的性能提升。我们的实践经验已在《RUST练习生如何在生产环境构建万亿流量》一文中与大家分享。然而,在这次大规模迁移中,我们观察到一个有趣的现象:大多数服务在迁移后性能都得到了显著提升,但有那么一小部分服务,性能提升却不尽如人意,仅仅在 10% 左右徘徊。

这让我们感到疑惑。明明已经用上了性能“王者”Rust,为什么还会遇到瓶颈?为了解开这个谜团,我们决定深入剖析这些“低提升”服务。今天,我就来和大家分享,我们是如何利用 Profiling 工具,找到并解决写入过程中的性能瓶颈,最终实现更高性能飞跃的!

在性能优化领域,盲目猜测是最大的禁忌。你需要一把锋利的“手术刀”,精准地找到问题的根源。在 Rust 生态中,虽然不像 Java 社区那样拥有 VisualVM 或 JProfiler 这类功能强大的成熟工具,但我们依然可以搭建一套高效的性能分析体系。

为了在生产环境中实现高效的性能监控,我们引入了 Jemalloc 内存分配器和 pprof CPU 分析器。这套方案不仅支持定时自动生成 Profile 文件,还可以在运行时动态触发,极大地提升了我们定位问题的能力。

二、配置项目:让Profiling“武装到牙齿”

首先,我们需要在 Cargo.toml 文件中添加必要的依赖,让我们的 Rust 服务具备 Profiling 的能力。以下是我们的配置,Rust 版本为 1.87.0。

[target.'cfg(all(not(target_env = "msvc"), not(target_os = "windows")))'.dependencies]
# 使用 tikv-jemallocator 作为内存分配器,并启用性能分析功能
tikv-jemallocator = { version = "0.6", features = ["profiling""unprefixed_malloc_on_supported_platforms"] }
# 用于在运行时控制和获取 jemalloc 的统计信息
tikv-jemalloc-ctl = { version = "0.6", features = ["use_std""stats"] }
# tikv-jemallocator 的底层绑定,同样启用性能分析
tikv-jemalloc-sys = { version = "0.6", features = ["profiling"] }
# 用于生成与 pprof 兼容的内存剖析数据,并支持符号化和火焰图
jemalloc_pprof = { version = "0.7", features = ["symbolize","flamegraph"] }
# 用于生成 CPU 性能剖析数据和火焰图
pprof = { version = "0.14", features = ["flamegraph""protobuf-codec"] }

简单来说,这几个依赖各司其职:

※ tikv-jemallocator

基于 jemalloc 的 Rust 实现,以其高效的内存管理闻名。

※ jemalloc_pprof

负责将 jemalloc 的内存剖析数据转换成标准的 pprof 格式。

※ pprof

用于 CPU 性能分析,可以生成 pprof 格式的 Profile 文件。

三、  全局配置:启动Profiling开关

接下来,在 main.rs 中进行全局配置,指定 Jemalloc 的 Profiling 参数,并将其设置为默认的全局内存分配器。

// 配置 Jemalloc 内存分析参数
#[export_name = "malloc_conf"]
pub static malloc_conf: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:16\0";


#[cfg(not(target_env = "msvc"))]
use tikv_jemallocator::Jemalloc;


// 将 Jemalloc 设置为全局内存分配器
#[cfg(not(target_env = "msvc"))]
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;

这段配置中的 lg_prof_sample:16 是一个关键参数。

它表示 jemalloc 会对大约每 2^16 字节(即 64KB)的内存分配进行一次采样。这个值越大,采样频率越低,内存开销越小,但精度也越低;反之则精度越高,开销越大。在生产环境中,我们需要根据实际情况进行权衡。

四、实现Profile生成函数:打造你的“数据采集器”

我们将 Profile 文件的生成逻辑封装成异步函数,这样就可以在服务的任意时刻按需调用,非常灵活。

内存Profile生成函数

#[cfg(not(target_env = "msvc"))]
async fn dump_memory_profile() -> Result<StringString> {
    // 获取 jemalloc 的 profiling 控制器
    let prof_ctl = jemalloc_pprof::PROF_CTL.as_ref()
        .ok_or_else(|| "Profiling controller not available".to_string())?;


    let mut prof_ctl = prof_ctl.lock().await;
    
    // 检查 profiling 是否已激活
    if !prof_ctl.activated() {
        return Err("Jemalloc profiling is not activated".to_string());
    }
   
    // 调用 dump_pprof() 方法生成 pprof 数据
    let pprof_data = prof_ctl.dump_pprof()
        .map_err(|e| format!("Failed to dump pprof: {}", e))?;


    // 使用时间戳生成唯一文件名
    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
    let filename = format!("memory_profile_{}.pb", timestamp);


    // 将 pprof 数据写入本地文件
    std::fs::write(&filename, pprof_data)
        .map_err(|e| format!("Failed to write profile file: {}", e))?;


    info!("Memory profile dumped to: {}", filename);
    Ok(filename)
}

CPU Profile生成函数

类似地,我们使用 pprof 库来实现 CPU Profile 的生成。

#[cfg(not(target_env = "msvc"))]
async fn dump_cpu_profile() -> Result<String, String> {
    use pprof::ProfilerGuard;
    use pprof::protos::Message;


    info!("Starting CPU profiling for 60 seconds...");


    // 创建 CPU profiler,设置采样频率为 100 Hz
    let guard = ProfilerGuard::new(100).map_err(|e| format!("Failed to create profiler: {}", e))?;


    // 持续采样 60 秒
    tokio::time::sleep(std::time::Duration::from_secs(60)).await;


    // 生成报告
    let report = guard.report().build().map_err(|e| format!("Failed to build report: {}", e))?;


    // 使用时间戳生成文件名
    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
    let filenameformat!("cpu_profile_{}.pb", timestamp);


    // 创建文件并写入 pprof 数据
    let mut file = std::fs::File::create(&filename)
        .map_err(|e| format!("Failed to create file: {}", e))?;


    report.pprof()
        .map_err(|e| format!("Failed to convert to pprof: {}", e))?
        .write_to_writer(&mut file)
        .map_err(|e| format!("Failed to write profile: {}", e))?;


    info!("CPU profile dumped to: {}", filename);
    Ok(filename)
}
  •  ProfilerGuard::new()   100  Hz 意味着每秒钟会随机中断程序 100 次,以记录当前正在执行的函数调用栈
  • tokio::time::sleep(std::time::Duration::from_secs(60)).await 表示 pprof 将会持续采样 60 秒钟
  •  guard.report().build() 这个方法用于将收集到的所有采样数据进行处理和聚合,最终生成一个 Report 对象。这个 Report 对象包含了所有调用栈的统计信息,但还没有转换成特定的文件格式
  •  report.pprof() 这是 Report 对象的一个方法,用于将报告数据转换成 pprof 格式

五、 触发和使用 Profiling:随时随地捕捉性能数据

有了上述函数,我们实现了两种灵活的触发方式。

※ 定时自动生成

通过异步定时任务,每隔一段时间自动调用 dump_memory_profile() 和  dump_cpu_profile() 。

fn start_profilers() {
    // Memory profiler
    tokio::spawn(async {
        let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
        loop {
            interval.tick().await;
            #[cfg(not(target_env = "msvc"))]
            {
                info!("Starting memory profiler...");
                match dump_memory_profile().await {
                    Ok(profile_path) => info!("Memory profile dumped successfully: {}", profile_path),
                    Err(e) => info!("Failed to dump memory profile: {}", e),
                }
            }
        }
    });
    // 同理可以实现CPU profiler
}

※ 手动 HTTP 触发

通过提供 /profile/memory 和 /profile/cpu 两个 HTTP 接口,可以随时按需触发 Profile 文件的生成。

async fn trigger_memory_profile() -> Result<impl warp::Reply, std::convert::Infallible> {
    #[cfg(not(target_env = "msvc"))]
    {
        info!("HTTP triggered memory profile dump...");
        match dump_memory_profile().await {
            Ok(profile_path) => Ok(warp::reply::with_status(
                format!("Memory profile dumped successfully: {}", profile_path),
                warp::http::StatusCode::OK,
            )),
            Err(e) => Ok(warp::reply::with_status(
                format!("Failed to dump memory profile: {}", e),
                warp::http::StatusCode::INTERNAL_SERVER_ERROR,
            )),
        }
    }
}
//同理也可实现trigger_cpu_profile()函数
fn profile_routes() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    let memory_profile = warp::post()
        .and(warp::path("profile"))
        .and(warp::path("memory"))
        .and(warp::path::end())
        .and_then(trigger_memory_profile);
    
    
    let cpu_profile = warp::post()
        .and(warp::path("profile"))
        .and(warp::path("cpu"))
        .and(warp::path::end())
        .and_then(trigger_cpu_profile);
    memory_profile.or(cpu_profile)
}

现在,我们就可以通过 curl 命令,随时在生产环境中采集性能数据了:

curl -X POST http://localhost:8080/profile/memory
curl -X POST http://localhost:8080/profile/cpu

生成的 .pb 文件,我们就可以通过 go tool pprof 工具,启动一个交互式 Web UI,在浏览器中直观查看调用图、火焰图等。

go tool pprof -http=localhost:8080 ./target/debug/otel-storage ./otel_storage_cpu_profile_20250806_032509.pb

六、性能剖析:火焰图下的“真相”

通过 go tool pprof 启动的 Web UI,我们可以看到程序的火焰图

如何阅读火焰图

※ 顶部: 代表程序的根函数。

※ 向下延伸; 子函数调用关系。

※ 火焰条的宽度: 代表该函数在 CPU 上消耗的时间。宽度越宽,消耗的时间越多,越可能存在性能瓶颈

CPU Profile

Memory Profile

在我们的 CPU 火焰图中,一个令人意外的瓶颈浮出水面:OSS::new 占用了约 19.1% 的 CPU 时间。深入分析后发现, OSS::new 内部的 TlsConnector 在每次新建连接时都会进行 TLS 握手,这是导致 CPU 占用过高的根本原因。

原来,我们的代码在每次写入 OSS 时,都会新建一个 OSS 实例,随之而来的是一个全新的 HTTP 客户端和一次耗时的 TLS 握手。尽管 oss-rust-sdk 内部有连接池机制,但由于我们每次都创建了新实例,这个连接池根本无法发挥作用!

七、优化方案:从“每次新建”到“共享复用”

问题的核心在于重复创建 OSS 实例。我们的优化思路非常清晰:复用 OSS 客户端实例,避免不必要的 TLS 握手开销

优化前

每次写入都新建 OSS 客户端。

fn write_oss() {
    // 每次写入都新建一个OSS实例
    let oss_instancecreate_oss_client(oss_config.clone());
    tokio::spawn(async move {
        // 获取写入偏移量、文件名
        // 构造OSS写入所需资源和头信息
        // 写入OSS
        let result = oss_instance
            .append_object(data, file_name, headers, resources)
            .await;
}
fn create_oss_client(config: OssWriteConfig) -> OSS {
    OSS::new(
    ……
    )
}

这种方案在流量较小时可能问题不大,但在万亿流量的生产环境中,频繁的实例创建会造成巨大的性能浪费。

优化前

※ 共享实例

让每个处理任务( DecodeTask )持有 Arc 共享智能指针,确保所有写入操作都使用同一个 OSS 实例。

let oss_client = Arc::new(create_oss_client(oss_config.clone()));
let oss_instance = self.oss_client.clone()
// ...
let result = oss_instance
    .append_object(data, file_name, headers, resources)
    .await;

※ 自动重建机制

为了应对连接失效或网络问题,我们引入了自动重建机制。当写入次数达到阈值或发生写入失败时,我们会自动创建一个新的 OSS 实例来替换旧实例,从而保证服务的健壮性。

// 使用原子操作确保多线程环境下的计数安全
let write_countself.oss_write_count.load(std::sync::atomic::Ordering::SeqCst);
let failure_countself.oss_failure_count.load(std::sync::atomic::Ordering::SeqCst);


// 检查是否需要重建实例...
fn recreate_oss_client(&mut self) {
 
    let new_oss_client = Arc::new(create_oss_client(self.oss_config.clone()));
    self.oss_client = new_oss_client;
    self.oss_write_count.store(0, std::sync::atomic::Ordering::SeqCst);
    self.oss_failure_count.store(0, std::sync::atomic::Ordering::SeqCst);
    // 记录OSS客户端重建次数指标
    OSS_CLIENT_RECREATE_COUNT
        .with_label_values(&[])
        .inc();
    info!("OSS client recreated");
}

八、优化效果:性能数据“一飞冲天”

优化后的服务上线后,我们观察到了显著的性能提升。

CPU 资源使用率

同比下降约 20%

OSS 写入耗时

同比下降约 17.2% ,成为集群中最短的写入耗时。

※ OSS写入耗时

※ OSS相关资源只占千分之一

内存使用率

平均下降 8.77% ,这部分下降可能也得益于我们将内存分配器从 mimalloc 替换为 jemalloc 的综合效果。

这次优化不仅解决了特定服务的性能问题,更重要的是,它验证了在 Rust 中通过 Profiling 工具进行深度性能分析的可行性。即使在已经实现了初步性能提升的 Rust 服务中,仍然存在巨大的优化空间。

未来,我们将继续探索更高效的 Profiling 方案,并深入挖掘其他潜在的性能瓶颈,以在万亿流量的生产环境中实现极致的性能和资源利用率。

引用

往期回顾

1.Valkey 单点性能比肩 Redis 集群了?Valkey8.0 新特性分析|得物技术

2.Java volatile 关键字到底是什么|得物技术

3.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术

4.正品库拍照PWA应用的实现与性能优化|得物技术

5.得物社区活动:组件化的演进与实践

文 / 炯帆 南风

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Valkey 单点性能比肩 Redis 集群了?Valkey8.0 新特性分析|得物技术

一、  背景

Valkey 社区于 2024 年 09 月发布了 Valkey8.0 正式版,在之前的文章《Redis 是单线程模型?》中,我们提到,Redis 社区在 Redis6.0 中引入了多线程 IO 特性,将 Redis 单节点访问请求从 10W/s 提升到 20W/s,而在 Valkey8.0 版本中,通过引入异步 IO 线程、内存预取(Prefetch)、内存访问分摊(MAA)等新特性,并且除了将读写网络数据卸载到 IO 线程执行外,还会将 event 事件循环、对象内存释放等耗时动作也卸载到 IO 线程执行,使得 Valkey 单节点访问请求可以提升到 100W/s,大幅提升 Valkey 单节点性能。

Valkey 8.0中引入的异步 IO 与 Redis 6.0 中的多线程 IO 有什么区别?Valkey8.0 中如何应用内存预取和内存访问分摊技术进一步来提升性能的?本篇文章让我们来一起看看。

  • 2024 年,Redis 商业支持公司 Redis Labs 宣布 Redis 核心代码的许可证从 BSD 变更为 RSALv2 ,明确禁止云厂商提供 Redis 托管服务,这一决定直接导致社区分裂。
  • 为维护开源自由,Linux 基金会联合多家科技公司(包括 AWS、Google Cloud、Oracle 等)宣布支持 Valkey ,Valkey 基于 Redis 7.2.4 开发,作为 Redis 的替代分支。
  • Valkey8.0 为 Valkey 社区发布的首个主要大版本。
  • 最新消息,在 Redis 项目创始人 antirez 今年加入 Redis 商业公司 5 个月后,Redis 宣传从 Redis8 开始,Redis 项目重新开源。

二、  异步 IO 线程背景

Redis6.0多线程IO

在 Redis 6.0 中引入了多线程 IO 特性,用来处理网络数据的读写和协议解析,读写数据执行流程如下所示:

在 Redis6.0 中,读数据流程是主线程先将所有可读客户端加入一个队列,全部处理完后,再通过 RR 算法将这些可读客户端分配给 IO 线程,由 IO 线程执行读数据;写数据流程类似处理。

尽管引入多线程 IO 大幅提升了 Redis 性能,但是 Redis6.0 的多线程 IO 仍然存在一些不足:

  • 主线程在处理客户端命令时,IO 线程会均处于空闲状态;由于主线程会阻塞等待所有 IO 线程完成读写数据,主线程在执行 IO 相关任务期间的性能受到最慢 IO 线程速度的限制
  • 由于主线程同步等待 IO 线程,IO 线程仅执行读取解析和写入操作,主线程仍然承担大部分 IO 任务
Valkey 8.0 异步 IO 线程

Valkey8.0 通过使用任务队列使主线程向 IO 线程发送任务,IO 线程异步并行执行任务提升整体性能。Valkey 8.0 异步 IO 线程工作流程整体设计图如下所示:

IO 线程初始化

在 Valkey 启动时进行初始化的时候,根据配置的线程数量server.io_threads_num 决定是否创建异步 IO 线程,如果server.io_threads_num == 1表示不开启,另外,IO 线程数量最大不超过 15 个;如果配置开启异步 IO 线程,则初始化的时候按需创建异步 IO 线程。

线程间通信

Valkey 初始化创建 IO 线程的时候,会给每个 IO 线程创建一个静态、无锁、固定大小(大小为 2048)的

环形缓冲区作为任务队列,用于主线程发送任务,以及 IO 线程接收任务。

环形缓冲区是从主线程到 IO 线程的单向通道。当发生读/写事件时,主线程会发送一个读/写任务,然后在进入 event 事件监测休眠之前,它会遍历所有待处理的读/写客户端,检查每个客户端的 IO 线程是否已经处理完毕。IO 线程通过切换客户端结构体上的原子标志 read_state / write_state 来表示它已经处理完一个客户端的读/写操作。

读数据流程

读数据流程如下图所示:

主线程监测到有读事件时,检查是否开启 IO 线程,如果开启了 IO 线程,会根据算法选择一个 IO 线程,检查选中的 IO 线程任务队列是否已满,如果任务队列未满,则将该待读事件客户端加入IO 线程的任务队列。

如果未开启 IO 线程,或者选中的 IO 线程任务队列已满,则由主线程完成读数据操作并执行命令。

IO 线程循环从任务队列获取任务,如果是读数据任务,则执行读数据流程。先读取数据,然后解析命令,并从命令列表中查找命令并保存在指定字段(这里也是把本来由主线程在执行命令时执行的动作卸载到 IO 线程完成)。

主线程在进入 event 事件监听睡眠前,循环遍历所有在等待 IO 线程读数据的客户端,检查数据是否读取完成,如果是则加入批量预取数据数组,当全部客户端都检查完成或者批量预取数据数组存满,则批量执行命令。

在 Redis6.0 中,需要先将所有可读客户端存入一个队列,再遍历可读客户端列表通过 RR 算法将可读事件分配到不同的 IO 线程中,然后主线程设置 IO 线程开启读数据,在主线程执行这些操作期间,IO 线程均处于空闲状态。

在 Valkey 8.0 中,每监测到一个可读事件,立即通过任务队列发送到一个 IO 线程,IO 线程立即可以开始读数据操作,主线程遍历后续可读事件期间,IO 线程异步在执行读取操作。

写数据流程

主线程执行完每个命令时,将客户端加入等待等写队列clients_pending_write,将响应客户端的数据写入到响应缓存 buf 或者 reply 链表。

主线程处理完所有命令后,循环遍历等待写队列clients_pending_write,将通过算法选择一个 IO 线程,如果选中的 IO 线程任务队列未满,将该客户端写数据任务加入 IO 线程的任务队列。

IO 线程循环从任务队列获取任务,如果是写数据任务,则执行写数据流,将数据写回给用户。

动态调整 IO 线程数量

每次在有可读事件或者可写事件需要执行前,Valkey 会根据可读/写事件数量,动态调整活跃 IO 线程数量,最大活跃 IO 线程数量不超过设置的允许 IO 线程数量(固定为 15)。

根据可读/写事件数量、每个 IO 线程可执行事件数量(可配置)、以及最大允许活跃 IO 线程数量,计算需要的目标活跃 IO 线程数量,当前活跃 IO 线程数量小于目标数量时,可增加活跃 IO 线程,当前活跃 IO 线程数量大于目标数量时,可减少活跃 IO 线程。

动态增加或者减少活跃 IO 线程数量,减少活跃 IO 线程并不会直接关闭创建出来的 IO 线程,而是通过加锁使当前没有任务可执行的 IO 线程暂停轮询查找任务,避免 IO 线程不必要的空轮询;同样增加活跃 IO 线程只需要主线程释放锁即可,IO 线程获取到锁后,开始轮询获取是否有可执行任务需要执行。

  • 尽管 I/O 线程数量可动态调整,具有动态特性,但主线程仍保持线程亲和性,确保在可能的情况下由同一个 I/O 线程处理同一客户端的 I/O 请求,从而提高内存访问的局部性。
卸载更多任务到 IO 线程

在 Valkey 8.0 中,除了读取解析数据/写入操作之外,还将很多额外的工作卸载到 I/O 线程,以便更好地利用 I/O 线程并减少主线程的负载。

事件轮询卸载到 IO 线程

在 Valkey 中使用了 IO 多路复用模型实现在主线程中来高效处理所有来自客户端的连接读写访问,而套接字轮询系统调用(例如epoll_wait)是开销很大的过程,仅由主线程来执行会消耗大量主线程时间。

在 Valkey8.0 中,当主线程有待处理的 I/O 操作或要执行的命令时,主线程都会将套接字轮询系统调用调度到 IO 线程执行,否则由主线程自身来执行。

为避免竞争条件,在任何给定时间,最多只有一个线程(io_thread 或主线程)执行epoll_wait,当主线程将事件轮询系统调用分配给一个 IO 线程执行后,主线程执行完命令处理后,不再执行事件轮询系统调用,而是直接检查 IO 线程的轮询等待结果,查看是否有可读写事件。

对象释放卸载到 IO 线程

在 Valkey 读取客户端数据后,命令解析过程中会分配大量命令参数对象,在命令处理完成后,需要释放为这些命令参数分配的内存空间,在 Valkey8.0 中,将这些命令参数内存空间释放分配给 IO 线程执行,并且会分配给执行该参数解析(内存分配)的同一个 IO 线程来执行(通过客户端 ID 进行标识)。

命令查找卸载

如前面在读数据流程中提到的,当 IO 线程解析来自客户端的 Querybuf 的命令时,它可以在命令字典中执行命令查找,并且 IO 线程会将查找到的命令存储在客户端的指定字段中,后续主线程执行命令时直接使用即可,可以节省主线程执行命令的时间。

三、 数据预取(Prefetch)与内存访问分摊(MAA)

在 Valkey8.0 中引入异步 IO 线程提高并行度,并且将更多的工作转移到 IO 线程,将主线程执行的 I/O 操作量降至最低,此时,经过测试,单个 Valkey 节点每秒处理请求可达 80W。

通过分析开启 IO 线程后 Valkey 性能,主线程大部分时间都花销在访问内存查找 key,这是因为 Valkey 字典是一个简单但低效的链式哈希实现,在遍历哈希链表时,每次访问 dictEntry 结构体、指向键的指针或值对象,都很可能需要进行昂贵的外部内存访问。

于是在 Valkey8.0 中引入了数据预取(Prefetch)和内存访问分摊(MAA) 技术,进一步提升 Valkey 单节点访问性能。

数据预取(Prefetch)

随着摩尔定律在过去 30 年间的持续生效,CPU 的运算速度大幅提升,而存储器(主要是内存)的速度提升相对较慢,这导致了存储器与 CPU 之间的速度差异。当 CPU 执行指令时,如果需要从内存中读取数据或指令,由于存储器速度的限制,CPU 可能需要等待访问存储器操作完成,从而导致性能瓶颈。

为了解决访问存储器瓶颈这一问题,现代计算机系统采用了多级缓存及内存层次结构,包括 L1、L2、L3 缓存以及主存等。尽管高速缓存(Cache)能够提供更快的访问速度,但其容量有限,当 CPU 访问的数据无法在高速缓存中找到时,就需要从更慢的内存层级中获取数据,这会导致较高的访问延迟,并降低整体性能。

数据预取(Prefetching)技术可以在一定程度上解决访问存储器成为 CPU 性能瓶颈的问题。数据预取是一种提前将数据或指令从内存中预先加载到高速缓存中的技术。通过预取,CPU 可以在实际使用之前将数据预先加载到缓存中,从而减少对内存的访问延迟。这样可以提高访问存储器的效率,减少 CPU 等待访问存储器的时间,从而提升整体性能。

__builtin_prefetch() 是 gcc 编辑器提供的一个内置函数,它通过对数据手工预取到 CPU 的缓存中,减少了读取延迟,从而提高程序的执行效率。

在 Valkey8.0 中,主线程在执行命令之前,通过使用 __builtin_prefetch() 命令,对所有即将操作的命令参数、key 及对应的 value 进行批量预取,提高主线程执行命令的效率。

内存访问分摊(MAA)

内存访问摊销 (MAA) 是一种旨在通过降低内存访问延迟的影响来优化动态数据结构性能的技术。它适用于需要并发执行多个操作的情况。其背后的原理是,对于某些动态数据结构,批量执行操作比单独执行每个操作更高效。

这种方法并非按顺序执行操作,而是将所有操作交错执行。具体做法是,每当某个操作需要访问内存时,程序都会预取必要的内存并切换到另一个操作。这确保了当一个操作因等待内存访问而被阻塞时,其他内存访问可以并行执行,从而降低平均访问延迟。

Valkey8.0 预取数据应用

Valkey 是一个键值对数据库,在 Valkey 中的键值对是由字典(也称为 hash 表)保存的,如下图所示的链式哈希表。

在 Valkey8.0 之前,在哈希表中查找一个 key 及对应的 value 步骤如下描述:

  1. 计算 key 的 hash 值,找到对应的 bucket
  2. 遍历存储在 bucket 中通过链表连接的 entry,直到找到需要的 key
  3. 如果找到 key,再访问 key 映射的 RedisObj(也就是存储的 value),如果存储的 value 是OBJ_ENCODING_RAW类型,还需要进一步访问内存地址获取真正的数据

每一步操作都需要等待前面的步骤完成内存数据读取,整个访问过程是一个串行步骤,这种动态数据结构会阻碍处理器推测未来可以并行执行的内存加载指令的能力,因此访问内存成为 Valkey 处理数据的性能瓶颈。

在 Valkey8.0 中,对于具有可执行命令的客户端(即 IO 线程已解析命令的客户端),主线程将创建一个最多包含 16 条命令的批次,批量处理这些命令。并且执行命令前,先将命令参数预取到主线程的一级缓存中,再将所有命令所需的字典条目 entry 和值 value 都从字典中预取。

同时,预取命令所需的字典条目 entry 和值 value 时遍历字典的方式与上述查找 key 过程类似,不同的是,每个 key 每次只执行一步,然后不等待从内存中完成读取数据,而只是预取数据,然后继续执行下一个 key 的下一次预取动作。这样当所有 key 都遍历完成第一步后,开始执行第二步的时候,执行第二步所需的第一步数据已经预取到了 L1 高速缓存。这样通过交错执行所有 key,并且结合预取,达到分摊访问内存的效果。

单个 key 预取流程如下所示:

每批次多个 key 预取流程则是循环遍历每个 key 交错执行上述步骤,先预取其中一个 key 的 bucket,然后不会执行预取该 key 的 entry,因为此时如果接着流程预取该 key 的 entry,需要等待将该 key 的 bucket 内存读取出来;而是执行下一个 key 的预取动作。也就是达成所有 key 的预取动作一直在并行执行效果,分摊内存访问时间。

多个 key 批量预取流程如下所示:

循环遍历每个 key 交错执行上述步骤,先执行一个 key 的预取动作,然后交错执行另一个 key 的预取动作,所有 key 的预取动作并行执行,降低所有 key 访问内存总时间。

同一批次所有 key 和 value 都完成预取后,主线程开始批量执行命令。相比在 Valkey8.0 之前的版本中,主线程逐个处理每个客户端命令,批量预取数据加上批量处理,大幅提升单节点 Valkey 服务器性能,社区测试单节点 Valkey 访问请求可以达到每秒 120W。

四、  总结

本文分析了在 Valkey8.0 中通过引入异步 IO 线程、内存预取(Prefetch)、内存访问分摊(MAA) 等新特性,极大的提升了 Valkey 单节点性能,这些技术手段和算法思想也值得我们在实际业务开发中借鉴和使用。

Valkey8.0 中以上性能提升特性由亚马逊贡献,亚马逊也做了一系列压测对比,在增强 IO 多路复用的加持下,Valkey 单节点 QPS 最大可以超过 100W,压测数据可以参考《推陈出新 – Valkey 性能测试:探索版本变迁与云托管的效能提升》 aws.amazon.com/cn/blogs/ch… ,单节点性能完全可以比肩 Redis 低版本中等规模集群了。

在 Valkey8.0 版本中,除了以上重大性能提升优化以外,还在提升内存利用率、加快主从复制效率、增强 resharding 过程中高可用性、实验性支持 RDMA,以及提升集群的观测性等方面都进行了多项优化。我们后续再详细介绍。

Valkey8.0 正式版发布至今时间还不算太长,经过一段时间的验证后,我们也会考虑将自建 Redis server 版本逐步升级到新版本,为业务提供性能更优的缓存服务。

往期回顾

1.Java SPI机制初探|得物技术

2.得物向量数据库落地实践

3.Java volatile 关键字到底是什么|得物技术

4.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术

5.从 “卡顿” 到 “秒开”:外投首屏性能优化的 6 个实战锦囊|得物技术

文 / 竹径

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物向量数据库落地实践

一,背景

信息通信技术(ICT)正经历着前所未有的变革浪潮,以大模型和生成式人工智能(GenAI)为代表的技术突破,正在引发全球产业体系的深刻变革,成为驱动企业技术架构革新和商业模式转型的关键引擎。

得物是广受年轻人喜爱的品质生活购物社区。在AI鉴别、图搜、算法、安全风控等场景下都广泛使用啦GenAI技术。

向量数据库作为GenAI的基础设施之一,通过量化的高维空间数据结构(如HNSW算法),实现对嵌入向量(Embeddings Vector)的高效存储、索引和最近邻搜索(ANN),支撑包括多模态数据表征在内的复杂智能应用。

二,认识向量数据库

向量数据来源和存储

image.png

一般向量数据库中向量的来源是将图片、音频、视频、文本等非结构化数据,将这些非结构化数据通过对应的量化算法计算出一个多维度的向量(生产使用一般向量维度会大于512),并且将向量数据持久化在特定的存储上。

向量数据库是如何工作

image.png 向量数据库在查询的时候一般会将需要查询的非结构化数据通过量化,计算成一个多维度向量数据,然后在数据库中搜索出和查询向量相似的数据。(需要注意的是这边查询的是相似的数据而不是相同的数据)。

三,向量数据库对比传统数据库

image.png

image.png 向量数据库在数据结构、检索方法、擅长领域与传统数据库有很大的不同。

传统数据库

结构是处理离散的标量数据类型(例如数字和字符串),并通过行和列来表达组织数据(就是一个表格)。传统数据库主要为了解决结构化数据的精确管理和高效查询问题。并且传统数据库通过B树索引、哈希索引等数据结构,能够快速定位到精确匹配的记录。更重要的是,传统数据库通过ACID事务特性(原子性、一致性、隔离性、持久性)确保了在数据中数据的绝对准确性。

向量数据库

为了解决非结构化数据的语义搜索问题,解决如何在海量的高维向量数据中,快速找到与查询向量最相似的结果。比如在推荐系统中找到与用户喜好相似的物品,或在图像库中检索出与查询图片最相近的图片。这类问题的特点是:

  1. 查询的不是精确匹配,而是相似度排名。
  2. 数据维度极高(通常128-2048维)。
  3. 数据规模庞大(可能达到十亿级别)。

传统数据库的精确查询方式在这种场景下完全失效,因为:

  1. 无法为高维向量建立有效的B树索引。
  2. 计算全量数据的精确相似度代价过高。
  3. 无法支持"相似但不完全相同"的搜索需求。

四,如何选择向量数据库

向量数据库比较

下面我们通过10个不同维度来比较一下不同向量数据库的区别:

image.png

从上面表格可以看到:

  1. 自 2016 年起 ,向量数据库逐渐崭露头角,成为 AI 和大数据领域的重要基础设施。而到了 2021 年之后 ,随着深度学习、大模型和推荐系统的迅猛发展,向量数据库正式迈入爆发式增长时代 ,成为现代数据架构中不可或缺的核心组件。
  2. 超过半数的向量数据库均采用分布式架构设计,并且这些支持分布式部署的系统普遍具备弹性扩缩容能力,能够根据业务需求实现资源的动态调整。
  3. 当业务需要处理亿级甚至更高规模的向量数据时,推荐以下高性能、可扩展的向量数据库:Vespa、Milvus/Zilliz、Vald、Qdrant。
  4. 当前主流的向量数据库普遍采用模块化、插件式的设计理念。其核心引擎大多基于 C/C++ 开发,以追求极致的性能表现。与此同时,Go 和 Rust 也正在这一领域崭露头角。
  5. 在向量数据库领域,NHSW(Hierarchical Navigable Small-World)和 DiskANN 正逐渐成为主流索引方案。其中NHSW主要以内存搜索为主,DiskANN主要以磁盘搜索为主。值得注意的是,Qdrant 在优化 NHSW 的基础上,进一步实现了 基于磁盘的 NHSW 检索能力。

选择流行的索引

在向量数据库技术领域,有NHSW 和 DiskANN 作为两大主流索引方案,各自展现了独特的技术优势。我们从以下关键维度进行专业对比分析。

image.png

从上表格我们可以得到,NHSW和DiskANN适用于不同的场景:

  • NHSW :以 内存优先 的设计实现高性能搜索,适合对 低延迟、高吞吐 要求严格的场景,如实时推荐、广告检索等。
  • DiskANN :以 磁盘存储优化 为核心,在保证较高召回率的同时 显著降低硬件成本 ,适用于大规模数据下的经济型检索需求。

随着数据规模的持续增长,NHSW 和 DiskANN 的混合部署模式 或将成为行业标准,让用户能根据业务需求灵活选择 "极致性能" 或 "最优成本" 的检索策略。

综合比较和选择

image.png

从表格中可以得到:

  1. 如果数据流比较小,并且自身对Redis、PG、ES比较熟悉,就可以选择Redis、PG、ES。如DBA团队就比较适合。
  2. 如果数据量比较大,并且前期人力不足可以使用云托管方案。选择Zilliz、Pinecone、Vespa或者Qdrant,如果后期计划从云上迁移到自建可以选择Zilliz、Vespa或者Qdrant。

得物选择Milvus作为向量数据库

我们的需求

社区图搜和AI鉴别需要大量的数据支持,得物业务场景要求能支持十亿级向量数据搜索,有如下要求:

  1. 大数据量高性能搜索,RT需要在90ms以内。
  2. 大数据量但是性能要求不高时,RT满足500ms以内。

需要支持快速扩缩容:

满足上面2点就已经锁定在Milvus、Qdrant这两个向量数据库。如果从架构复杂度和维护/学习成本的角度考虑,我们应该优先选择Qdrant,因为它的架构相比Milvus没有那么复杂,并且维护/学习成本没有Milvus高,重要的Qdrant可以单独集群部署,不需要k8s技术栈的支撑。

Milvus 和 Qdrant 架构比较

Milvus架构

Milvus部署依赖许多外部组件,如存储元信息的ETCD、存储使用的MinIO、消息存储Pulasr 等等。

image.png

Qdrant

Qdrant完全独立开发,支持集群部署,不需要借助ETCD、Pulsar等组件。

image.png

选择Milvus的原因

※ 业务发展需求

业务属于快速发展阶段,数量的变化导致扩缩容频繁,使用支持k8s的Milvus在扩缩容方面会比Qdrant快的多。

※ 技术储备和社区良好

对DBA而言,向量数据库领域需要持续的知识更新和技术支持。从问题解决效率来看,国内技术社区对Milvus的支持体系相较于Qdrant更为完善。

※ 契合得物DBA开发栈

Milvus使用的开发语言是Go,契合DBA团队技术栈,在部分运维场景下情,通过二次开发满足运维需求。例如:使用milvus-backup工具做迁移,部分的segment有问题需要跳过。自行修改一下代码编译运行即可满足需求。

image.png

五,Milvus在得物的实践

部署架构演进

小试牛刀

初始阶段,我们把Milvus部署在K8S上,默认使用HNSW索引。架构图如下,Milvus整个架构较为复杂,外部依赖的组件多,每个集群需要部署自己的 ETCD、ZK、消息队列模块,多套集群共享着同一个存储。

image.png

存储拆分,每个集群独立存储

共享存储瓶颈导致稳定性问题凸显。

随着业务规模扩展,集群数量呈指数级增长,我们观测到部分集群节点出现异常重启现象,经诊断确认该问题源于底层共享存储存在性能瓶颈。

image.png

image.png

独立资源池迁移至共享资源池

通过混布的方式提升资源利用率。

前期为了在性能和稳定性上更好的服务业务,Milvus部署的底层机器都是独立的,目的就是为了和其他应用隔离开,不相互影响。但是随着集群的越来越多,并不是所有的集群对稳定性和性能要求那么高,从监控上看Milvus集群池的资源使用不超过10%。为了提高公司资源利用率,我们将独立部署的Milvus迁移高共享资源池中,和大数据、业务应用等K8S部署相关服务进行混合部署。

image.pngDiskANN索引的使用

数据量大且搜索QPS小时选择DiskANN 作为索引。 通过监控发现有很多集群数据量比较大,但是QPS并不是那么高,这时候就考虑对这些性能要求不高的集群是否有降本的方案。通过了解我们默认使用的HNSW索引需要将所有数据都加载到内存中进行搜索,第一反应就是它的内存查询和Redis一样,那是否有类似pika的方案内存只存少部分数据大部分数据存在磁盘上。这时候发现DiskANN就能达到这样的效果。

性能压测

※ 集群规格

image.png

QPS

image.png

延时(ms)

新增DiskANN索引后集群架构

增加DiskANN后我们需要对相关服务器上挂载 NVME SSD 磁盘,用于在磁盘上搜索最终数据。

image.pngDiskANN 加载数据过程

image.png引入Zilliz

经过大规模生产验证,Milvus在实际业务场景中展现出卓越的性能表现和稳定性,获得业务方的高度认可。并且也吸引来了C端核心业务系统的使用。在使用前,我们使用了业务真实流量充分的对Milvus进行了压测,发现Milvus在亿级别数据量的情况下满足不了业务,因此对于部分核心场景我们使用了Zilliz。

Milvus和Zilliz 压测

业务的要求是集群返回的RT不能操过90ms。

使用真实的业务数据(亿级别)和业务请求对Milvus进行压测,发现Milvus并不能满足业务的需求。

类型 QPS 平均RT(ms) 客户端性能图
Milvus 110 200 image.png
zilliz 350 65 image.png

Milvus RT 200不满足业务需求,并且QPS一直上不去,无论我们对QueryNode扩容多大,其中还发生过,将Query扩容到60个后,反而RT上升的问题,排查后是因为有的QueryNode和Proxy交互的时候网络会抖动影响了整体的RT。

从上面可以看出就算业务能容忍RT=200ms的,Milvus也需要创建3个相同的集群提供业务访问,并且业务需要改造代码实现多写、多读的功能,最终还会发现3个集群的成本远高于Zilliz。

通过成本和性能上的考虑,对于大数据量并且性能和稳定性要求高场景,我们将选用Zilliz。

迁移方案

对于不同业务场景,我们分别制定了以下3种迁移方案:

方案1:业务自行导入数据使用

image.png方案2:备份恢复 + 业务增量

image.png

方案3:全量 + 增量 + 业务双写/回滚

image.png

高可用架构部署

随着业务关键性持续提升,Milvus对应的SLA变得越来越重要。在此背景下,构建完善的Milvus高可用架构与灾备体系已成为系统设计的核心考量要素。比如:主从、多zone部署,Proxy高可用,Minio高可用,一个zone完全挂了怎么办等问题?

方案1: 同城多机房混部

正常访问:

  • 该方案会有客户端会有跨机房访问的情况。

跨机房访问节点:

客户端 -> SLB

SLB -> Proxy

Proxy -> QueryNode

  • SLB有高可用
  • Proxy有高可用

image.png

当部分节点不可用:

  1. 当zone 1中的proxy 1不可用,不影响整个访问链路,其他Proxy依然可以接受请求。

  2. 当 zone 1 中的 QueryNode 1 不可用,会出现访问报错的问题。需要重建QueryNode1,有可能在 Zone 2 新建QueryNode 5,原本请求QueryNode 1 的流量会重新指向 QueryNode 5。

图片转存失败,建议直接上传图片文件

当 Zone1 不可用:

  1. 访问会切换到 Zone 2 的备用SLB中。
  2. 备用SLB会访问本机房的Proxy。
  3. 由于 QueryNode 1 和 QueryNode 2 已经不可用,需要重建QueryNode,新生成 QueryNode 5、QueryNode 6并且加载数据提供访问。

image.png

方案2: 同城多zone多副本就近访问

正常访问:

  1. 不同zone的客户端访问本地的SLB。
  2. 使用了QueryNode多副本特性,各自zone的QueryNode都加载了所有数据。

Proxy -> QueryNode 的访问,目前Proxy只能随机访问所有zone的QueryNode(这是Milvus的限制)

image.png

当部分节点不可用:

  1. 当每个zone 都有1个Proxy故障,并不影响业务正常访问。
  2. 由于QueryNode开启了副本,只要每个zone不相同的QueryNode故障,集群还是能正常运行。需要注意的是这时候需要考虑剩下的QueryNode性能是否满足需求。所以一般业务需要有限流功能,在剩余的QueryNode不满足需求时,业务需要限流,直到其他QueryNode恢复。

image.png

整个Zone不可用:

  1. 当Zone1整个不可用,不影响Zone2的访问。

image.png

方案3: 同城多zone单独部署业务交叉访问互相backup

正常访问:

  1. 每个zone都有单独部署的milvus集群。
  2. 每个集群的有同时满足 业务1、业务2 访问的数据。
  3. 业务访问Proxy的时候是有交叉访问的情况
  4. 业务改造会比较多,需要实现双写。

image.png

当部分节点不可用:

  1. 当Zone1中的Proxy1部可用,不会影响Zone1的整个集群访问。
  2. 当Zone1的QueryNode1不可用,会影响到线路1、2的正常访问,这时候业务需要切换不访问Zone1的SLB。

image.png

当整个zone不可用

  1. 整个zone1不可用,由于线路1会访问到zone1的SLB,因此线路1访问会报错,业务需要将线路1切换成线路2。

image.png

六,向量数据库运维沉淀

索引结构和搜索原理

NHSW 索引

※ 相关信息

image.png

※ 内存结构

由于空间问题,图中并没有完全按 M=16、ef=200 参数进行画图。

image.png

※ NHSW搜索过程

现在需要搜索向量N = [....]

第一步:

在第一层随机选择一个节点,如:3。

image.png

第三步:

  1. 。通过节点6,从第二层跳到底3层
  2. 在第3层,通过节点6获取到相邻的节点:节点2、节点3、节点6、节点9,其中 节点2、节点3、节点6 已经存在,因此只需要将 节点9 放入候选结果集。
  3. 如果候选结果集合没有满,则继续便利 节点2、节点3、节点9 的邻居,直到 节点数=ef=200。

image.png第二步:

  1. 。通过节点3,从第1层转跳到第2层
  2. 在第2层,通过节点3获取到相邻的节点:节点1、节点2、节点5、节点6。
  3. 将搜索节点N逐个和 节点1、节点2、节点5、节点6 进行计算出各自的距离。并且选择距离最短的节点6。
    1. 节点N -> 节点1:10
    2. 节点N -> 节点2:6
    3. 节点N -> 节点5:9
    4. 节点N -> 节点6:3
    5. 节点N -> 节点3:4
  4. 将 节点1、节点2、节点5、节点6、节点3 放入结果候选集中。

image.png

DiskANN 索引

※ 相关信息

image.png

※ 存储结构和裁剪过程

由于画图空间问题,没办法将 聚类数=10、100/内存聚类、1w/磁盘聚类 信息画全。

image.png

  1. 初始化随机连接: DiskANN算法会将向量数据生成一个密集的网络图,其中点和点是随机链接的,并且每个点大概有500个链接。
  2. 裁剪冗余链接: 通过计算点和点点距离裁剪掉一些冗余的链接,留下质量高的链接。
  3. 添加快速导航链接: 计算出图中若干个中心点,并且将这些中心点进行链接,并且这些链接会跳过其他点,如果图中黄色链接。
  4. 重复进行裁剪优化过程,达到最优的情况。
  5. PQ量化操作生成索引:
    1. 将向量分成多个子空间。
    2. 独立对每个子空间进行聚类操作,并且计算出多个质心。
    3. 将每个子向量映射到最近的质心ID。

※ DiskANN搜索过程

现在需要搜索向量N = [....]

第一步(内存索引中搜索):

将搜索的向量进行量化。

将量化后的数据在内存中索引寻找到离自己较近的质心为入口进行下一步搜索。

image.png

第二步(内存中搜索):

通过第一个节点,寻找到它的所有相邻节点。

通过内存PQ代码计算搜索节点和相邻节点的近似距离。

这些邻居节点都是潜在的下次搜索候选口节点。

image.png

第三步(磁盘中搜索):

通过计算搜索节点和相邻节点的真实距离,并且得到距离最近的一个节点(需要从磁盘上读取证实的节点数据并计算)。

并且通过得到的最新节点获取到新的相邻节点。

image.png第四步(磁盘中搜索):

反复先进二、三步骤操作,直到找到足够数量的邻居。

image.png

并不是你想的那样

querynode 越多越快?

querynode 越多,查询越快,并发越高?

※误区原因

将querynode看成redis cluster,增加节点数能提高查询并发,然而并不是。redis cluster 增加节点,数据量会尽可能的打散到每个节点中,所以增加节点和性能提升是相对成正比。但是milvus不一样,milvus打散的基本单位是segment,一般segment大小(1G/个),他的粒度比redis cluster要大。理论上的理想情况是1个segment对应1个querynode,但是实际情况会收到多因素的干扰,会导致querynode越多出现不稳定的概率越大,如某个querynode网络抖动会影响整体的查询RT。

不能提升性能例1:

部分segment数 < querynode数,或部分querynode没有任何segment。

image.png

不能提升性能例2:

querynode 过多,其中1个querynode RT 高,导致整体客户端RT高。

image.png

标量索引提高性能

在标量上创建索引,搜索带上标量过来能提高性能?

※误区原因

使用传统关系型数据库的索引查询来理解Milvus的索引查询,字段上创建了索引能使用到索引扫描进行数据查询,比全表扫描快。然而并不是,关系型数据库的属于精确查询,Milvus属于近似最近邻搜索(ANNS),milvus的查询是不保证绝对精确,使用了标量索引查询反而会导致数据变稀疏查询会变慢。

使用标量索引筛选不一定快原因,如下示例:

通过标量搜索后再使用ANNS搜索过程:

1.Collection A 中的数据如下,其中is_delete 是标量,其中的值有0和1。

image.png

3.再使用 ANNS 搜索获取最终数据。

image.png

2.使用标量搜索is_delete=1后剩余的数据。

image.png

通过标量is_delete=1搜索成功之后,有可能结果集会很大,结果集数据如果内存存放不下需要使用到磁盘存储,之后再在结果集中通过ANNS获取到最终需要的数据,如果需要频繁进行磁盘交互则搜索性能会很差。

通过ANNS搜索后再使用标量搜索,过程如下:

1.Collection A 中的数据如下,其中is_delete 是标量,其中的值有0和1。

image.png

2.使用ANNS搜索

使用ANNS搜索能直接很快地获取到满足条件的数据。

image.png

3.再使用标量过滤,获取到最终的数据。

image.png

思考:

在第二步如果使用ANNS搜索完成之后到底是否需要使用标量索引进行搜索。

如果需要使用标量索引进行搜索那边在ANNS搜索后的结果集需要额外的进行索引构建,然后再进行过滤。构建构建过程其实也是需要便利结果集,那么是不是可以直接在便利的时候直接进行结果集的筛选。

那么其实在某种程度上是不是标量索引没那么好用。

大量单行dml不批量写入能提高数据库性能

大量单行dml,不使用批量写入操作,能提高数据库性能。

※误区原因

使用传统关系型数据库为了让系统尽量少的大事务,减少锁问题并且提高数据库性能。然而实际上Milvus如果有很多的小事务反而会影响到数据库的性能。因为Milvus进行dml操作会生成deltalog、insertlog,当dml都是小事务就会生成大量的相对较小的deltalog和insertlog文件,deltalog和insertlog在和segment做合并的时候会增加打开和关闭文件次数,并且增加做合并次数,导致io一直处于繁忙状态。

deltalog 和 insertlog 生成的契机有2种:

  1. 当数据量达到了一定的阈值会进行生成deltalog 或 insertlog。
  2. Milvus会定时进行生成deltalog 或 insertlog。

eltalog、insertlog  和 segment 合并过程

image.png人为让 deltalog、segment 执行时机可预测

如果业务对数据是实现要求不是那么高,建议使用定时批量的方式对数据进行写入,比如可以通过监控获取到每天的波谷时间段,在波谷时间段内进行集中式数据写入。原因是如果不停的在做写入,无法判断进行合并segment的时间点,要是在高峰期进行了合并操作,很有可能会影响到集群性能。

错误处理

2.2.6 批量删除数据bug,导致业务无法查询

报错:

pymilvus.exceptions.MilvusException: <MilvusException: (code=1, message=syncTimestamp Failed:err: find no available rootcoord, check rootcoord state, /go/src/github.com/milvus-io/milvus/internal/util/trace/stack_trace.go:51 github.com/milvus-io/milvus/internal/util/trace.StackTrace/go/src/github.com/milvus-io/milvus/internal/util/grpcclient/client.go:329 github.com/milvus-io/milvus/internal/util/grpcclient.(*ClientBase[...]).ReCall/go/src/github.com/milvus-io/milvus/internal/distributed/rootcoord/client/client.go:421 github.com/milvus-io/milvus/internal/distributed/rootcoord/client.(*Client).AllocTimestamp/go/src/github.com/milvus-io/milvus/internal/proxy/timestamp.go:61 github.com/milvus-io/milvus/internal/proxy.(*timestampAllocator).alloc/go/src/github.com/milvus-io/milvus/internal/proxy/timestamp.go:83 github.com/milvus-io/milvus/internal/proxy.(*timestampAllocator).AllocOne/go/src/github.com/milvus-io/milvus/internal/proxy/task_scheduler.go:172 github.com/milvus-io/milvus/internal/proxy.(*baseTaskQueue).Enqueue/go/src/github.com/milvus-io/milvus/internal/proxy/impl.go:2818 github.com/milvus-io/milvus/internal/proxy.(*Proxy).Search/go/src/github.com/milvus-io/milvus/internal/distributed/proxy/service.go:680 github.com/milvus-io/milvus/internal/distributed/proxy.(*Server).Search/go/pkg/mod/github.com/milvus-io/milvus-proto/go-api@v0.0.0-20230324025554-5bbe6698c2b0/milvuspb/milvus.pb.go:10560 github.com/milvus-io/milvus-proto/go-api/milvuspb._MilvusService_Search_Handler.func1/go/src/github.com/milvus-io/milvus/internal/proxy/rate_limit_interceptor.go:47 github.com/milvus-io/milvus/internal/proxy.RateLimitInterceptor.func1)>

解决: 将集群升级到2.2.16,并且让业务 批量删除和写入数据。

※ find no available rootcoord, check rootcoord state

报错:

[2024/09/26 08:19:14.956 +00:00] [ERROR] [grpcclient/client.go:158] ["failed to get client address"] [error="find no available rootcoord, check rootcoord state"] [stack="github.com/milvus-io/milvus/internal/util/grpcclient.(*ClientBase[...]).connect/go/src/github.com/milvus-io/milvus/internal/util/grpcclient/client.go:158github.com/milvus-io/milvus/internal/util/grpcclient.(*ClientBase[...]).GetGrpcClient/go/src/github.com/milvus-io/milvus/internal/util/grpcclient/client.go:131github.com/milvus-io/milvus/internal/util/grpcclient.(*ClientBase[...]).callOnce/go/src/github.com/milvus-io/milvus/internal/util/grpcclient/client.go:256github.com/milvus-io/milvus/internal/util/grpcclient.(*ClientBase[...]).ReCall/go/src/github.com/milvus-io/milvus/internal/util/grpcclient/client.go:312github.com/milvus-io/milvus/internal/distributed/rootcoord/client.(*Client).GetComponentStates/go/src/github.com/milvus-io/milvus/internal/distributed/rootcoord/client/client.go:129github.com/milvus-io/milvus/internal/util/funcutil.WaitForComponentStates.func1/go/src/github.com/milvus-io/milvus/internal/util/funcutil/func.go:65github.com/milvus-io/milvus/internal/util/retry.Do/go/src/github.com/milvus-io/milvus/internal/util/retry/retry.go:42github.com/milvus-io/milvus/internal/util/funcutil.WaitForComponentStates/go/src/github.com/milvus-io/milvus/internal/util/funcutil/func.go:89github.com/milvus-io/milvus/internal/util/funcutil.WaitForComponentHealthy/go/src/github.com/milvus-io/milvus/internal/util/funcutil/func.go:104github.com/milvus-io/milvus/internal/distributed/datanode.(*Server).init/go/src/github.com/milvus-io/milvus/internal/distributed/datanode/service.go:275github.com/milvus-io/milvus/internal/distributed/datanode.(*Server).Run/go/src/github.com/milvus-io/milvus/internal/distributed/datanode/service.go:172github.com/milvus-io/milvus/cmd/components.(*DataNode).Run/go/src/github.com/milvus-io/milvus/cmd/components/data_node.go:51github.com/milvus-io/milvus/cmd/roles.runComponent[...].func1/go/src/github.com/milvus-io/milvus/cmd/roles/roles.go:102"]

问题: rootcoord和其他pod通信出现了问题。

解决: 先重建rootcoord,再依次重建相关的querynode、indexnode、queryrecord、indexrecord。

※ 页面查询报错

(Search 372 failed, reason Timestamp lag too large lag)

[2024/09/26 09:14:13.063 +00:00] [WARN] [retry/retry.go:44] ["retry func failed"] ["retry time"=0] [error="Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_search.go:529] ["QueryNode search result error"] [traceID=62505beaa974c903] [msgID=452812354979102723] [nodeID=372] [reason="Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_policies.go:132] ["failed to do query with node"] [traceID=62505beaa974c903] [nodeID=372] [dmlChannels="[by-dev-rootcoord-dml_6_442659379752037218v0,by-dev-rootcoord-dml_7_442659379752037218v1]"] [error="code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_policies.go:159] ["retry another query node with round robin"] [traceID=62505beaa974c903] [Nexts="{"by-dev-rootcoord-dml_6_442659379752037218v0":-1,"by-dev-rootcoord-dml_7_442659379752037218v1":-1}"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_policies.go:60] ["no shard leaders were available"] [traceID=62505beaa974c903] [channel=by-dev-rootcoord-dml_6_442659379752037218v0] [leaders="[<NodeID: 372>]"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_policies.go:119] ["failed to search/query with round-robin policy"] [traceID=62505beaa974c903] [error="Channel: by-dev-rootcoord-dml_7_442659379752037218v1 returns err: code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)Channel: by-dev-rootcoord-dml_6_442659379752037218v0 returns err: code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_search.go:412] ["failed to do search"] [traceID=62505beaa974c903] [Shards="map[by-dev-rootcoord-dml_6_442659379752037218v0:[<NodeID: 372>] by-dev-rootcoord-dml_7_442659379752037218v1:[<NodeID: 372>]]"] [error="code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_search.go:425] ["first search failed, updating shardleader caches and retry search"] [traceID=62505beaa974c903] [error="code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [INFO] [proxy/meta_cache.go:767] ["clearing shard cache for collection"] [collectionName=xxx][2024/09/26 09:14:13.063 +00:00] [WARN] [retry/retry.go:44] ["retry func failed"] ["retry time"=0] [error="code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)"][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/task_scheduler.go:473] ["Failed to execute task: "] [error="fail to search on all shard leaders, err=All attempts results:\nattempt #1:code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)\nattempt #2:context canceled\n"] [traceID=62505beaa974c903][2024/09/26 09:14:13.063 +00:00] [WARN] [proxy/impl.go:2861] ["Search failed to WaitToFinish"] [traceID=62505beaa974c903] [error="fail to search on all shard leaders, err=All attempts results:\nattempt #1:code: UnexpectedError, error: fail to Search, QueryNode ID=372, reason=Search 372 failed, reason Timestamp lag too large lag(28h44m48.341s) max(24h0m0s) err %!w(<nil>)\nattempt #2:context canceled\n"] [role=proxy] [msgID=452812354979102723] [db=] [collection=xxx] [partitions="[]"] [dsl=] [len(PlaceholderGroup)=4108] [OutputFields="[id,text,extra]"] [search_params="[{"key":"params","value":"{\"ef\":250}"},{"key":"anns_field","value":"vector"},{"key":"topk","value":"100"},{"key":"metric_type","value":"L2"},{"key":"round_decimal","value":"-1"}]"] [travel_timestamp=0] [guarantee_timestamp=0]

问题: pulsar 组件对应相关pod问题导致不进行消费。

解决: 将pulsar 组件相关pod进行重建,查看日志,并且等待消费pulsar完成。

※ Query Node 限制内存不足

 (memory quota exhausted)

报错:

<MilvusException: (code=53, message=deny to writereason: memory quota exhaustedplease allocate more resourcesreq: /milvus.proto.milvus.MilvusService/Insert)>

原因: 配置中Query Node配置内存上线达到瓶颈。

解决: 增加Query Node配置或者增加QueryNode节点数。

※ 底层磁盘瓶颈导致ETCD访问超时

报错:

image.png

解决: 从架构方面上进行解决,在集群维度将磁盘进行隔离,每个集群使用独立磁盘。

七,未来展望

数据迁移闭环

数据迁移闭环:对于业务数据加载到向量数据库的场景,业务只关心数据的读取和使用,不需要关心数据的量化和写入。DBA侧建立数据迁移闭环(下图绿色部分)。

数据准确性校验

对于上游数据(如MySQL)和下游向量数据库数据库一致性校验问题,DBA业将协同业务、Milvus进行共建校验工具,保障数据的准确性。

image.png

往期回顾

1.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术

2.从Rust模块化探索到DLB 2.0实践|得物技术

3.eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

4.正品库拍照PWA应用的实现与性能优化|得物技术

5.汇金资损防控体系建设及实践 | 得物技术

文 / 呆呆 少晖

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Java volatile 关键字到底是什么|得物技术

Java volatile 关键字到底是什么|得物技术

一,前言

volatile 作为 Java 的基础关键字,一直是个熟悉又神秘的存在。我们在日常做并发编程的过程中经常用到,我们知道在什么场景下需要用到,但却始终不清楚底层究竟做了什么。互联网上搜出来的大多数博客都在解释 volatile 关键字是为了解决指令重排序、内存可见性问题,或是什么内存屏障、缓存一致性协议一类“形而上的词汇”。而究竟什么是指令重排序,为什么要重新排序,什么是可见性问题,底层原理是什么,volatile 又是如何解决的却鲜有提及。引得 Java 开发者们如雾里看花,线上线下充满了疑惑的空气。

本文将浅浅探究一下这一切的底层原理,一起来学习“没有用”的知识,各位看官看懂了可以出去和面试官对线。

二,指令重排序

在了解指令重排序问题之前,我们先来看一个由指令重排序造成并发问题的例子:

static int x0, y = 0;
static int a0, b = 0;

public static void main(String[] args) throws InterruptedException {  
    for (int i0; true; i++) { 
        x0; y = 0; a = 0; b = 0;
        Thread one = new Thread(new Runnable() {            
            public void run() {                
                a1;                
                x = b;            
            }        
        });        
        Thread other = new Thread(new Runnable() {
            public void run() {                
                b1;                
                y = a;            
            }        
        });        
        one.start();other.start();        
        one.join();other.join();        
        if (x == 0 && y == 0) {            
            System.err.println("bingo!i: " + i);            
            break;        
        }    
   }
}

happens-before 八条原则

  1. 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
  2. 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
  3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  4. 线程启动规则:Thread 对象 start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则:线程A等待线程B完成,在线程A中调用线程B的join()方法实现),当线程B完成后(线程A调用线程B的join()方法返回),则线程A能够访问到线程B对共享变量的操作。
  6. 线程中断规则:对线程 interrupt() 方法的调用先行,发生于被中断线程的代码,检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则:一个对象的初始化完成(构造函数结束)先行发生于它的 finalize()方法的开始。
  8. 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

hanpens-beofre 是 JVM 对开发者的保证,即不管 JVM 如何优化(JIT 编译),都会保证上述原则一定成立。而对于开发者来说,只要了解上述原则,无需硬件交互的复杂性,也能够写出可预测的代码,从而保证线程安全。

从 hanpens-beofre 中 程序次序规则 和 线程终止规则 可得,上述代码最终运行结果的可能性会有以下几种:

图片

可以明显看出,理论上不会存在 x =0 && y = 0 的运行结果,然而实际上程序在执行了一段时间后,最终的确产生了 x = 0 && y = 0 的结果!

图片

这就引出了 volatile 解决的第一个问题:避免指令重排序。指令重排序在编译器和 CPU 层面(乱序执行)都会发生。

CPU 的乱序执行

我们知道,CPU 运算的本质就是不断获取下一条指令然后执行,编译器给它什么指令它就执行什么,何来的乱序执行呢?

这还要从计算机的诞生之初讲起。

内存拖后腿

计算机诞生之初,CPU 和内存之间的速度差异并不明显,一切相安无事。随着科学的进步,CPU 的运算速度越来快,根据摩尔定律计算,相当于 CPU 的性能每年增长 60%,相比之下,内存性能的增长却相对缓慢,每年约为 7%。到今天,CPU 运算和内存访问的速度产生了巨大鸿沟,已经达到了 120 倍之多。这时如果 CPU 还以传统计算机架构,数据从内存中读取的话,将会严重拖慢 CPU 的运行速度

图片

※ 局部性原理

在程序运行过程中,芯片工程师总结了两条存在局部性原理:时间局部性空间局部性

  • 时间局部性: 由于在代码中循环操作的普遍存在,因此当某部分数据被访问时,不久后该数据很可能会再次被访问,基于此原理诞生了 CPU 的高速缓存。
  • 空间局部性: 由于代码是顺序执行的,因此当某一份数据被访问时,后续的数据也将很快被访问,基于此原理诞生了缓存行。

※ CPU 内的高速缓存

为了弥补 CPU 运行速度与内存访问速度之间的巨大差异,提升 CPU 执行效率,CPU 在内部封装了高速缓存。

高速缓存是一种静态随机访问存储器(SRAM),相对于使用电容存储的内存(DRAM)来说,速度快得多,访问速度在纳秒级别,终于能勉强不再拖 CPU 后腿了。

图片

CPU 缓存共分为三级:

  • 按访问速度从大到小排序为:L1 > L2 > L3
  • 按容量从大到小排序为:L3 > L2 > L1

其中 L3 缓存 CPU 共享,L1、L2 缓存为各 CPU 独占。CPU 在访问内存数据时,会优先从高速缓存中访问,访问顺序依次为 L1、L2、L3,若高速缓存中都不存在,则再访问内存。

缓存的引入,降低了 CPU 直接访问内存的频率,大大提升了 CPU 的执行效率。

※ 缓存行

根据空间局部性原理,当 CPU 访问了一块数据时,相邻的数据很可能也即将被访问。那么是否可以通过预加载相邻的数据到高速缓存中,提升高速缓存的命中率呢?

当然可以,我们把预加载的这部分内存数据叫做缓存行。

图片

由图所示,内存被划分为若干缓存行的块,缓存行的大小是固定的,通常为 64 字节,高速缓存数据块最小粒度就是缓存行(换句话说,高速缓存内的数据就是由一个个缓存行构成的)。当 CPU 需要访问位于内存的数据 X 时,会将整个缓存行同时加载到高速缓存中,以提升程序后续执行时的缓存命中率。

CPU 内的“分布式”问题

高速缓存是把双刃剑,在大幅提升 CPU 运行效率的同时,也引来了一个 “分布式” 问题。

图片

记得我们前面说过,CPU 的 L1、L2 缓存是各核心独占的,在两个 CPU 的 L2 缓存同时加载了同一个缓存行的情况下,当 CPU 0 数据 X 做了写操作(X = 1),其他 CPU 对这一修改是不可见的,这时 CPU 1 如果依然访问自己高速缓存中的数据,势必会产生数据不一致。

为了解决这个问题,缓存一致性协议便诞生了。

※  MESI 协议

缓存一致性协议有多种,最出名的就是 MESI 协议。

MESI 是 Modified   Exclusive Shared   Invalid 四个单词的缩写,分别表示缓存行的四种状态:

  • Modified:表示缓存行中数据已经被 CPU 修改了。
  • Exclusive:缓存行处于独占但尚未修改的状态,该状态表示其他 CPU 不可以预读取这个缓存行到自己的高速缓存中。
  • Shared:表示缓存行数据已经被多个 CPU 预加载到缓存中,且各 CPU 均未对该缓存行做修改。
  • Invalid:表示有其他 CPU 修改了该缓存行,缓存行数据已经失效。

图片

  1. CPU 0 需要修改缓存行中 X 的数据时,将当前缓存行标记为 Modified ,并向总线发送一条消息,表明缓存行 CPU 0 已经修改。
  2. CPU 1 接收到该缓存行已失效的消息后,会将本地缓存行标记为 Invalid ,并 ACK 给 CPU 0。
  3. CPU 0 收到其他 CPU 已经将本地缓存行标记失效消息后,修改 X 的值。
  4. 此时 CPU 0 高速缓存中缓存了 X 的最新值,其他 CPU 如果需要访问 X ,将会通过总线从CPU 0中获取。

MESI 协议非常复杂,比如各 CPU 之间是如何通信的、多个 CPU 同时发送失效事件怎么办等等。

篇幅所限仅做本文用的着的部分介绍。有兴趣了解具体实现可以点击 www.scss.tcd.ie/Jeremy.Jone…

缓存一致性协议有效解决了各 CPU 间数据一致性问题。那么,代价是什么呢?

禁止 CPU 摸鱼

上图可以看出 CPU 0 在执行修改 X 的值之前,需要与其他 CPU 进行通讯,收到其他 CPU 将本地消息修改完成后,才可修改本地缓存行的数据。在这期间 CPU 0 一直无事可做。而不管是前面提到过的编译器指令重排序还是超线程、指令流水线等技术,目的都是在提升 CPU 的运行效率,减少 CPU 空跑时间。如果由于缓存一致性协议造成 CPU 空闲的话,这对于我们来说显然是不可接受的!

图片

为了让 CPU 满负荷运转,芯片工程师在 CPU 与 L1 缓存之间又加了一层——store buffer。

引入了 store buffer 后,CPU 写缓存行不再需要等待其他 CPU 回复消息,而是直接读写 store buffer,等到特定时刻,再将 store buffer 中的数据 flush 到高速缓存中

图片

  1. CPU 0 需要修改缓存行中 X 的数据时,将当前缓存行标记为 Modified ,并向总线发送一条消息。
  2. CPU 0 不再等待 CPU 1 回复,而是直接将修改后的数据写入 store buffer 中。
  3. CPU 0 收到 CPU 1 标记缓存已经失效的回复消息后,将 store buffer 中的值 flush 到高速缓存中。

问题会这么完美的解决吗?

乱序执行

现在我们将 store buffer 纳入考量,再来回头看本节开始的这段代码:

图片

最终由于 store buffer 中数据的 flush 时间晚于 CPU 直接写高速缓存中数据的时间,客观上产生了 CPU 执行的指令顺序与实际代码中不一致的现象(X = B 早于 A = 1 执行,Y = A 早于 B = 1 执行),即乱序执行。最终得到了(A = 1,B = 1,X = 0,Y = 0)的结果。

既然发现了问题,那么要如何解决呢?这就要提到另一项技术:内存屏障。

随着 store buffer 技术的引入引起的问题还有很多,于是牵扯出一系列其他技术,如 Store Forwarding、Invalidate Queues 等技术。由于与本文涉及到的内容无关,这里就不做赘述了,各位有兴趣可以自行了解相关内容。

内存屏障

内存屏障听起来比较高大上,实际总结起来非常简单,就一句话:

去告诉 CPU,我在此处定义了一个内存屏障,自这里开始,后续所有针对高速缓存的写入,都必须先把 store buffer 中的数据全部 flush 回高速缓存中!

图片图片

在 Java 中 volatile 关键字避免 CPU 乱序执行的原理其实就是在访问 volatile 变量时添加了内存屏障。限制后续数据的写入操作一定把当前 store buffer 中的数据 flush 到高速缓存中,再通过缓存一致性协议保证数据一致性。

回到本节一开始的代码,你一定想到了要如何让这段程序永远执行下去的办法了?

static int x0, y = 0;
static volatile int a0, b = 0;

没错,我们只需要限制针对数据 X、Y 的写操作之前,位于 store buffer 中的数据 A、B 全部 flush 到高速缓存即可。所以最终的解决方案就是给变量 A、B 添加 volatile 关键字即可!

想想为什么在变量 X、Y 上加 volatile 不可以?说加 4 个 volatile 的那位同学,课后把内存屏障这一章节抄写 3 遍!

前面说过,指令重排序问题在 CPU 和编译器层面都存在,CPU 层面说完了,那编译器层面呢?

编译器重排序

由于 JIT 编译后的指令不好扒,我们以 C 语言为例,先来看看下面的 C 语言例子:

int func(int a, int b, int c, int d) {
    return a + b + c + d;
}

如果编译器不做优化,如果完全顺从我们代码语义,以上函数生成的汇编伪代码如下:

图片图片

而现代 CPU 会有多个执行单元,例如读写单元、运算单元,这些执行单元之间可以独立工作。在执行上面的指令时,只能顺序执行,不能并行执行。要想发挥两个执行单元的效率,只需调整一下顺序即可:

图片图片

可以看出,虽然指令的数量一样,但在对指令做简单的重新排序后,优化一下指令的提交顺序,就可以更快的完成任务。

在 JVM 中,JIT 编译同样也会遵循这一原则,在不改变源码语义的情况下,改变 CPU 指令的执行顺序,就可以更快的完成运算任务,提升执行效率。这在单线程情况下运行良好,但多线程运行时,就可能会存在一些意料之外的问题。

三,可见性问题

再来看另一个示例:

private static boolean running = true;

public static void main(String[] args) {
    int i = 0;    
    Thread thread = new Thread(() -> {        
        try {            
            Thread.sleep(1000L);        
        } catch (InterruptedException e) {
        }        
        running = false;
    });    
    thread.setDaemon(true);    
    thread.start();    
    while (running) {        
        i++;    
    }    
    System.err.println(i);
 }

上面的程序,并不会按照我们预期的那样正常输出程序结束后退出,而是会永远的执行下去(不要尝试用前文中的 store buffer 来强行解释这个问题,store buffer 本质上也是个 buffer,在某一时刻数据依然会 flush 到高速缓存中,从而让其他线程感知到最新的值)。

这就引出 volatile 关键字解决的另一个问题:内存可见性问题。

分层编译

我们知道,Java 是跨平台的。一个 Java 源码文件的执行需要两个过程:

  1. AOT 编译:源代码文件编译为 class 文件。
  2. JIT 编译:JVM 加载 class 文件,将 class 文件中字节码转换为计算器可执行的机器指令。

字节码的执行也有两种方式:

  1. 解释执行:优点是启动速度快,缺点是需要逐条将字节码解释为机器指令,开销大,性能低。
  2. 编译执行(JIT 编译):优点是执行效率高,与本地编译性能基本没差别,缺点是编译本身需要消耗 CPU 资源,以及编译后的指令数据需要存储,需要消耗内存空间。

而 JIT 编译器又分为两种,Client Compiler、Server Compiler。之所以叫 client,server 是因为在一开始设计这俩编译器的时候,前者是设计给客户端程序用的,就比如像 idea 这种运行在个人电脑上的 Java 程序,不会长时间使用,反而更注重应用的启动速度以及快速达到相对较优性能。而后者则是设计给服务端程序用的。

HotSpot 虚拟机带有一个 Client Compiler —— C1 编译器。这种编译器启动速度快,但是性能相对Server Compiler 来说会差一些。Server Compiler 则更为激进,优化后的性能要比 Client Compiler 高30%以上。HotSpot 虚拟机则带有两个 Server Compiler,默认的 C2 以及 Graal。

在 Java 7 之前,需要开发者根据服务的性质手动选择编译器。自 Java 7 开始,则引入了分层编译(Tiered Compilation)。

0:由解释器解释执行

1:C1 NO profiling:执行不带 profiling 的 C1 代码。

2:C1 LIMITED profiling:执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码。

3:C1 FULL profiling:执行带所有 profiling 的 C1 代码。

4:C2:执行 C2 代码。

谁动了我的代码

运行时编译有那么多层级,到底是哪层影响了代码的?

想要探究个问题很简单,只需要在JVM启动参数里增加 -XX:TieredStopAtLevel=XX 参数即可。TieredStopAtLevel 是控制 JVM 最大分层编译级别的参数,当我们配置的值 < 4 时,前文的代码均可以正常终止,那么结论很明显了:C2 编译器全责!

C2 你在干什么?!

为了探究 C2 编译对我们代码做了什么,我们决定使用一款工具:JITWatch。JITWatch 专门用来探究 JIT 编译后的代码对应汇编指令。

关于 JITWatch 使用的流程这里就不做赘述了,网上有大量说明。本节的案例也很好复现,大家可以动手试一试。

我们将前文的源码文件编译后,提交以下命令执行:

# Ubuntu 22.04 下
java -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=~/Desktop/jit.log -XX:+PrintAssembly -XX:+TraceClassLoading Main

接着启动 JITWatch,加载 jit.log 文件:

图片

双击右侧 main 方法,查看 C2 编译后的结果:

记得选 OSR(栈上替换) 那个 C2,因为代码属于死循环,代码块不会退出,无法完整替换 C2 编译后的机器指令,只能通过栈上替换技术来进行。

图片

绿色框中为我们的核心代码,我在此处将它放大并增加了注释,如下:

图片

什么?无条件跳转?!

可以看出,由于 C2 编译器的激进优化,编译后的机器码不再判断 running 变量,从而产生了内存可见性问题。而即使 C2 编译后的机器指令依然会执行安全点检查。想想是不是可以利用安全点检查机制,用一些操作来让进程停止?比如提交执行一次 GC、打个断点之类的。

四,总结

说了那么多,能不能说点有用的?

有的有的,我们在多线程开发中,只要变量被多个线程共享,且是可变的,加上 volatile 准没错:)

往期回顾

1.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术

2.从Rust模块化探索到DLB 2.0实践|得物技术

3.eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

4.正品库拍照PWA应用的实现与性能优化|得物技术

5.得物社区活动:组件化的演进与实践

文 / 空载

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

从Rust模块化探索到DLB 2.0实践|得物技术

一、前言

在云原生架构高速迭代的背景下,基础设施的性能瓶颈与安全隐患成为技术演进的关键挑战。本文系统记录了团队基于Rust语言改造Nginx组件的完整技术路径:从接触Cloudflare的quiche库,引发对Rust安全特性的探索,到通过FFI实现核心逻辑的跨语言调用;从突破传统C模块开发范式自研 ngx_http_rust_module SDK ,到全面采用Pingora框架构建新一代DLB 2.0流量调度平台。

实践表明,Rust的内存安全机制与异步高并发能力可显著提升负载均衡组件的性能边界与可靠性,为超大规模流量调度场景提供全新解决方案。本技术演进过程将详述架构设计、核心模块实现及性能优化策略,为同类基础设施升级提供可复用的工程经验。

二、Nginx+Rust 的模块化探索

探索的起点源于和quiche(cloudflare开发的高效quic实现)的初次邂逅,这扇门将项目组成员引入了 Rust 语言的世界。Rust 以其卓越的内存安全、无惧并发的特性以及出色的性能潜力,迅速展示了其作为系统级编程语言的优势。这份吸引力促使我们思考:能否将 Rust 的安全与性能注入我们更广泛的基础设施中?作为核心组件的 Nginx 自然成为了探索的焦点。

我们首先聚焦于FFI(外部函数接口)技术,通过它构建Rust与C语言的交互桥梁。借助FFI,我们将核心业务逻辑以Rust实现,并将Rust代码编译为符合C-ABI规范的动态链接库。这种设计使得Nginx能够像调用原生C模块一样无缝集成Rust编写的库,在保障系统稳定性的同时提升性能。

图片

采用该方案的限流模块示例如下:

图片

鉴于单向调用模式在应用场景上的局限性,如果仅仅支持上面的单向调用流,使用的场景将大打折扣,目前Nginx 中大量的功能以三方模块的形式呈现,C module的开发难度较高,需要理解的组件概念颇多,团队尝试开发了ngx_http_rust_module模块作为一个探索期的折中方案。

图片

ngx_http_rust_module本质上是一个Rust SDK,是对传统C模块开发模式的一种现代化补充尝试。SDK层封装好的胶水function极大便利了rust module上层开发,可以实现纯Rust编码来实现业务功能,实践验证具备较高工程价值。

目前已封装的部分SDK展示以及设置响应header方法示例:

图片图片

三、全面拥抱Rust进入DLB2.0阶段

完成Nginx模块的初步探索后,团队技术路线转向Cloudflare开源的Pingora框架,该高性能Rust框架专为构建可编程、高可靠的流量调度平台而设计。

核心优势

  • 云原生架构 :通过异步任务调度消除Nginx的进程隔离瓶颈,实现CPU负载均衡与高效连接复用。
  • 性能突破 :实测每秒可处理10万请求,资源消耗降至传统方案的三分之一。
  • 协议生态 :原生支持HTTP/1-2、Websocket端到端代理。
  • 安全演进 :基于Rust内存安全特性,集成FIPS认证的加密模块解决C/C++方案的安全隐患。
  • 扩展能力 :提供可编程负载均衡API与热升级机制,满足超大规模流量调度需求。

在原型验证其技术可行性之后,团队决定在该框架骨干上构建了DLB 2.0产品体系:

图片

核心能力设计

  • 声明式配置管理
    • 提供基于YAML的声明式配置接口,显著提升配置可读性与维护效率。
    • 支持热加载机制,实现流量无损的配置更新,彻底规避传统代理重载导致的503服务中断。
  • 流量处理
    • 支持单一端口多域名TLS证书托管能力,简化HTTPS服务部署。
    • 提供与Nginx完全兼容的server/path路由匹配逻辑,确保无缝迁移。
    • 实现路径重写引擎,满足复杂流量调度需求。
    • 采用模块化Filter链设计,支持按需插拔流量处理组件。
  • 服务发现
    • 集成静态资源配置与动态DNS服务发现双模式。
    • 支持sylas注册中心。
    • 企业级监控。
    • 提供增强型访问日志。
    • 输出完全兼容DLB 1.0的监控指标(VTS格式)。
    • 保留流量录制数据规范,确保监控体系平滑升级。

每个模块的设计均遵循"高内聚低耦合"原则,在保障生产环境稳定性的前提下,为超大规模流量调度场景提供可扩展的技术支撑,后续将逐一拆解部分关键模块的技术实现细节与性能优化策略。

配置体系

静态配置

DLB 2.0在配置层面按类型拆分成多个细粒度的yaml文件,其中最核心的是 server.yaml 以及 upstream.yaml ,为了对标Nginx核心概念、这部分不引入新的术语,继续沿用 server 、 location 、 upstream 三大基础模块。

  • 通过 server 模块声明虚拟主机,支持多域名监听及端口绑定,兼容 server_name 的泛域名解析能力,同时实现单端口多域名 TLS 证书的精准匹配。
  •  location 模块完整继承 Nginx 的路径匹配逻辑(含精确匹配 = 、正则匹配 ~ 等模式),支持基于路径的请求路由与正则表达式重写规则,确保策略迁移的零成本适配。同时支持 proxy_pass 、 if 、 proxy_headers 、 return 等核心指令。
  •  upstream 动态服务发现机制支持权重负载均衡,通过 YAML 结构化配置实现后端集群的声明式管理,并与DNS的服务发现深度集成,彻底消除传统配置中硬编码 IP 的维护负担。
id"hjob.shizhuang-inc.com"  server_name: "hjob.shizhuang-inc.com"  service_in:  - "default_80"  - "default_443"  redirect: true  location:  - path: "/"    access_rule_names:    - "access_allow_d803a06f39ad4dcd8dfe517359a33a61"    - "access_deny_all"    client_max_body_size: "100M"    proxy_headers:    - "clientport:$remote_port"    - "Upgrade:$http_upgrade"    - "Connection:$http_connection"    - "Host:$host"    - "X-Forwarded-For:$proxy_add_x_forwarded_for"    - "X-Forwarded-Proto:$scheme"    proxy_pass: "http://hangzhou-csprd-hjob-8899"
 - name: "hangzhou-csprd-hjob-8899"  peers:  - server: "1.1.1.1:8899"    weight: 100    backup: false    down: false  - server: "2.2.2.2:8899"    weight: 1    backup: false    down: false  max_fails: 3  fail_timeout: "10s"  max_connections: 1000

配置解析

在DLB 2.0的配置模型中, server 、 location 、 upstream 三者构成层次化路由架构:

图片

  •  server 作为虚拟服务单元,通过 Vec 聚合任意数量的 location 路由规则。
  •  location 作为请求路径处理器,可独立关联至不同的 upstream 服务组。
  •  upstream 采用原子引用计数机制( Arc )封装配置,通过Arc::strong_count() 实时监控引用状态,避免冗余配置拷贝,基于Rust的并发安全特性,最终设计为 Arc<Mutex> 结构:
    •  Mutex 保障多线程环境下的内部可变性,支撑配置热更新**需求。
    •  Arc 维持跨线程的只读共享能力,确保访问高效性。

main thread解析完server.yaml与upstream.yaml后,将生成两个核心哈希映射:

  •  server 配置映射表:关联域名与路由规则集。
  •  upstream 线程安全容器:托管负载均衡服务组状态。
/// A map of server names to their respective configurations.#[serde(skip)]pub servers: HashMap<String, Arc<Mutex<ServerConf>>>,/// A map of upstream names to their respective configurations.#[serde(skip)]pub upstreams: HashMap<String, Arc<Mutex<UpstreamConf>>>,

运行时配置转化

上述的 ServerConf 与 UpstreamConf 面向的是用户,特点是易于理解与维护、支持YAML反序列化。

而为了专注运行时效率(比如负载均衡策略中的字符串转化为枚举类型),我们会将 UpstreamConf 转化为 RunTimeUpStream 结构, ServerConf 同理。

impl TryFrom<&UpstreamConf> for RunTimeUpStream {    type Error = Error;    fn try_from(value: &UpstreamConf) -> std::result::Result<SelfSelf::Error> {    }}

转化之后得到全局唯一的 GlobalConf :

pub static GLOBAL_CONF: Lazy<RwLock<GlobalConf>> = Lazy::new(|| {    RwLock::new(GlobalConf {        main_conf: MainConf::default(),        runtime_upstreams: HashMap::with_capacity(16),        runtime_servers: HashMap::with_capacity(16),        host_selectors: HashMap::with_capacity(16),    })});
#[derive(Default)]pub struct GlobalConf {    // main static configuration    pub main_conf: MainConf,    //one-to-one between upstreams and runtime_upstreams    pub runtime_upstreams: HashMap<String, Arc<RunTimeUpStream>>,    //one-to-one between servers and runtime_servers;    pub runtime_servers: HashMap<String, Arc<RunTimeServer>>,    //one service one host selector    pub host_selectors: HashMap<String, Arc<HostSelector>>,}

流量处理

域名匹配

如果仅有上面的 runtime_servers 这一个哈希表,还不能实现复杂的Nginx域名匹配规则,Nginx域名匹配的优先级机制包括:精确匹配>前置通配符>正则匹配(后置通配符在1.0版本未使用,暂且忽略),为了确保无缝迁移,需要提供与Nginx完全兼容的server匹配逻辑,考虑到代码可维护性,可以这样组织运行时数据:

  • 为精确域名使用HashMap,实现O(1)查找。
  • 前置通配符匹配存储为Vec,且确保最长匹配优先。
  • 正则表达式只能顺序匹配,保持Vec原顺序。

最终得到这样的结构体:

/// A struct to manage server selection based on host names.////// This struct contains three fields: `equal`, `prefix`, and `regex`./// The `equal` field is a HashMap that stores server names and their corresponding IDs/// when the server name exactly matches the host./// The `prefix` field is a Vec of tuples, where each tuple contains a prefix and its corresponding server ID./// The `regex` field is a Vec of tuples, where each tuple contains a Regex and its corresponding server ID.////// The `HostSelector` struct provides methods to insert server names and IDs,/// and to match a given host name with a server ID based on the rules defined in the struct.#[derive(Clone)]pub struct HostSelector {    pub equal: HashMap<String, String>,    pub prefixes: Vec<(String, String)>,  //原始前通配符数据    pub prefix_nested_map: NestedHashMap, // 嵌套哈希结构优化匹配效率    pub regex: Vec<(Regex, String)>,}

其中需要留意的是成员 prefix_nested_map ,为了确保最长匹配优先,我们将 prefixes: Vec<(String, String)> 转化为了 NestedHashMap 结构, NestedHashMap 为一个嵌套哈希结构,可基于域名分段实现高效检索。

#[derive(Debug, Clone)]pub struct NestedHashMap {    data: HashMap<String, NestedHashMap>, //层级域名节点    value: Option<String>, // 终端节点关联服务器ID}
impl NestedHashMap{    /// 基于域名分段实现高效检索(从右向左匹配)    pub(crate) fn find(&self, key: &str) -> Option<String> {        let tokens = key.split('.').collect::<Vec<&str>>();        let mut current_map = self;        let mut result = None;        // 遍历域名层级(如 www.example.com → [com, example, www])        for token in tokens.iter().rev() {            // 优先记录当前层级的有效值(实现最长匹配)            if current_map.value.is_some() {                result = Some(current_map.value.as_ref().unwrap());            }            // 向下一级域名跳转            let child = current_map.data.get(*token);            match child{                Some(child) => {                    current_map = child;                }                None => {                    break;                }            }        }        result.map(|value| value.to_owned())    }}

路由匹配

讲完了域名匹配,我们再深入路由匹配,在开始之前,我们先回顾一下Nginx的location指令。

Syntax:location [ = | ~ | ~* | ^~ ] uri { ... }location @name { ... }Default:—Context:server, location

 location 通常在 server{} 块内定义,也可以嵌套到 location{} 内,虽然这不是一种推荐的配置方式,但它确实是被语法规则支持的, localtion 语法主要有如下几种形式:

※  修饰符语义及优先级(依匹配精度降序排列)

  1.  = :精确匹配(Exact Match),URI必须与模式完全一致时生效(最高优先级)。
  2.  ^~ :最佳前缀匹配(Prefix Match),选中最长非正则路径后终止搜索(优先级次于=)。
  3.  ~ :区分大小写的正则匹配(Case-Sensitive Regex)。
  4.  ~* :不区分大小写的正则匹配(Case-Insensitive Regex)。
  5.  @ :内部定位块(Named Location),仅限 try_files 或 error_page 指令调用,不对外暴露。

Nginx在解析完 location 之后会进行一系列的工作,主要包括:

  • 分类:  根据location的修饰符参数标识不同的类型,同时去除name前面的修饰符
  • 排序: 对一个server块内的所有location进行排序,经过排序之后将location分为了3类
    • 通用类型,通用类型的location将建立一棵最长前缀匹配树
    • 正则类型,顺序为配置文件中定义的顺序,正则会用pcre库先进行编译
    • 内部跳转类型,顺序也为配置文件中定义的顺序
  • 拆分:将分类的3种类型拆分,分门别类的处理

其中最复杂的是最长前缀匹配树的构建,假设location规则如下,构造一棵最长前缀匹配树会经过如下几个步骤:

图片

  1. 把locations queue变化locations list,假设一个location的name是A的话,所有以A前缀开头的路由节点都会放到A节点的list里 (最长前缀匹配)。

图片

2.按照上述步骤递归初始化A节点的所有list节点,最终得到下面的list。

图片

3.在上述创建的list基础上,确定中间节点,然后从中间节点把location分成两部分,然后递归创建左右子树,最后处理list队列,list队列创建的节点会加入到父节点的tree中,最终将生成一棵多叉树。

图片

现在你应该已经明白了最长前缀匹配树的构建流程,让我们回到2.0的设计上来,这部分同样维护了三个结构分别对应精确匹配、正则匹配以及最长前缀匹配。

#[derive(Clone, Default)]#[allow(unused)]/// A struct representing a shared runtime server configuration.pub struct RunTimeServer {    /// Unique identifier for the server.    pub id: String,    /// Name of the server.    pub server_name: String,    /// Indicates whether the server should redirect requests.    pub redirect: bool,    /// A HashMap storing equal-matched locations, where the key is the path and the value is the location.    pub equal_match: HashMap<String, Arc<RunTimeLocation>>,// 精确匹配字典    /// A Vec storing regex-matched locations, where each tuple contains a Regex and the location.// 正则匹配队列    pub regex_match: Vec<(Regex, Arc<RunTimeLocation>)>,    /// The root node of the static location tree.    pub prefix_root: Option<Arc<static_location_tree::TreeNode>>,}

精确匹配、正则匹配比较简单,我们重点介绍最长前缀匹配,最长前缀匹配树的构建基本上是把Nginx代码原原本本的翻译过来,通过 create_list() 分组节点、 create_tree() 生成多叉树。通过 find_location 遍历树结构查找最长有效路径,其中路径比较函数 path_cmp() 确保按字典序定位子树,匹配成功时返回( need_stop, location ),其中 need_stop 标志是否中止搜索(模拟 ^~ 行为)。

 pub fn find_location(path: &str, node: &Arc<TreeNode>) -> Option<(bool, Arc<RunTimeLocation>)> {    let mut nodeSome(node);    let mut uri_len0;    let mut search_nodeNone;
    while let Some(current) = node {        let n = std::cmp::min(current.path.len(), path.len() - uri_len);        let node_path = &current.path[..n];        let temp_path = &path[uri_len..uri_len + n];
        match path_cmp(node_path, temp_path) {            std::cmp::Ordering::Equal => {                uri_len += n;                search_node = Some((current.need_stop, current.val.clone()));                node = current.tree.as_ref();                if uri_len >= path.len() { break; }            }            std::cmp::Ordering::Greater => node = current.left.as_ref(),            std::cmp::Ordering::Less => node = current.right.as_ref(),        }    }    search_node}

路由重写

路由重写是实现请求路径动态转换的核心能力,在语义层面,我们完全兼容Nginx的配置语义。

 regex replacement [flag] ,同时采用预编译正则引擎,在路由加载期完成规则编译。

#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum RewriteFlags {    Break,    Last,    Redirect,    Permanent,    NONE,}
pub struct RewriteRule {    pub reg_source: String,    pub reg: Regex,    pub target: String,    pub flag: RewriteFlags,}

模块化Filter链

Pingora 引擎已经将请求生命周期划分了足够细的各个阶段,为了更精细化控制同一phase执行的各个Filter,可通过自定义的 ProxyFilter trait,与 Pingora 引擎的phase关联起来。

#[async_trait]pub trait ProxyFilter: Sync + Send {    fn phase(&self) -> ProxyFilterPhase;        fn name(&self) -> ProxyFilterName;        fn order(&self) -> i32;        async fn handle(&self, _session: &mut Session, _ctx: &mut ProxyContext) -> HandleResult {        HandleResult::Continue    }}

 ProxyFilter 主要包含四个方法:

  • phase : Filter 的执行阶段, 生命周期阶段锚点,可以根据实际需要进行扩展插入更细粒度的阶段进行请求处理。
  • name : Filter的名称。
  • order : 在同一个phase内Filter的执行顺序。
  • handle : Filter 的执行逻辑,若返回的是 HandleResult::Continue ,则表示当前filter执行完成,继续执行下一个 filter,否则停止filter chain 的执行动作。
#[derive(Debug, PartialEq, Clone, EnumString)]pub enum HandleResult {    /// 表示当前filter执行完成,继续执行下一个 filter。    Continue,    /// 表示当前filter操作被中断,停止filter chain 的执行动作。    Break,}

目前我们已经实现的Filter包括但不限于:

图片

四、总结

作为《从Rust模块化探索到DLB 2.0实践》系列的第一篇,本文介绍了开发DLB 2.0的背景以及详述了DLB 2.0如何通过声明式配置管理、分层路由架构及与Nginx完全兼容的匹配逻辑,实现亿级流量调度场景下的高可用与零迁移成本。

当前成果验证了Rust在负载均衡产品中改造中的工程价值:依托线程安全的运行时结构(如 Arc<Mutex> )、高效前缀树路由( HostSelector )及最长前缀匹配,性能与可维护性均突破传统方案边界。

在后续篇章中,我们将继续深入剖析服务发现、监控与日志等核心模块,为超大规模云原生架构提供完整的参考实践。

往期回顾

1.eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

2.正品库拍照PWA应用的实现与性能优化|得物技术

3.汇金资损防控体系建设及实践 | 得物技术

4.得物社区活动:组件化的演进与实践

5.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

文 / 雷泽

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

一、背景

云存储 NAS 产品是一个可共享访问、弹性扩展、高可靠、高性能的分布式文件系统。 NAS 兼容了 POSIX 文件接口,可支持数千台计算节点共享访问,可挂载到弹性计算 ECS、容器实例等计算业务上,提供高性能的共享存储服务。

鉴于多主机间共享的便利性和高性能, NAS 在得物的算法训练、应用构建等场景中均成为了基础支撑。

图片

在多业务共享的场景中,单个业务流量异常容易引发全局故障。目前,异常发生后需依赖云服务厂商 NAS 的溯源能力,但只能定位到主机级别,无法识别具体异常服务。要定位到服务级别,仍需依赖所有使用方协同排查,并由 SRE 多轮统计分析,效率低下(若服务实例发生迁移或重建,排查难度进一步增加)。

为避免因 NAS 异常或带宽占满导致模型训练任务受阻,因此需构建支持服务级流量监控、快速溯源及 NAS 异常实时感知的能力,以提升问题定位效率并减少业务中断。

二、流量溯源方案调研和验证

NAS工作原理

NAS 本地挂载原理

在 Linux 平台上,NAS 的产品底层是基于标准网络文件系统 NFS(Network File System),通过将远端文件系统挂载到本地,实现用户对远端文件的透明访问。

NFS 协议(主要支持 NFS v3 和 v4,通常以 v3 为主)允许将远端服务挂载到本地,使用户能够像访问本地文件目录一样操作远端文件。文件访问请求通过 RPC 协议发送到远端进行处理,其整体流程如下:

图片

文件系统访问时的数据流向示意

图片

Linux 内核中 NFS 文件系统

NFS 文件系统读/写流程

在 Linux NFS 文件系统的实现中,文件操作接口由 nfs_file_operations 结构体定义,其读取操作对应的函数为: 

//NFS 文件系统的 VFS 层实现的函数如下所示:const struct file_operations nfs_file_operations = {        .llseek           = nfs_file_llseek,        .read_iter        = nfs_file_read,        .write_iter       = nfs_file_write,        // ...};

针对 NFS 文件系统的读操作涉及到 2 个阶段(写流程类似,只是函数名字有所差异,本文仅以读取为例介绍)。由于文件读取涉及到网络操作因此这两个阶段涉及为异步操作:

两个阶段

  • 读取请求阶段: 当应用程序针对 NFS 文件系统发起 read() 读操作时,内核会在VFS层调用 nfs_file_read 函数,然后调用 NFS 层的 nfs_initiate_read 函数,通过 RPC 的 rpc_task_begin 函数将读请求发送到 NFS Server,至此向 NFS Server 发起的请求工作完成。
  • 读响应阶段: 在 NFS Server 返回消息后,会调用 rpc_task_end 和 nfs_page_read_done 等函数,将数据返回到用户空间的应用程序。

图片

在了解 NFS 文件系统的读流程后,我们回顾一下 NFS Server 为什么无法区分单机访问的容器实例或进程实例。

这是因为 NFS 文件系统的读写操作是在内核空间实现的。当容器 A/B 和主机上的进程 C 发起读请求时,这些请求在进入内核空间后,统一使用主机 IP(如 192.168.1.2)作为客户端 IP 地址。因此,NFS Server 端的统计信息只能定位到主机维度,无法进一步区分主机内具体的容器或进程。

图片

内核空间实现示意

方案调研和验证

进程对应容器上下文信息关联

内核中进程以 PID 作为唯一编号,与此同时,内核会建立一个 struct task_struct 对象与之关联,在 struct task_struct 结构会保存进程对应的上下文信息。如实现 PID 信息与用户空间容器上下文的对应(进程 PID 1000 的进程属于哪个 Pod 哪个 Container 容器实例),我们需基于内核 task_struct 结构获取到容器相关的信息。

通过分析内核代码和资料确认,发现可以通过 task_struct 结构中对应的 cgroup 信息获取到进程对应的 cgroup_name 的信息,而该信息中包含了容器 ID 信息,例如 docker-2b3b0ba12e92...983.scope ,完整路径较长,使用 .... 省略。基于容器 ID 信息,我们可进一步管理到进程所归属的 Pod 信息,如 Pod NameSpace 、 Pod Name 、 Container Name 等元信息,最终完成进程 PID 与容器上下文信息元数据关联。

struct task_struct {        struct css_set __rcu                *cgroups;}
struct css_set {        struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];}
struct cgroup_subsys_state {        struct cgroup *cgroup;}
struct cgroup {  struct kernfs_node *kn;                /* cgroup kernfs entry */}
struct kernfs_node {        const char                *name;  // docker-2b3b0ba12e92...983.scope}

以某容器进程为例,该进程在 Docker 容器环境中的 cgroup 路径完整为   /sys/fs/cgroup/cpu/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podefeb3229_4ecb_413a_8715_5300a427db26.slice/docker-2b3b0ba12e925820ac8545f67c8cadee864e5b4033b3d5004d8a3aa742cde2ca.scope 。

经验证,我们在内核中读取 task->cgroups->subsys[0]->kn->name 的值为 docker-2b3b0ba12e925820ac8545f67c8cadee864e5b4033b3d5004d8a3aa742cde2ca.scope。

图片

其中容器 ID 字段为 docker- 与 .scope 间的字段信息,在 Docker 环境中一般取前 12 个字符作为短 ID,如 2b3b0ba12e92 ,可通过 docker 命令进行验证,结果如下:

docker ps -a|grep 2b3b0ba2b3b0ba12e92        registry-cn-hangzhou-vpc.ack.aliyuncs.com/acs/pause:3.5      

NAS 上下文信息关联

NAS 产品的访问通过挂载命令完成本地文件路径的挂载。我们可以通过 mount 命令将 NAS 手工挂载到本地文件系统中。

mount -t nfs -o vers=3,nolock,proto=tcp,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport \  3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com:/test /mnt/nas

执行上述挂载命令成功后,通过 mount 命令则可查询到类似的挂载记录:

5368 47 0:660 / /mnt/nas rw,relatime shared:1175 \     - nfs 3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com:/test \         rw,vers=3,rsize=1048576,wsize=1048576,namlen=255,hard,nolock,\     noresvport,proto=tcp,timeo=600,retrans=2,sec=sys, \     mountaddr=192.168.0.91,mountvers=3,mountport=2049,mountproto=tcp,\     local_lock=all,addr=192.168.0.92

核心信息分析如下:

# 挂载点 父挂载点 挂载设备号   目录     挂载到本机目录  协议   NAS地址5368     47       0:660     /       /mnt/nas     nfs    3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com:/test                maror:minor 

挂载记录中的 0:660 为本地设备编号,格式为 major:minor , 0 为 major 编号, 660 为 minor 编号,系统主要以 minor 为主。在系统的 NFS 跟踪点 nfs_initiate_read 的信息中的 dev 字段则为在挂载记录中的 minor 编号。

cat /sys/kernel/debug/tracing/events/nfs/nfs_initiate_read/formatformat:              field:dev_t dev;        offset:8;         size:4;        signed:0;         ...        field:u32 count;        offset:32;        size:4;        signed:0;

通过用户空间 mount 信息和跟踪点中 dev_id 信息,则可实现内核空间设备编号与 NAS 详情的关联。

内核空间信息获取

如容器中进程针对挂载到本地的目录 /mnt/nas 下的文件读取时,会调用到 nfs_file_read() 和 nfs_initiate_read 函数。通过 nfs_initiate_read 跟踪点我们可以实现进程容器信息和访问 NFS 服务器的信息关联。

通过编写 eBPF 程序针对跟踪点 tracepoint/nfs/nfs_initiate_read 触发事件进行数据获取,我们可获取到访问进程所对应的 cgroup_name 信息和访问 NFS Server 在本机的设备 dev_id 编号。

图片

获取cgroup_name信息

  • 进程容器上下文获取:  通过 cgroup_name 信息,如样例中的 docker-2b3b0ba12e92...983.scope ,后续可以基于 container_id 查询到容器对应的 Pod NameSpace 、 Pod Name 和 Container Name 等信息,从而定位到访问进程关联的 Pod 信息。
  • NAS 上下文信息获取:  通过 dev 信息,样例中的 660 ,通过挂载到本地的记录,可以通过 660 查询到对应的 NAS 产品的地址,比如3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com 。

用户空间元信息缓存

图片

在用户空间中,可以通过解析挂载记录来获取 DEV 信息,并将其与 NAS 信息关联,从而建立以 DevID 为索引的查询缓存。如此,后续便可以基于内核获取到 dev_id 进行关联,进一步补全 NAS 地址及相关详细信息。

对于本地容器上下文的信息获取,最直接的方式是通过 K8s  kube-apiserver 通过 list-watch 方法进行访问。然而,这种方式会在每个节点上启动一个客户端与 kube-apiserver 通信,显著增加 K8s 管控面的负担。因此,我们选择通过本地容器引擎进行访问,直接在本地获取主机的容器详情。通过解析容器注解中的 Pod 信息,可以建立容器实例缓存。后续在处理指标数据时,则可以通过 container-id 实现信息的关联与补全。

三、架构设计和实现

整体架构设计

内核空间的信息采集采用 Linux eBPF 技术实现,这是一种安全且高效的内核数据采集方式。简单来说,eBPF 的原理是在内核中基于事件运行用户自定义程序,并通过内置的 map 和 perf 等机制实现用户空间与内核空间之间的双向数据交换。

在 NFS 和 RPC 调用事件触发的基础上,可以通过编写内核空间的 eBPF 程序来获取必要的原始信息。当用户空间程序搜集到内核指标数据后,会对这些原始信息进行二次处理,并在用户空间的采集程序中补充容器进程信息(如 NameSpace、Pod 和 Container 名称)以及 NFS 地址信息(包括 NFS 远端地址)。

图片

内核eBPF程序流程

以 NFS 文件读为例,通过编写 eBPF 程序跟踪 nfs_initiate_read / rpc_task_begin / rpc_task_end / nfs_page_read_done 等关键链路上的函数,用于获取到 NFS 读取的数据量和延时数据,并将访问链路中的进程上下文等信息保存到内核中的指标缓存中。

图片

如上图所示, nfs_initate_read 和 rpc_task_begin 发生在同一进程上下文中,而 rpc_task_begin 与 rpc_task_end 是异步操作,尽管两者不处于同一进程上下文,但可以通过 task_id 进行关联。同时, page_read_done 和 rpc_task_end 则发生在同一进程上下文中。

图片

 nfs_initiate_read 函数调用触发的 eBPF 代码示例如下所示:


SEC("tracepoint/nfs/nfs_initiate_read")int tp_nfs_init_read(struct trace_event_raw_nfs_initiate_read *ctx)    // 步骤1 获取到 nfs 访问的设备号信息,比如 3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com    // dev_id 则为: 660     dev_t dev_id = BPF_CORE_READ(ctx, dev);    u64 file_id = BPF_CORE_READ(ctx, fileid);    u32 count = BPF_CORE_READ(ctx, count);       struct task_struct *task = (struct task_struct *)bpf_get_current_task();
    // 步骤2 获取进程上下文所在的容器 cgroup_name 信息    // docker-2b3b0ba12e925820ac8545f67c8cadee864e5b4033b3d5004d8a3aa742cde2ca.scope    const char *cname = BPF_CORE_READ(task, cgroups, subsys[0], cgroup, kn, name);    if (cname)    {        bpf_core_read_str(&info.container, MAX_PATH_LEN, cname);    }
    bpf_map_update_elem(&link_begin, &tid, &info, BPF_ANY);}
SEC("tracepoint/nfs/nfs_readpage_done")int tp_nfs_read_done(struct trace_event_raw_nfs_readpage_done *ctx){   //... 省略}
SEC("tracepoint/sunrpc/rpc_task_begin")int tp_rpc_task_begin(struct trace_event_raw_rpc_task_running *ctx){    //... 省略}
SEC("tracepoint/sunrpc/rpc_task_end")int tp_rpc_task_done(struct trace_event_raw_rpc_task_running *ctx){   //... 省略}

用户空间程序架构

图片

元数据缓存

NAS 挂载信息缓存

通过解析挂载记录,可以获取 DEV 信息与 NAS 信息的关联关系。以下是实现该功能的关键代码详情:

scanner := bufio.NewScanner(mountInfoFile)count := 0for scanner.Scan() {    line := scanner.Text()    devID,remoteDir, localDir, NASAddr = parseMountInfo(line)
    mountInfo := MountInfo{       DevID:         devID,       RemoteDir:     remoteDir,       LocalMountDir: localDir,       NASAddr: NASAddr,    }    mountInfos = append(mountInfos, mountInfo)

※  容器元信息缓存

通过 Docker 或 Containerd 客户端,从本地读取单机的容器实例信息,并将容器的上下文数据保存到本地缓存中,以便后续查询使用。

podInfo := PodInfo{    NameSpace:     labels["io.kubernetes.pod.namespace"],    PodName:       labels["io.kubernetes.pod.name"],    ContainerName: labels["io.kubernetes.container.name"],    UID:           labels["io.kubernetes.pod.uid"],    ContainerID:   conShortID,}

数据处置流程

用户空间程序的主要任务是持续读取内核 eBPF 程序生成的指标数据,并对读取到的原始数据进行处理,提取访问设备的 dev_id 和 container_id 。随后,通过查询已建立的元数据缓存,分别获取 NAS 信息和容器 Pod 的上下文数据。最终,经过数据合并与处理,生成指标数据缓存供后续使用。

func (m *BPFEventMgr) ProcessIOMetric() {    // ...    events := m.ioMetricMap    iter := events.Iterate()
    for iter.Next(&nextKey, &event) {       // ① 读取到的 dev_id 转化为对应的完整 NAS 信息       devId := nextKey.DevId       mountInfo, ok := m.mountMgr.Find(int(devId))
       // ② 读取 containerID 格式化并查询对应的 Pod 上下文信息       containerId := getContainerID(nextKey.Container)       podInfo, ok = m.criMgr.Find(containerId)             // ③ 基于事件信息、NAS 挂载信息和 Pod 上下文信息,生成指标数据缓存        metricKey, metricValue := formatMetricData(nextKey, mountInfo, podInfo)       value, loaded := metricCache.LoadOrStore(metricKey, metricValue)    }        // ④ 指标数据缓存,生成最终的 Metrics 指标并更新     var ioMetrics []metric.Counter    metricCache.Range(func(key, value interface{}) bool {       k := key.(metric.IOKey)       v := value.(metric.IOValue)
       ioMetrics = append(ioMetrics, metric.Counter{"read_count", float64(v.ReadCount),             []string{k.NfsServer, v.NameSpace, v.Pod, v.Container})         // ...       }       return true    })        m.metricMgr.UpdateIOStat(ioMetrics)}

启动 Goroutine 处理指标数据:通过启动一个 Goroutine,循环读取内核存储的指标数据,并对数据进行处理和信息补齐,最终生成符合导出格式的 Metrics 指标。

※  具体步骤

  • 获取 NAS 信息: 从读取的原始数据中提取 dev_id ,并通过 dev_id 查询挂载的 NAS 信息,例如远端访问地址等相关数据。
  • 查询 Pod 上下文: 对 containerID 进行格式化处理,并查询对应的容器 Pod 上下文信息。
  • 生成指标数据缓存: 基于事件数据、NAS 挂载信息和 Pod 上下文信息,生成指标数据缓存。此过程主要包括对相同容器上下文的数据进行合并和累加。
  • 导出 Metrics 指标: 根据指标数据缓存,生成最终的 Metrics 指标,并更新到指标管理器。随后,通过自定义的 Collector 接口对外导出数据。当 Prometheus 拉取数据时,指标会被转换为最终的 Metrics 格式。

通过上述步骤,用户空间能够高效地处理内核 eBPF 程序生成的原始数据,并结合 NAS 挂载信息和容器上下文信息,生成符合 Prometheus 标准的 Metrics 指标,为后续的监控和分析提供了可靠的数据基础。

自定义指标导出器

在导出指标的场景中,我们需要基于保存在 Go 语言中的 map 结构中的动态数据实时生成,因此需要实现自定义的 Collector 接口。自定义 Collector 接口需要实现元数据描述函数 Describe() 和指标搜集的函数 Collect() ,其中 Collect() 函数可以并发拉取,因此需要通过加锁实现线程安全。该接口需要实现以下两个核心函数:

  •  Describe() :用于定义指标的元数据描述,向 Prometheus 注册指标的基本信息。
  •  Collect() :用于搜集指标数据,该函数支持并发拉取,因此需要通过加锁机制确保线程安全。
type Collector interface {    // 指标的定义描述符    Describe(chan<- *Desc)       // 并将收集的数据传递到Channel中返回    Collect(chan<- Metric)}

我们在指标管理器中实现 Collector 接口, 部分实现代码,如下所示:

nfsIOMetric := prometheus.NewDesc(    prometheus.BuildFQName(prometheusNamespace, """io_metric"),    "nfs io metrics by cgroup",    []string{"nfs_server""ns""pod""container""op""type"},    nil,)
// Describe and Collect implement prometheus collect interfacefunc (m *MetricMgr) Describe(ch chan<- *prometheus.Desc) {    ch <- m.nfsIOMetric}
func (m *MetricMgr) Collect(ch chan<- prometheus.Metric) {    // Note:加锁保障线程并发安全    m.activeMutex.Lock()    defer m.activeMutex.Unlock()        for _, v := range m.ioMetricCounters {       ch <- prometheus.MustNewConstMetric(m.nfsIOMetric, prometheus.GaugeValue, v.Count, v.Labels...)    }

四、总结

当前 NAS 溯源能力已正式上线,以下是主要功能和视图介绍:

※  单 NAS 实例整体趋势

支持基于环境和 NAS 访问地址过滤,展示 NAS 产品的读写 IOPS 和吞吐趋势图。同时,基于内核空间统计的延时数据,提供 P95 读写延时指标,用于判断读写延时情况,辅助问题分析和定位。

图片图片

在 NAS 流量溯源方面,我们结合业务场景设计了基于任务和 Pod 实例维度的流量分析视图:

※  任务维度流量溯源

通过聚合具有共同属性的一组 Pod 实例,展示任务级别的整体流量情况。该视图支持快速定位任务级别的流量分布,帮助用户进行流量溯源和多任务错峰使用的依据。

图片

※  Pod 实例维度流量溯源

以 Pod 为单位进行流量分析和汇总,提供 Pod  NameSpace 和 Name 信息,支持快速定位和分析实例级别的流量趋势,帮助细粒度监控和异常流量的精准定位。

图片

在整体能力建设完成后,我们成功构建了 NAS 实例级别的 IOPS、吞吐和读写延时数据监控大盘。通过该能力,进一步实现了 NAS 实例的 IOPS 和吞吐可以快速溯源到任务级别和 Pod 实例级别,流量溯源时效从小时级别缩短至分钟级别,有效提升了异常问题定位与解决的效率。同时,基于任务流量视图,我们为后续带宽错峰复用提供了直观的数据支持。

往期回顾

1.正品库拍照PWA应用的实现与性能优化|得物技术

2.汇金资损防控体系建设及实践 | 得物技术

3.一致性框架:供应链分布式事务问题解决方案|得物技术

4.得物社区活动:组件化的演进与实践

5.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

文 / 泊明

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

正品库拍照PWA应用的实现与性能优化|得物技术

一、背景与难点

背景

目前得物ERP主要鉴别流程,是通过鉴别师鉴别提需到仓库,仓库库工去进行商品补图拍照,现有正品库59%的人力投入在线下商品借取/归还业务的操作端,目前,线下借取的方式会占用商品资源,同时在使用用途上,每借出10件会出现1次拍照留档,因此会有大量的线上阅图量在日常鉴别和学习中发生;正品库可通过图库搭建,提升图库质量,大大节约线下用工和物流成本支出。

但目前库内存量10~20W件,待进行拍照同步到正品库中,且目前仍不断有新品入库,现有的补图流程效率约每天30件,难以满足快速正品库建立的需要, 主要有以下问题:

※  补图图片上传途径繁琐

仓端接收到补图任务后,需使用ERP网页端完成图片拍摄&上传操作,流程繁琐,操作冗余。

※  留档图拍摄上传质量压缩

新品图片&补图图片上传ERP后,图片质量压缩,部分留档图因不清晰需重新拍摄,浪费作业人力。

※  鉴别借还操作途径单一

鉴别借用&归还只能于PC端操作,不利于鉴别在库内现场进行借用&归还。

※  正品流转效率问题

在图库建立前有很多鉴别是需要借用到实物的,借用之后的登记、归还等流程会大大影响流传效率,同时存在异地仓库借阅的情况,成本和周期更高。

优化前后整体方案对比

图片

综合来说,其实相当于整体的操作都需要在手持设备上完成(包括上传、拍摄、通知等),这减少了过程操作繁多而导致的效率问题和图片质量问题。

难点

在Web端上,去实现一个自定义的相机拍摄能力是相对简单的,实现一个获取视频流转化为图片的能力也不复杂的。我们的初版应用的拍摄标准是1280x1280的图片,但鉴别师希望有更高的分辨率,能够得到原相机一模一样的拍摄结果,所以必须需要提高分辨率,按照手机原相机的分辨率去加工处理图片。以仓库的 iPhoneX 为例:若需分辨率达到超高清范畴的4032 * 3024,库工需要连续拍摄几十次甚至上百次的各个模板位的图片,才能完成一件正品的存档工作。

综合难点

※  分辨率激增带来的内存压力

  1. 内存占用暴增,单个从6.4M左右跃升到48.8M,增长7.6倍。
  2. 超高清分辨率需要更多的GPU内存和计算资源。
  3. 高分辨率与流畅体验难以兼顾。

※  PWA内存分配限制

  1. 多层内存限制:拿iPhoneX为例,从3GB系统内存到300~500MB的实际可用内存,层层削减。若除去一些基础的开销(比如js引擎、WebKit开销等开销)后则更少,更容易达到系统限制的内存红线,进而产生卡顿、失败、被强制回收,降频等情况。
  2. Webkit严格限制,浏览器对单个标签页内存使用有硬性上限。

※  视频流与图像处理的资源竞争

  1. 视频流和图像处理同时占用大量内存。
  2. GPU资源竞争,视频解码和Canvas绘制争夺GPU资源。

※  移动设备性能差异化

  1. 硬件碎片化:不同设备内存和性能差异巨大。
  2. 兼容性问题:需要为不同性能的设备提供不同策略,保障任务的进行。

※  浏览器内存管理的不可控性

  1. 内存分配不可预测:系统会根据整机的内存压力动态调整分配。自身web应用无法参与调控。
  2. GC时机不可控:垃圾回收可能在关键时刻触发,影响作业流程。
  3. 进程终止风险:极端情况下浏览器自己会终止页面,reload。

二、实现方案

整体技术实现

我们整体的技术实现基于 WebRTC 和 HTML5 Canvas 以及Web worker。

※  WebRTC

navigator.mediaDevices.getUserMedia 是 WebRTC API 的一部分,用于访问用户设备的摄像头和麦克风。它可以请求用户授权以获取视频或音频流,并将实时媒体流绑定到 标签上。

※  HTML5 的 video

用于显示摄像头捕捉到的实时视频流。

※  Canvas

通过 canvas 元素,可以从 标签的当前帧中捕获图像(拍照),并将其转换为图片格式(如 PNG 或 JPEG)。

※  WebWorker

通过允许在后台线程中运行脚本,避免阻塞主线程(UI 线程),从而解决复杂计算导致的页面卡顿问题。

整体架构

图片

整体方案简要

  1. 在pwa页面中开启摄像头
  2. 获取视频流: CameraStreamManager管理相机流,提供video元素
  3. 等待帧稳定
  4. 通过视频流,创建ImageBitmap
  5. Worker处理: 将ImageBitmap传递给Worker进行处理
  6. 策略选择,根据设备情况做策略选择
  7. Worker中使用chunked、chunkedConvert等策略分块处理大图像
  8. 生成结果: 返回ObjectUrl(内存中的文件或二进制数据)
  9. 更新UI: 更新预览和上传队列
  10. 资源回收
  11. 结束或下一步

其中的实现细节内更多偏向于资源的精细化管理、回收释放、重试机制、容错机制等。

最核心的准则是:性能优先,稳定保底。

产品使用流程

图片

操作流程里的核心是针对此前在电脑和手机中反复切换拍摄、录入、上传等复杂的操作,转变为在手持设备中一站式完成补图、拍摄、上传和通知等。

操作时序

图片

三、性能优化

图片

性能优化思维导图

为什么需要性能优化

  • 页面卡顿
  • 低端机型无法顺畅拍照
  • 图片转化慢,手机热..
  • 高频出现图像转化失败
  • 突破内存峰值,系统回收内存降频等,程序reload
  • ...

首先看下此前的策略中的性能表现,首先我们用的的是超高分辨率的约束配置条件:


const videoConstraints useRef({    video: {      facingMode'environment',      width: {        min1280,        ideal4032,        max4032      },      height: {        min720,        ideal3024,        max3024      },      frameRate: {        ideal30, // 适当降低可以降低视频缓冲区的内存占用,我们先按照这样的场景来看。        min15      },      advanced: [        { focusMode"continuous" },      ]    } as MediaTrackConstraints,});

如果单独拍摄一张图内存,粗略计算为如下(主要以iPhoneX的情况做解析):

// 视频流约束const iphoneXStreamConfig = {  width: 4032,  height: 3024,  frameRate: 24,  format'RGBA' // 4字节/像素};
// 单帧内存计算const frameMemoryCalculation = {  // 单帧大小  pixelCount: 4032 * 3024,                    // = 12,192,768 像素  bytesPerFrame: 4032 * 3024 * 4,             // = 48,771,072 字节  mbPerFrame: (4032 * 3024 * 4) / (1024 * 1024), //46.51 MB};
// 实际运行时内存占用const runtimeMemoryUsage = {  // 视频流缓冲区 (至少3-4帧)  streamBuffer: {    frameCount: 4,    totalBytes: 48771072 * 4,        //186.04 MB    description: '视频流缓冲区(4帧)'  },   // 处理管道内存  processingPipeline: {    captureBuffer: 46.51,            // 一帧的大小    processingBuffer: 46.51,         // 处理缓冲    encoderBuffer: 46.51 * 0.5,      // 编码缓冲(约半帧)    totalMB: 46.51 * 2.5,           //116.28 MB    description: '视频处理管道内存'  },    // 总体内存  total: {    peakMemoryMB: 186.04 + 116.28,  //302.32 MB    stableMemoryMB: 186.04 + 93.02//279.06 MB    description: '预估总内存占用'  }};

单张图的内存占用

图片

按照上文的视频约束条件,单帧大小:约 46.51MB,实际单张内存需要76.7M左右(15 + 15 + 46.5 + 0.2 「objectURL引用」),三五张图大概就会达到内存限制红线,这样的内存占用对移动设备来说太大了,实际上,在项目上线初期,业务使用也反馈:拍照几张手机发热严重,页面经常卡死。

PWA相机应用内存占用情况

图片

在移动端中,特别是ios,内存限制是动态的,依赖多个因素,如:设备物理内存总量,设备当前可用内存,后台的软件运行情况。上文可以看出至少有300M是固定支出的,还需增加一些WebRtc视频帧缓冲累积的占用、浏览器内存缓存解码帧的堆积。

在iPhone的WeKit的内核浏览器下,官方内存限制虽是1.5G,实际上可能在是800-1200M左右,在实际的测试场景下,甚至还要低很多。

拍摄过程内存变化

图片

秒数是为了更直观的观察区分内存数据的变化。

有些并不能立即回收canvas对象,需要等之前的二进制blob文件被回收后才可进行,这无疑是在慢慢增加内存的压力。

内存压力趋势分析

基于上文的单独内存占用和相机应用的内存占用(按照1.5G的分配),可以粗略分析出:

图片图片

这些大部分都是官方的数据计算和累积,在实际操作中,如果操作过快,差不多会在第三、四张时开始出现问题了。因为变量比较多,比如充电或发热情况;而连续作业时候的情况又各不同,但是整体规律是差不多的。上文分析的是5张开始危险,实际情况则是第三张就已经出现问题了。

不仅如此,在拍摄作业流程中,还有CPU的热节流风险,如内存85%使用率超过30秒,cpu会降频至70%或更低的性能。

这其中的主要消耗是:视频流处理(35-45%) + Canvas处理(25-35%)  及4032×3024这类大分辨率导致的计算密集型操作。

做了哪些优化

  • canvas主线程绘制更改为离屏渲染绘制
  • 视频流管理、前置设备参数预热
  • 分辨率管理
  • 引入Webworker线程单独绘制
  • 优化设备检测策略
  • 异步上传管理
  • 产品兜底,页面reload,缓存历史数据
  • 内存分配模型

方案选择与实现

实现原相机拍摄的最初的一版,是通过把canvas内容转为base64后,同步上传图片,最初通过一些低端机的测试情况来看,最主要的问题是图片比较大,生成的base64的code自然也比较大,在数据体积上会增大33%左右。 因为是移动设备,这么大的图片上传的速度又相对缓慢,导致操作的过程需要等待和加载。

在这样的场景下为什么要异步上传呢,如果拍摄的快些,页面会变得很卡顿。由于大量的字符串涌入到页面中,再加上cavans转化这么大的image到base64 code又会比较消耗内存,所以整体有丢帧卡顿的表现。进而考虑替换为blobUrl。

toDataURL 和 toBlob对比

图片

如上所示,我们最终选择了性能更好的canvas to Blob并使用二进制的形式。

更快的回显

更快的转化

更小的内存占用

在运用了 Blob 后, 通过埋点等操作,页面渲染和流畅度虽然有所缓解,但会在比较高频的情况下出现图片转化失败,而且也是间隔性的,如上文所示,我们根据渲染和一些实际案例分析过后,发现问题还是存在于内存峰值和CPU资源。

canvas.convertToBlob失败主要是因为内存的限制问题,特别是在处理大图像时。编码同一图像可能在资源充足时成功,资源紧张时失败,这也就解释了为什么是间隔性的出现转化失败。

因为有大量的绘制需在主线程完成,但由于JS的单线程问题,严重影响了页面的操作和后续的渲染, 使得库工的作业流程被迫等待。因此,我们引入了WebWorker以及OffscreenCanvas,开启新线程专一用来做绘制。当然Webworker中的内存的管理也是比较复杂的,同样会占据大量内存,也有数据通信成本,但是相较于用户体验,我们不得不做一定程度的平衡和取舍。

Web Worker + OffscreenCanvas 架构

图片

  • 主线程不阻塞:图像处理在Worker中进行,UI保持响应
  • 更好的性能:OffscreenCanvas在独立线程中渲染
  • 内存隔离:Worker独立内存空间,避免主线程内存压力

好处就是可以多张并发,降低内存泄漏风险,劣势是开发复杂度增加,调试困难, 数据传输开销(ImageBitmap需要转移所有权)。

相机资源的动态管理与释放

我们知道每个机器的分辨率与他们对WebRtc相关能力的支持是不同的。比如iPhoneX 的最大分辨率支持是:4032 * 3024,其他的机器则会不同,所以固定的分辨率配置是行不通的,需要在进入相机后检查设备支持情况等。以及视频通道的保留操作和暂时性暂停,也对操作流程产生着很大积极影响。在继续服用的场景下仅暂停数据传输,保持活跃连接,在下一张拍摄的时候复用连接,而非重新进行初始化、连接和检查等操作。

图片

ImageBitmap 直接创建策略

在绘制中,如果 imageData 是普通的 Image 或 Canvas,每次 drawImage 都可能涉及格式转换和内存拷贝,无疑增大了内存支出。引入 ImageBitmap,因其是专门为高性能图像作处理设计,数据存储在 GPU 内存中,最重要的是:它支持内存的复制转义,可以交到Webworker中去处理,可以在主线程和 Worker 之间零拷贝传输,在worker中直接使用,无需解码。

直接从视频流创建ImageBitmap,跳过Canvas中间步骤。

...let imageBitmap: ImageBitmap | null = null;// 判断是否为视频元素,如果是则尝试直接创建ImageBitmap// 支持img 和 vedioif ((source instanceof HTMLVideoElement || source instanceof HTMLImageElement) && supportsImageBitmap) {  try {    console.log('尝试直接从视频元素创建ImageBitmap');    // 直接从视频元素创建ImageBitmap,跳过Canvas中间步骤    if (source instanceof HTMLVideoElement) {      imageBitmap = await createImageBitmap(        source,        00, sourceWidth, sourceHeight      );    } else {      // 支持img      imageBitmap = await createImageBitmap(source);    }    console.log('直接创建ImageBitmap成功!!');  } catch (directError) {    console.warn('这直接从视频创建ImageBitmap失败,回退到Canvas:', directError);    // 失败后将通过下面的Canvas方式创建    imageBitmap = null;  } } ...

createImageBitmap 实际上是:

  • 创建一个位图引用
  • 可能直接使用视频解码器的输出缓冲区
  • 在支持的平台上,直接使用GPU内存中的纹理
  • 最重要的是:不涉及实际的像素绘制操作、高效的跨线程传输(支持通过结构化克隆算法高效传输避免了序列化/反序列化开销,能高效传送到Worker)

※  综合表现

  • 性能最优: 避免Canvas绘制的中间步骤。
  • 内存效率: 直接从视频帧创建位图,占用更低。
  • 硬件加速: 可利用GPU加速。

Worker中的图像处理策略

在web端,主线程和Worker间的数据传输有三种方式,结构化克隆和Transferable对象,ShareArrayBuffer(共享内存访问,支持度有问题),整体上使用Transferable对象的形式,可降低内存消耗。接下来,我们简单介绍这里用到的两种执行策略。

※  chunked策略(chunked processing分块处理)

主要源于内存控制,避免图像过大导致的内存溢出。将大图像分割成多个小块,使用一个小的临时画布逐块处理后绘制到最终画布,通过"分而治之"的策略显著降低内存峰值使用,避免大图像处理时的内存溢出问题。

劣势是处理时间增加,算法复杂度高。

图片

chunked策略流程示意

class ChunkedProcessStrategy extends ImageProcessStrategy {  readonly name = 'chunked';    protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> {    const { width, height, quality } = options;    const optimalChunkSize = ResourceManager.calculateOptimalChunkSize(width, height);        const chunkConfig: ChunkConfig = {      size: optimalChunkSize,      cols: Math.ceil(width / optimalChunkSize),      rows: Math.ceil(height / optimalChunkSize),    };        const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height);    const { canvas: tempCanvas, ctx: tempCtx } = ResourceManager.createCanvas(optimalChunkSize, optimalChunkSize);       try {      for (let row = 0; row < chunkConfig.rows; row++) {        for (let col = 0; col < chunkConfig.cols; col++) {          await this.processChunk(            imageData,            tempCanvas,            tempCtx,            finalCtx,            row,            col,            chunkConfig,            width,            height          );                 await new Promise(resolve => setTimeout(resolve, 0));        }      }            return await finalCanvas.convertToBlob({        type: 'image/jpeg',        quality,      });    } finally {      ResourceManager.releaseResources(tempCanvas, tempCtx);      ResourceManager.releaseResources(finalCanvas, finalCtx);    }  }    private async processChunk(    imageData: ImageBitmap,    tempCanvas: OffscreenCanvas,    tempCtx: OffscreenCanvasRenderingContext2D,    finalCtx: OffscreenCanvasRenderingContext2D,    row: number,    col: number,    chunkConfig: ChunkConfig,    width: number,    height: number  ): Promise<void> {    const x = col * chunkConfig.size;    const y = row * chunkConfig.size;    const chunkWidth = Math.min(chunkConfig.size, width - x);    const chunkHeight = Math.min(chunkConfig.size, height - y);       tempCtx.clearRect(00, chunkConfig.size, chunkConfig.size);       tempCtx.drawImage(      imageData,      x, y, chunkWidth, chunkHeight,      00, chunkWidth, chunkHeight    );        finalCtx.drawImage(      tempCanvas,      00, chunkWidth, chunkHeight,      x, y, chunkWidth, chunkHeight    );  }}  ...

主要针对中等性能的机型,适用于直接转化可能失败的情形。

※  chunkedConvert策略(分块处理转化)

将大图像分块后,每块独立转换为压缩的Blob存储,最后再将所有Blob重新解码,同时合并到最终画布,通过"分块压缩存储 + 最终合并"的策略实现极致的内存控制,但代价是处理时间翻倍,属于时间换内存的策略。

图片

chunkedConvert策略流程示意

// 分块转化 最终返回class ChunkedProcessStrategy extends ImageProcessStrategy {  readonly name = 'chunked';   protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> {    const { width, height, quality } = options;    const optimalChunkSize = ResourceManager.calculateOptimalChunkSize(width, height);       const chunkConfig: ChunkConfig = {      size: optimalChunkSize,      cols: Math.ceil(width / optimalChunkSize),      rows: Math.ceil(height / optimalChunkSize),    };       const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height);    const { canvas: tempCanvas, ctx: tempCtx } = ResourceManager.createCanvas(optimalChunkSize, optimalChunkSize);       try {      for (let row = 0; row < chunkConfig.rows; row++) {        for (let col = 0; col < chunkConfig.cols; col++) {          await this.processChunk(            imageData,            tempCanvas,            tempCtx,            finalCtx,            row,            col,            chunkConfig,            width,            height          );                   // 给GC机会          await new Promise(resolve => setTimeout(resolve, 0));        }      }            return await finalCanvas.convertToBlob({        type: 'image/jpeg',        quality,      });    } finally {      ResourceManager.releaseResources(tempCanvas, tempCtx);      ResourceManager.releaseResources(finalCanvas, finalCtx);    }  }    private async processChunk(    imageData: ImageBitmap,    tempCanvas: OffscreenCanvas,    tempCtx: OffscreenCanvasRenderingContext2D,    finalCtx: OffscreenCanvasRenderingContext2D,    row: number,    col: number,    chunkConfig: ChunkConfig,    width: number,    height: number  ): Promise<void> {    const x = col * chunkConfig.size;    const y = row * chunkConfig.size;    const chunkWidth = Math.min(chunkConfig.size, width - x);    const chunkHeight = Math.min(chunkConfig.size, height - y);       tempCtx.clearRect(00, chunkConfig.size, chunkConfig.size);      tempCtx.drawImage(      imageData,      x, y, chunkWidth, chunkHeight,      00, chunkWidth, chunkHeight    );        finalCtx.drawImage(      tempCanvas,      00, chunkWidth, chunkHeight,      x, y, chunkWidth, chunkHeight    );  }}
......
class ChunkedConvertStrategy extends ImageProcessStrategy {  readonly name = 'chunkedConvert';   protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> {    const { width, height, quality } = options;    const config = WorkerConfig.getInstance();       const chunks: Array<{      blob: Blob;      x: number;      y: number;      width: number;      height: number;    }> = [];       // 分块处理    for (let y = 0; y < height; y += config.chunkSize) {      for (let x = 0; x < width; x += config.chunkSize) {        const chunkWidth = Math.min(config.chunkSize, width - x);        const chunkHeight = Math.min(config.chunkSize, height - y);               const chunk = await this.processSingleChunk(          imageData, x, y, chunkWidth, chunkHeight, quality        );              chunks.push({ ...chunk, x, y, width: chunkWidth, height: chunkHeight });                await new Promise(resolve => setTimeout(resolve, 0));      }    }        // 合并块    return chunks.length === 1 ? chunks[0].blob : await this.mergeChunks(chunks, width, height, quality);  }    private async processSingleChunk(    imageData: ImageBitmap,    x: number,    y: number,    width: number,    height: number,    quality: number  ): Promise<{ blob: Blob }> {    const { canvas, ctx } = ResourceManager.createCanvas(width, height);       try {      ctx.drawImage(imageData, x, y, width, height00, width, height);      const blob = await canvas.convertToBlob({        type: 'image/jpeg',        quality,      });      return { blob };    } finally {      ResourceManager.releaseResources(canvas, ctx);    }  }    private async mergeChunks(    chunks: Array<{ blob: Blob; x: number; y: number; width: number; height: number }>,    width: number,    height: number,    quality: number  ): Promise<Blob> {    const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height);       try {      for (const chunk of chunks) {        const imgBitmap = await createImageBitmap(chunk.blob);              try {          finalCtx.drawImage(            imgBitmap,            00, chunk.width, chunk.height,            chunk.x, chunk.y, chunk.width, chunk.height          );        } finally {          imgBitmap.close();        }              await new Promise(resolve => setTimeout(resolve, 0));      }          return await finalCanvas.convertToBlob({        type: 'image/jpeg',        quality,      });    } finally {      ResourceManager.releaseResources(finalCanvas, finalCtx);    }  }}

会有更小的峰值,适配与更低端的机型和极大图像。不会内存溢出,但是也会降低转化效率。在可用与效率方面,选择了可用。

其中整体方案里还有一些其他的策略,如Direct直接转化、边转化边绘制等,会根据不同的机型进行选择。目前,重点保障低端机型,因为中高端机器在使用过程中没有性能上的卡点。

优化后对比

首先,我们明确了这几个主要策略:

  • Web Worker架构 - 主线程内存压力分散
  • ImageBitmap直接传输 - 减少内存拷贝
  • 绘制分块处理 - 降低内存峰值
  • 资源管理优化 - Canvas复用和及时释放

最重要策略:增加很多管理器和优化方式降低内存的峰值,即那一瞬间的值。

同时,将可以在后台做转化和运算的操作,投入到web worker中去做,降低主线程的内存压力。

优化后单图内存占用情况

图片

优化后PWA相机应用内存占用

图片

优化后的效果

※  内存优化结果

图片

  1. 单张图片处理峰值减少33% - 从123.2MB降至82.2MB。
  2. 单张图片持久占用减少61% - 从76.7MB降至30.2MB。
  3. PWA应用整体内存优化16-26% - 根据图片数量不同。
  4. 内存压力等级显著降低,如从3-4张开始有明显警示压力,到操作快速秒级拍摄速率时才出现(实际操作过程中大概10-15秒一张,因需要摆放和根据模版与提醒进行拍摄)。

※  用户体验

  • 最终在高清图片的绘制作业流程中,由原来的3张图告警到一次性可以拍摄50张图的情况,大大降低了失败风险。提升了作业的流畅度。
  • 用户体验改善,消除UI阻塞,响应时间减半。

四、业务结果

通过几轮的策略优化,整个pwa应用已可以相对顺畅、高效的绘制原相机标准的正品图,已完全达到鉴别师高清图的要求,同时不会有操作流的中断。

  • 目前日均的拍摄件数提升 330%,达成预期目标。
  • 将每件的人力投入成本降低 41.18%。
  • 目前通过PWA项目快速搭建了图库项目,Q2拍照数据占比72.5%,预期后面比例会逐步升高,图库流转效率提高到了20%,超出业务预期。

图片

五、规划和展望

在技术的实现上,许多时候要去做用空间换时间或用时间换空间的策略方案,本质上还是根据我们当前的业务场景和诉求,追求当下收益。有些时候可能不止局限在实现上,需要从实际需求出发,不应该只停留在工具的层面,而深入到业务里剖析挖掘其潜在的业务价值,做更深远的思考,从工具思维转向价值发现与传递的方向上。

未来我们还会思考:

  1. 前置对设备的综合能力评估,更精细化的拆分低、中、高端设备和适配策略,收集更多的实际处理时间和内存峰值、CPU 性能指标等,用于不断优化策略选择算法。

  2. 根据类目做区分(比如鞋服、奢品),这些在鉴别的时候图片质量有不同的品质要求的分类。后续可能会进行更加具有定制化属性的方案,针对鉴别打标,针对当前业务中图片拍摄重试场景下的AI图像识别,针对重复拍摄场景做优化,进一步提高效率。

  3. 针对目前 10 到 15 秒的拍摄时间,能进一步压缩问题,思考更加智能的拍摄能力。根据设备的真实情况,或基于色温分析的光线评估,提高图像质量和降低重复率。基于正品特征进行构图优化,在设备上做实时拍摄指导,不只以单一模板和示例进行人工检查,而是进一步标准化,降低人力参与度。

  4. 针对于商研侧业务和前置拍照流程,将拍照H5的方案也纳入采卖商品入库流程,同时支持鉴别师对于图库的验收,加快图库的验收入库效率,缩短库内的拍照数据积压周期。

往期回顾

1.汇金资损防控体系建设及实践 | 得物技术

2.一致性框架:供应链分布式事务问题解决方案|得物技术

3.Redis 是单线程模型?|得物技术

4.得物社区活动:组件化的演进与实践

5.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

文 / 维克

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌