阅读视图

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

得物灵犀搜索推荐词分发平台演进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.得物社区活动:组件化的演进与实践

文 / 炯帆 南风

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

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

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

❌