阅读视图

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

从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性能优化实战全揭秘|得物技术

文 / 维克

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

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

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

汇金资损防控体系建设及实践 | 得物技术

一、为什么要做资损防控

随着互联网电商平台竞争的加剧,各平台的业务复杂度不断提升,线上环境的稳定性面临更大挑战。在汇金领域,由于其高资金属性,除了确保链路可用性达到99%以上,防止资损亦成为关键保障事项。得物汇金业务涉及复杂的资金流和大额资金敞口,因此实施资损防控尤为重要。

  • 防资损、资金安全

  • 保障企业财务健康:

    资损防控措施能有效识别和应对风险,保护企业现金流和资产,维护股东投资收益。

  • 降低风险敞口:

    面对市场波动和欺诈等风险,实施资损防控能显著减少对企业财务的负面影响。

  • 增强抵御危机能力:

    在经济不确定或突发事件(如市场崩溃、疫情等)发生时,稳健的防控措施帮助企业保持资金流动性和安全性。

  • 防客诉

  • 提升客户信任:

    资损防控有助于提高服务质量和客户满意度,降低资金管理不当造成的风险,从而增强客户信任。

    减少客户投诉:

    不当的资金管理可能引发服务延误和错误收费,良好的防控措施可避免这些问题,确保客户顺畅的服务体验。

    维护品牌声誉:

    客户投诉频繁会影响品牌形象,实施有效的资损防控可保持良好的客户关系,并促进长期发展。

经过不断的演进与发展,我们已经沉淀出一套汇金资损防控体系的方法论,并在实践中取得了一定成效。因此,我们希望通过知识梳理与分享,鼓励大家共同交流学习,持续推进资损防控的提升与优化。

二、如何做资损防控

整体方案:

图片

开展思路:

根据平台特性,涉及到交易和资金流,就会考虑到是否会发生资损,那么如何避免产生资损,总结出一套适合业务特点的方法便成为资损防控的关键。汇金平台和业界内的其他平台采用的资损防控方法论基本一致,但是不同的每个阶段所覆盖的产出的内容不一样。

图片

从项目全生命周期来看,已发布时间和出现问题时间为时间点,发布时间前的阶段为事前阶段,出现问题的时间点为事中阶段,出现问题后应急响应为事后阶段。

  • 事前

  • 阶段:项目发布前的时间段,在这段时间内会经历需求评审、研发设计评审、测试用例评审、稳定性项目评审,我们要从4个关键事项对焦如何从需求、代码、线上核对/监控等发现手段上做到防资损、及时发现资损问题。

  • 关注的内容:需求层面,挖掘是否直接涉及资金流,或间接涉及资金流,如果涉及资金流,了解资金如何进行流转,进而挖掘到资金流涉及的上下游。技术设计或编码层面,实现资金计算的逻辑、计算公式,明确上下游之间的资金交互元素、金额/币种/单位,持久化资金数据,异常监控报警逻辑,业务单据幂等逻辑,资金平衡校验等。测试层面,从正常流程和异常流程验证代码实现逻辑是否符合预期,如资金计算、金额大小、方向、币种、单位,上下游传递,数据存储等,基于验收通过后的逻辑编写自动化,自动化要核对金额的正确性,用编写自动化目的是为了沉淀资金场景的测试手段,为后续迭代改造的保证质量及提高效率。

  • 事中

  • 阶段:生产环境出现问题的阶段,对于不同的问题发现有不同要求,重资损链路要做到1分钟发现,即系统出现问题后1分钟发现,系统有告警。从系统告警后5分钟内介入做出响应,即5分钟内有人看到告警并进行跟进。所以重资损链路的问题要做到1-5。非资损链路可做到D+1发现,D+1介入和修复即可,相比资损链路而言,发现能力没有太强要求。如果没有问题的发现能力,最终可能会导致资损的慢性流血发生。不论线下环境如何测试,都很难保障测试环境100%覆盖,所以线上问题的主动发现能力尤为重要。

  • 关注的内容:系统出现问题后,是否有实时或者非实时的告警能力。对于告警内容,要根据业务优先级及系统实现,编写实时/非实时核对脚本。如果业务复杂性高,可以编写抽检脚本,就是系统实现的重算逻辑,从旁路发现问题。那么如何验证脚本有效性,发现问题是否进行报警,就要进行攻防演练。通过演练,可以检查是否具备问题的能力,以及开发的响应能力,如果不达标,要进行改进和优化直到达标。

  • 事后

  • 阶段:发现问题后的止血阶段。一般分为两方面:当前问题的扼制,不再新增问题;存量问题的解决。止血应急能力要有相应的预案或者建立新的应急能力。如果止血比较快,能降低问题的影响,如果止血比较慢,可能会扩大问题的影响,提高问题的严重等级。

  • 关注的内容:对于资损问题要做到10分钟的止血,从发现问题到消除增量问题产生,要在10分钟内解决。对于存量问题的解决,可根据业务特性,在相应时效内修复即可。在修复前可以通过挂公告的方法,暂时消除或者降低问题事态的影响。对于比较核心或者比较固定的问题,可以形成执行预案,当发现问题后,可及时执行预案进行问题止血。对于比较复杂的业务,要根据不同的问题及时进行编码修复问题。不管是进行代码或者编写预案代码,如果涉及代码修复,开发测试均要参与保证代码的正确性。如果只是一个角色进行修复,可能会因为预案问题导致的二次事故发生。

三、资损防控产出阶段

对于项目实施阶段,当承接新功能、新建系统或者分析存量系统时,如何判定是否要做资损防控,可以从两个角度出发分析:信息流或者资金流。资金流和信息流之间是相互依赖的。当业务需求中涉及资金流时,系统要实现业务需求,那么系统之间就要设计信息如何流转最终完成资金流转。当系统中存在资金字段的信息流时,可最终推导出直接或者间接的资金流。资金流通过信息流实现资金流转,信息流是资金流转的载体。所以当信息流中存储或者涉及资金交互,资金传递时,就要做资损防控,分析资损场景及如何编写资损脚本。

图片

对于项目发布后阶段,当项目前期如果没有做资损防控,那么也可以从线上稳定性来看是否要做资损防控。一般可以从线上故障、线上工单等结果分析需要做的资损场景有哪些。从线上问题来看可以比较直观的看到缺少哪些防控手段并做针对性的补充,这样能起到立竿见影的效果。这种是从问题点切入的方法进行分析跟进,但比较好的做法是从面上进行分析,集合需求、问题全面分析,从多个点同时作为抓手判定资损防控的必要。

图片

以上两个方法,均在汇金域进行了实践,在项目发布前和发布后都会进行资损防控补充。

四、如何挖掘及度量资损防控规则

当要实施资损防控时,如何挖掘实施资损规则变得尤为重要。当规则挖掘的不对或者偏少,不利于及时发现问题。当规则过多时,对规则的投入成本会变高,规则保鲜会成为挑战,最终也会影响到发现问题的及时性。

那么如何比较全面的挖掘资损规则呢?目前汇金域从三方面切入,分析资损规则并推进资损防控覆盖的成熟度度量。我们从这3方面进行资损规则分析并编写规则脚本,完成资损布防。

  • 资损字段覆盖度量
  • 业务指标覆盖度量
  • 跨域资金安全覆盖度量

图片

资损字段覆盖【字段】

当系统链路涉及的数据库有资损字段时,在Dcheck平台上做资损字段标记,资损字段标记资损,非资损字段标记非资损。从字段上挖掘到要有资损规则覆盖。当在Dcheck上编写完对应规则后,要进行字段和规则的绑定,维护字段和规则之间的关联关系,这样也可以在报表上看出来资损字段是否有对应的线上布防能力。

字段层面覆盖是比较简单可以做到的资损规则分析,常见的资损字段如金额、币种、单位、汇率、计算公式、数量、日期、状态等。如果链路中涉及这些字段,都可以进行对应的规则实施和布防。一般此类字段覆盖的规则可以通过实时核对实现,这种正确性时效要求比较高,如果存储不正确也比较容易发现问题。资损字段覆盖是比较入门并快速上手的手段,但不能作为发现全部资损问题的手段之一。除此之外,还需要通过其他方式挖掘规则。比如字段内容正确,但是其他指标异动方面较大有影响,这种从字段覆盖层面无法发现问题。

图片

业务指标/场景覆盖【业务】

不同的业务域关注的指标不一样,但可以通过观测这些指标可以发现潜在的问题,进而避免可能产出的投诉或者扩大影响。常见的业务指标比如:时效性巡检、成功率异动巡检、失败率异动巡检、中间态异动巡检或者其他指标异常巡检。通过对这些指标的监控覆盖,可以补全数据正确但系统有问题的发现手段。一般业务指标类的覆盖时效性不高,非实时核对方式实现,可能是D+h或者离线D+1方式实现。

图片

上下游资金安全覆盖【跨域】

资损字段或者业务指标覆盖,更多的是聚焦在内部的稳定性上面,对于和外部间资金覆盖较少。当然资损字段可能也会涉及到外部之间的核对,但上下游之间的资金安全覆盖会涉及更多,可能是直接的上下游资金覆盖,或者全链路上的非直接上下游的资金场景覆盖。常见场景如:下单支付场景,订单域的支付金额和支付域的金额、状态一致性check,各种费用项的一致性校验;采购结算付款链路,付款场景下的金额要和采购结算单据的金额币种保持一致等。通过在发生资金流转的时间,做上下游资金安全check,能和业务侧的金额做校验,进而保证流转的资金安全。

图片

业务域度量探索实践效果

  • 建立核对场景分层覆盖策略,围绕字段/业务/跨域开展。
  • 探索定义各层级的度量方法,并在各子域实践落地,经过与对应功能开发owner对焦,确定了度量方式的有效性。

图片

  • 示例如下:子域2025Q1落地结果,核对覆盖率100%(平台配置采用率100%,共120+个业务场景,60+个跨域场景覆盖率100%;资损字段30+个,核对覆盖率100%)。

图片

五、如何选择资损实现方式

得物实现资损防控的平台为Dcheck平台,作为实现线上核对的平台,支持资损场景核对或者非资损场景核对,从时效性上实现了实时核对或者定时巡检,也支持配置变更的核对。数据源上支持监听生产环境数据库的binlog消息,连接离线数仓、连接业务库。支持语言上可以用Groovy语言编写核对脚本,离线数仓或者通用SQL编写SQL脚本进行核对。同时支持对编写的脚本进行演练,检查脚本有效性。当发生报警后设置通知群@到具体人进行日清处理。业务域可以根据业务特性灵活选择不同的实现方式满足业务目标。平台本身支持的能力比较多样化,灵活性也比较强,支持各种变更的核对。

图片

  • 实时核对原理

    通过实时监听binlog消息/自定义消息/实时数仓消息,触发规则核对。监听binlog和自定义消息的脚本要用Groovy实现,监听实时数仓消息要用SQL实现,Groovy实现的规则会涉及到接口间调用,会出现超时发布导致调用不同的情况,稳定性有一定影响。用SQL实现相比起来更灵活更稳定。监听实时数仓的前提是数据源要接入实时模版平台。

图片

  • 定时巡检原理

    定时巡检是通过配置定时任务触发核对,核对脚本可以通过离线SQL或者Groovy脚本实现,Groovy脚本可以连接业务数据库,比实时核对有小时级别或者天级别的延迟。离线SQL的延迟就是D+1。对于时效性不高的规则场景可以使用此种方式。

图片

  • 核对方案选型

图片

六、如何做资损防控运营

迭代需求运营

图片

  • 汇金独立项目&迭代资损布防:日常迭代或者项目如果涉及到资损防控,有一套运营流程机制保证资损场景的分析及布防。

    • 当需求涉及到资损时,会对需求进行打标标记。

    • 在测试用例评审时,编写资损场景及组织开发评审,保证场景有效性。

    • 目前按照RDC“资”需求打标--->测试用例打标资损用例--->Dcheck规则实现--->用例平台绑定Dcheck规则运营流程推进。

    • 在测试前置阶段完成资损用例分析,高优资损场景在预发、生产流量发布前完成资损布防,低优场景在放量一周内完成规则实现,分析到的核对场景布防率100%。

    • 在生产环境有流量前实现资损规则并进行演练,推进上线状态。当线上数据触发报警时,有值班人员跟进日清,如果发现bug,会在系统上标记bug并进行bug修复。整体流程也会不断进行复盘和review,提升资损防控的投入和产出价值。

  • 研发测试分工:目前迭代或独立项目通过Dcheck方式实现的资损场景主要是测试负责推进,一般在项目开展时或项目灰度前推进完成。涉及的资损场景,测试会邀请开发进行评审。对账平台实现的离线核对场景及实现主要是开发负责,一般偏于迭代测试周或者后续迭代进行开发实施,测试参与度低。

    • 关于Dcheck规则报警,测试报警后会做初步排查,然后确定不是脚本问题后@对应的开发介入处理,开发负责协助进行问题分析,如果是代码bug或者数据问题,开发进行紧急或者排期修复。

    • 测试过程中,如发现资损风险,不适合Dcheck手段布防,开发添加监控。

  • 资金SOP验收执行:

    背景:汇金属于强资金业务线,涉及的资金敞口大,资金流错综复杂,其资金安全对稳定至关重要。为规范资金需求类的产品研发流程,避免低级流程问题导致的资金安全问题,需规范各职责角色和产出,确保需求流转过程中的高效协作和最终交付质量。

    验收方案:整体流程如下:

图片

如何做资损规则保鲜

  • 保鲜策略:

    目前通过提出保险管理策略,平台实现后,让用户发现规则的有效性和质量,及时发现僵尸规则,做到规则保险。

    保鲜策略如下:

    • 一段时间内核对失败占比,时间支持选择;
    • 核对无执行记录;
    • 状态为下线规则;
    • 无演练记录或者演练失败的规则。

图片

  • 保鲜运营:

    保鲜功能Q4上线后,汇金域内完成存量僵尸规则治理。且随着资损防控成熟度建设,场景规则有效性已作为成熟度指标之一,保鲜治理已随迭代常态化运营。

    • 子域1:下线24个规则,剩余规则全部有效

    • 子域2:下线12个规则,剩余规则全部有效

    • 子域3:下线8个规则,剩余规则全部有效

    • 子域4:下线6个规则,剩余规则全部有效

    • 子域5:下线6个规则,剩余规则全部有效

如何做资损规则日清SOP

明确目标及范围:针对业务巡检、实时核对报警,梳理告警跟进SOP,形成闭环处理问题流程,确保资损防控处理的高效性和处理一致性,提升日清率,降低误报,提升有效问题发现。针对资损问题进行日清,同时也是资损成熟度的指标,随迭代运营开展。非资损问题发生报警同时也会进行日清处理。

具体的操作步骤:说明资损防控告警运营的具体步骤,见下图,需要清晰易懂,确保操作性强。

责任人:说明具体步骤对应的责任人,以及不同步骤需要知会的人,确保问题有效推进解决。

监督措施:定期评估SOP的实施效果,并进行必要的改进。监督机制的设计应该确保SOP的执行情况得到有效监督和管理,保障SOP的实施效果。

图片图片

各步骤定义说明:

图片

资损发现问题复盘模版:

图片

示例:

图片

七、资损防控实践及收益

汇金域通过资损防控专项的实践,不断总结沉淀出一套体系化的方法:需求识别资损规则-->如何分析资损规则-->如何选择实现技术。此方法可以降低人员对资损防控专项的学习门槛,提升学习效率。通过挖掘资损规则的方式,可以较快分析产出资损规则。通过学习实现方式,能较快的选取合适的实现方式,减少试错成本。

自2024年全年至2025年5月共完成了520+个规则,发现了160+个问题。其中5+个问题为资损问题,155+个非资损问题。有效遏制了线上的资损发生和有效保障了线上稳定性。

图片

利用Dcheck手段,降低客诉明显。

  • 核算&发票巡检及发票接入配置化项目:通过不断通过完善发票平台线上巡检,制定各类配置问题跟进的SOP,共产出8类配置巡检规则,成功将配置等工单问题从15降到0且持续保持平稳。配置类问题线上问题主动发现率100%。
  • 来自TS反馈:

图片

  • 子域有明显下降趋势,整体降低41%(业务咨询减少&配置类治理有显著效果)。

八、总结及规划

经过汇金在资损防控专项的体系化建设及实践,取得了显著进展。从事前挖掘资损规则、代码预防性建设,事中及时布防资损规则、巡检规则、开发添加监控,事后及时执行预案以及补充未布防场景规则,以及经过各种挖掘资损方法的探索及分享,大部分员工具备资损防控意识和资损规则挖掘、布防、日清保鲜的能力。并且在整个推荐过程中,研发测试协同分工,共同保障及推进线上稳定性稳步提升。目前体系化流程已初见成效,后续除常态化运营继续开展外,让全员具备资损防控意识,同时也会重点治理以下环节中的痛点问题,不断提升专项的ROI。

  • 资损分析:AI资损场景分析

    目前资损分析从三层架构出发,沉淀了一定的规则规律逻辑,后面尝试AI推荐资损场景分析,减少人工输出成本。

  • 脚本实现:通用SQL降噪

    目前通用SQL实现的脚本规则噪音比较大,因为Flink平台底层数据存储在Redis,当多表比对的时候,会出现单表查询Redis超时,对比不一致的情况。后续尝试支持重试或者其他方案降噪解决问题,减少噪音干扰和降低人工成本。

  • 降噪归因:报警自动归因日清

    随着业务复杂升级,资损规则不断增多,脚本失败报警日清处理成本会越来越高,同时随着AI应用普及,尝试通过AI自动归因日清,进一步降低人工投入成本。

往期回顾

1. 给Javaer看的大模型开发指南|得物技术

2. Cursor Rules优化实战:构建高效稳定的AI代码生成规范体系|得物技术

3. 一致性框架:供应链分布式事务问题解决方案|得物技术

4. Redis 是单线程模型?|得物技术

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

文 / 文姬

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

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

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

给Javaer看的大模型开发指南|得物技术

一、概述

伴随着大模型的性能提升、成本下降,在Web在线对话场景以外,大模型也越来越多的被集成到传统业务场景。

在大模型API交互模式、业务集成模式经百家争鸣现已趋于稳定的背景下,Spring作为Java生态里的OSS巨头也下场为LLM提供生态支持,于近期释出 spring-ai 正式版。

需要说明的是,Spring-AI 所提供的能力并不神秘,业务上也并非必须用Spring-AI不可。但是,就像过去Spring对新的数据库、新的中间件提供生态支持一样,Spring-AI提供了一套和Spring全家桶兼容并且语义一致、良好设计、易拓展的大模型交互的Java API,可以极大的降低LLM集成和开发的成本。

从大模型的工程化、实用化角度来说,当你厘清Spring-AI这一套API设施的逻辑后,事情最后还是会回归到业务开发人最熟悉的CRUD领域。就像使用Mybatis操作MySQL一样,我们会用 spring-ai 来操作大模型。

那我们开始今天的讨论吧!

二、什么是大模型

大模型的舞台上,从来不缺新面孔。自ChatGPT开启AI新纪元后,各类大模型层出不穷。

但是我们不去考虑大模型的训练原理、推理/运算架构、参数调优等较为复杂的数学范畴的东西,就像我们很少关心MySQL是怎么用代码来实现效果的一样。

此处类比我们熟悉的知识,对大模型有一个盲人摸象式的基础且能够自洽的认识即可。

  1. 从某种意义上来说,模型训练就是通过分析海量文本(如维基百科、图书、网页等)寻找到人类语言的规律,再将这个规律固化成一个包含数十亿【参数】的超级【数学公式】。就像简单公式 y = 5x + 8 中的 5 和 8 ,这两个【参数】决定了将输入X如何转化为输出Y。 
  2. 训练好的【数学公式】就像代码,需要部署在算力平台上,借助【显卡】的并行运算能力来实现高效运算。
  3. 用户的输入作为这个【数学公式】的入参,经公式运算后,得到相关的【输出】。

图片

假设大模型是上述的数学公式,不同的大模型「ChatGPT/DeepSeek」是不同的架构、不同的公式,那么模型训练就是通过对海量文本的分析、学习,找到合适的参数值。 

三、大模型的特点

接下来我们关注在工程应用场景下,需要开发人关注的大模型特点。

就像MySQL,我们集成时也需要关注不同的存储引擎(InnoDB/MyISAM)的特点。

无状态

图片

大模型是没有记忆、没有状态的,它是一个纯函数。

它不知道之前跟你说过什么。所以每次进行大模型输入的时候,我们需要根据业务场景把之前的【输入】,【反馈】一并给它,避免大模型失忆导致的对话不流畅。

图片

结构化输出

大模型是具备结构化输出能力的,虽然有些模型支持的不够好,但是没关系,只是支持的程度不同,重要的是它们都支持!

所谓的结构化输出是指,大模型除了可以返回口语化、没有模式的自然语言文本外,还可以按你需求给你返回其他的文本格式,比如:JSON。

图片

你看,这像不像在调一个REST接口?甚至是一个万能接口,毕竟大模型什么都会,不会的也可以现编。

图片

函数调用

其实看到这里我们就可以实现一个大模型驱动的RPC调用引擎了!

图片

大模型帮你推理、规划得到了需要执行的函数和对应的函数参数,至于这个【函数名】对应的到底是一个进程内的方法、HTTP接口、Dubbo接口还是MCP接口都没有那么重要,这只是智能体实现的一个技术细节而已。

我们可以用自然语言表述需求,同时告诉大模型有哪些辅助【工具/函数】可以供它备用。它会推理、编排这些工具来达成需求。

图片

  1. 把用户输入和可用函数输入给大模型,大模型推理发现需要调用外部函数,于是返回函数名+函数调用参数。
  2. 智能体捕获输出,对指定函数发起调用,再将用户输入和函数结果一起输入到大模型,大模型基于这些上下文推理输出结果。

考虑到大模型发起函数调用的普遍需求,大模型供应商一般都在API层面提供了【function call】能力,用于将文本输出和函数调用输出区分开,明白了原理,我们知道这只是API抽象层次的问题。

四、大模型接口

考虑到大模型对硬件资源的特别需求(如显卡),所以大模型一般是独立部署,以SaaS模式提供能力。就像MySQL对资源有特别的需求(如大内存),所以一般也是进行独立部署。

图片

训练好的大模型就是一套二进制数据集,SaaS化需要做外围的服务化、产品化封装,同一套模型可以在不同的算力平台部署,提供截然不同的服务化API。

模型封装

示例伪代码如下:

图片

我们可以简单看下当下比较热门的几大供应商提供的API文档:

  1. OpenAI-会话补全

    openai.apifox.cn/api-6788398…

  2. DeepSeek-会话补全

    api-docs.deepseek.com/zh-cn/api/c…

  3. 硅基流动-会话补全

    docs.siliconflow.cn/cn/api-refe…

  4. Ollama-会话补全

    www.runoob.com/ollama/olla…

硅基流动和Ollama都属于大模型算力/治理平台。他们不研发大模型,只是大模型的搬运工。可以把大模型理解成微服务集群,把硅基流动和Ollama理解成微服务构建/发布平台即可。

大概浏览一下,会发现核心API都差不多,毕竟有OpenAI珠玉在前,许多系统都已对接了OpenAI的API。后发的大模型为了兼容,降低接入难度,基本上也都和OpenAI的API大差不差。

就像是MySQL,尽管数据库产品类型百花齐放,但都兼容SQL语法。

我们在此只讨论【会话补全】这一点,会发现会话补全接口的输入/输出大概都是以下情况:

接口输入

{  "stream": false, // 是否是流式输出(要不要SSE)  "model""deepseek-chat"//选用的哪个模型  "messages": [ // 历史对话消息,因为大模型无状态,所以按场景提供一定数量的历史消息    {      "content""You are a helpful assistant",      "role""system"    },    {      "content""Hi"//消息内容      "role""user" //消息类型    }  ],  "tools": null, //外部函数列表,【函数调用】能力在 API 层面的支持  "frequency_penalty"0,  //无关紧要的模型行为控制参数  "presence_penalty"0//无关紧要的模型行为控制参数  "temperature"1//无关紧要的模型行为控制参数  "top_p"1//无关紧要的模型行为控制参数  "logprobs": false, //无关紧要的模型行为控制参数  "top_logprobs": null //无关紧要的模型行为控制参数}

这里以目标达成作为要点,内容中部分不理解的参数可以忽略。

接口输出

{  "id""<string>"//无关紧要  "choices": [    {      "message": {        "role""assistant",        "content""<string>"// 大模型生成的内容        "reasoning_content""<string>",        "tool_calls": [  //需要发起的【函数调用】          {            "id""<string>",            "type""function",            "function": {              "name""<string>",              "arguments""<string>"            }          }        ]      },      "finish_reason""stop" //有点重要,但是我们先不管    }  ],  "usage": {  //token使用量 计数、计费    "prompt_tokens"123,    "completion_tokens"123,    "total_tokens"123  },  "created"123,  //无关紧要  "model""<string>",  //无关紧要  "object""chat.completion"  //无关紧要}

看到这里时,你是不是已经开始跃跃欲试了?是不是感觉打造一个垂直领域的智能体没有想象中那么困难了~

五、RAG架构

除非是围绕特定业务场景结合私域数据训练的专用大模型,否则涉及到一些企业内部的私域信息时,通用大模型也只能不懂装懂的现编。

例如:当你询问大模型【DJob如何接入与使用】,除非训练大模型时输入了相关资料,不然大模型只能现编了。

考虑到专用大模型的成本,工程上解决这个问题的方法一般是通过外挂知识库来实现:

  1. 结合具体业务场景,将相关的文档与资料提前录入到【知识库】中。
  2. 用户提交一个【输入】后,先使用 用户【输入】作为搜索条件,去【知识库】中搜索得到相关的【资料】。
  3. 将用户【输入】和【资料】一起提供给大模型。

此【知识库】组件的具体选型属于实现细节,简单的可以用MySQL、Elasticsearch,如果想提升【知识库搜索结果】的匹配度,也可以使用近期讨论度很高的【向量数据库】。

添加了RAG后,流程如下:

图片

详情可参考下文:

www.zhihu.com/tardis/zm/a…

六、MCP协议

可以看到,将大模型作为一个【函数调用】的规划引擎,借助它的推理与生成能力,可以实现复杂的业务流程。如果说大模型是【脑】,那提供给大模型规划、使用的【函数】就是它的【手】和【脚】。有脑有手的大模型,可以迸发出巨大的业务潜力。

那如何打通大模型和传统软件系统(如存量微服务)呢?

我们关注的问题,开源社区也在积极的关注,这就是MCP协议诞生的背景和目的。

图片

MCP协议介绍

mcp-docs.cn/introductio…

在这里我们不展开MCP协议的细节,仅作个人对MCP协议的思考,重点在于打破MCP协议的神秘感、破除MCP迷信。

  1. MCP协议本身并非高精尖的内容,简单来说,就是常用人群约定系统间调用的流程、格式。若不考虑通用,谁都可以设计符合自己需求的、领域特定的交互协议。
  2. MCP协议的优势在于,它出现的非常及时,且基本满足了常规交互需求,因此快速在社区达成了共识。
  3. 不管是MAP、MBP还是MCP,都没有那么重要,但是形成共识非常重要。协议达成了共识,开源社区才可以合力围绕协议进行生态建设。

七、Spring-AI

到了这一步,我们开始探讨Java代码,首先我们需要熟悉下 spring-ai 的整套代码架构,一步一步来,以整体到到细节的节奏进行讨论。

模型抽象

核心的API实体是 Model ,是一个带泛型的纯函数,提供了对大模型能力的顶层抽象:

图片

org.springframework.ai.model.Model

大模型的能力本质就是:输入一个( request ),返回一个输出。

至于输入/输出的具体类型,由细分的子类限定:

图片

不同模态的大模型支持不同类型的输入/输出,在此我们只讨论 ChatModel 。

图片

org.springframework.ai.chat.model.ChatModel

图片

 spring-ai 提供了不同平台、不同模型的API集成,开发者只需要提供接口地址、调用凭证即可开箱使用~

聊天会话

考虑到大模型对话是热点场景, spring-ai 针对性的提供了会话接口抽象。

图片

org.springframework.ai.chat.client.ChatClient

RAG拓展

类似Spring-AOP, spring-ai 基于请求横切提供了开箱即用的RAG能力抽象。

图片图片

org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor

代码示例

基于供应商构建ChatModel

图片

构建ChatClient发起会话

图片

八、智能体示例

到这里,我们已经自上而下的理解了大模型的工程化,现在我们来开发一个【DJob智能助手】吧!

接口骨架

图片

通过 POST 接口,响应 Content-Type 为 text/event-stream 。

构造外部函数定义

假设有以下几个函数可以给大模型提供能力:

图片

将上述3个本地方法封装成 ChatClient API 认识的【ToolCallback】:

图片

构建可用的 函数/工具 信息,这里用本地方法来mock。实际使用时可以利用MCP/HTTP/gRPC/Dubbod等实现跨系统调用。

系统提示词

由于不能让大模型自由发挥,因此需要在用户输入的内容外,给大模型一些定向信息补充或场景限定,帮助大模型更好地解决问题!

图片

发起调用

图片

  1. 考虑到大模型无状态,所以每次会话时历史消息也需要一并输入。
  2. 历史消息可以由前端收集、提交,也可以由后端每次会话存储、收集。

九、总结

综上所述,太阳底下没有新鲜事,工程领域所有的新生事物都可以暂时把它当做MySQL,没有人比Java工程师更懂MySQL了(开玩笑)。

以上,除Java代码外,都是经过盲人摸象的方法探索出的内容。如有错误,欢迎指正。

往期回顾

1.一致性框架:供应链分布式事务问题解决方案|得物技术

2.Redis 是单线程模型?|得物技术

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

4.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

5.CSS闯关指南:从手写地狱到“类”积木之旅|得物技术

文 / 羊羽

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

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

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

Cursor Rules优化实战:构建高效稳定的AI代码生成规范体系|得物技术

一、背景

随着AI辅助编程工具的普及,Cursor IDE已经成为越来越多开发者的选择。然而,在实际使用过程中,我们发现了一个关键问题:如何让AI真正理解项目需求并生成高质量、一致性的代码?

答案在于构建一套系统化的AI协作规范。与传统的代码规范不同,AI协作规范需要考虑更多维度:

  • 如何让AI准确理解业务逻辑和技术要求
  • 如何确保生成代码的架构一致性和质量标准
  • 如何在团队中推广和维护统一的开发模式
  • 如何避免规范冲突和维护成本过高的问题

本文将分享我们在Cursor Rules优化过程中的实践经验,展示如何从混乱的规范体系演进到清晰、高效的AI协作规范架构。

二、旧版Rules痛点

在优化之前,团队已有的规范体系存在三个核心问题,这些问题影响了AI代码生成的质量和效率。

问题一:规则冗余与表述模糊

旧规范存在大量无效描述,包括模糊要求(如"确保高性能")、重复定义和基础能力提示。这些冗余信息不仅增加token消耗,更分散AI注意力,显著降低代码生成效率。

问题二:提示词冲突

规范中角色定义混乱,不同文档将AI指定为架构师、开发者等矛盾角色。同时缺乏规则优先级机制,导致多规则同时生效时产生行为矛盾,无法形成明确执行路径。

问题三:维护困境

文档职责边界不清,新增规则时难以定位归属文件。修改单一功能需跨多文件调整,且规则间依赖关系不透明,造成维护成本指数级增长。

三、新版Rules设计理念

基于已有问题的深入分析,提出了一套新的设计理念,核心是:分层架构 + 职责分离 + 按需调用。

三层结构设计

新版本采用清晰的三层架构,每层都有明确的职责和边界:

图片

标准化规则格式

为了确保规范的一致性和可维护性,我们定义了统一的规则格式:

# 规则名称
## 基础规范- 明确的技术要求和实现标准
## 强制行为- 必须执行的具体操作和约束
## 禁止行为  - 严格禁止的操作和做法,需要避免的常见错误
## 示例代码- 具体的代码示例和最佳实践- 也通过 [文件名](mdc:路径) 引用外部示例

※  该格式优势

  • 结构清晰:每个部分的职责明确,便于AI理解。
  • 可执行性:强制/禁止行为都有明确的操作指导。
  • 示例驱动:用实际代码代替抽象描述。

AI协作执行协议

为了确保AI能够正确理解和执行规范,我们设计了一个明确的AI协作协议提示词:

图片

# AI协作执行规则
## 规则分类- basic/下的通用规则: 必须调用,通用基础规范- modules/下的模块规则: 按需调用,架构分层规范  - workflow/下的流程规则: 按需调用,业务场景规范
## 执行流程1. 识别场景 → 调用相关规则2. 读取示例代码 → 作为生成参考3. 执行强制/禁止行为 → 确保代码质量4. 应用设计原则 → 组件化、单一职责、分层设计
## 质量保障- 所有规则必须100%执行,重点关注强制行为和禁止行为

四、三层结构深度剖析

接下来我们详细分析新版本架构的设计特点和技术实现。

基础层的精细化设计

基础层是整个规范体系的根基,我们将原来混乱的MDC文件,精确拆分为7个职责单一的规范文件:

文件名 职责 核心内容
basic.mdc 项目基础规范 目录结构、技术栈、开发流程
code-quality.mdc 代码质量控制 复杂度限制、安全性要求
ts.mdc TypeScript规范 类型定义、严格模式配置
comment.mdc 注释规范 JSDoc格式、文件头注释
code-names.mdc 命名规范 变量、函数、组件命名约定
style.mdc 样式规范 CSS/Less编写标准
lint.mdc 代码检查 ESLint、Prettier配置

※  此拆分好处

  • 职责明确: 每个文件只关注一个特定领域。
  • 维护便利 修改某个规范不会影响其他领域。
  • 学习友好: 新人可以逐个理解每个规范的要求。

示例:code-quality.mdc定义了代码质量分规范:

# 代码质量分规范(通用规则)
## 强制行为
- 所有请求必须采用 HTTPS 协议- 确保第三方库安全可靠
## 禁止行为
- 代码复杂度限制  - 单个文件不得超过 500 行  - 条件复杂度不得超过 10  - 单个函数不得超过 199 行  - 超过限制时,应优先按功能模块拆分为多个函数或文件- 禁止使用非得物域名的外部 CDN 资源- 禁止在代码中包含明文密码或硬编码 token- 禁止出现敏感词- 避免重复代码块- 不允许单词拼写错误或不符合命名规范- 避免在前端直接进行金额计算(导致精度丢失)- 禁止使用魔数(如 a === '3'),应使用常量(如 a === statusMap.login)

模块层的分层设计

模块层的设计遵循前端分层架构思想,将复杂的应用拆分为职责明确的模块:

  • 表现层: components.mdc(组件规范)、pages.mdc(页面规范)
  • 业务逻辑层: hooks.mdc(状态管理)、utils.mdc(工具函数)
  • 数据服务层 service.mdc(API接口**)、constants.mdc(配置管理)
  • 路由层 route.mdc(路由配置和导航)

示例:服务层规范(service.mdc)规范定义了API接口的标准化开发流程:

# API接口生成规范(模块规则)
## 存放位置规范(按优先级)- [p0] 页面级API:src/pages/{pageName}/services/{modules}.ts- [p1] 全局API:src/services/{modules}.ts- 类型文件:对应的 .interface.ts 文件
## 标准代码模板```import { request } from '@/utils/request';import { UniversalResp } from '@/utils/request-operation';import { IUserListReq, IUserListDataRes } from './interface';
/** * 获取用户列表 * @param data 请求参数 */export const fetchUserListApi = async (data: IUserListReq) => {  return request.post<UniversalResp<IUserListDataRes>>(    '/api/user/list',    data  );};```## 强制行为- 使用MCP Server的mooncake_get_api_details工具获取接口详情- 响应数据必须使用UniversalResp<T>泛型包装- 接口命名采用fetch{ApiFileName}Api格式- 类型定义必须完整,包含完整字段注释

流程层的场景化设计

流程层是当前架构的创新点,针对具体业务场景定制化规范,将复杂的业务场景标准化。

流程文件 业务场景 核心功能
curd-page.mdc curd页面开发 curd页面完整使用流程
log.mdc 错误监控 APM监控和错误日志处理流程
sendBuried.mdc 数据埋点 用户行为埋点的标准流程
......

示例: curd-page.mdc 定义了完整的表格页面开发流程:

图片

※  该流程确保

  • 开发效率: 标准化流程减少决策时间。
  • 质量一致性: 所有表格页面都遵循相同的标准。
  • 维护性: 统一的结构便于后期维护。
# pro-table生成新页面(流程规则)深入研究代码并理解[insert feature]是如何工作的。一旦你明白了,让我知道,我将提供我的任务给你。
##  工作流程按以下流程进行任务执行,如果评估存在非必须流程,可跳过。- MCP读取接口信息- 从用户输入中提取以下信息:   - 列表名称   - 筛选项(需标记hideInTable)   - 展示项(需标记hideInSearch)   - 操作项   - 工具栏按钮- 评估完整的需求内容复杂度,考虑未来的扩展性,合理设计分层目录结构    - 各个模块保持单一职责,考虑合理的业务组件拆分,避免大量代码都在页面主入口文件    - 使用命令行批量创建目录文件(包含各类文件ts、tsx、less等)    - 文件暂不生成代码- 配置页面的路由信息- 生成类型文件,确保所有类型定义清晰- 生成constants文件,定义所需常量- 生成services文件,实现数据服务- 生成所需的 hooks 文件- 生成页面(必需)和components(如需)文件 完成UI层
## 强制行为- 使用pro-table进行开发,包括筛选表单,符合最佳实践- 筛选项和列表项配置创建useColumns.tsx声明,筛选项(需标记hideInTable)、展示项(需标记hideInSearch)- 左侧字段按需固定,操作项右侧固定,最多显示两个,超出折叠显示- 文本左对齐,数字右对齐,状态枚举居中显示- 分页设置支持10、20、50、100- .....
# 禁止行为.....

五、最佳实践

快速开始

第一步:创建基础架构

.cursor/rules/├── ai.mdc              # AI协作总纲├── basic/              # 基础规范目录│   ├── basic.mdc│   ├── code-quality.mdc│   ├── ts.mdc│   ├── style.mdc│   ├── comment.mdc│   ├── code-names.mdc│   └── lint.mdc├── modules/            # 模块规范目录│   ├── components.mdc│   ├── pages.mdc│   ├── hooks.mdc│   ├── service.mdc│   ├── constants.mdc│   ├── utils.mdc│   └── route.mdc└── workflow/           # 流程规范目录    ├── curd-page.mdc    ├── log.mdc    └── send-buried.mdc    └── ......

第二步:配置AI协作协议

在 ai.mdc 中定义核心协作规则:

# AI协作执行规则
## 规则分类- basic/下的通用规则: 必须调用,通用基础规范- modules/下的模块规则: 按需调用,架构分层规范  - workflow/下的流程规则: 按需调用,业务场景规范
## 执行流程1. 识别场景 → 调用相关规则2. 读取示例代码 → 作为生成参考3. 执行强制/禁止行为 → 确保代码质量4. 应用设计原则 → 组件化、单一职责、分层设计
## 质量保障所有规则必须100%执行,重点关注强制行为和禁止行为

分阶段实施计划

阶段 目标 关键活动
试点阶段 验证规范有效性 选择1-2个项目试点,收集反馈
优化阶段 完善规范内容 根据试点反馈优化规范,开发工具
标准化阶段 形成团队标准 制定团队级标准,持续改进机制

六、总结

基于以下设计思路,并通过构建三层架构的AI协作规范体系:

  • 单一职责:每个规范文件只负责一个功能领域,规则维护简单,冲突减少。
  • 分层架构:基础→模块→流程的清晰层级,规则依赖明确,扩展容易。
  • 按需调用:根据开发场景智能调用相关规范,使得上下文信息精准,效率提升。
  • 示例驱动:用代码示例代替抽象描述,AI理解准确,执行到位。
  • 持续进化:支持规范的迭代优化和扩展,研发适应变化,持续改进。

我们成功缓解了AI辅助编程中的核心问题,这套方法论不仅适用于Cursor Rules,更可以推广到其他AI协作工具的规范设计中。在AI辅助编程快速发展的今天,构建一套清晰、系统化的协作规范,将是每个开发团队的核心竞争力。

往期回顾

1.一致性框架:供应链分布式事务问题解决方案|得物技术

2.Redis 是单线程模型?|得物技术

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

4.得物研发自测 & 前端自动化测试体系建设

5.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

文 / 阳凯

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

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

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

Redis 是单线程模型?|得物技术

一、背景

使用过Redis的同学肯定都了解过一个说法,说Redis是单线程模型,那么实际情况是怎样的呢?

其实,我们常说Redis是单线程模型,是指Redis采用单线程的事件驱动模型,只有并且只会在一个主线程中执行Redis命令操作,这意味着它在处理请求时不使用复杂的上下文切换或锁机制。尽管只是单线程的架构,但Redis通过非阻塞的I/O操作和高效的事件循环来处理大量的并发连接,性能仍然非常高。

然而在Redis4.0开始也引入了一些后台线程执行异步淘汰、异步删除过期key、异步执行大key删除等任务,然后,在Redis6.0中引入了多线程IO特性,将Redis单节点访问请求从10W提升到20W。

而在去年Valkey社区发布的Valkey8.0版本,在I/O线程系统上进行了重大升级,特别是异步I/O线程的引入,使主线程和I/O线程能够并行工作,可实现最大化服务吞吐量并减少瓶颈,使得Valkey单节点访问请求可以提升到100W。

那么在Redis6.0和Valkey8.0中多线程IO是怎么回事呢?是否改变了Redis原有单线程模型?

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

本篇文章主要介绍Redis6.0多线程IO特性。

二、Redis6.0 多线程 IO 概述

Redis6.0引入多线程IO,但多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。默认是不开启的,需要进程启动前开启配置,并且在运行期间无法通过 config set 命令动态修改。

参数与配置

多线程IO涉及下面两个配置参数:

# io-threads 4  IO 线程数量# io-threads-do-reads no  读数据及数据解析是否也用 IO 线程
  •  io-threads 表示IO线程数量, io-threads 设置为1时(代码中默认值),表示只使用主线程,不开启多线程IO。因此,若要配置开启多线程IO,需要设置 io-threads 大于1,但不可以超过最大值128。
  • 但在默认情况下,Redis只将多线程IO用于向客户端写数据,因为作者认为通常使用多线程执行读数据的操作帮助不是很大。如果需要使用多线程用于读数据和解析数据,则需要将参数 io-threads-do-reads 设置为 yes 。
  • 此两项配置参数在Redis运行期间无法通过 config set 命令修改,并且开启SSL时,不支持多线程IO特性。
  • 若机器CPU将至少超过4核时,则建议开启,并且至少保留一个备用CPU核,使用超过8个线程可能并不会有多少帮助。

执行流程概述

Redis6.0引入多线程IO后,读写数据执行流程如下所示:

图片

流程简述

  1. 主线程负责接收建立连接请求,获取socket放入全局等待读处理队列。
  2. 主线程处理完读事件之后,通过RR(Round Robin)将这些连接分配给这些IO线程,也会分配给主线程自己。
  3. 主线程先读取分配给自己的客户端数据,然后阻塞等待其他IO线程读取socket完毕。
  4. IO线程将请求数据读取并解析完成(这里只是读数据和解析、并不执行)。
  5. 主线程通过单线程的方式执行请求命令。
  6. 主线程通过RR(Round Robin)将回写客户端事件分配给这些IO线程,也会分配给主线程自己。
  7. 主线程同样执行部分写数据到客户端,然后阻塞等待IO线程将数据回写socket完毕。

设计特点

  1. IO线程要么同时在读socket,要么同时在写,不会同时读和写。
  2. IO线程只负责读写socket解析命令,不负责命令执行。
  3. 主线程也会参与数据的读写。

三、源码分析

多线程IO相关源代码都在源文件networking.c中最下面。

初始化

主线程在main函数中调用InitServerLast函数,InitServerLast函数中调用initThreadedIO函数,在initThreadedIO函数中根据配置文件中的线程数量,创建对应数量的IO工作线程数量。

/* Initialize the data structures needed for threaded I/O. */void initThreadedIO(void) {    io_threads_active = 0/* We start with threads not active. */        /* Don't spawn any thread if the user selected a single thread:     * we'll handle I/O directly from the main thread. */    if (server.io_threads_num == 1) return;        if (server.io_threads_num > IO_THREADS_MAX_NUM) {        serverLog(LL_WARNING,"Fatal: too many I/O threads configured. "                             "The maximum number is %d.", IO_THREADS_MAX_NUM);        exit(1);    }       /* Spawn and initialize the I/O threads. */    for (int i = 0; i < server.io_threads_num; i++) {        /* Things we do for all the threads including the main thread. */        io_threads_list[i]listCreate();        if (i == 0) continue; /* Thread 0 is the main thread. */               /* Things we do only for the additional threads. */        pthread_t tid;        pthread_mutex_init(&io_threads_mutex[i],NULL);        io_threads_pending[i]0;        pthread_mutex_lock(&io_threads_mutex[i]); /* Thread will be stopped. */        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");            exit(1);        }        io_threads[i] = tid;    }}
  • 如果 io_threads_num 的数量为1,则只运行主线程, io_threads_num 的IO线程数量不允许超过 128。
  • 序号为0的线程是主线程,因此实际的工作线程数目是io-threads - 1。

初始化流程

  • 为包括主线程在内的每个线程分配list列表,用于后续保存待处理的客户端。
  • 为主线程以外的其他IO线程初始化互斥对象mutex,但是立即调用pthread_mutex_lock占有互斥量**,将io_threads_pending[i]设置为0,接着创建对应的IO工作线程。
  • 占用互斥量是为了创建IO工作线程后,可暂时等待后续启动IO线程的工作,因为IOThreadMain函数在io_threads_pending[id] == 0时也调用了获取mutex,所以此时无法继续向下运行,等待启动。
  • 在startThreadedIO函数中会释放mutex来启动IO线程工作。何时调用startThreadedIO打开多线程IO,具体见下文的「多线程IO动态暂停与开启」。

IO 线程主函数

IO线程主函数代码如下所示:

void *IOThreadMain(void *myid) {    /* The ID is the thread number (from 0 to server.iothreads_num-1), and is     * used by the thread to just manipulate a single sub-array of clients. */    long id = (unsigned long)myid;    char thdname[16];       snprintf(thdname, sizeof(thdname), "io_thd_%ld", id);    redis_set_thread_title(thdname);    redisSetCpuAffinity(server.server_cpulist);       while(1) {        /* Wait for start */        for (int j = 0; j < 1000000; j++) {            if (io_threads_pending[id] != 0) break;        }               /* Give the main thread a chance to stop this thread. */        if (io_threads_pending[id] == 0) {            pthread_mutex_lock(&io_threads_mutex[id]);            pthread_mutex_unlock(&io_threads_mutex[id]);            continue;        }               serverAssert(io_threads_pending[id] != 0);                if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));                /* Process: note that the main thread will never touch our list         * before we drop the pending count to 0. */        listIter li;        listNode *ln;        listRewind(io_threads_list[id],&li);        while((ln = listNext(&li))) {            client *c = listNodeValue(ln);            if (io_threads_op == IO_THREADS_OP_WRITE) {                writeToClient(c,0);            } else if (io_threads_op == IO_THREADS_OP_READ) {                readQueryFromClient(c->conn);            } else {                serverPanic("io_threads_op value is unknown");            }        }        listEmpty(io_threads_list[id]);        io_threads_pending[id]0;               if (tio_debug) printf("[%ld] Done\n", id);    }}

从IO线程主函数逻辑可以看到:

  • 如果IO线程等待处理任务数量为0,则IO线程一直在空循环,因此后面主线程给IO线程分发任务后,需要设置IO线程待处理任务数 io_threads_pending[id] ,才会触发IO线程工作。
  • 如果IO线程等待处理任务数量为0,并且未获取到mutex锁,则会等待获取锁,暂停运行,由于主线程在创建IO线程之前先获取了锁,因此IO线程刚启动时是暂停运行状态,需要等待主线程释放锁,启动IO线程。
  • IO线程待处理任务数为0时,获取到锁并再次释放锁,是为了让主线程可以暂停IO线程。
  • 只有io_threads_pending[id]不为0时,则继续向下执行操作,根据io_threads_op决定是读客户端还是写客户端,从这里也可以看出IO线程要么同时读,要么同时写。

读数据流程

主线程将待读数据客户端加入队列

当客户端连接有读事件时,会触发调用readQueryFromClient函数,在该函数中会调用postponeClientRead。

void readQueryFromClient(connection *conn) {    client *c = connGetPrivateData(conn);    int nread, readlen;    size_t qblen;        /* Check if we want to read from the client later when exiting from     * the event loop. This is the case if threaded I/O is enabled. */    if (postponeClientRead(c)) return;    ......以下省略}
/* Return 1 if we want to handle the client read later using threaded I/O. * This is called by the readable handler of the event loop. * As a side effect of calling this function the client is put in the * pending read clients and flagged as such. */int postponeClientRead(client *c) {    if (io_threads_active &&        server.io_threads_do_reads &&        !ProcessingEventsWhileBlocked &&        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))    {        c->flags |= CLIENT_PENDING_READ;        listAddNodeHead(server.clients_pending_read,c);        return 1;    } else {        return 0;    }}

如果开启多线程,并且开启多线程读(io_threads_do_reads 为 yes),则将客户端标记为CLIENT_PENDING_READ,并且加入clients_pending_read列表。

然后readQueryFromClient函数中就立即返回,主线程没有执行从客户端连接中读取的数据相关逻辑,读取了客户端数据行为等待后续各个IO线程执行。

主线程分发并阻塞等待

主线程在beforeSleep函数中会调用handleClientsWithPendingReadsUsingThreads函数。

/* When threaded I/O is also enabled for the reading + parsing side, the * readable handler will just put normal clients into a queue of clients to * process (instead of serving them synchronously). This function runs * the queue using the I/O threads, and process them in order to accumulate * the reads in the buffers, and also parse the first command available * rendering it in the client structures. */int handleClientsWithPendingReadsUsingThreads(void) {    if (!io_threads_active || !server.io_threads_do_reads) return 0;    int processed = listLength(server.clients_pending_read);    if (processed == 0) return 0;       if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);       /* Distribute the clients across N different lists. */    listIter li;    listNode *ln;    listRewind(server.clients_pending_read,&li);    int item_id = 0;    while((ln = listNext(&li))) {        client *c = listNodeValue(ln);        int target_id = item_id % server.io_threads_num;        listAddNodeTail(io_threads_list[target_id],c);        item_id++;    }        /* Give the start condition to the waiting threads, by setting the     * start condition atomic var. */    io_threads_op = IO_THREADS_OP_READ;    for (int j = 1; j < server.io_threads_num; j++) {        int count = listLength(io_threads_list[j]);        io_threads_pending[j] = count;    }       /* Also use the main thread to process a slice of clients. */    listRewind(io_threads_list[0],&li);    while((ln = listNext(&li))) {        client *c = listNodeValue(ln);        readQueryFromClient(c->conn);    }    listEmpty(io_threads_list[0]);        /* Wait for all the other threads to end their work. */    while(1) {        unsigned long pending = 0;        for (int j = 1; j < server.io_threads_num; j++)            pending += io_threads_pending[j];        if (pending == 0) break;    }    if (tio_debug) printf("I/O READ All threads finshed\n");       /* Run the list of clients again to process the new buffers. */    while(listLength(server.clients_pending_read)) {        ln = listFirst(server.clients_pending_read);        client *c = listNodeValue(ln);        c->flags &= ~CLIENT_PENDING_READ;        listDelNode(server.clients_pending_read,ln);                if (c->flags & CLIENT_PENDING_COMMAND) {            c->flags &= ~CLIENT_PENDING_COMMAND;            if (processCommandAndResetClient(c) == C_ERR) {                /* If the client is no longer valid, we avoid                 * processing the client later. So we just go                 * to the next. */                continue;            }        }        processInputBuffer(c);    }    return processed;}
  • 先检查是否开启多线程,以及是否开启多线程读数据(io_threads_do_reads),未开启直接返回。
  • 检查队列clients_pending_read长度,为0直接返回,说明没有待读事件。
  • 遍历clients_pending_read队列,通过RR算法,将队列中的客户端循环分配给各个IO线程,包括主线程本身。
  • 设置io_threads_op = IO_THREADS_OP_READ,并且将io_threads_pending数组中各个位置值设置为对应各个IO线程分配到的客户端数量,如上面介绍,目的是为了使IO线程工作。
  • 主线程开始读取客户端数据,因为主线程也分配了任务。
  • 主线程阻塞等待,直到所有的IO线程都完成读数据工作。
  • 主线程执行命令。

IO 线程读数据

在IO线程主函数中,如果 io_threads_op == IO_THREADS_OP_READ ,则调用readQueryFromClient从网络中读取数据。

IO 线程读取数据后,不会执行命令。

在readQueryFromClient函数中,最后会执行processInputBuffer函数,在processInputBuffe函数中,如IO线程检查到客户端设置了CLIENT_PENDING_READ标志,则不执行命令,直接返回。

            ......省略/* If we are in the context of an I/O thread, we can't really             * execute the command here. All we can do is to flag the client             * as one that needs to process the command. */            if (c->flags & CLIENT_PENDING_READ) {                c->flags |= CLIENT_PENDING_COMMAND;                break;            }            ...... 省略

写数据流程

命令处理完成后,依次调用:

addReply-->prepareClientToWrite-->clientInstallWriteHandler,将待写客户端加入队列clients_pending_write。

void clientInstallWriteHandler(client *c) {    /* Schedule the client to write the output buffers to the socket only     * if not already done and, for slaves, if the slave can actually receive     * writes at this stage. */    if (!(c->flags & CLIENT_PENDING_WRITE) &&        (c->replstate == REPL_STATE_NONE ||         (c->replstate == SLAVE_STATE_ONLINE && !c->repl_put_online_on_ack)))    {        /* Here instead of installing the write handler, we just flag the         * client and put it into a list of clients that have something         * to write to the socket. This way before re-entering the event         * loop, we can try to directly write to the client sockets avoiding         * a system call. We'll only really install the write handler if         * we'll not be able to write the whole reply at once. */        c->flags |= CLIENT_PENDING_WRITE;        listAddNodeHead(server.clients_pending_write,c);    }}

在beforeSleep函数中调用handleClientsWithPendingWritesUsingThreads。

int handleClientsWithPendingWritesUsingThreads(void) {    int processed = listLength(server.clients_pending_write);    if (processed == 0) return 0/* Return ASAP if there are no clients. */        /* If I/O threads are disabled or we have few clients to serve, don't     * use I/O threads, but thejboring synchronous code. */    if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {        return handleClientsWithPendingWrites();    }        /* Start threads if needed. */    if (!io_threads_active) startThreadedIO();       if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);        /* Distribute the clients across N different lists. */    listIter li;    listNode *ln;    listRewind(server.clients_pending_write,&li);    int item_id = 0;    while((ln = listNext(&li))) {        client *c = listNodeValue(ln);        c->flags &= ~CLIENT_PENDING_WRITE;        int target_id = item_id % server.io_threads_num;        listAddNodeTail(io_threads_list[target_id],c);        item_id++;    }       /* Give the start condition to the waiting threads, by setting the     * start condition atomic var. */    io_threads_op = IO_THREADS_OP_WRITE;    for (int j = 1; j < server.io_threads_num; j++) {        int count = listLength(io_threads_list[j]);        io_threads_pending[j] = count;    }        /* Also use the main thread to process a slice of clients. */    listRewind(io_threads_list[0],&li);    while((ln = listNext(&li))) {        client *c = listNodeValue(ln);        writeToClient(c,0);    }    listEmpty(io_threads_list[0]);       /* Wait for all the other threads to end their work. */    while(1) {        unsigned long pending = 0;        for (int j = 1; j < server.io_threads_num; j++)            pending += io_threads_pending[j];        if (pending == 0) break;    }    if (tio_debug) printf("I/O WRITE All threads finshed\n");        /* Run the list of clients again to install the write handler where     * needed. */    listRewind(server.clients_pending_write,&li);    while((ln = listNext(&li))) {        client *c = listNodeValue(ln);               /* Install the write handler if there are pending writes in some         * of the clients. */        if (clientHasPendingReplies(c) &&                connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR)        {            freeClientAsync(c);        }    }    listEmpty(server.clients_pending_write);    return processed;}
  1. 判断clients_pending_write队列的长度,如果为0则直接返回。
  2. 判断是否开启了多线程,若只有很少的客户端需要写,则不使用多线程IO,直接在主线程完成写操作。
  3. 如果使用多线程IO来完成写数据,则需要判断是否先开启多线程IO(因为会动态开启与暂停)。
  4. 遍历clients_pending_write队列,通过RR算法,循环将所有客户端分配给各个IO线程,包括主线程自身。
  5. 设置io_threads_op = IO_THREADS_OP_WRITE,并且将io_threads_pending数组中各个位置值设置为对应的各个IO线程分配到的客户端数量,目的是为了使IO线程工作。
  6. 主线程开始写客户端数据,因为主线程也分配了任务,写完清空任务队列。
  7. 阻塞等待,直到所有IO线程完成写数据工作。
  8. 再次遍历所有客户端,如果有需要,为客户端在事件循环上安装写句柄函数,等待事件回调。

多线程 IO 动态暂停与开启

从上面的写数据的流程中可以看到,在Redis运行过程中多线程IO是会动态暂停与开启的。

在上面的写数据流程中,先调用stopThreadedIOIfNeeded函数判断是否需要暂停多线程IO,当等待写的客户端数量低于线程数的2倍时,会暂停多线程IO, 否则就会打开多线程。

int stopThreadedIOIfNeeded(void) {    int pending = listLength(server.clients_pending_write);        /* Return ASAP if IO threads are disabled (single threaded mode). */    if (server.io_threads_num == 1) return 1;       if (pending < (server.io_threads_num*2)) {        if (io_threads_active) stopThreadedIO();        return 1;    } else {        return 0;    }}

在写数据流程handleClientsWithPendingWritesUsingThreads函数中,stopThreadedIOIfNeeded返回0的话,就会执行下面的startThreadedIO函数,开启多线程IO。

void startThreadedIO(void) {    serverAssert(server.io_threads_active == 0);    for (int j = 1; j < server.io_threads_num; j++)        pthread_mutex_unlock(&io_threads_mutex[j]);    server.io_threads_active = 1;}
void stopThreadedIO(void) {    /* We may have still clients with pending reads when this function     * is called: handle them before stopping the threads. */    handleClientsWithPendingReadsUsingThreads();    serverAssert(server.io_threads_active == 1);    for (int j = 1; j < server.io_threads_num; j++)        pthread_mutex_lock(&io_threads_mutex[j]);    server.io_threads_active = 0;}

从上面的代码中可以看出:

  • 开启多线程IO是通过释放mutex锁来让IO线程开始执行读数据或者写数据动作。
  • 暂停多线程IO则是通过加锁来让IO线程暂时不执行读数据或者写数据动作,此处加锁后,IO线程主函数由于无法获取到锁,因此会暂时阻塞。

四、性能对比

测试环境

两台物理机配置:CentOS Linux release 7.3.1611(Core) ,12核CPU1.5GHz,256G内存(free 128G)。

Redis版本

使用Redis6.0.6,多线程IO模式使用线程数量为4,即 io-threads 4 ,参数 io-threads-do-reads 分别设置为 no 和 yes ,进行对比测试。

压测命令

redis-benchmark -h 172.xx.xx.xx -t set,get -n 1000000 -r 100000000 --threads ${threadsize} -d ${datasize} -c ${clientsize}
单线程 threadsize 为 1,多线程 threadsize 为 4datasize为value 大小,分别设置为 128/512/1024clientsize 为客户端数量,分别设置为 256/2000如:./redis-benchmark -h 172.xx.xx.xx -t set,get -n 1000000 -r 100000000 --threads 4 -d 1024 -c 256

统计结果

当 io-threads-do-reads 为 no 时,统计图表如下所示(c 2000表示客户端数量为2000)。

图片

当 io-threads-do-reads 为 yes 时,统计图表如下所示(c 256表示客户端数量为256)。

图片

结论

使用redis-benchmark做Redis6单线程和多线程简单SET/GET命令性能测试:

  1. 从上面可以看到GET/SET命令在设置4个IO线程时,QPS相比于大部分情况下的单线程,性能几乎是翻倍了。

  2. 连接数越多,多线程优势越明显。

  3. value值越小,多线程优势越明显。

  4. 使用多线程读命令比写命令优势更加明显,当value越大,写命令越发没有明显的优势。

  5. 参数 io-threads-do-reads 为yes,性能有微弱的优势,不是很明显。

  6. 总体来说,以上结果基本符合预期,结果仅作参考。

五、6.0 多线程 IO 不足

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

  • CPU核心利用率不足:当前主线程仍负责大部分的IO相关任务,并且当主线程处理客户端的命令时,IO线程会空闲相当长的时间,同时值得注意的是,主线程在执行IO相关任务期间,性能受到最慢IO线程速度的限制。
  • IO线程执行的任务有限:目前,由于主线程同步等待IO线程,线程仅执行读取解析和写入操作。如果线程可以异步工作,我们可以将更多工作卸载到IO线程上,从而减少主线程的负载。
  • 不支持带有TLS的IO线程。

最新的Valkey8.0版本中,通过引入异步IO线程,将更多的工作转移到IO线程执行,同时通过批量预读取内存数据减少内存访问延迟,大幅提高Valkey单节点访问QPS,单个实例每秒可处理100万个请求。我们后续再详细介绍Valkey8.0异步IO特性。

六、总结

Redis6.0引入多线程IO,但多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。通过开启多线程IO,并设置合适的CPU数量,可以提升访问请求一倍以上。

Redis6.0多线程IO仍然存在一些不足,没有充分利用CPU核心,在最新的Valkey8.0版本中,引入异步IO将进一步大幅提升Valkey性能。

往期回顾

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

2.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

3.CSS闯关指南:从手写地狱到“类”积木之旅|得物技术

4.以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

5.大语言模型的训练后量化算法综述 | 得物技术

文 / 竹径

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

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

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

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

一、前言:社区与活动

“得物平台上的大量商品都具有文化与精神属性,用户往往通过社区来进行了解和分享。”

得物平台作为潮流文化与电商融合的前沿阵地,其社区活动业务的演进备受瞩目。得物平台的很多商品蕴含深厚的文化与精神内涵,这吸引用户在社区进行深度的了解与分享。潮流以品牌和带有潮流元素的产品为载体,以社区和内容作为传播媒介。

从业务视角审视,种草激励、MCN 入驻、达人签约以及各类活动玩法,本质上皆是内容与品牌之间的博弈。通过活动业务,能够获取更为丰富、优质的内容,而这些内容又能进一步强化品牌文化与形象,进而筑牢得物社区的潮流心智根基。

图片

依据以往大盘数据分析,活动所带来的发布在 UGC 大盘中占比及推荐流阅读时长上均有显著作用。

由此可见,活动业务在得物社区的 UGC 发布等相关指标中,占据着举足轻重的地位。回顾得物社区的发展历程,品牌文化的深耕与品类挖掘的潮流大势清晰可辨。得益于这一趋势,社区活动业务近年来备受关注,各类玩法如雨后春笋般层出不穷。

二、业务催化剂

效果与效率的辩证探索

为了探寻活动效果的极致境界,我们始终在活动玩法的搭配上不断推陈出新、勇于尝试。

话题类玩法致力于用户心智的精耕细作、激励类玩法侧重于内容质量的提升拔高、互动类玩法着重于用户粘性的稳固增强。活动效果大多取决于玩法本身,这也正是活动玩法日新月异的根源所在。

图片

然而,效率与效果有着不同的侧重点。

效率并不追求差异化的活动玩法,而是在既定框架内,以最优的流程达成目标。它着眼于资源的高效利用,强调标准化与模板化,让活动能够按部就班、有条不紊地推进。但这并不意味着效果与效率相互对立,相反,我们需要在两者之间找到一个恰到好处的平衡点,使其共同为活动业务的破圈发展添砖加瓦。

现存问题剖析

其一,每当推出新活动,不同玩法和激励功能的显著差异,要求我们必须从零开始进行开发与测试。这一过程不仅耗费大量的人力、物力和时间资源,还极大地拖慢了市场响应速度,导致迭代成本居高不下,且难以有效控制。

以常见的活动玩法为例:

图片

其二,新功能的引入对线上服务的稳定性构成潜在威胁,尤其是涉及资产/交易的业务,其潜在风险不容忽视。

最后,由于不同产品团队对同一玩法的理解和实现方式存在差异,使得功能在迭代过程中难以保持持续性和一致性。这种情况不仅造成同质化功能重复开发,还使得宝贵的开发经验无法有效沉淀和传承,严重制约了业务的长期发展。

在活动玩法体系中,任务判定作为核心环节,其复杂性尤为突出。任务条件具有种类繁多,配置灵活的特点,这不仅增加了业务逻辑的复杂度,还大幅提升了线上问题排查和修复的难度与成本。

图片

破局与机遇

面对上述种种挑战,我们将其组件化,使其成为行之有效的解决思路。

解决思路

※  高频业务迭代的适配

活动业务的频繁更迭为组件化提供了得天独厚的条件。通过将复杂功能拆解为独立、可复用的组件,能够迅速顺应变化,敏捷响应市场需求。

※  业务特性的契合

大部分活动功能具有可拆分和可抽象的特性,这为组件化的实施奠定了坚实的基础,使其得以顺利开展。

※  降本提效的需求驱动

随着业务的持续增长和市场要求的稳步提升,采用组件化方式能够切实有效地降低成本,提升运营效率,为业务的稳健增长提供强大助力。

组件化的核心在于从价值向设计的转变。每一个组件都应具备高内聚、低耦合的特性,确保能够在不同场景下灵活复用,而不受具体业务逻辑的束缚。

  • 模块化设计: 需将复杂系统拆解为多个模块,每个模块负责一项独立的功能,便于开发和维护。
    • 大系统小做,做从根本上化解业务复杂度问题,拆分大的功能到最小组件上,注重组件之间的接口与衔接。
    • 小功能大做,从单纯的任务条件判定到规则引擎,小的功能往往多且差异性大,通过系统化的设计将他们的能力聚合并出口,保持对后续业务的灵活适配能力,并将影响最小化。
  • 接口抽象: 定义清晰明确的接口,使各组件之间的交互简洁明了,通过实现接口来替代业务的直接依赖,提升系统的灵活性与可扩展性。
  • 标准化配置: 制定统一的配置标准,简化配置过程,减少迭代调整的频率与复杂性,提高工作效率。

三、组件化:从想法到实践

OOP

现实世界事物纷繁复杂、千头万绪,难以全面认识和深入理解。人类通过分类归纳的方法,将复杂事物系统化、条理化,从而使其变得井然有序。

在业务设计中亦是如此,需求千差万别、形形色色,每个人都可能参与其中,即便在基础编码规范的约束下,仍可能出现风格各异的设计,进而导致维护成本攀升、重复能力难以有效复用等问题。

这便催生了我们所熟知的设计范式。

There are four major benefits to object-oriented programming:

  • Encapsulation: in OOP, you bundle code into a single unit where you can determine the scope of each piece of data.
  • Abstraction: by using classes, you are able to generalize your object types, simplifying your program.
  • Inheritance:** because a class can inherit attributes and behaviors from another class, you are able to reuse more code.
  • Polymorphism:** one class can be used to create many objects, all from the same flexible piece of code.

系统和业务

系统, 作为整个活动与任务体系的架构基础,必须具备高度的健壮性和灵活的扩展能力。它主要承担数据聚合、规则判定和状态流转三大核心功能。

以抽奖活动玩法为例,系统负责收集整合各个渠道的用户数据,判断用户是否符合参与活动的资格、是否为风险用户、是否符合发布要求,并依据用户行为和活动规则进行状态流转。

业务,则是体系的核心价值体现,具有动态性和多样性的特点。它主要涵盖实时行为、滞后行为和被动行为三种类型。

在抽奖玩法活动中,实时行为包括用户在活动页面的即时交互操作,如分享活动链接、浏览活动页面、订阅直播等;滞后行为涉及用户发布内容后的质量评估和审核流程;被动行为则包含数据清洗、数据回扫等后台处理任务。

事件驱动

系统和业务之间的关系基于事件驱动来实现。在活动业务中,事件驱动宛如一条无形却坚韧的纽带,将系统和业务紧密相连,同时又保持各自的独立性。通过事件驱动,系统能够敏锐感知业务的变化与需求,业务也能借助系统的强大能力实现自身目标。

图片

基于事件驱动机制,我们定义了核心的任务模版方法和激励模版方法。不同的任务和激励形式可以根据具体业务需求进行个性化定制,从而实现系统与业务的有效分离。

模块与模块

除了系统与业务的解耦,系统内部模块之间也需要进行有效的隔离。我们引入事件总线架构的思想,将其作为事件发布者和订阅者之间的通信中介。在标准化各模块输入输出接口的基础上,结合哪吒搭建平台,通过配置关联的方式对各个模块进行编排,实现模块之间的通信与协作,同时避免模块间的直接依赖,达到模块解耦的目的。

图片

核心概念&设计

活动系统整体将业务层和系统层拆分为两大板块,通过事件驱动媒介实现二者的隔离与闭环,确保系统层和业务职能清晰明确,为活动业务的可持续迭代发展保驾护航。

图片

活动和任务

随着近期社区活动业务的蓬勃发展与活跃迭代,当前版本的活动体系在应对诸多玩法时愈发显得力不从心、捉襟见肘,主要体现在以下两个方面:

  • 活动层以及奖励粒度的搭配难以满足多元化玩法的需求,存在明显的适配不足。
  • 活动、任务、奖励等层面的个性化要求配置无法充分满足,限制了业务的创新发展。

为此,我们从  “活动与玩法的适配、任务粒度及激励个性化” 几个关键方向发力,进行体系重构。

首先,将激励完全抽象为 “激励值”,无论是抽奖次数、瓜分机会、兑换积分、优惠券金额,都以 “值的形式” 进行抽象化处理。

图片

这一抽象设计极大提高了活动、任务和激励之间关系的灵活性,能够满足当前绝大多数活动业务的设计需求。然而,抽象处理也带来了一些问题,例如在用户界面展示方面,难以直观呈现用户在某活动中是否获得过特定奖励。

其次,构建了新的关联框架。单个页面可以关联多个活动,单个活动可以关联多个任务,单个任务可以进一步关联多个任务明细,抽象化的激励值直接与具体任务相关联。

图片

最后,完成三层概念设计。

在活动层,通过设置严格的报名策略和人群筛选条件,确保活动参与的精准性;在任务层,明确任务下发策略、达成条件和执行频率控制,使任务执行更加规范可控;在激励层,设定激励获取上限并制定智能分配策略,实现激励资源的合理分配。通过这种方式,实现了活动、任务、激励之间的自由组合和灵活配置,有效提升了整体运营效率。

任务判定抽离:规则引擎

在传统任务模块代码中,大量的 case 语句导致条件判定逻辑分散在各处。随着业务的不断迭代,业务逻辑变得愈发复杂,逐渐暴露出以下问题:

  • 每次条件改动,甚至是数值调整,都需要开发人员介入,耗费大量人力和时间。
  • 各种条件判断与业务紧密耦合,开发效率低下且难度极大,增加了开发成本和风险。
  • 任务体系的可扩展性和稳定性差强人意,难以适应快速变化的业务需求。
  • 业务透明度极低,团队协作推进困难,新人难以快速上手。

为解决这些问题,我们引入规则引擎,将业务决策逻辑从任务模块中抽离出来。规则引擎通过接收动态数据输入,依据内部预设规则进行计算和判断,输出决策结果,从而实现业务逻辑的独立维护和动态更新。

图片

任务模型

在活动系统中,任务模块扮演着至关重要的角色,主要体现在以下两个方面:

  • 核心驱动力: 任务模块是推动活动进展的核心动力源泉。它通过设置一系列明确的任务,引导用户按照活动预期方向进行操作,从而确保活动目标的顺利实现。例如在电商促销活动中,设置 “分享活动页面至社交平台,邀请 5 位好友助力” 的任务,以此扩大活动影响力,吸引更多潜在用户参与。
  • 数据洞察源: 任务模块所产生的数据是了解用户行为和活动效果的关键依据。通过分析用户完成任务的情况,如完成率、完成时间等,可以精准洞察用户的兴趣点和参与度,为优化活动策略提供有力支撑。

为应对任务多元化这一挑战,任务模块化成为必然选择。模块化设计将任务拆解为独立的组件,显著提高了开发效率和灵活性。同时,模块化架构便于维护和更新,当某个模块出现问题或需要升级时,只需单独处理该模块,不会影响其他部分,有效降低了业务定制的成本和风险,使任务模块能够更好地适应复杂多变的业务环境。

根据业务特性,我们首先定义了任务接口,并严格划分接口内外的职责边界,确保接口内部专注于业务逻辑实现,接口外部实现系统功能。

任务模型定义如下:

// TaskMode 任务模型type TaskMode interface {    // TaskType 任务类型    TaskType() consts.ActTaskType    // TaskUniqueFlag 事件唯一标识    TaskUniqueFlag() string    // ExpressFunctions 自定义函数集    ExpressFunctions(ctx context.Context) map[string]govaluate.ExpressionFunction    // ExpressArguments 任务条件参数搜集器    ExpressArguments(ctx context.Context, pending []db.PendingTasks) (dto.ExpressArguments, error)}

具体实现何种任务模型,则参考业务方的实际诉求,只要按照「TaskMode」接口规范进行实现即可。

这里以  “发布篇数任务-TaskPublishTimes”  的实现为例进行说明:

type TaskPublishTimes struct {    event *dto.TaskPublishEvent}
func NewTaskPublishTimes(event *dto.TaskPublishEvent) (taskMode TaskMode, err error) {    if event.ContentId == 0 {       return nil, errors.New("missing necessary parameters")    }    taskMode = &TaskPublishTimes{       event: event,    }    return}
// TaskType 任务类型func (t *TaskPublishTimes) TaskType() consts.ActTaskType {    return consts.ActTaskTypePublishTimes}
// TaskUniqueFlag 任务唯一标识func (t *TaskPublishTimes) TaskUniqueFlag() string {    return fmt.Sprintf("CNT_ID_%d", t.event.ContentId)}
// ExpressFunctions 自定义函数集func (t *TaskPublishTimes) ExpressFunctions(ctx context.Context) map[string]govaluate.ExpressionFunction {    return map[string]govaluate.ExpressionFunction{       // "WITH_ANY_TOPIC(tag_ids, (1001,1002)) == TRUE"       "WITH_ANY_TOPIC": func(args ...interface{}) (interface{}, error) {          // ...          var intersect, _ = arrayx.Intersect(carryIds, condIds)          return float64(len(intersect.Interface().([]uint64))) > 0, nil       },       // 省略部分...    }}
// ExpressArguments 条件参数搜集器func (t *TaskPublishTimes) ExpressArguments(ctx context.Context, pending []db.PendingTasks) (dto.ExpressArguments, error) {    var eg = gox.NewErrGroup(ctx)    var args = dto.ExpressArguments{}       // 基础信息    eg.Go(func() error { /**/ })    // 互动信息    eg.Go(func() error { /**/ })    // 审核信息    eg.Go(func() error { /**/ })    // ...
    err = eg.Wait()    return args, err}

与传统面向对象编程实现方式相比,Golang 的接口实现更加灵活。只要 Struct 实现了接口定义的所有方法,即可隐式实现该接口,无需显式声明,这种特性为任务模型的开发提供了更高的灵活性。

任务触发器

任务触发器与任务模型相互独立,不存在直接依赖关系。当系统接收到行为事件时,会实例化具体的任务模型,并将其作为参数注入任务触发器,从而驱动系统与任务的协同运行。

任务触发器应与业务逻辑完全分离,所有与业务相关的改动集中在任务模型中,修改后只需针对相应改动点进行回归测试。而与系统相关的改动则集中在任务触发器中,由于这部分改动可能影响所有任务模型的执行结果,因此需要谨慎处理,并确保进行全面的回归测试。

// TaskTrigger 任务触发器func (s *ActSysTaskService) TaskTrigger(ctx context.Context, taskMode act_sys.TaskMode) (err error) {    // 用户粒度任务锁    // ...        // 列表用户任务    var pending, err = dao.ListUserTasks(ctx, event.UserId)    if err != nil {       return    }        // 二次过滤(任务状态、活动时间)    // ...       // 自定义函数集 & 条件参数搜集    var condFunc = taskMode.ExpressFunctions(ctx)    var condArgs, err = taskMode.ExpressArguments(ctx, pending)    if err != nil {       return    }
    // 任务达成判定    for _, v := range pending {       var reach bool       reach, err = act_sys.RunTaskExpress(ctx, v.TaskCondExpr, condFunc, condArgs)       if err != nil {          continue       }
       if reach {           // 更新or写入任务明细记录 & 任务进展 & 发放/回退奖励值           // ...
           // 执行HOOKS(这里是不同业务的个性化订阅者)           // ...       }    }    return}

在任务触发器的设计中,重点涉及任务初始化与下发流程。由于行为事件入口众多,难以采用传统方式进行显性任务下发,因此我们将任务下发逻辑嵌入任务触发器中,使每次事件注入触发器时都成为一次任务下发的机会。

当然,这种设计也存在一定风险,在触发器中添加代码可能会对整个任务体系的运行产生影响,甚至可能增加任务推进逻辑的响应时间(RT)。

图片

业务协同

在实现业务功能的同时,系统的稳定性和健壮性至关重要。模块化架构不应成为阻碍业务定制化发展的因素,而应更好地服务于业务创新。在实际业务场景中,如输出当日各核心节点的数据画像、任务完成后对用户进行消息触达等需求,我们通常采用钩子(HOOK)机制,在各个核心节点完成后执行一系列自定义处理逻辑,实现系统与业务的闭环交互。

图片

以高价值奖品兑换活动为例,当用户完成一系列任务后,系统会触发多个钩子,通知业务模块进行风险评估、收益预判、状态更新等操作。同时,业务模块可以通过注册观察者模式,实时监控系统运行状态,如兑换流程是否正常、服务器是否稳定等,一旦发现异常情况,立即进行处理,确保系统和业务的协同稳定运行。

钩子机制

在任务模块化进程中,HOOK 机制发挥了至关重要的作用。

HOOK 机制本质上是一种程序控制技术,它允许开发者在系统运行的特定阶段插入自定义代码。借助这一特性,我们能够在任务触发时、执行过程中、执行完成后等关键节点灵活添加额外逻辑,最终实现业务差异化。

首先,我们需要定义观察者接口、事件主体及其实现,这里以任务明细达成节点为例进行说明:

// ActSysObserver4TaskDetail 任务明细进展转发type ActSysObserver4TaskDetail interface {    Unique() string    Forward(ctx context.Context, detail *db.ActUserTaskDetail) error}
type ActSysSubject4TaskDetail struct {    mutex     sync.Mutex    observers []ActSysObserver4TaskDetail}
func (sj *ActSysSubject4TaskDetail) Attach(observer ActSysObserver4TaskDetail) {    if uk := observer.Unique(); len(uk) > 0 {       sj.mutex.Lock()       sj.observers = append(sj.observers, observer)       sj.mutex.Unlock()    }}
func (sj *ActSysSubject4TaskDetail) Notify(ctx context.Context, detail *db.ActUserTaskDetail) error {    for _, observer := range sj.observers {      if err := observer.Forward(ctx, detail); err != nil {         return err      }    }    return nil}

示例仅伪代码,实际业务上应当考虑重复注册、同步执行 or 异步执行、是否强事务等问题。

然后,对于个性化的业务场景,只需实现业务自己的观察者,以打卡玩法触达提醒为例:

// ActTaskDetailObserver2CheckinReach 任务明细进展·打卡玩法的私信HOOKtype ActTaskDetailObserver2CheckinReach struct {}
func (ob *ActTaskDetailObserver2CheckinReach) Forward(ctx context.Context, detail *db.ActUserTaskDetail) error {    // 兜底判断任务明细未完成    if detail.Status != consts.ActTaskDetailStatusDone {        return nil    }    // 触达用户(动态达标提醒)   return NewReachClient().Send(ctx, "act_checkin_task_detail_done", detail.userId)}

最后,在初始化时,将观察者注册到主体中即可。

不同的活动玩法最终都是通过观察者模式的设计来实现差异化,甚至任务模块本身也可以通过该方式来实现更上层的任务架构。

四、新陈更迭与系统化

新陈更迭

优秀的系统并非一蹴而就的,而是要历经无数次的业务迭代和市场检验,也就必然存在新陈更迭的过程。

从业务层面来看,我们需要清楚当前业务的真实诉求,明确业务功能的拆分方式(从难易程度、风险系数、影响面等多维度考量)。其次,要了解该业务的历史演进及产品习惯,识别新老业务之间可能存在的关联风险。

从技术层面而言,首先要划分清楚能力和业务的界限,其次通过巧妙的设计思路进行分化与衔接。

综合来讲,业务的高质量交付应始终摆在首位,结合业务的粒度去适配新方案的能力,在测试阶段需要明确对齐并进行全面性回归,老方案的业务和新方案的技术应该具备兜底和互动能力,以确保系统的稳定过渡和持续扩展。

走进系统化

在活动系统组件化的基础上,我们以运营标准化提效、数据可视化赋能、协作体系化破局为核心目标,构建全链路协同能力,推动活动业务从 “模块化” 向 “系统化” 跃迁。

我们以三大核心体系为抓手,全面提升活动业务效能:构建可复用的生产体系,依托标准化活动要素库与低代码平台,实现组件化拼装与流程标准化,大幅降低开发成本、提升上线效率;打造智能决策中枢,整合多维度数据,通过实时监控与智能分析驱动策略优化,助力运营从经验驱动迈向数据决策;打通跨团队协同链路,统一协作标准、集成开发工具链,并成立中台委员会推动能力沉淀,有效减少重复工作、缩短迭代周期,破除部门协作壁垒 ,最终推动活动业务从 “模块化” 向 “系统化” 全面跃迁。

图片

五、展望潮流生态

得物社区活动业务将以组件化、系统化成果作为基石,持续深化创新。

随着 AIGC、边缘计算等前沿技术的融合应用,活动系统将朝着 “智能自优化” 加速迈进。一方面,通过 AI 算法精准预测用户兴趣与行为趋势,自动优化活动策略与任务设计,让潮流内容与用户需求实现更高效匹配。另一方面,借助边缘计算降低数据处理时延,为用户带来更流畅、即时的活动交互体验。

在商业价值与文化影响力层面,得物将进一步打破潮流文化与电商消费的壁垒,通过活动业务构建更紧密的用户、品牌、文化生态闭环。未来,无论是小众潮流文化的破圈传播,还是品牌与用户间的深度情感联结,得物都将凭借不断进化的技术与创新模式,持续引领潮流社区电商的发展风向,书写行业新篇章。

往期回顾

1.得物研发自测 & 前端自动化测试体系建设

2.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

3.得物自研DScript2.0脚本能力从0到1演进

4.社区造数服务接入MCP|得物技术

5.CSS闯关指南:从手写地狱到“类”积木之旅|得物技术

文 / 小龙

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

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

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

得物研发自测 & 前端自动化测试体系建设

一、背景 & 现状

在得物技术团队双周迭代模式下,前端自动化测试体系的建设已成为提升研发效能的关键突破口。当前技术部门推行研发自测的核心诉求,其核心诉求在于通过建立可信的质量保障机制释放测试资源,以此承接更多的业务需求,提升需求吞吐率。

双周迭代的机制对研发流程提出了双重挑战:既要保障两周内完成需求开发、测试验证到交付上线的完整闭环,又需保障研发交付的代码质量稳定可靠且经过充分的测试验证。

服务端已通过流量回放、代码覆盖率检测等成熟方案构建质量护城河。我们统计了各个前端业务域在2025 年Q1中的自测率,服务端实际自测率为:24.45%,而前端的实际自测率仅有:15.35% 。因此,在完成技术部研发自测率25% 的目标的情况下,前端是一个较大的短板。而制约前端实际自测率提升的一个重要的因素就是缺乏像服务端流量回放和代码覆盖率检测技术这样的自动化代码质量保障技术,导致测试同学对于前端自测质量的置信度存疑,无法检测和衡量负责该需求的前端是否已经完成了足够详尽的自测。

因此,如果需要提升前端的研发自测率,我们首先需要从这些质量保障技术出发,夯实地基,构建属于前端的质量保障护城河。

二、意义何在

上面我们说了,目前得物的前端领域缺乏自动化代码的质量保障技术,我们想知道这些技术是否真的具有必要性呢?如果有了这些技术,真的能够带来研发效能的提升吗?要回答这个问题,首先需要分析一下各种质量保障方式的优劣:

图片

从上表的分析中,我们可以看到,不同的质量保障方式各有优劣,每种方式都有各自适合的场景,而研发自测场景和前端代码覆盖率相互结合,便可以解决前端研发自测置信度低的问题,再加上E2E自动化测试,就可以补充全量用例自动化回归的缺口。

因此,补齐前端自动化测试能力对于提升研发自测比例有着相当大的正向作用。

三、方案详情

正如上文所述,我们是通过补齐前端场景缺失的质量保障工具的方法作为支点撬动技术部研发自测比例的天平,让更多符合研发自测标准的需求既能进行研发自测释放测试资源,又能有一定的质量保障机制,确保前端交付代码的质量,稳定生产。

为了方便大家更加理解这个项目,我们将会从技术实现方向、运营方向、各域推广这几个方面具体聊一下在这个项目的具体操作原因和过程。

技术方案

图片图片

前端代码运行时覆盖率

※  插桩技术

图片

前端代码覆盖率检测最核心的点,就是需要想办法检测出我们修改的每一行代码(JS代码)在运行时是否被执行过,只要想办法拿到这个数据,我们就可以在这个数据的系统上进行一定的清洗、整理、合并,来得到我们想要的前端代码运行时覆盖率的报告了。

经过调研,想要知道某一行JS是否被运行过,其实市面上已经有比较成熟的方案了,例如:著名的JS前端测试框架 Jest 就是基于 istanbul 对需要检测的代码进行插桩并收集代码的运行数据。

代码插桩

代码编译过程中,在代码的抽象语法树(AST)每个语句节点中插入特定代码,从而改变最终生成产物的一种非侵入性代码修正方案。

图片

// code.jsvar a1;var b2;var c = a + b;var d = a < b ? a : b;function test() {  return a + b;}if (Math.random() > 0.5) {  test();} else {    console.log("done");}

上述代码是一份简易版本的插桩 SDK和测试代码段,通过左侧插桩逻辑处理右侧的代码后,我们就可以得到以下代码:

图片

接下来我们在页面中进行测试,没有执行到的代码就可以被检测出来。

图片图片

当然,上述只是一个简易版的插桩代码,仅用于演示,如果要在实际项目中使用,可以考虑使用: babel-plugin-istanbul 。

有了插桩能力之后,SDK剩下的逻辑就简单了,只需要按照一定规则收集相关的覆盖率报告并上报到指定服务器即可实现。

※  覆盖率服务

图片图片

覆盖率服务是整个前端代码覆盖率体系的核心,起到「承上启下」的作用。

  • 上则与接入了覆盖率SDK的业务系统相通,接收各个业务系统的原始覆盖率数据,并进行清洗、整理、合并、存储的操作。
  • 下则与其他关联系统(覆盖率报告展示平台、覆盖率平台、发布平台、流水线等)连通,为各个关联系统提供核心覆盖能力。

覆盖率服务核心能力

  • 收集处理报告: 收集浏览器上报的测试覆盖率数据,按「应用」、「分支」、「Color」、「时间段」做数据合并和存储。
  • 版本发布处理:版本发布后仅删除「当前应用 + 当前环境 + 当前分支」,改动文件的报告数据。
  • 覆盖率报告: 覆盖率数据展示数据支持和备份能力。
  • 桥接覆盖率平台:提供必要的接口,比如权限对接、报告管理、任务调度、流水线编排(发布拦截读取覆盖率平台指标)等交互。
  • 报告机器人:通过报告机器人在特定时机通过飞书消息形式向特定群组发送覆盖率报告。

覆盖率服务旨在实现「应用维度」,未来会支持「需求维度」、「人员维度」、「页面维度」的覆盖率:

图片图片

图片图片

※  报告展示

图片图片图片

为了让开发同学能够更加清晰且实时的知道哪一行代码没有被覆盖,我们提供了两种报告形式:

  • 实时覆盖率报告: 与Master分支对比,得到测试分支与主干分支的差异,并过滤出增量变动代码的覆盖率情况,且报告是实时更新的,无需反复生成报告,测试完刷新即可查看最新覆盖情况。
  • 覆盖率报告快照: 每个版本准入和准出阶段,我们会保存一份覆盖率报告的快照,这个快照会固定与生成快照时的commit**进行对比,生成之后不会改变,方便我们查看不同版本各个应用的覆盖率情况。

此外,我们也支持目录维度的覆盖率情况,方便开发同学快速查看未覆盖代码的定位。

目前,我们也支持「需求维度覆盖率报告」、「人员维度覆盖率报告」,将每行代码的改动与具体的需求和人员关联上,支持这个功能以后,我们可以更好地衡量某个需求的的代码测试质量,开发同学也可以利用人员维度的覆盖率报告更加高效地处理各自负责代码的未覆盖问题,提升自测和测试效率。

※  覆盖率平台 & 协同流水线

图片图片

有了针对指定应用和环境生成覆盖率报告的基础能力后,我们就可以将我们的能力对接到覆盖率平台,这样可以借助覆盖率平台现有的管理能力:

  • 应用注册
  • 环境管理
  • 报告管理
  • 任务管理

除此之外,我们还可以复用覆盖率平台与协同流水线之间现成的通信协议和能力,快速对接到协同流水线当中,实现需求、应用的「准入」和「准出」卡口,让覆盖率不达标的应用无法操作提测和上线,以此筑起质量保障的长城,为稳定生产保驾护航。

E2E 自动化测试

上面我们简单聊了整个前端代码覆盖率的各个模块,细心的同学应该发现了,我们一直说的都是「增量代码覆盖率」,但没有提到「全量代码覆盖率」。难道全量代码覆盖率对我们来说可有可无吗?其实不然!

上文我们主要聊的都是增量代码的场景,其原因是我们之前还不具备全量代码覆盖率收集的能力。很多同学可能会问了,代码覆盖率不是就是代码插桩收集吗?那全量和增量似乎也没区别呀,为什么增量可以实现,全量不行呢?

其实,这并不是我们技术能力上达不到,而是我们不可能要求测试或开发同学每次测试都把这个系统回归一边,要知道,一个成熟的业务系统,动辄就是几十万行代码级别的,我们不可能依靠人工进行全量回归。

所以,就到了另一个前端质量保障的主角登场了,那就是 「E2E自动化测试」,只要有了自动化测试的能力,只要录制(自动分析)一次,下次需求开发之后,就可以直接全量自动化回归了。

关于E2E自动化测试是前端平台增长域的同学负责推进的,在这里就不过多展开E2E的技术细节了。

推广方案

至此,我们已经大概地过了一遍整个前端自动化测试体系的技术方案了。但光是把东西做出来,如果没人用或者用户基数低,那么这些工具的ROI也是非常有限的。因此,我们借助技术部推行研发自测的契机,也制定了前端代码覆盖率体系的推广计划。

应用接入

2025年Q1在实验域的应用内试点运行几个版本后,覆盖率相关功能已相对比较稳定,因此Q2正式开始在前端平台内部的其他业务域中推广,各个业务域根据各自应用的情况,按照以下标准对应用设定接入优先级。

※  接入优先级

  • P0 应用:应用可接入且优先级高(中后台类应用)。
  • P1 应用:应用可接入但是相对优先级较低,或改动频率较低,对于收集覆盖率诉求不高。
  • P2 应用:应用不可接入或者暂不支持接入,未来考虑实现支持收集覆盖率功能。

为确保应用接入顺利,我们保证绝大部分应用可以开箱即用地接入SDK,除了少数RsPack和MF架构的应用需要特殊接入外,其他应用的接入成本均相当低廉。

统一标准

应用接入完成之后,各域的已接入应用就可以通过代码覆盖率来衡量研发自测的质量了,那么接下来就要正式对我们的终极目标“研发自测率”发起进攻了。

各个域对于「可研发自测需求」的颗粒度标准是参差不齐的,但如果要在技术部范围内将研发自测顺利的推行起来,方便后期统一出具相关报告,并根据情况调整科研发自测标准,那么,摆在我们面前的一个最大的难题就是:统一研发自测颗粒度标准。

在进行标准统一的讨论的过程中,我们也遇到了很多问题,其中反馈最多的问题就是:

  • 有些业务域一旦按照统一标准判定可自测需求的话,可自测需求池子就会比原先多出很多可自测需求,虽然可自测需求不代表必须自测,但也需要相关的研发同学和测试同学进行一定的沟通,确认该需求是否能够实际自测并打标,这会增加沟通成本。如果需要反复去 “需求管理平台” 筛选确认,效率太低。

针对这种问题,我们前期通过脚本,按照最新标准将各业务域每个版本的应自测需求都导出到多维表格,并将可以辅助判断需求是否可以自测的一些信息(如当前需求名称、链接、预计是否可自测、实际是否自测、需求总估时[含测试]、需求总估时[不含测试]等)都一同导出,方便各域负责人快速确认(后期研发效能同学会统一开发相关研发自测的数据统计报表和需求明细,方便各域进行分析和确认):

图片

此外,我们还确定下来,由于各域的实操标准目前参差不齐,可自测需求可以统一标准。但各域实操时,还是按照各域现行标准实行,即目前这个阶段,我们仅扩大可自测需求的池子,但最终是否自测,还是按照各域实操标准来,后续再根据运营情况调整策略,逐步逼近目标。

目标制定

图片

我们通过对前端平台各域Q1的自测数据进行分析,得到了当前各域的现状:

  • 实际自测率:15.29%
  • 可自测完成率:42.22%

可见,无论是实际自测率还是自测完成率都是较低的。

基于这种现状,如果想要一蹴而就直接打到25%是不太可能的,因此,我们将战线拉长,分两个季度来逐步完成目标:

  • Q2:统一可自测标准、提升可自测完成率,通过自测完成率的提升带动实际自测率的提升,因此确定下来:
    • Q2 自测率:21%
    • Q2 完成率:65%
  • Q3:加强自测能力,提高可自测标准,通过扩大池子来整体提升自测率,因此确定下来:
    • Q3 自测率:25%
    • Q3 完成率:80%

以上为前端平台整体的目标,上面也说了,各域由于业务特性,存在不同的情况,如果一概而论,对于某些域来说难度堪比登天,对于某些域来说又是轻而易举。因此我们还针对各域Q2的现状,为各域量身定制的一套差异化目标:

  • 实际自测率:
    • 在高位,持续保持(30%)
    • 在中低位,但是空间大 (25%)
    • 在低位,空间有限的,取可自测率为目标
  • 自测完成率:
    • 原本处于低位(9.52%):设定特殊目标25%
    • 原本处于中位(27%~41%):设定最低目标65%
    • 原本处于高位,在原基础上提升一定比例即可

需求研发自测影响因素分析

在标准完成统一以及目标完成制定之后,我们进一步下钻数据,想要通过各版本的自测数据分析,找出可能影响研发自测率的因素。首先,我们先预设了几个可能影响研发自测的因素:

  • 需求全栈率: 由于全栈开发的目标需求也是简单的小颗粒度需求,跟研发自测的目标需求有一定重合,而如果一个需求是完全全栈的话,就不需要前端参与了,会导致需要前端参与的小颗粒度简单需求数量减少。

全栈

即通过可视化配置的页面来替代人工源码开发的一种解决方案,若某个需求完全推行全栈策略,则无需前端参与,由服务端同学直接配置即可。

图片

  • 大颗粒度需求占比:由于前端总体资源是相对固定的,一个版本中,如果大颗粒度需求比较多,那么前端能够承接的小颗粒度需求自然就会变少,从而导致前端整体可自测需求比较少,直接影响自测率。

图片

  • 小颗粒度需求占比:有些版本,即使大颗粒度的版本不多,小颗粒度的需求也不见得会变多,需求有可能集中在不大不小的区间内,因此小颗粒度需求的多少也会影响到自测率。

图片

  • 版本平均颗粒度:除了大颗粒度需求和小颗粒度需求外,整个版本的平均颗粒度的高低也会一定程度上影响到可自测需求的多少,通常来说,平均颗粒度越高,可自测需求就会相对越少,反之亦然。当然版本平均颗粒度并不会直接影响「实际自测率」,而是通过「可自测率」影响可自测需求池的大小,从而最终影响「实际自测率」。

    但由于是间接影响,中间可能受到「自测完成率」以及「额外自测需求数」等其他因素的影响,「版本平均颗粒度」和「实际自测率」之间,并不总是成负相关的关系,因此需要进一步下钻分析。

图片图片

图片图片

【平均颗粒度】【应自测率】:通常成反比关系

【自测完成率】【实际自测率】:通常成正比关系

  • 纯前端需求占比:根据过往数据分析,纯前端需求自测占比相较于非纯前端需求自测占比会高很多,因此,一个迭代当中,纯前端需求的多少也会影响自测率。

图片

  • 版本周期:受到各种节假日的影响,得物每个迭代周期可能都不太一样,如 567由于有五一假期,因此该迭代周期为13天,而569由于有端午假期,版本周期只有9天。版本周期不一样,能够承接的需求数量、难易程度、颗粒度也都会有差异,也会影响自测率。
  • 独立项目占比:目前很多域都有不断提升独立项目在迭代需求中的占比的趋势,由于项目管理相对独立,且存在需求庞大,时间周期长,基本不可能自测的特点,如果一个版本中独立项目的占比提升了,那么势必会挤占正常迭代需求的时间,导致可承接的需求数量变小,可研发自测的需求也会变小,影响自测率。

图片

基于上述的集中可能影响研发自测的因素,我们拉取了560~568的9个版本的迭代数据进行了细致分析。

从数据分析中我们发现,版本周期和独立项目占比对需求自测率占比的影响是比较明显的,通常版本周期变长,自测率会提升,反之则降低。而独立项目占比提升通常会导致自测率降低,反之提升。其他条件没有太明显的有规律变化,但不代表他们不会对自测率造成影响,应该是还有一些其他未知因素暗中影响导致的。

因此,我们后续推进时,可以重点关注一下版本周期和独立项目两个影响因素,其他因素也可以看情况加强关注。

运营方案

工具开发好了不代表就完事了,如果不用心去运营的话,肯定也是无法达到技术部 25%的需求研发自测目标的,我们需要一个详尽的运营策略持续跟进覆盖率的运营。当覆盖率有保障了,才能够提升前端自测置信度,让测试放心将更多的小颗粒度需求给研发测试。

简单来说我们会在每个版本需求提测之前,要求负责需求开发的研发在指定环境完成自测,如果未自测或自测不达标,可以直接通过「前端代码覆盖率」工具监控出来并实时提醒。在需求上线之前,我们也会观察待上线应用的准出代码覆盖率情况,用来衡量或辅助判定测试是否充分,是否存在漏测场景,以此保证生产质量和稳定。

四、现阶段成果

图片

基础能力

完成了包括应用维度覆盖率、实时覆盖率报告、覆盖率报告快照、覆盖率准出卡口等基础能力的建设,初步搭建起了前端代码覆盖率体系。Q2预计完成需求维度覆盖率报告、人员维度覆盖率报告、自动化报告推送机器人等能力。

应用接入情况

我们在Q2进行了一次集中的各域应用接入,接入完成率达126.67%,远超预期。虽然后续不会再集中接入了,但还会逐渐单独接入其他支持应用。

图片

覆盖率运营

我们对接入的部分应用代码覆盖率进行了抽样统计和分析:

图片

从覆盖率数据上来看,统计的应用在正式启动运营之后,相较于566有较大幅度的提升,无论是准入覆盖率还是准出覆盖率都远远超出了目标标准,其中平均准入覆盖率为:78.58%,准出覆盖率为:87.06%(准入目标:60%,准出:80%)。可见只要我们运营得当,主动推进,是能够得到直接的正向反馈的。但从代码覆盖率来说,先不说覆盖率的提升对于研发自测率的影响,就单是对于前端代码交付质量的提升的收益而言,已经比较喜人了。

研发自测

我们抽样查看了实验业务域的研发自测情况,我们可以看到,实验域实际自测率已经超出了Q1的目标(21%)3个百分点,可见实际投入运营后给研发自测率带来的正向效果。(当然,每个版本受限于一些不可控因素,如:需求特性、版本周期、独立项目占比等影响,数据会有一定程度的波动,我们每个版本需要通过数据下钻分析原因,保证顺利向目标进发)。

图片

五、未来规划

图片

我们在Q1和Q2分别完成了「基础能力建设」「研发自测标准化&全域项目试点运营」,基础能力和标准都已经确定下来了,那么后续我们就要从以下两个方向努力了:

构建质量保障矩阵

图片

我们当前已经支持了中后台应用的代码覆盖率检测了,已经支持了公司内部很大一部分的前端应用,但C端应用和NodeJs应用也占了不小的比重,后续需要补齐这一部分能力,让代码覆盖率将这一部分应用都囊括进来,整体提升前端项目的交付质量。

图片

除此之外,我们也会进一步联动E2E自动化工具和影响面评估工具,进一步提升测试完整度。

此外,我们还可以通过支持覆盖率评论、页面维度覆盖率报告、AI智能推荐最佳测试路径、以及影响面评估工具等,提升研发和测试快速精准地找到漏测页面和潜在风险点,提升自测和测试效率。

在测试质量方面,我们打算利用AI能力,分析PRD并生成核心自测用例,补齐研发自测没有测试用例这一短板,提升研发自测的测试质量。

覆盖率数据精细化运营

通过搭建前端代码覆盖率大盘,观测各域各应用以及前端平台全域的覆盖率变化曲线,针对覆盖率较低的业务域和应用,进行专项推进与提升,整体提升前端平台接入应用的交付质量。并通过机器人告警等方式实时通知未达覆盖率最低标准应用的覆盖率情况,针对性分析需求、人员因素的影响。

通过对各域覆盖率、自测率等核心指标的精确分析,不断的优化推行策略和运营策略,可以更早地发现我们在推进的过程中遇到的阻碍和瓶颈,提前制定合适的备案,保障完成最终目标。

常态化运营

在完成了所有的能力建设后,我们就需要针对每个版本的需求进行精细化运营了。每个版本迭代结束,及时对上个版本的数据进行复盘和分析,看一下有哪些地方没做好,导致原本可以研发自测的需求,最终没有自测。并根据上一个Q的运营情况,实时调整研发自测的标准和各域的差异化目标,确保研发自测的正常健康推进。

六、结语

在数字化进程加速的产业背景下,前端工程的质量保障已从单一功能验证演进为体系化工程实践。上文通过技术架构、运营机制、推广策略三个维度,系统解构了我们在推进前端自动化测试体系的建设路径与研发自测的实践价值,为前端构建起质量护城河,提升前端代码交付质量,并以此撬动研发自测的杠杆,向着整体提升需求吞吐率的目标发起冲锋。

作为现代前端工程师,质量保障责任不能完全委托于测试团队。有研究表明,经过严格自测的代码提测后无论是缺陷密度还是代码回滚率都会有较大幅度的下降。前端开发者通过浏览器中对于功能的详尽自测,能够深度理解业务逻辑边界条件;在覆盖率报告分析过程中,可常发现未覆盖的异常分支或冗余代码,这对代码可维护性提升具有显著价值。

通过覆盖率报告建立个人/需求质量档案,持续跟踪自测覆盖率、缺陷引入率等指标,遇到问题时能够精准快速溯源,快速判断Bug逃逸原因是否是因为功能点漏测导致的。之前曾看过这样一句话:"优秀的代码不仅是能运行的代码,更是经得起反复验证的代码",这种严谨的工程态度正是专业开发者的核心素养。

通过上述体系建设,可使前端质量保障从被动发现转向主动检测&防御,从个体实践升级为团队能力,最终实现研发效能与产品质量的双重提升。这既是应对复杂前端工程的必然选择,也是项目在高速迭代过程中保障交付代码质量,稳定生产的关键路径。

往期回顾

1.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

2.得物自研DScript2.0脚本能力从0到1演进

3.社区造数服务接入MCP|得物技术

4.CSS闯关指南:从手写地狱到“类”积木之旅|得物技术

5.从零实现模块级代码影响面分析方案|得物技术

文 / 康辉

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

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

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

社区造数服务接入MCP|得物技术

一、背景

今年 MCP 的概念非常火,市面上也涌现出了一大批 MCP 相关工具。作为技术一线者,都会按捺不住地去实操一下,很早的时候就有个设想,如果把我们的测试工具都改造为符合 MCP 服务协议标准,然后全部接入 AI Agent,打造一个集万千工具于一体的智能管家来帮助我们提效,是不是一个很完美的设想。很多宏伟或者天马行空的想法想要真正的落地,必然需要不断向下,拆解成可落地的任务模块,这里我们先从造数开始。

二、AI 造数设想

在实际业务需求测试中,我们依赖的测试数据需要很多前置的数据要求,这时候会涉及到分步使用不同的造数脚本。比如团长拉新做任务,需要一个 30 天内没发过动态的账号,加入团队,发一篇动态,动态过一审,过二审,阅读数满足 300 个。

为了完成这个场景的造数,我们需要去造数工厂、接口自动化、脚本代码等平台找对应的造数工具,分别去执行才能完成这一系列的操作。可以从下图中看到,总计需要 6 个步骤才能完成。如果不是熟悉所有的业务,哪怕有现成的造数脚本,组合起来使用还是有一定的门槛。

微信图片_2025-05-28_144236_485.png

那么在 AI 风行的年代,我们想要实现的是:按照用户输入的测试数据要求,能够按照已有造数能力自动编排,生成对应的测试数据给用户使用。

最终实现效果案例:我需要一个团长拉新的测试数据,要求是 30 天内没有发过动态,进入团队 A,然后发布一条动态,需要过一审风控审核,二审标注,最后需要获得 300 个阅读数。

AI 造数自动去造数池子中寻找对应的造数接口,按照提问的顺序要求来依次执行造数,最后返回给用户对应的测试账号。

这里不再重复介绍 MCP 的概念,我们参考官方给出的 client-server 通用架构图来画一个 AI 造数的架构图,便于理解在落地到 AI 造数的场景,我们可以做哪些事。本篇文章主要就讲解了图中的其中一环,落地社区造数服务的 MCP 接入。

微信图片_2025-05-28_144241_121.png

三、社区造数服务 tools

框架介绍

社区的造数服务技术栈是基于 FastAPI 框架实现的,通过 uv工具来管理依赖库、虚拟环境等,这个工具亲测的确比传统的 pip 或者 poetry 等工具更好用。从安装 uv到启动项目,只要 4 步就能无痛搞定环境,不用担心本地其他环境的干扰。

## uv命令1. 安装uv : `curl -LsSf https://astral.sh/uv/install.sh | sh`2. 创建环境 - 自定义环境名称和Python版本   `uv venv tools_venv --python 3.12`3. 激活环境    `source tools_venv/bin/activate`4. 安装依赖包    `uv pip install -r pyproject.toml`
## 本地启动项目直接运行main.py文件中的main方法即可,debug模式自己pycharm中设置if __name__ == "__main__":    import uvicorn    uvicorn.run(app, host="0.0.0.0", port=8000)

中间件相关配置全部通过 ARK 来管理,项目结构如下:

## 项目结构
```bash
├── main.py  # 启动 APP 入口文件
├── README.md  # 开发手册
├── Dockerfile  # Docker 镜像文件
├── alembic  # alembic 迁移 DB 自动生成的相关文件
│   ├── README
│   ├── .env.py
│   ├── script.py.mako
│   └── versions  # 存放每次迁移的版本,可用于回滚 DB 版本
├── alembic.ini  # alembic 配置文件
├── app
│   ├── __init__.py  # 注册 app
│   ├── api  # api 开发目录
│   ├── core  # app 的全局配置
│   ├── crud  # 每个 table 的增删改查操作
│   ├── db  # db 配置
│   ├── models  # 存放表结构
│   ├── schemas  # pydantic 模型
│   └── utils  # 工具类
├── .pre-commit-config.yaml  # 配置 git commit 时自动检测工具└── pyproject.toml  # 依赖库管理```

统一部署到公司的发布平台,通过 http://{造数服务域名}/tools/docs#/ ,地址可以访问目前社区所有的造数接口。同时也对接了造数工厂,可以直接去造数工厂使用。

微信图片_2025-05-28_144245_254.png

微信图片_2025-05-28_144248_803.png

改造思路

老方案-基于 MCP Python SDK

早在出现 MCP 这个概念的时候,我就想过有天把我们的造数服务通过 MCP 工具暴露出来,这样就可以非常方便的集成各种 Agent,打造 AI 造数。在出现这个 FastAPI-MCP 框架之前,想要把造数服务改造成支持 MCP ,就需要通过引入 MCP 依赖库来实现。但这个方案对于已有的造数服务来说改造成本有些高,可以看老方案的案例。

从官方文档面向服务器开发者 - MCP 中文文档中可以找到有对应的 MCP Python SDK,主要就是安装 MCP 这个依赖库。这里举一个简单的 demo,通过手机号查询用户信息的方法。可以很清晰的看出来这个 SDK 的语法结构是需要 @mcp.tool()  这个装饰器来修饰,那么原有的造数服务暴露出来的所有接口方法是否都需要改造,这仍有一定的成本(未考虑其他复杂场景情况下)。

# server.pyfrom mcp.server.fastmcp import FastMCPfrom tools.tools_set import get_user_infoimport uvicorn# Create an MCP servermcp = FastMCP("Demo")
@mcp.tool()async def get_user_info_tool(mobile: str) -> Coroutine[Any, Any, Any]:    """根据输入的手机号获取用户信息        Args:        mobile: 手机号    """    info = get_user_info(mobile)    return info
if __name__ == "__main__":    """Initialize and run the server"""    # mcp.run(transport="sse")    """Start the FastAPI server with uvicorn"""    uvicorn.run(app, host="0.0.0.0", port=8003)

基于上述代码 demo,我们通过 uvicorn 启动服务,当然也可以单独启动 MCP 服务。控制台输出如下,代表启动成功,接下来我们就可以使用 MCP 客户端工具进行连接使用了,这里使用 Cursor来做演示。

微信图片_2025-05-28_144252_056.png

看图标显示绿色,无报错说明连接成功,这里也能看到 demo 中的 get_user_info_tool 方法作为 MCP 工具暴露了出来。演示到这里,说明了该方案是可行的。因为本文重点讲解采用的新方案,此处就不再多介绍,感兴趣的可以去看官方文档。

微信图片_2025-05-28_144255_419.png

四、FastAPI-MCP

安装运行

“Expose your FastAPI endpoints as Model Context Protocol (MCP) tools, with Auth! ”

这是引用官网介绍的第一句话,翻译过来大概的意思就是:把你的 FastAPI 服务作为 MCP 工具暴露出来成为现实!

  1. 安装 FastAPI-MCP 库

      uv add fastapi-mcp  or  uv pip install fastapi-mcp 

  2. 使用 FastAPI-MCP,只需要 3 行代码就能把 FastAPI 框架改造成一个 MCP 服务

  3. 通过 uvicorn 启动服务器,使用http://localhost:8000/mcp 来访问 MCP server

from fastapi import FastAPIimport uvicornfrom fastapi_mcp import FastApiMCP
# Create (or import) a FastAPI appapp = FastAPI()
# Create an MCP server based on this appmcp = FastApiMCP(app)
# Mount the MCP server directly to your appmcp.mount()
if __name__ == "__main__":    uvicorn.run(app, host="0.0.0.0", port=8000)

用法介绍

自定义配置

通过看源码 FastApi-MCP 类,基本能清晰的看出来各个参数的用处,这里将介绍几个常用的。

class FastApiMCP:    """    Create an MCP server from a FastAPI app.    """        def __init__(        self,        fastapi: Annotated[            FastAPI,            Doc("The FastAPI application to create an MCP server from"),        ],        name: Annotated[            Optional[str],            Doc("Name for the MCP server (defaults to app.title)"),        ] = None,        description: Annotated[            Optional[str],            Doc("Description for the MCP server (defaults to app.description)"),        ] = None,        describe_all_responses: Annotated[            bool,            Doc("Whether to include all possible response schemas in tool descriptions"),        ] = False,        describe_full_response_schema: Annotated[            bool,            Doc("Whether to include full json schema for responses in tool descriptions"),        ] = False,        http_client: Annotated[            Optional[httpx.AsyncClient],            Doc(                """                Optional custom HTTP client to use for API calls to the FastAPI app.                Has to be an instance of `httpx.AsyncClient`.                """            ),        ] = None,        include_operations: Annotated[            Optional[List[str]],            Doc("List of operation IDs to include as MCP tools. Cannot be used with exclude_operations."),        ] = None,        exclude_operations: Annotated[            Optional[List[str]],            Doc("List of operation IDs to exclude from MCP tools. Cannot be used with include_operations."),        ] = None,        include_tags: Annotated[            Optional[List[str]],            Doc("List of tags to include as MCP tools. Cannot be used with exclude_tags."),        ] = None,        exclude_tags: Annotated[            Optional[List[str]],            Doc("List of tags to exclude from MCP tools. Cannot be used with include_tags."),        ] = None,        auth_config: Annotated[            Optional[AuthConfig],            Doc("Configuration for MCP authentication"),        ] = None,    ):    ...

※ Server metadata

  • name:MCP 服务名
  • description:对 MCP 服务的描述

※ Tool and schema descriptions

创建 MCP 服务器时,可以通过修改 describe_all_responses ,把所有可能的响应模式包含在工具描述中,或通过更改 describe_full_response_schema 把完整的 json 包含在工具描述中。

from fastapi import FastAPIfrom fastapi_mcp import FastApiMCP
app = FastAPI()
mcp = FastApiMCP(    app,    name="My API MCP",    description="Very cool MCP server",    describe_all_responses=True,    describe_full_response_schema=True)
mcp.mount()

※ Customizing Exposed Endpoints

  1.  include_operations , 暴露 operation_id=XXX 的接口
  2.  exclude_operations , 排除 operation_id=XXX 的接口
  3.  include_tags , 暴露 tags=XXX 的接口
  4.  exclude_tags ,排除 tags=XXX 的接口

组合使用:

  •  include_operations 和 exclude_operations 不能同时使用
  •  include_tags 和 exclude_tags 不能同时使用
  •  include_operations 和 include_tags 可以组合使用,匹配任一个条件就满足
from fastapi import FastAPIfrom fastapi_mcp import FastApiMCP
app = FastAPI()
# 案例1:include_operationsmcp = FastApiMCP(    app,    include_operations=["get_user""create_user"])
# 案例2:exclude_operationsmcp = FastApiMCP(    app,    exclude_operations=["delete_user"])
# 案例3:include_tagsmcp = FastApiMCP(    app,    include_tags=["users""public"])
#案例4:exclude_tagsmcp = FastApiMCP(    app,    exclude_tags=["admin""internal"])
# 案例5:Combinedmcp = FastApiMCP(    app,    include_operations=["user_login"],    include_tags=["public"])
mcp.mount()

工具命名

FastAPI 中的路由通过 operation_id 参数来作 MCP 工具名称,如果没有显示命名,框架会自动生成一个。此处经测试,如果不显示命名,自动生成的名字不仅会很奇怪,还会影响 AI 造数的准确性,所以这里最好作好规范,必须要显示命名。

# Auto-generated operation_id (something like "read_user_users__user_id__get")@app.get("/users/{user_id}")async def read_user(user_id: int):    return {"user_id": user_id}
# Explicit operation_id (tool will be named "get_user_info")@app.get("/users/{user_id}", operation_id="get_user_info")async def read_user(user_id: int):    return {"user_id": user_id}

五、接入造数服务

框架升级及改造

微信图片_2025-05-28_144258_471.png

在接入的时候,要查一下官方文档要求的 Python,FastAPI 等版本,先进行框架升级,防止出现不兼容的问题。这项通过管理工具安装依赖库时能自动校验,其他一些兼容问题在启动服务后根据实际场景一一去解决即可。这里推荐使用 uv 工具进行管理,亲测比之前的 poetry 更好用。

列几个核心库的版本,都是验证过没有兼容问题的。在过程中也是遇到一些兼容问题花了点时间,因为 FastAPI-MCP 框架比较新,网上资料还不全,遇到没法解决的问题大家可以去项目 issue 中找,提升解决问题效率。

python = "^3.12"fastapi = "0.115.12"fastapi-mcp ="0.3.1"mcp="1.7.0"pydantic = "^2.11.0"pydantic-settings = "^2.2.0"

步骤

第一步: 引入 fastapi-mcp 

第二步: main.py 中添加 MCP 服务

微信图片_2025-05-28_144302_088.png

第三步: 也是工作量最大的一步,将每个造数接口都做显示命名,并且做好文档注释,写的越清楚 AI 造数的准确率越高,需要对应编写造数场景测试,共同完成

微信图片_2025-05-28_144305_354.png

最后一步: 启动服务 uvicorn.run('main:app', host='0.0.0.0', port=8023, reload=True, workers=2) ,无报错基本就没有问题了。再通过 MCP 客户端工具连接使用即可

微信图片_2025-05-28_144308_486.png

接入 Cursor

改造完之后的造数服务成功对外暴露了 MCP 服务,现在我们可以通过 MCP 客户端去连接使用了,这里选用了 Cursor,因为 Cursor 使用的人比较多,同时集成了市面上的主流大模型。

步骤

第一步: 创建一个 mcp.json,按照标准 json 配置即可

{  "mcpServers": {    "fastapi-mcp": {      "url": "http://localhost:8022/mcp",      "description": "本地开发环境MCP服务配置"    },    "tools-mcp": {      "url": "http://localhost:8011/mcp",      "description": "本地开发环境MCP服务配置"    },        "demo-mcp": {      "url": "http://localhost:8001/sse",      "description": "本地开发环境MCP服务配置"    },    "tools-mcp-prod": {      "url": "http://XXXXXX/mcp",      "description": "线上"    }}}

第二步:点击右上角设置 icon,进入 Cursor Settings,选择 MCP

微信图片_2025-05-28_144311_820.png

第三步: 这里可以看到,在刚才 mcp.json 中配置的 MCP工具均加载过来,打开开关,运行状态显示为绿色,无报错并说明了服务接入正常,接下来就可以正常使用 Cursor 中的 Agent 进行对话了

实操演练

我们现在只希望使用造数能力,因此我们可以指定刚才配置的 MCP 工具。

场景化案例

需求:给手机号为 11120210001 的用户发布一个点评动态,并且通过风控一审。

这里注意一下提问方式,因为我们没有对大模型进行特别的训练,AI 不一定知道 111 开头的是我们测试使用的虚拟手机号,有可能会误解为 userId,所以我们需要告诉 AI 这是一个手机号。

可以看到在这个 demo 中, Agent 自动帮我们分了三步去调用对应的 MCP tool,第一步通过我们输入的手机号去获取 userId,第二步通过 userId 去发布点评动态,第三步通过点评动态 id 去通过风控一审。原本需要三步完成的造数场景,现在通过一句话描述就完成了。

图片

调优案例

需求:随机创建 10 个测试账号

※  调优之前

造数代码,主要看文档注释内容。

@router.post('/create-account', operation_id="create_account",summary="创建测试账号")async def c_create_account(        env: str = Body(..., description='环境'),        phonenumber: str = Body(..., description='手机号'),        pwd: str = Body(..., description='密码'),        usernum: str = Body(None, description='数量'),) -> Any:    """    创建测试账号,默认111开头       args        env: 环境,默认:t1        phonenumber: 手机号        pwd: 密码,默认:test123        usernum: 数量    """   

把这个造数需求发送给 AI,发现报错了。我们去代码中看下为何返回了 false,原来是因为接口返回非 200,排查下来是因为 t1 环境测试账号造数默认填了 111,不需要再加 111,所以接口直接 500 了。

这里 AI 犯了两个错误:

  1. 因为默认手机号都是 11 位的,这里 AI 不知道只需要传 8 位就行。
  2. 我没有输入具体的手机号,所以按照代码逻辑应该是支持自动随机生成的,但是 AI 也不知道这个逻辑,“自作主张”给我传入了一个手机号。

微信图片_2025-05-28_144321_435.png

※  调优后

通过排查我们已经明确知道 AI 犯了哪些错误,那么我们针对这些错误去调优即可。所谓的调优主要就是修改文档注释,可以前后对比下注释内容。

"""创建测试账号,默认111开头,不用填写111,只需要后面8位不传手机号phonenumber,默认随机生成手机号
args    env: 环境,默认:t1    phonenumber: 手机号,非必填,不填自动生成    pwd: 密码,默认:test123    usernum: 数量"""

※  最终效果

微信图片_2025-05-28_144325_003.png

通过这个案例可以看到,准确率依赖我们对造数接口的文档注释,所以在实际使用过程中,前期需要我们不断地去调优,才能达到我们想要的效果。

当然随着后续迭代,可能可以用更优雅的方式完成这个工作,比如再引入静态代码分析工具,通过 AI 编程自动完成注释。

六、总结

技术实践成果

通过将社区造数服务改造成符合 MCP(Model Context Protocol) 标准的工具,我们成功实现了以下目标:

AI 驱动的测试数据自动化

用户通过自然语言描述需求,AI Agent 可自动编排造数接口生成复杂测试数据,将原本需手动执行 3 步的操作简化为一步指令。

低成本框架升级

基于 fastapi-mcp 框架,仅需少量代码改造即可将 FastAPI 服务快速接入 MCP 协议,解决了传统 SDK 方案的高适配成本问题。

工具链整合

通过对接 Cursor 等 AI 工具平台,验证了 MCP 协议在跨平台协作中的可行性,为后续构建“社区智能管家”奠定技术基础。

核心实践经验

注释即规范

AI 调用接口的准确性高度依赖代码注释的清晰度。通过优化接口文档(如参数默认值、输入格式说明),可显著提升 Agent 的任务解析成功率。

渐进式调优

初期需通过人工干预优化 Agent 的接口调用逻辑,未来可引入代码静态分析工具自动生成标准化注释。

未来优化方向

动态编排增强

当前接口调用为线性执行,后续可探索基于依赖关系的动态编排(如并行执行独立步骤、自动重试失败操作)。

多 Agent 协作

结合领域知识库与测试断言工具,实现从“造数”到“验证”的全链路 AI 自治。

协议扩展性

探索 MCP 与更多协议(如 OpenAPI)的互操作性,提升工具服务的跨平台复用能力。

价值与启示

本次实践印证了 “AI+协议化工具” 在测试领域的巨大潜力:降低技术门槛 (非技术人员可直接描述需求)、提升执行效率 (分钟级操作秒级完成)、释放创新空间 (复杂场景的自动化长链路测试)。

随着 MCP 生态的完善,测试工程将逐步从“工具堆砌”走向“智能协作”,为研发效能带来质的突破。

七、感想

“我不是英雄,只是一个拿锤子的约德尔人”

站在巨人的肩膀上总是能看的更高更远,追随技术大牛们的步伐,把 AI 应用到工作中、生活中。回想九年前初入测试行业时捧读的《Google 软件测试之道》,书中“人类智慧的最后一英尺”已然越来越近。重读了 2022 年在公司内部博客发表的《Google 软件测试之道:结合实践的总结》一文,发现仅仅过了3 年,如果现在再去写,又是完全不一样的想法了,技术的发展已发生翻天覆地的变化。

此刻回望测试领域的演进曲线,愈发感到:「拿锤者」的价值不在于挥舞工具的姿态,而在于持续校准认知坐标的能力 。当 AI 重构测试链路的每个环节时,唯以「锤者」的务实与「巨人」的视野双轨并行,方能在技术洪流中锚定价值支点。

加油吧!不忘初心,你我终将能抵达一个又一个“终点”!

往期回顾

1.CSS闯关指南:从手写地狱到“类”积木之旅|得物技术

2.从零实现模块级代码影响面分析方案|得物技术

3.以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

4.得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

5.得物自研DGraph4.0推荐核心引擎升级之路

文 / 阿凯

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

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

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

CSS闯关指南:从手写地狱到“类”积木之旅|得物技术

一、背景

在Web开发网页设计中,CSS(层叠样式表)扮演着至关重要的角色,它用于控制网页的布局、外观和视觉效果。CSS不仅可以美化网页的视觉表现,还可以提高网页的可访问性、可维护性和响应式设计。在我们进行网页开发的时候,CSS是必不可少的一个环节。但是在早期的纯手写CSS阶段时会存在很多的痛点,这些痛点催生了 CSS 预处理工具(如 Sass/Less)和 CSS-in-JS 方案的兴起,进入工具曙光时代,但它们本质上仍未能突破"手动编写样式规则"的范式。直到原子化 CSS 理念的回归——通过预定义的实用类(Utility Classes)组合样式,配合智能化的工具链,为解决传统 CSS 困境提供了新的思路。

二、纯手写CSS的黑暗年代

在前端开发的早期阶段,一直以"纯手写"的方式主导着开发者的工作流。我们习惯于在 .css 文件中逐行编写选择器,包含布局控制,视觉设计,响应式设计,动画交互效果,可访问性等等一些列的关键要素,通过类名、ID 或标签选择器来定义样式规则。这种方式看似直观,但随着项目规模的扩大和团队协作的深入,传统手写 CSS 的局限性逐渐暴露如下的一些问题。

代码冗余与维护成本

每个元素的样式都需要手动编写,开发者会陷入一个“复制粘贴炼狱”的困境,同时也会导致大量重复代码。例如一个简单的 flex 布局需要在多个组件中重复定义 display: flex; justify-content: center; align-items: center; ,项目体积无意义膨胀。最经典的当属于按钮,输入框等表单样式的定义,这些元素在我们进行网页开发的时候是非常常见的,尤其是后台管理页面的开发。典型表现为: 

/* 重复定义的按钮样式 */.primary-btn {  padding8px 16px;  background#42B983;  border-radius4px;  color: white;}
.submit-button { /* 相同样式不同命名 */  padding8px 16px;  background#42B983;  border-radius4px;  color: white;}
/* 散落在各文件的边距定义 */.article-list {  margin-bottom24px;}
.mb24 {   margin-bottom24px/* 相同值重复定义 */}
/* 后续迭代新增 */.section-spacing {  margin-bottom24px/* 开发者可能已忘记已有定义 */}

这种代码冗余导致三重灾难

※ 文件体积失控

导致页面的CSS文件大小达到MB的级别,而且其中很多都是重复规则。

※ 修改成本倍增

调整基础间距值时,开发者需要在多个位置进行修改。

※ 增加认知负担

开发者需要记忆 margin-bottom: 24px 可能存在于 mb24 、 section-spacing 等多种不同实现。

上下文割裂的开发体验

在传统开发前端页面时,一般在 .html 文件中定义页面结构, .css 文件中定义页面的样式,所以开发时需要频繁在 HTML 模板文件和 CSS 文件之间切换,特别是在现代组件化框架中,这种割裂感更加明显。查看某个元素的样式需要跨文件检索,打断编码心流,影响开发的效率。例如下面一个React组件:

{/* 社交链接组件 */}<ul class="social-links" style={{ marginBottom24 }}>    <li><a href="https://twitter.com/yourcompany" target="_blank">Twitter</a></li>    <li><a href="https://facebook.com/yourcompany" target="_blank">Facebook</a></li>    <li><a href="https://linkedin.com/company/yourcompany" target="_blank">LinkedIn</a></li></ul>
// 组件.less.social-links {    margin-bottom: 10px; // 组件内部的CSS    > li + li {        margin-top: 5px;    }}
// 全局的.lessul {    margin: 0; // 全局重置}

这个 ul 元素的样式定义在了三个地方,有时候修改样式的时候,我们需要进行切换到不同文件才能修改元素的样式,定位成本也会剧增。

命名困境与样式污染

类名设计逐渐演变成哲学问题——既要语义化(.user-card-container)又要避免冲突,最终演变成冗长的 BEM 命名( .user-card__avatar--rounded )。即便如此,全局作用域仍可能导致样式意外覆盖。

/* 经典BEM实践 */.user-card__avatar-container--rounded { /* 长度达39字符 */  border-radius50%;  overflow: hidden;}
/* 应对主题化的变异 */.user-card__avatar-container--rounded-dark-mode { /* 突破50字符 */  filterbrightness(0.8);}
/* 组件库维护者的绝望 */.namespace-user-card__avatar-container--rounded-primary-theme-2024 {  /* 类名已成为密码学谜题 */}

BEM 命名方式

在一定程度上能缓解命名冲突,但是也会带来一些新的问题:

※ 命名长度失控

企业级项目中平均类名长度可达30+字符。

※ 可读性悖论

过度细化的命名反而导致理解成本上升,开发者需要很长时间才能解析这单个类名。

※ 重构噩梦

当我们需要重命名 user-card 组件时,那我们可能需修改N个相关类名。

响应式与动态样式的笨重实现

面对复杂的响应式需求时,传统 CSS 需要编写多个媒体查询区块;动态样式(如颜色主题切换)往往依赖 CSS 变量或预处理器的混入,增加了架构复杂度。例如:

/* 典型响应式布局实现 */.card-container {  width100%;  margin10px;}
@media (min-width640px) {  .card-container {    width50%;    margin15px;  }}
@media (min-width1024px) {  .card-container {    width33.33%;    margin20px;  }}
/* 针对深色模式的叠加修改 */@media (prefers-color-scheme: dark) {  .card-container {    background#1a1a1a;  }}

此类代码导致

※ 维护黑洞

单个组件每增加一种响应式的设备,响应式代码可能需要在原有的样式代码上翻一倍。

※ 调试困境

调试困境:开发者需同时关注视口尺寸、设备类型、主题状态等多个变量。

样式与结构分离的代价

虽然关注点分离是良好实践,但在现代组件化开发中,过度分散的样式定义反而降低了组件的内聚性。当需要修改组件样式时,开发者不得不同时维护模板文件和样式文件。

三、工程化曙光

原生CSS开发曾因全局作用域污染、样式冗余和维护成本高昂等问题让开发者备受煎熬,技术演进催生出多种解决方案体系:Sass/Less等预处理器通过变量机制和嵌套语法实现样式逻辑抽象,使代码复用率提升60%;CSS Modules借助哈希类名构建本地作用域,从根本上消除样式冲突隐患;BEM命名规范采用模块化语义结构,将团队协作效率提升70%;随着组件化开发范式普及,CSS-in-JS方案通过样式与组件的深度绑定,实现动态主题切换等复杂需求,使React组件复用率突破90%。这一系列工程化实践推动CSS从手工模式迈向标准化协作体系,构建起现代前端开发的样式基础设施,给我们至暗的纯手写时代带来了一束光明。

CSS预处理器的救赎

CSS预处理器(CSS Preprocessor)是一种通过扩展语法+编译工具,让开发者能用编程思维写样式的方案。其核心功能具有变量、嵌套、函数、混合(Mixin)、继承、运算等编程特性,使得CSS更加灵活和强大。为传统CSS注入了工业化基因。采用Sass的项目代码复用率提升至50%+,CSS体积平均缩减40%+,标志着样式开发进入"工程化觉醒"时代。

救赎改进点

CSS预处理器给开发者带来了如下的曙光:

※  代码复用革命

// 变量系统 - 设计Token统一管理  $primary-color#42B983;  $spacing-unit: 6px;  
// Mixin工厂 - 封装复用逻辑  @mixin flex-center {    display: flex;    justify-content: center;    align-items: center;  }  
// 继承体系 - 避免重复定义  %button-base {    padding: $spacing-unit * 2;    border-radius4px;  }  
.submit-btn {    @extend %button-base;    background: $primary-color;    @include flex-center;  }  

能够抽取公共样式,定义变量,能够做到一处修改处处生效,提高代码复用率。

※  结构嵌套优化

.navbar {    padding12px;      &__item {      margin-right20px;          &--active {        color: $primary-color;      }    }  }  /* 编译后 */  .navbar { ... }  .navbar__item { ... }  .navbar__item--active { ... }  

嵌套语法将代码组织效率提升,但需警惕过度嵌套导致选择器层级膨胀等问题。

※  逻辑控制能力

// 条件语句动态生成主题  @mixin theme($mode) {    @if $mode == dark {      background#1a1a1a;    } @else {      background#ffffff;    }  }  
// 循环生成间距工具类  @for $i from 1 through 8 {    .mt-#{$i} {      margin-top: $spacing-unit * $i;    }  }  

增加编程的思路,能够定义变量,支持条件判断语句和循环的能力,一定程度上减少代码的体积,增加可阅读性。

曙光中的阴影

尽管预处理器大幅提升了样式工程能力,但仍存在本质性局限:

※  工具链依赖困境

必须依赖Node.js/Ruby等编译环境,需要借助一些编译工具将CSS预处理器的语法编译成浏览器能够识别的CSS语法,同时编译时长随项目规模线性增长,编译后的代码量和纯手写的代码量区别不是很大,一定程度上也会影响页面的加载。

※  浏览器兼容性断层

# 开发环境需实时编译  sass --watch input.scss:output.css  

浏览器无法直接解析 .scss 文件,导致热更新延迟,以及无法在浏览器控制台直接编辑源码等相关的问题。

※  作用域污染无解

// 编译后的CSS仍是全局样式  .navbar__item--active { ... }  // 其他组件可能定义相同类名导致冲突  

Sass仅提供语法糖,未改变CSS底层作用域模型,全局样式污染的问题存在。

※  上下文割裂加剧

<!-- HTML模板 -->  <div class="navbar">    <div class="navbar__item navbar__item--active"></div>  </div>  
<!-- 对应的SCSS文件 -->  /* styles/navbar.scss */  .navbar { ... }  

开发者仍需在结构层与样式层之间反复切换,认知断层率无法有效得到解决。

CSS命名规范实践

CSS(层叠样式表)命名规范是确保CSS代码结构清晰、易于维护和可扩展的关键。遵循一套明确的命名约定可以大大提高团队协作的效率,减少样式冲突,并使代码更加可读。常见的CSS命名规范有:BEM规范、SMACSS规范、OOCSS规范。

BEM规范

将CSS类名分为块、元素和修饰符三个部分。举个例子:

<div class="block">      <h2 class="block__title">标题</h2>      <ul class="block__list">            <li class="block__list-item">列表项1</li>            <li class="block__list-item block__list-item--highlighted">列表项2</li>      </ul></div>

其中block代表一个组件或UI部件, block__title 和 block__list 代表块的子元素, block__list-item 代表列表项。 block__list-item--highlighted 是一个修饰符,表示该列表项被突出高亮显示。

SMACSS规范

SMACSS不仅仅是命名规范,还包括CSS文件结构的组织规范。SMACSS主要是将样式分成五大类,分别是Base、Layout、Module、State、Theme。其中:

  • Base类主要是基本样式规则,例如重置浏览器默认样式、设置全局的基本样式等。这些样式通常以选择器(标签选择器、通用选择器)为基础,并且适用于整个项目。
  • Layout类用于创建页面布局和网格系统,它定义了页面的整体结构、栏目布局、容器和网格样式等。
  • Module类用于定义可重复使用的模块样式。
  • State类用于定义组件的状态样式,如 .btn 和 .btn-primary 的样式。
  • Theme类主要是主题相关的样式,如 .site-title 和 .module-title 的样式。
/* Base */a {    color#42B983;    text-decoration: none;}
/* Layout */.container {    width90%;    margin0 auto;    padding0 15px;}
/* Modules */.btn {    display: inline-block;    padding10px 20px;    margin5px 0;    border: none;    border-radius5px;    cursor: pointer;}
.btn-primary {    background-color#42B983;    color#fff;}
/* State */.btn:hover {    background-color#0056B3;}
.btn:disabled {    opacity0.6;    cursor: not-allowed;}
/* Theme (Optional) */.theme-dark {    background-color#333;    color#fff;}

OOCSS规范

OOCSS规范主要遵循结构(Structure)与外观(Skin)分离的原则,例如:

<div class="box box-red">你好</div><div class="box box-blue">OOCSS规范</div>

其中结构部分用 .box ,外观部分用 .box-red 来命名。

CSS命名规范优点

※  避免冲突

合理的命名可以减少CSS选择器之间的冲突,特别是在大型项目中,这可以避免不必要的样式覆盖问题。

※  可维护性

良好的命名规范使得代码更容易理解和维护。当团队中的成员或者未来的你(在几个月或几年后)需要修改或扩展样式表时,清晰的命名会大大减少认知困惑和错误。

※  可读性

清晰、一致的命名风格可以提高代码的可读性。这对于快速定位问题或添加新功能至关重要。

※  可扩展性

通过使用有意义的命名,你可以更容易地预见未来的需求变化,并设计出能够轻松扩展的样式表。

※  团队协作

在团队项目中,统一的命名规范可以减少沟通成本,使得不同成员之间的工作更加协调和高效。

※  语义化

好的命名应该反映元素的功能或内容,而不是仅仅基于其外观。这有助于开发者更好地理解每个样式的用途和作用。

CSS模块化方案

在CSS模块化中,CSS模块化是一个CSS文件在JavaScript中的一种使用方式,它允许你使用本地作用域的CSS类名,从而避免了全局命名空间污染的问题。CSS模块化通过Webpack等模块打包工具实现,使得CSS文件能够以模块的形式导入到JavaScript文件中,进而在React、Vue等现代前端框架中使用。一些常见CSS模块化的方案包括Vue里的scoped方案,CSS Modules with React方案。

CSS Modules with React

需要借助Webpack等编译工具,再结合 css-loader ,在Webpack配置文件中添加相应的loader:

module.exports = {  module: {    rules: [      {        test: /.css$/,        use: [          'style-loader', // 将JS字符串生成为style节点          {            loader: 'css-loader', // 将CSS转化成CommonJS模块            options: {              modules: true // 开启CSS Modules            }          }        ]      }    ]  }};

在你的React组件或其他JavaScript模块中,你可以这样导入和使用CSS Modules:

import styles from './index.module.css';
export default function Container() {  return <div className={styles.container}>Hello World</div>;}

在 index.module.css 中,你可以定义CSS类:

.container {  display: block;}

CSS模块化优点

※  作用域化

每个类名在编译时会被转换成唯一的标识符,避免了全局命名冲突。

※  组合

可以使用 :global 或 :local 伪类来指定全局或局部样式。

例如: :global(.someClass) 会将 .someClass 定义为全局样式,类名不会转换成唯一的标识符。

※  变量

可以使用CSS变量(自定义属性),例如 --main-color ,在模块内部使用。

※  嵌套

虽然CSS Modules本身不支持CSS的嵌套语法(如Sass的嵌套),但你可以通过预处理器如Sass或Less来实现嵌套,然后通过相应的loader(如 sass-loader 或 less-loader )处理。

CSS-in-JS方案

CSS-in-JS是一种将CSS样式直接写入JavaScript代码中的方法,通过将样式与组件绑定,可以避免全局样式的冲突问题。一些常见的CSS-in-JS解决方案包括Styled Components、Emotion和JSS等。

Styled Components

 styled-components 是一个流行的 CSS-in-JS 库,用于在 React 或其他 JavaScript 应用中编写组件级样式。它通过将 CSS 直接嵌入 JavaScript/TypeScript 代码中,实现了样式与组件的紧密绑定,同时支持动态样式和主题管理。其核心特性如下所示:

※  组件化样式

样式与组件一一对应,避免全局 CSS 的命名冲突问题。

import styled from 'styled-components';
const Button = styled.buttonbackground: ${props => props.primary ? 'blue' : 'gray'};  color: white;  padding: 10px 20pxborder-radius: 4px;`;
// 使用<Button primary>Click Me</Button>

※  动态样式

支持通过 props 或全局主题动态调整样式。

const Text = styled.divcolor: ${props => props.theme.primaryColor};  font-size: ${props => props.large ? '20px' : '16px'};`;

※  自动 Vendor Prefixing

自动为 CSS 属性添加浏览器前缀(如 -webkit- ,  -moz- )。

※  主题支持

通过   全局传递主题变量。

import { ThemeProvider } from 'styled-components';
const theme = {primaryColor'#007bff'};
<ThemeProvider theme={theme}><App /></ThemeProvider>

CSS-in-JS方案优点

※  作用域隔离

通过自动生成的唯一类名,可以避免全局CSS命名冲突。

※  组件化

CSS直接与React组件(或其他JavaScript框架/库的组件)绑定,使得样式与组件逻辑紧密相关联。

※  动态样式

可以更方便地根据组件的props动态生成样式。

※  易于维护

与组件代码放在一起,便于管理和维护,减少文件之间来回切换的成本。

四、原子化革命

原子化CSS是一种将样式拆解为最小功能单元的CSS方法论,每个类名对应单一的CSS属性(如 .m-4 表示 margin:1rem , .text-red 表示 color:red ),通过组合多个原子类快速构建界面样式,既提升代码复用性又减少冗余。

其核心思想是通过预设的设计系统(如间距、颜色、字号等规则)生成原子类,确保视觉一致性并加速开发。常见的框架包括Tachyons,Tailwind CSS、UnoCSS和Windi CSS,它们通过工具自动生成原子类库,适用于中大型项目、设计系统及需要高维护性和性能优化的场景。下面是原子化CSS框架演进路线图表格:

图片

Tailwind CSS

Tailwind CSS 是一种流行的原子化 CSS 框架,通过提供预设的实用类(Utility Classes),允许开发者直接在 HTML 中组合类名来构建界面,无需手动编写传统 CSS 代码。它的核心理念是“通过组合原子类实现设计,而非自定义样式”,强调高复用性、设计一致性和开发效率。

使用流程

※  快速初始化与配置

# 创建基础工程  npx create-react-app my-app --template tailwind  # 配置文件生成  npx tailwindcss init -p  

在 tailwind.config.js 中定义设计系统约束: 

module.exports = {    content: ["./src/**/*.{js,jsx,ts,tsx}"],    theme: {      extend: {        colors: {          primary"#42B983"// 主题色        },      },    },  };  

※  原子类组合开发

// React 组件示例  function ProductCard({ title, price }) {    return (      <div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-all">        <h3 className="text-xl font-bold text-gray-800 mb-2">{title}</h3>        <div className="flex items-center justify-between">          <span className="text-brand text-2xl">${price}</span>          <button className="bg-brand text-white px-4 py-2 rounded-md hover:bg-blue-600">            立即购买          </button>        </div>      </div>    );  }  

无需维护独立 CSS 文件,所有的CSS都通过原子类的形式添加到元素上,间距、颜色等设计决策直接映射到类名,可以在元素上直观的查看元素的布局,大小,颜色的特性;通过 md:grid-cols-3 等前缀声明断点逻辑,支持很好的响应式方式。

对比传统开发模式的核心优势

※  消除样式冗余与全局污染

  • 传统模式:
/* styles/button.css */  .btn-primary {    padding12px 24px;    background#3b82f6;    color: white;    border-radius8px;  }  
/* styles/card.css */  .card-header {    padding12px 24px;  /* 重复定义 */    background#3b82f6;  /* 与按钮颜色耦合 */  }  
  • Tailwind 模式:
<button class="px-6 py-3 bg-blue-500 text-white rounded-lg">  <header class="px-6 py-3 bg-blue-500">  

使用 Tailwind CSS 可以快速构建出现代化的网站和应用程序。通过使用预定义的原子类,开发人员可以快速地创建各种样式,而不必手动编写大量的 CSS 代码,提高代码复用率,减少冗余代码,减少项目体积,同时的话很好的解决命名冲突的问题。

※  提升响应式开发效率

  • 传统媒体查询:
.container {    width100%;  }  @media (min-width768px) {    .container {      width50%;    }  }  
  • Tailwind 方案:
<div class="w-full md:w-1/2"></div>  

※  可自由高度定制性

Tailwind CSS 提供了丰富的配置选项,允许开发人员根据项目需求进行自定义。你可以修改颜色、字体、间距、阴影等各种样式属性,使得 Tailwind CSS 可以适应各种设计风格和品牌标识。

// tailwind.config.js  module.exports = {  content: [    "./pages/**/*.{js,ts,jsx,tsx}"  ],  darkMode: "class",  theme: {    extend: {      colors: {        "dark-blue": "#11151C",        "light-gray""#22262D",        primary: "#43a4fe",        "primary-100""#f0faff",        "primary-200""#e6f6ff",        "primary-300""#bde6ff",        // ...        danger: "#FF3D71",        divider: "#9AA5B0",        light: "#C9D1D9",        "input-bg""#11151C",        "addon-bg""rgba(0, 0, 0, 0.02)",      },      boxShadow: {        "inset-left": "inset 10px 0 8px -8px #00000026",        "inset-left-dark""inset 10px 0 8px -8px #C9D1D920",        "inset-right""inset -10px 0 8px -8px #00000026",        "inset-right-dark""inset -10px 0 8px -8px #C9D1D920",      },      screens: {        "3xl": "1920px",      },      keyframes: {        heartBeat: {          "0%, 50%, 100%": {            transform"scale(1)",          },          "25%, 75%": {            transform"scale(1.3)",          },        },      },      spacing: {        108"27rem",        120"30rem",        132"33rem",      },    },  },  plugins: [    require("@tailwindcss/forms"),    require("@tailwindcss/line-clamp"),    require("tailwind-scrollbar"),  ],  variants: {    scrollbar: ["rounded"],  },};

尽管 Tailwind CSS 提供了大量的预定义原子类,但它仍然非常灵活,允许开发人员根据需要进行定制和扩展。你可以根据项目需求添加自定义的原子类,或者通过配置文件修改默认的样式设置。

※  强制执行设计规范

  • 通过配置约束消除像素级自由定义:
// tailwind.config.js  spacing: {    0'0',    1'4px',    2'8px',    // 禁止使用非标值  }  

可以确保项目中的样式保持一致性。通过在整个项目中重复使用相同的原子类,可以确保不同的元素具有相似的外观和行为,从而提高用户体验和用户界面的一致性。

※  高性能和丰富社区:

相比于传统的 CSS 框架或预处理器,Tailwind CSS 的学习曲线相对较低。由于它采用了原子类的概念,开发人员不需要记忆复杂的命名规则或层叠样式表的优先级,只需根据需要选择合适的类名即可。

Tailwind CSS 通过优化样式表的生成方式,可以生成高效的 CSS 代码。在构建过程中,Tailwind CSS 会根据项目实际使用的原子类来生成最终的样式表,避免了传统 CSS 框架中可能出现的未使用样式的冗余。

Tailwind CSS 还拥有庞大的社区支持和活跃的开发团队。你可以在社区中找到大量的教程、文档和插件,以及与其他开发人员交流和分享经验。

局限性及应对策略

※  学习曲线与类名记忆

开发者需要掌握 200+ 核心工具类命名规则:

示例: text-lg (大号文字) vs  text-xl (超大文字) 

  • 使用VSCODE Tailwind IntelliSense 插件实现自动补全,同时hover到class上的时候会显示具体的样式值。

图片

※  HTML 可读性下降

当元素上CSS样式过多时,会导致html的可读性下降,一般情况下尤其是还存在响应式等样式的时候。

复杂组件示例:

<button    class="flex items-center justify-center px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 disabled:opacity-50"  >    提交订单  </button>  

优化策略:

  • 提取组件,结合 @apply 指令,将多个原子样式组合成新的样式类。
// 提取组件 + 抽象语义  <div class="btn-primary">提交订单</div>  
// 结合 @apply 指令  .btn-primary {    @apply flex items-center justify-center px-6 py-3           bg-blue-500 text-white rounded-lg;  }

※  深度定制场景成本

当设计系统需要突破默认约束时,Tailwind 允许通过 tailwind.config.js 文件进行自定义配置,例如如下需要拓展间距相关的CSS熟悉时:

// tailwind.config.js  // 需要扩展非标值  theme: {    extend: {      spacing: {        '128': '32rem',        '13''3.25rem' // 违反默认进制规则      }    }  }
// 使用<div class="w-128 p-13">新的 Spacing 规则<div>

可能会破坏原子化一致性原则。

最佳实践:

  1. 尽量遵循默认约束体系 。
  2. 通过 CSS 变量注入特殊值: 
<div class="w-[327px]"></div> <!-- 临时解决方案 -->  

UnoCSS

UnoCSS 是一个高性能且高度灵活的原子化 CSS 引擎,由 Vite 核心团队成员 Anthony Fu** 开发。它的核心理念是“按需生成原子类”,以极快的构建速度和极简的配置为特点,成为现代 Web 开发中 Tailwind CSS 的强力替代品。

使用流程

※  安装依赖

# 使用 npm  npm install -D unocss  
# 使用 yarn  yarn add -D unocss  
# 使用 pnpm  pnpm add -D unocss  

※  框架集成

// vite.config.tsimport UnoCSS from 'unocss/vite'  
export default {    plugins: [UnoCSS()]  }  
// main.js(注入运行时)  import 'virtual:uno.css'  

※  核心配置解析

创建 uno.config.ts 实现深度定制: 

// uno.config.ts  import { defineConfig, presetUno } from 'unocss'  
export default defineConfig({    content: {    filesystem: [      './src/**/*.{html,js,ts,jsx,tsx,vue}',      './packages/**/*.{html,js,ts,jsx,tsx,vue}'    ]  },  // 预设系统(必选)    presets: [      presetUno(),            // 核心原子类      presetAttributify(),    // 属性化模式支持      presetIcons(),         // 图标系统集成    ],     // 自定义规则    rules: [      // 动态间距规则      [/^space-(\d+)$/, ([, d]) => ({ 'margin-inline': `${d * 4}px` })],      // 自定义颜色系统      [/^c-(red|blue|green)$/, ([, c]) => ({ color: `var(--color-${c})` })],    ],     // 快捷方式    shortcuts: {      'btn': 'px-4 py-2 rounded bg-blue-500 text-white',      'flex-center''flex justify-center items-center',    },     // 主题系统    theme: {      colors: {        primary: '#01c2c3',      danger: '#ef4444'      }    }  })  

※  原子类使用实战

  • 基础用法: 
<!-- 传统类名模式 -->  <div class="m-4 p-2 flex items-center">    <div class="w-1/2 h-[200px] bg-#BADA55"></div>  </div>  
<!-- 属性化模式(需 presetAttributify 插件) -->  <div m="4" p="2" flex items-center>    <div w="1/2" h="200px" bg="#BADA55"></div>  </div>  
  • 响应式与状态: 
<!-- 断点系统 -->  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"></div>  
<!-- 悬停/焦点状态 -->  <button class="bg-blue-500 hover:bg-blue-600 focus:ring-2"></button>  
<!-- 深色模式 -->  <div class="bg-white dark:bg-gray-800"></div>  
  • 动态生成: 
<!-- 任意值支持 -->  <div class="w-[calc(100%_-_32px)]"></div>  
<!-- 组合指令 -->  <div class="grid-cols-[repeat(auto-fit,minmax(200px,1fr))]"></div>  

※  高级功能解锁

  • 图标系统集成: 
// 安装图标引擎  npm install -D @unocss/preset-icons @iconify/json  
// 配置  presets: [    presetIcons({      scale: 1.2,      extraProperties: {        'display': 'inline-block',        'vertical-align''middle',      },    })  ]  
// 使用  <div class="i-mdi-alarm text-red-500"></div>  
  • CSS 层级控制:
<div class="[&>:nth-child(3)]:text-red-500">    <div>Item 1</div>    <div>Item 2</div>    <div>Item 3(将变红)</div>  </div>  
  • 动画系统: 
// 配置自定义动画  theme: {    animation: {      keyframes: {        'fade-in': '{0% {opacity:0} 100% {opacity:1}}'      },      durations: {        'fade-in': '0.5s'      }    }  }  
// 使用  <div class="animate-fade-in"></div>  

对比 Tailwind CSS 的范式优势

※  生成策略的本质差异

图片

※  配置系统的自由度

  • Tailwind 的约束配置: 
// 只能扩展预设主题  theme: {    extend: {      spacing: {        '128': '32rem'      }    }  }  
  • UnoCSS 的开放规则: 
// 可完全重写规则体系  rules: [    [/^m-(\d+)$/, ([, d]) => ({ margin: `${d}px` }),    [/^p-(\d+)$/, ([, d]) => ({ padding: `${d}px` })  ]  

※  跨框架的原生支持

图片

当前局限性

※ 规则冲突的调试成本

// 多个正则规则可能冲突  rules: [    [/^m-(\d+)$/, ([, d]) => ({ margin: `${d}px` }) ,    [/^m-(\d+)-(\d+)$/, ([, x, y]) => ({ margin: `${x}px ${y}px` })  ]  // 输入 m-4-2 可能触发错误匹配  

解决方案:

  1. 精确正则约束(如 ^m-(\d+)-(\d+)$ )。 
  2. 使用 enforce: 'pre' 调整规则优先级 。

※ 生态工具链成熟度

图片

※ 团队协作规范压力

  • 自由度过高的风险场景: 
<!-- 开发者A写法 -->  <div class="flex items-center"></div>  
<!-- 开发者B写法 -->  <div flex items-center></div>  
<!-- 开发者C写法 -->  <div style="display:flex; align-items:center"></div>  

最佳实践:

  • 使用 Biomejs 规则限制写法统一 
  • 制定《UnoCSS 团队规范白皮书》 
  • 通过预设强制约束:
// 禁用原生 style  blocklist: [/style=".*"/]  

五、总结

在 Web 开发中,无论是纯手写 CSS、采用工程化方案(如 Sass、CSS Modules),还是直接使用原子化 CSS 框架(如 Tailwind、UnoCSS),其核心目标始终围绕效率与质量的平衡。通过提高代码复用率、减少冗余逻辑、统一设计规范,开发者能够避免重复造轮子的时间损耗,同时降低维护成本。手写 CSS 追求极致的灵活性与语义化,适合对样式控制要求极高的小型项目;工程化工具通过变量、嵌套和模块化机制,为复杂系统提供结构化解决方案;而原子化框架则以“组合优先”的实用主义,将样式拆解为可复用的颗粒化单元,尤其契合快速迭代和团队协作的场景。值得注意的是,代码的可读性与风格统一性始终是技术选型的关键考量——杂乱无章的类名堆砌或过度抽象的样式封装,都可能成为长期维护的隐患。因此,开发者需根据项目规模、团队习惯与设计诉求灵活抉择:若钟爱原子化框架的即时反馈与设计约束,便不必囿于传统 CSS 的“纯净性”;若追求语义化与动态样式的深度控制,亦可拥抱工程化工具的强大扩展能力。技术终为手段而非目的,唯有匹配实际需求与个人心智模型的高效实践,才能在代码的严谨性与开发的愉悦感之间找到最优解。

往期回顾

1.从零实现模块级代码影响面分析方案|得物技术

2.以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

3.得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

4.得物自研DGraph4.0推荐核心引擎升级之路

5.大语言模型的训练后量化算法综述 | 得物技术

文 / 三七

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

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

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

AI生成功能设计用例|得物技术

一、AI背景

人工智能生成内容(AIGC,AI-Generated Content)技术的快速发展正在改变内容生产的方式,并逐渐渗透到各个行业,例如:在自媒体平台自动编写文案并发布,快速分析数据,写小说,画漫画等。强大的文本生成能力已经实现了生产力超过生产资料,提供了更加高效的生产力,将AI引入到工作中成为发展的方向。

目前公司编写测试用例为人工编写,存在手工编写用例的普遍痛点,例如:重新编写,费时费力,边界遗漏,兼容遗漏等。AI拥有自动生成文本并快速整合的能力,以AI辅助功能用例编写成为推动行业创新和效率提升的关键点。

AI编写用例的优点:

效率提升

AI可以快速生成大量测试用例,显著减少人工编写所需的时间,提升整体测试效率。

测试覆盖提升

AI能够自动识别潜在的测试场景和边界条件,从而提高测试覆盖率**,确保更全面的检测。

※  一致性和准确性提升

AI生成测试用例具有较高的一致性和易理解性,减少人为错误,增强测试的可靠性和准确性。

AI热词:

图片

二、设计方案

本部分介绍使用AI编写测试用例的的设计方案,包括使用流程和架构图。

AI编写用例流程图

图片

AI编写用例架构图

图片

三、设计核心介绍

本部分介绍如何使用AI辅助生成功能用例,详细讲解了从PRD文档->测试点->测试用例->Xmind用例->使用采纳,整条链路的核心设计与实现。

PRD文件解析器

平台支持飞书PRD文档中文本、多维表格、电子表格内容的解析,暂不支持对图片、流程图解析。文档读取分为6个步骤,分别为:获取飞书token、获取用户token、获取文件block列表、Table表格解析、电子表格解析、解析结果组装。以下主要介绍解析部分内容:

结构组成设计:

图片

实现方案详情

※  飞书文档读取

图片图片

※  Table的提取与sheet表格的提取

  • Table提取:提取表格过程中需要将表格相关的块与子块关联绑定,递归解析所有的数据。并根据第一行各字段的长度<20做是否为表头判定,默认第一行为表头信息。
  • sheet提取:在飞书表格提取过程中需要使用多个递归,分别获取表格所有内容与元素

图片

※  AI解析PRD文档:

  • PRD解析:通过与AI交互将文本内容解析为:需求关键字、测试背景、测试需求详情三部分,并按照特定字段将数据存储。
  • 结构设计:

PRD解析结构设计

图片

核心代码逻辑:

图片

※  获取关联测试需求业务背景:

  • 根据PRD解析关键字信息匹配最相关的测试用例模块,使用向量和关键字双权重对RAG**模块做测试用例提取:
  1. keyword_weight:0.3
  2. vector_weight:0.7
  3. 同时设置AI模型准确度为0.85
  • 匹配过程中分别针对不同的关键字,从RAG数据中提取热度最高的3个测试模块,合并后提取所有模块中热度最高的三个模块作为业务历史背景。
  • RAG提取架构设计

图片

  • 核心代码逻辑

图片

模型设计

图片

测试点生成器

测试点生成器为AI生成用例的核心,实现PRD到测试点的转换。生成过程中结合需求背景、关键字、需求详情、业务背景、测试分析等信息作为业务背景,以更准确的生成测试用例。核心结构如下:

结构组成设计

图片

实现方案详情

图片图片

模型设计

图片

测试用例生成器

测试用例生成器为AI用例生成器,负责将AI测试点转换为Xmind测试用例,主要实现两个功能,第一步将AI测试点转换为markdown结构的测试用例,包括用例名称、前置条件、执行步骤、期望结果等。第二部负责将第一步测试用例转换为Xmind结构。

实现方案详情

※  测试点解析生成markdown格式用例:

生成markdown格式用例

图片

解析结果

图片

※  AI markdown格式转换为Xmind结构用例

转换Xmind结构

图片

生成结果

图片

模型设计

图片

知识库搭建

LLM大模型有通用的推荐能力,针对公司业务场景是无法准确识别相关功能的,针对“最后一公里”问题,平台使用搭建测试用例知识库的方式,以提升推荐准确度。

平台会以历史测试用例与业务需求文档作为历史业务背景。在推荐功能用例过程中自动匹配历史业务背景,以提升推荐准确度。

知识库搭建

※  知识库涉及范围

图片

※  实现方案详情

  • Xmind测试用例转换知识库

图片

  • 业务文档转换知识库

图片

※  模型设计:

  •   测试用例转换文本AI模型

图片

  •   业务文档转换业务文档模型

图片

四、实现结果展示

图片图片图片图片

五、总结 & 规划

目前平台侧已经实现自动生成功能用例的功能,实现了从 PRD自动解析->测试点生成-> Xmind用例生成->同步平台的完整流程。可以一定程度上提升用户编写用例效率。

后续规划

  1. 支持PRD文档图片/流程图等多模态数据解析
  2. 持续完善RAG模型与测试用例知识库的维护

往期回顾

1.从零实现模块级代码影响面分析方案|得物技术

2.以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

3.得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

4.得物自研DGraph4.0推荐核心引擎升级之路

5.大语言模型的训练后量化算法综述 | 得物技术

文 / 执一

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

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

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

从零实现模块级代码影响面分析方案|得物技术

一、名词解释

代码影响面(Code Impact Analysis)

是指在代码变更后,分析这些变更对系统中其他部分的影响范围。它帮助开发团队理解代码修改的潜在影响,从而减少意外问题并提高代码质量。

模块级

是指以模块(Module)为单位的代码组织、分析和管理的粒度。模块是代码的基本单元,通常包含一组相关的功能,可以是 JavaScript 文件、UI 组件、页面或其他功能单元。

二、背景 & 价值

在过往交易域稳定性建设中,我们完成了多项关键工作,包括后台应用拆分、历史债务重构、权限配置管控和核心H5页面定期巡检任务等。此外,我们还整合了前端监控平台的各类异常数据分析与告警能力,帮助提前发现系统性风险,以提升系统的整体稳定性。

通过对于以往故障案例的复盘,我们也识别出一些导致系统稳定性问题的潜在隐患,尤其是随着业务复杂度提升,单个版本往往涉及大量页面改动和复杂的依赖关系。现有的影响面评估方式难以全面覆盖这些变更,在这种情况下容易导致出现生产问题时止血时间的拉长,影响了系统的稳定性和用户体验。

图片

在迭代发布视角下,代码影响面的分析尤为重要。每次迭代发布通常涉及多个功能或模块的更新,而这些更新可能会对系统的其他部分产生直接或间接的影响。

问题梳理

风险评估滞后

依赖人工经验判断改动影响面,在涉及多人协作和多个模块的团队开发或Monorepo等复杂场景下尤其低效。

信息维度割裂

现有研发协同平台以需求为纬度聚合研发相关信息,而前端稳定性保障则更需要以页面为纬度聚合迭代相关信息。

变更追踪困难

关键变更信息散落在群聊或各个系统中,缺乏一个统一的平台来聚合这些信息,导致信息同步和协作效率低下。

因此,我们希望实现一套自动化收集模块级代码影响面分析的方案,并以此评估版本需求发布对于系统整体稳定性的影响,从而提前确保重点模块能够得到有效的预警和监控,并创建相应的预案计划。

价值收益

研发自测能力提升

能够更精准地识别更改影响的页面或模块,确保需求影响范围符合预期。

测试覆盖率优化

结合变更影响,确保关键路径的完整测试,提升测试的有效性和覆盖率。

评估系统复杂度

有助于全面评估版本发布影响面范围;对系统各业务模块进行合理资源分配。

三、技术方案

代码影响面分析的完整方案分为多个关键步骤,通过这些步骤可以实现自动化收集模块级代码影响面分析,并评估版本需求发布对系统整体稳定性的影响。

具体可以参考下面的流程图了解👇:

图片

详细设计

影响面分析引擎

通过结合代码变更、依赖关系、业务逻辑等多维度数据,帮助开发团队快速识别和评估代码修改的潜在影响,从而减少生产问题的发生,提升系统的稳定性和代码质量。

依赖关系图构建

  • 使用静态分析工具分析项目中模块的依赖关系
  • 根据项目类型分别构建依赖关系图
  • 展示变更模块对其他模块的影响路径

图片

代码变更分析

  • 使用版本对比工具分析代码变更
  • 基于DIFF数据,统计变更的函数和变量
  • 根据依赖关系图,初步分析变更的影响范围

图片

影响范围标记

  • 从变更点出发,追踪调用路径,标记所有受影响的节点
  • 将影响范围分为模块、功能、接口和数据四类
  • 解析文件路由信息,输出页面列表

图片

根据简化后的代码,可以快速理解核心功能的实现原理。

class CodeEffectAnalyzer {  private fileImports: { [key: string]: FileImport[] };
    // 收集文件的导入依赖  private collectImports(filePath: string, ast: any): void {    traverse(ast, {      ImportDeclaration: ({ node }) => {        // 记录导入关系        node.specifiers.forEach((specifier) => {          this.fileImports[filePath].push({            filePath: path.resolve(path.dirname(filePath), node.source.value),            importedName: specifier.imported.name,            localName: specifier.local.name,          });        });      },    });  }
    // 分析文件,提取导出变量和函数  private analyzeFile(filePath: string): FileDetails {    const exports: FileExports = {};
    // 遍历 AST,提取导出项    traverse(ast, {      ExportDefaultDeclaration: (path) => {         exports['default'] = generate(path.node).code;      },      ExportNamedDeclaration: (path) => {        const declaration = path.node.declaration;        exports[declaration.id.name] = generate(path.node).code;      },    });        return { exports };  }    // 影响面分析检索  public analyzeImpact(affectedFiles: string[]): AffectedResult {    const analyzeImpactRecursive = (filePath: string): void => {      const { exports } = this.analyzeFile(filePath);      const modifiedList = Object.keys(exports); // 假设所有导出项都被修改      const referencedList: string[] = [];            // 找出引用了修改项的代码      for (const imported of this.fileImports[filePath] || []) {        if (modifiedList.includes(imported.importedName)) {          referencedList.push(imported.localName);          analyzeImpactRecursive(imported.filePath); // 递归分析影响面        }      }    };        // 分析每个受影响文件    for (const file of affectedFiles) {      analyzeImpactRecursive(file);    }  }}

平台数据聚合

在各个系统平台之间实现系统稳定性数据的一致性和实时更新,以确保各个部分能够获取最新的、准确的信息,进一步实现高效协作和准确分析。

天网权限系统对接

  • 获取菜单层级结构和页面路径信息,支持功能权限配置校验
  • 数据扁平化转换,微前端场景下提取子应用标识

研发协同平台同步

  • 获取迭代需求效能数据,进行汇总与计算
  • 建立需求任务与代码模块的关联

前端监控平台集成

  • 获取页面性能指标(首屏加载时间-FCP、接口响应耗时)、异常数据(JS异常数、接口成功率)以及流量数据(页面访问量-PV、页面访问数-UV)
  • 数据清洗工作(异常值过滤、重复数据移除),数据格式标准化

结果信息可视化

将代码变更的影响范围以直观、易懂的图形或图表形式展示出来,并嵌入研发生命周期,帮助开发团队快速理解变更的潜在影响,并做出相应的决策。

使用可视化工具

  • 通过图形化界面直观展示代码变更的影响范围,降低理解门槛
  • 交互联动,点击不同模块直接跳转至关联的平台详情页

生成多维报告

  • 从多个核心维度分析影响面指标
  • 提供各维度的分析数据填充至报告模版

集成 CI/CD 流程

  • 在合并请求(MR)阶段触发影响面分析并生成报告
  • 同时支持手动创建影响面分析任务

数据库设计

根据架构方案设计,规划出如下四个表数据结构,用来存储发布应用数据、影响面结果数据、页面异常/性能数据、研发效能等信息,支持高效查询和扩展性。

图片

业务效果

迭代发布对系统整体的影响是多维度的,从不同视角进行发布影响面的全面评估,可以协助责任人制定发布重点监控方向,从而有效减少风险。

按人员类型划分成不同角色视角

测试视角

图片

研发视角

图片

管理视角

图片

按影响面维度划分成多个展示效果

※  任务详情

图片

※  模块列表

图片

※  接口信息

图片

※  需求信息

图片

四、挑战 & 优化

在大型项目中,模块间的依赖关系复杂,如何高效、准确地构建依赖关系图是一个挑战。

挑战1:复杂依赖关系分析

※  问题描述

  1. 代码风格与框架差异。 不同项目采用不同技术栈、模块化方案、动态语法及特殊语法导致解析困难重重
  2. 动态依赖难以追踪。 运行时依赖(如按需加载、环境变量分支逻辑)无法通过静态分析捕获
  3. 系统路由规则差异。 不同系统采用不同的路由方案,其中微前端场景下,主应用与子应用的路由可能独立管理,形成多层嵌套路由结构

※  解决思路

  1. 多语言/框架适配。 统一AST解析引擎,兼容主流模块化规范
  2. 运行时依赖追踪。 选择动态分析工具并添加日志记录
  3. 统一路由元信息提取。 多框架路由解析适配器,微前端主子应用路由协同

挑战2:跨内部平台系统集成

※  问题描述

  1. 接入流程繁琐。 各内部平台系统需单独申请权限配置令牌,重复操作多,维护成本高
  2. 数据实时性与一致性。各平台数据更新频率不同,聚合时可能产生冲突

※  解决思路

  1. 模块化设计架构。 功能模块独立开发,优先级划分,MVP思维
  2. 数据版本快照。 版本控制管理,对关键数据人工干预兜底

优化1:跳过额外分析检测

在CI/CD流程中,部分代码变更(如文档更新、配置文件调整)无需触发完整的代码影响面分析。通过检测机制,可减少不必要的资源消耗,提升流水线执行效率。

  1. 条件判断跳过分析。 根据变更文件类型或所在目录信息,动态决定是否执行分析
  2. 提交信息比对。  比较两次检测之间的 commit** 差异,无内容主动跳过分析
  3. 白名单机制。 对特定文件或目录配置白名单,包含无需分析的特定文件或目录

优化2:缓存机制优化

合理的缓存策略和异步任务处理可以优化检测效率,降低 CPU 使用率和内存占用,进而提升系统整体性能。

  1. 设置适当的缓存失效策略。 以模块或文件的唯一标识(如文件路径、Git提交哈希)作为缓存键,当依赖项或代码发生变更时,清空相关缓存
  2. 任务异步处理。 将依赖分析和 AST 解析任务异步处理,使用消息队列将任务排入队列,避免阻塞主线程

五、总结展望

通过实现一套自动化收集模块级代码影响面分析的方案,我们可以更精准地评估版本需求发布对于系统整体稳定性的影响,从而提前确保重点模块能够得到有效的预警和监控,并创建相应的预案计划。这将有助于提升研发自测能力、优化测试覆盖率、评估系统复杂度,最终提高系统的稳定性和代码质量。

之后我们将继续优化影响面分析引擎,提升依赖关系分析的准确性和效率,进一步融合多维度数据,完成在线流量报表、全栈大盘数据建设,实现更高效的数据聚合和可视化展示,为开发团队提供更强大的支持。

往期回顾

1. 得物自研DSearch3.0搜索核心引擎升级之路

2. 以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

3. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

4. 大语言模型的训练后量化算法综述 | 得物技术

5. DPP推荐引擎架构升级演进之路|得物技术

文 / 卓翎

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

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

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

得物自研DSearch3.0搜索核心引擎升级之路

一、背景

随着交易和社区搜索业务稳步快跑,基建侧引擎越来越复杂,之前搜索底层索引查询结构已经存在较为严重的性能瓶颈。成本和运维难度越来越高。在开发效率上和引擎的稳定性上,也暴露出了很多需要解决的运维稳定性和开发效率短板。而在引擎的业务层部分也需要逐步升级,来解决当前引擎中召回层和业务层中各个模块强耦合,难维护,迭代效率低下等问题。

图片

二、引擎开发技术方案

DSearch1.0索引层整体结构

DSearch1.0的索引结构比较特殊一些,总体上使用了全局rcu的设计思想,整体架构上单写多读,所以实现了并发高性能无锁读,内部数据结构都是无锁数据结构,所以查询性能高。在写操作上因为rcu机制实现写入无锁。整体上优点读性能高,没有传统段合并操作带来的磁盘抖动。缺点是索引地址和操作系统强相关,运维复杂,热更新受限。全局地址分配难以并行写入,构建瓶颈明显。无法对浪费的内存进行回收导致内存空间利用率低,索引空间占用大。总体结构如图所示:

图片

DSearch2.0的索引升级

DSearch2.0分段索引整体设计

引擎2.0索引升级采用经典段合并架构,除了继承了段合并中优异的高性能写入性能和查询已经索引合并等优势外,针对段合并中频繁的正排字段更新等带来的高IO缺点。我们设计了新的正排字段原地更新索引,使新的DSearch2.0引擎拥有Redis的高性能写入和查询,也拥有lucene的紧凑索引和索引合并带来的内存空间节省的优势。

※ 索引段结构

  1. 每个索引段包含了文档文件,用于紧凑存放document中的各个字段的详细信息。字符串池文件是对document中所有的字符串进行统一顺序存储,同时对字符串进行ID化,每个字符串ID就是对应于字符串池中的offset偏移
  2. 可变数组文件是专门存放数组类型的数据,紧凑型连续存放,当字段更新的时候采用文件追加append进行写。最终内存回收通过段之间的compaction进行。FST索引文件是专门存放document中全部字符串索引。每个fst的node节点存放了该字符串在字符串池中的偏移offset。而通过字符串的offset,能够快速在倒排termoffset数组上二分查找定位到term的倒排链。
  3. 倒排文件是专门存放倒排docid,词频信息、位置信息等倒排信息,其中docid倒排链数据结构会根据生成段的时候计算docid和总doc数的密度来做具体判断,如果密度高于一定阈值就会使用bitmap数据结构,如果小于一定阈值会使用array的数据结构
  4. 标记删除delete链主要是用于记录段中被删除的document,删除操作是软删除,在最后查询逻辑操作的时候进行最后的过滤。
  5. 实时增量的trie树结构,实时增量段中的前缀检索和静态段中的前缀检索数据结构不一样,trie因为能够进行实时更新所以在内存中使用trie树。
  6. 段中的metadata文件,metadata文件是记录每个段中的核心数据的地方,主要记录段内doc数量,段内delete文档比例,实时段的metadata会记录kafka的offset等核心数据。

微信图片_2025-05-14_105656_384.png

Document文档和索引结构

Document文档数据结构

  1. Document文档使用紧凑型存储,其中array和字符串类型单独存放,其他字段连续存放,string和array字段存放。
  2. array字段类型数据直接存放在可变数组文件区,连续追加写。
  3. string字符串池对所有字符串进行连续存放,多个doc中同一个字符串引用同一个字符串地址,节省大量字符串存放空间。

倒排索引文件结构

  1. 倒排索引文件存放docid倒排和Tf以及位置position数据。其中内存实时段中的倒排索引数据结构是固定一种类型array类型。而内存实时段固化为静态段的时候,倒排数据结构会根据docid中的密度进行选择array和bitmap存储。当docid密度大于一定阈值是bitmap,反之是array结构。
  2. Tf数据结构是一个uint16的数组,数组长度和docid的数组长度一致,所以当确定了某个docid时候,也随即确定了它的tf信息。
  3. postion信息存储是一个二维数组的格式,第一层数组存放的是对应于term的在字符串池的offset,因为term在字符串池中已经ID化,所以offset可以表示唯一term。第二层数组是该term在字段中多次出现的位置,使用uint16存储。

前缀检索文件

  1. FST静态段文件

    a. 静态段中前缀是fst的数据结构,因为fst一旦建立是不能够进行修改的,所以在段合并的时候需要对所有term进行排序然后再构建fst结构。

    b. fst的node节点存放了对应于term的字符串池的offset。当需要查询一个term的倒排结构时候,需要先查询该term的字符串池的offset,然后拿该offset去倒排的termoffset文件中二分查找找到对应的倒排positionlist结构拿到对应倒排。所以一次term到倒排的查询需要查询一次fst+一次二分查询。

    c. term到倒排的查询一次fst+一次二分查找效率不高,所以针对term到倒排查询,新增了第二种HashMap索引,直接通过term到倒排的offset索引,这个选项在建表的时候可以配置。

  2. 实时段RcuTrie树索引

    a. 实时段中需要支持边写边读,前缀检索需要支持并发读写。引擎中trie树是rcu实现,单线程更新,多线程并发读,trie树写更新节点内存延迟回收。

微信图片_2025-05-14_105704_249.png

倒排索引和查询树逻辑

倒排链优化

  1. DSearch1.0的roaringbimap倒排索引在低密度数据量上存在一些瓶颈,比如对于倒排链比较短的情况下,roaringbitmap的container大部分都是array结构,在倒排链查询和合并都会进行一次二分查找,在大面积的倒排链合并中是个相当大的性能瓶颈。
  2. 针对上面所说的情况对roaringbitmap进行了精简,只存array或者bitmap合并的时候不需要查找,直接链式合并。

逻辑树合并优化

  1. DSearch2.0重点从逻辑语法树和倒排入手,优化语法树,减少合并树高,从二叉树合并变成单层合并。
  2. 优化倒排链合并方式,采用原地倒排链合并,消除倒排合并临时对象,同时引入多线程并行合并,减少长尾提高性能。

微信图片_2025-05-14_105712_581.png

增量更新逻辑

增量实时写入逻辑

  1. 引擎支持多个并发实时段,这个由配置文件通过配置来进行配置。多个实时段能够提升并发写入的性能。
  2. 每个实时段对应一个写入队列,提高并发写入吞吐。
  3. 每个段真实写入一条信息会同步原子更新消费的kafka的offset,用于对后面进程重启等恢复数据做准备。
  4. 当进程重启或者异常退出时候,会读取metadata文件中的最后一条kafka offset进行重新消费增量在内存中重新构建新的正排、文档和倒排等信息,完成数据的恢复。

微信图片_2025-05-14_105722_614.png

实时段固化和段合并策略

实时段固化逻辑:

  1. 当实时段内随着增量写,doc文件大小超过128M时候会进行内存实时段固化操作。
  2. 固化操作开始时,会先生成新的内存实时段,老的内存实时段会变成只读内存段。
  3. 遍历按整个只读内存段,构建新的索引和新的正排结构生成新的静态段。

段合并策略:

  1. 实时段固化的小静态段因为大小比较小,会优先和之前固化后的小段进行合并,按照1,2,4,8进行合并,逐步合并成静态段最大的上限。
  2. 静态段的合并触发策略是当静态段中delete的doc比例超过了30%会触发静态段之间的合并,合并会按照近邻合并原则,从左右近邻中选取一个最小doc数的段进行合并,进而新生成一个新的段。

微信图片_2025-05-14_105730_298.png

查询和更新中的并发控制

查询流程

引擎查询时候,先遍历查询实时段,然后再查询静态段。实时段查询存在最大增量查询截断,当实时段查询到最大增量截断时实时段停止查询。

实时段查询后,查询静态段。静态段中包含了全量构建索引的全量最大offset记录同时全量的doc是通过质量分进行排序,所以在全量段查询的时候,先遍历质量分最大的全量段,逐步往后面静态段查询,直到查询到全量截断。

实时段查询和静态段查询结果进行merge作为最终的查询结果。

更新并发控制

因为DSearch2.0的索引更新是直接在实时段或者静态段进行更新,所以存在多线程读写问题。尤其是正排字段更新写入量大更新频繁。同时更新涉及到所有的实时段和静态段,较为复杂。

为了解决正排字段和倒排的更新问题,新版本引擎引入了document文档锁池,对每个doc进行hash计算落到锁池中具体一个锁上来减少锁冲突,当前锁池内有多个个文档锁。文档锁在文档进行拷贝和更新的时候会进行锁住。

DSearch3.0搜索核心升级

异步非阻塞图调度框架

微信图片_2025-05-14_105739_148.png

引擎主要改造:

  1. 图框架支持RPC异步非阻塞请求:引擎图框架RpcServer服务使用brpc的异步处理无需同步阻塞等待调度完成,只需框架调度完算子返回结果,不阻塞RpcServer线程,例如:当前引擎调用neuron服务是同步调用,当neuron服务负载高阻塞时,同步调用会导致拖住引擎RpcServer处理线程,新的异步非阻塞模式引擎client在调用引擎后已经返回,等待引擎RpcServer中异步调度框架中remote异步算子回调,减少外部服务影响引擎。
  2. 减少线程切换: 图框架调度器会优先调度当前运行线程,同时使用M:N类型的bthread线程池,线程切换会更小,执行效率高。
  3. RPC服务和框架算子独立: 引擎RPC服务和框架算子完全解耦,跨集群部署算子服务无需任何改造,实现算子脱离运行环境。
  4. 高效的算子异常处理和超时机制: 每个算子维护自己的运行超时时间和请求到算子调度执行的超时时间,对整个请求流程中各算子执行更加精准。 
  5. 动态图支持: 图框架支持静态图和动态图业务组合式调用。支持静态子图和动态子图调用等复杂业务组合。
  6. 复杂子图支持: 图框架支持嵌套子图,支持自调用模型,可以实现复杂单节点多功能调用。

算子间数据交换Table设计

微信图片_2025-05-14_105745_282.png

引擎主要改造:

  1. 列式数据共享优化: 算子交换数据全部存放在Table列中,Table中全部共享列式数据,省去大面积数据拷贝,大幅提升引擎业务执行性能。
  2. 兼容引擎索引中doc数据: 引擎索引中doc行式存储有很多优点,比如多字段访问效率高等,Table设计中考虑了行式存储优点,不仅存高频的列字段也储存了引擎内部的doc以及对应FieldDef,能直接方便访问索引数据,接口统一,易于迭代。
  3. 打通FlatBuffer序列化协议: 当前引擎FlatBuffer序列化传输协议和引擎内部数据出口需要多次遍历转换,需要拷贝很多数据,新Table的设计内部数据列和FlatBuffer内部的数据列互转互通,节省大量内部拷贝同时避免了字段兼容等问题。
  4. 支持原地排序和标记删除: Table数据表,支持原地sort操作和标记删除操作,节省数据排序时大量数据的拷贝和删除操作中导致的数据重排等拷贝操作,提升性能。

算子间数据交换Table设计

微信图片_2025-05-14_105752_514.png

引擎主要改造:

  1. 动态图支持: DSsearch3.0支持动态图编排,主要通过业务方通过动态编排请求来组织对应的算子编排逻辑,实现业务方自主编排调度逻辑,方便整体业务开发。
  2. Remote远程调用支持: 通过开发远程异步调用算子,支持DSearch3.0跨集群调用,实现多机算子化互联互通。提高引擎的整体纵向拓展能力。
  3. 引擎算子库复用: 通过设计统一的算子接口,开发基础的可复用框架算子,支持配置化组合运行图,实现业务逻辑快速复用和开发,提高整体引擎开发效率。

三、性能和效果提升

DSearch在2024年Q1季度索引升级开发完成后逐步推全到交易和社区等各个主场景业务中,最后拿到了很多超预期结果:

索引内存优化超出预期: 社区搜索和交易搜索总索引单分片优化60%。

构建和写入性能优化超出预期: 社区搜索和交易搜索主表写入性能提升10倍。

索引更新优化超预期: 社区和交易主表更新时间提升接近10倍。

性能优化符合预期: 社区搜索平均rt降低一倍,P99晚高峰降低2倍。

四、总结

DSearch引擎从开始的DSearch1.0的搜索引擎逐步经历了DSearch2.0的分段式索引改造升级,又经历了DSearch3.0的全图化引擎升级。逐步将DSearch引擎升级到业界较为领先的支持内存型、磁盘型多段式搜索引擎,为支持得物业务的发展做出了重要的贡献,后续DSearch会围绕着通用化、自迭代、高性能等多个方向继续升级,将DSearch引擎迭代到业界领先的引擎。

算法团队大量HC,欢迎加入我们:得物技术大量算法岗位多地上线,“职”等你来!

往期回顾

1. 以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

2. 最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术

3. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

4. 得物自研DGraph4.0推荐核心引擎升级之路

5. 大语言模型的训练后量化算法综述 | 得物技术

文 / 苏黎

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

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

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

以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术

一、前言

在得物技术部,「稳定」「效率」「体验」「成长」「创新」是我们的关键词。这些关键词就像是战略航行的导航系统:在短期诱惑前构筑认知屏障,筛选干扰项;在组织进化中沉淀文化基因,保持创新。其中的「成长」就意味着专业深耕中永不自满的自我迭代、跨边界协作中主动打破能力天花板的勇气,以及在成就业务目标的同时构建个人价值护城河的清醒认知。

作为得物技术保障部的容器技术团队成员,孟同学在入职两年内迅速成长为团队标杆人物,其主导的【一站式大模型训练与推理平台项目】 不仅极大降低了大模型接入成本,在社区、客服、公司内部应用等场景成功落地,增强了业务价值与用户体验。在公司内外多次积极分享技术成果,提升了公司技术影响力,更以极致细节与自驱力在内部形成示范效应。

正值Q2成长宣传季,技术运营牵头做人物采访,本季度将会采访两位在得物成长比较快的同学,看看他们究竟做了什么?又是如何将「成长」「自驱」融入工作中?今天我们来看看第一位同学「孟同学」,看看他背后的故事。

二、初心与选择:得物的创新很吸引我

孟同学之前在多家互联网公司工作,包括腾讯、Paypal、唯品会、蚂蚁,2019年后阿里达摩院从事算法工程开发;2022年10月加入得物,在得物容器技术从事算法工程相关工作,主要负责得物大模型平台的相关业务。

当时,得物发布了一个云原生AI的职位,要求既有云原生技术背景,又能涉猎AI领域。这两个方向在都是业界的热门趋势,一时间挺难招到比较合适的人,这时候孟同学出现了。他正好在阿里达摩院从事类似的工作,且具备一定的云原生与AI的经验。他说:业界大多数岗位通常会专注于云原生或AI某一方向,但得物把这两者结合起来招聘,给了他一个新的视角和机会。于是他就抱着试试的心态来了得物。

他说:“这样的职位可以让我在专业技术上做一些新的探索,尝试将云原生与AI融合,可能带来更多创新的空间。带着“试试看”的心态,我投递了得物的这个职位,最终决定加入得物,去应对这个充满挑战和机遇的新环境。”

加入得物后,得物的文化和他也超级契合,他说,“在文化价值观中,最吸引我的是得物对 “效率”和“创新” 的高度重视,在当前快速发展的科技环境中,得物不仅倡导快速迭代,还鼓励在保证高效执行的基础上,持续创新并不断突破常规。我现在所在的容器团队就深刻体现了这一点。我们的工作模式通常是先通过小范围验证,快速实验新技术或方案的可行性,确认其技术路径可行且能够带来预期收益后,再进入大规模的开发与应用。这样整个容器团队紧跟技术发展,高效上线了很多新的好的技术优化方案。”

他也是得物技术飞速成长的员工之一,当技术运营问到他如何理解「成长」这一关键词,他表示, “成长”是一个持续自我突破和不断提升的过程。在快速变化的环境中,技术人员不仅要不断提升专业能力,还需要敢于走出舒适区,迎接新的技术挑战。成长不仅体现在技术上,更是一种心态,保持持续学习和反思的能力,追求更高标准。

他在主导“一站式大模型推理平台项目”时,面临的最大挑战是如何降低大模型接入的高成本。2023年初,刚开始接入大模型时,由于推理引擎性能较低,需要大量GPU卡,很多业务难以落地。如果按常规思路,这个项目很难推进。既然常规思路走不通,那么他就换了个思路,通过主动关注社区的最新论文和开源代码**,去尝试社区中提到的优化技巧和加速方案。通过把社区的创新思路与得物内部场景结合,快速验证并落地到得物内部的大模型推理平台中,将优化方案应用到实际场景中,降低了成本并提升了平台性能。

Sean曾在π问π答说过:成长 = 当你遇到复杂问题的时候,能解决复杂问题。 就是说,在这个过程中,克服困难,牵头思考解决方案、寻找解决方案,这样的成长是最快的。回顾孟同学的成长过程,亦是如此。

三、专业与细节:细节直接影响整体效果

在从0到1 建设一站式大模型训练推理平台时,孟同学也遇到了很多问题。包括做这件事的 ROI 是什么?为什么要做这个项目?怎么落地?需要谁来协同?

ROI是什么?

从大模型在业内的落地情况来看,大模型相比传统小模型在效果上有显著提升,尤其在处理复杂任务,的确很有价值。但是从一个开源的大模型到能落地到我们的实际业务场景,需要投入较大的人力资源、研发周期、机器成本等。当时孟同学也是实打实去做了很多调研,包括从初期的人力投入、开发周期,到后续的计算成本,这些都是需要深入权衡ROI的因素。

当我问到,如何平衡收益和建设时,孟同学说一般从以下角度进行权衡:

一是聚焦核心应用场景:我们在选择大模型应用场景时,会优先聚焦那些能够带来最大业务增值的领域。例如,在客户服务和社区管理等场景中,大模型可以有效提高自动化水平,改善用户体验,从而大大提升效率,预期收益也比较显著。通过与算法团队的紧密合作,将大模型的应用精准落地到这些高价值场景中,我们能够在资源有限的情况下,最大化模型的价值和投入产出比。

二是持续优化大模型性能,降低大模型部署成本:大模型的部署成本高是不可忽视的现实,但我们注重的是在实施过程中持续优化大模型的性能,并结合社区最新的大模型推理优化技术进行调整。例如,我们引入了最新的Radix Attention,并行推理,大模型量化,DeepSeek MTP推理加速等技术,结合得物的具体业务场景,进行多方面的性能优化。这些优化不仅提升了大模型在实际应用中的效果,也有效降低了大模型部署的成本,从而实现更好的ROI。

三是通过资源池合并,多部门共用GPU资源的方式降低大模型训练与推理成本:在这方面,我们通过构建多个部门共用的大模型训练资源池,来降低大模型训练成本。在推理阶段,我们通过复用空闲的GPU资源,提供大模型的公共服务,等多种方式,使得多个部门可以共享这些资源,从而降低了大模型的推理成本。这种资源池的合并和共享方式,使得我们能够更加高效地利用公司现有的计算资源,降低整体的开销。

四是持续优化大模型训练与推理平台的效率,缩短上线时间:在训练与推理平台的效率优化上,我们做了大量的工作,通过构建更加高效的训练和推理流水线,减少了大模型上线的时间。同时,我们通过一键微调、一键部署等功能,使得业务方同学能够快速根据业务需求调整和部署大模型。这个自动化的流程不仅提高了工作效率,也大大缩短了从模型开发到实际应用的周期,进一步提升了项目的ROI。

通过上述方法,他们在建设大模型平台时能够尽量控制成本,优化投入产出比,确保大模型的业务落地在合理的时间周期内能带来可观的收益。

为什么做这个项目?

ROI 和团队说清楚了之后,还要跟团队说清楚,我们为什么要做「大模型训练推理平台」,以及怎么去做?

在这个项目中,孟同学担任了一个二合一的角色,既负责产品设计,也负责功能开发。时间回溯到2022年底至2023年初,伴随着ChatGPT的发布,大模型概念的爆发式增长**。与此同时,公司内部也有很多同学开始关注如何部署大模型、如何利用大模型为他们的业务带来实际的收益。当时面临的问题,业务需求不断增加,大模型不断发展,没有统一大模型专用平台,更别说利用大模型来为业务做有效支撑和带来实际收益了。

孟同学就意识到,必须尽快构建一个大模型的专用的平台,让大家能够在这个平台上以低门槛的方式使用并接入大模型。在这个背景下,孟同学他们就开始构建一站式大模型训练与推理平台。

怎么做?

在落地这个项目过程中,还需要考虑到几个实际问题。

首先这个平台支持大模型的快速部署。伴随大模型概念的火爆,通过Lora**微调大模型的方式,因其成本低,效果好,很快流行出来了。于是他们把大模型微调功能也加到平台上了。这样很多业务方便可以使用少量数据,以较低的成本快速微调他们自己的专用大模型。这个便是一站式大模型训练与推理平台最初的架构。但那个时候很多云厂商都还没有相关的平台,落地全靠一步步摸索。

从收益角度来看,首先,通过集中训练与统一部署大模型,我们可以进行统一的优化与资源配置,显著降低了大模型训练与部署的成本。其次,这个平台打破了技术壁垒,使得公司内部非算法同学也能够通过平台自助式操作,基于自己的数据进行模型微调与快速部署。

最终「一站式大模型训练推理平台」也是在得物内部顺利落地,不仅极大降低了大模型接入成本,在社区、客服、公司内部应用等场景成功落地,增强了业务价值与用户体验。

项目成功上线后,有项目小伙伴吐槽到,孟同学对「用户动线设计/代码注释规范」简直是有“强迫症”。

孟同学表示,强迫症肯定没有,就是有点爱抠细节。

在每个项目开始之前,他都会与业务方进行详细的需求梳理,并通过多轮评审确保需求的准确性和可执行性。这种做法在开发大模型平台时,帮助他们避免了许多潜在的风险。

他还说:“在工作中,细节直接影响整体效果,尤其是在用户动线设计和代码注释规范上。用户动线设计关系到用户体验的流畅性,而代码注释则是团队协作的关键,能帮助成员快速理解和优化代码。任何细节上的不足,都可能导致后续问题的产生。  

例如,平台操作步骤过于复杂或逻辑不清晰,会直接影响用户体验和平台的使用频率。在大模型平台的设计过程中,通过反复优化用户操作流程,简化步骤,减少不必要的点击,确保用户体验顺畅。”

四、自驱与成长:补齐短板,让长板更长

从项目牵头设计到最终落地,孟同学的成长无疑是非常快的,在沟通过程中,还发现孟同学是一个「自驱」的小伙伴,入职后,主动牵头了向量数据库Milvus平台构建这个项目。这对他来说完全是一个全新的领域,但是他竟然可以在短时间学习相关知识,快速补齐短板。

图片

他说,“我有幸牵头了Milvus向量数据库平台的建设项目。虽然我之前有一定的数据库和分布式系统的经验,但向量数据库的应用和优化对我来说是一个全新的领域。这个挑战让我希望能够学习并掌握更多技术,拓宽自己的视野,提升专业能力。”

为了尽快弥补不足,他采取了两个方法。

一是,通过多种途径学习Milvus相关的理论和实践,深入理解其原理和应用,特别是如何处理大规模向量数据、优化索引和提升检索效率等。这样,不仅积累了经验,还能帮助他在项目中做出更加合适的技术决策。

二是,加入了Milvus开源社区,积极与社区的开发者和专家进行交流,主动去向社区专家请教问题,了解他们的经验和解决方案。这种互动不仅让他学到了很多实用的知识,还获得了很多帮助,也让他能更好地理解Milvus的最新动态和功能。

在项目的早期,Milvus的某些版本在高并发和大规模数据量下存在稳定性问题。为了解决这些问题,他们进行了多次性能压测,分析系统瓶颈并向社区反馈,最终在社区的帮助下逐步优化了性能,确保了平台的稳定和高效运行。这个过程中,他积累了Milvus的系统调优经验,也加深了对Milvus架构的理解。

除了工作上的成长外,孟同学还经常受到来自外部行业大会的邀请去分享相关的实战经验。孟同学说,“我认为行业分享是自我成长的途径。” 在准备分享时,他会回顾自己的工作,思考技术的有效性,帮助他识别和改进可能忽视的细节。

他认为,“每次分享不仅是与他人交流,也是提升自己知识体系的机会,促使自己可以不断学习和拓展知识。分享还可以结识行业内外的优秀实践和新朋友,吸收新见解,拓宽视野,促进与其他专家和企业的合作,这对个人成长和公司影响力都很有益。”

微信图片_2025-05-12_140458_402.jpg 对他来说,分享虽然需要消耗时间和精力,但他认为这是长期投资,提升个人影响力,推动团队进步,为公司带来更多价值,这是一件值得长期去做的事情,不断地去通过持续学习、分享,自己也会不断的向前探索。

五、工作与平衡:“计划驱动”和“灵活调整”

有时候他们也会面临紧急项目,当项目和生活中重要事情冲突时,孟同学表示,“我始终保持 “计划驱动”和“灵活调整” 相结合的方法,以确保项目按时交付并达成预期的业务结果。我的经验是先从小规模验证开始,再逐步扩大应用,确保每一步都有清晰的反馈和调整。”

以大模型平台项目为例,在项目初期,他们构建了最小可行产品(MVP),并邀请相关同学进行试用。虽然前期看似投入了较多时间,但通过小范围的验证,能够在功能扩展前发现潜在问题,确保后续的推广和扩展更具保障。这种方式避免了大规模投入后发现问题的风险,并让他们能在优化过程中积累实际经验。

类似地,在进行推理服务性能优化的CPU与GPU分离项目时,也是先进行了小范围验证,并在验证效果良好后,才将其正式上线并在更多业务中推广。尽管前期验证看起来会浪费一些时间,但通过实际数据的反馈,他们就及时优化了方案,最终大规模部署时效果显著,节省了成本并提升了性能。

他表示,“这种逐步推进、快速反馈与调整的方法,帮助我们在高压环境下保持灵活性,确保项目能在预定时间内顺利完成,并且保证了最终的业务收益。”

六、展望与建议:保持成长型思维,勇于突破自我边界

孟同学说“未来三年,他也会持续专注于大模型的部署性能优化和应用场景落地。”

当前,大模型的推理性能和高昂成本是制约其广泛应用的主要因素,特别是在计算资源和效率方面。与此同时,像Rag,Agent这样的应用场景在各行业的落地也面临一些技术挑战,仍需要更深入的研究。

他也有自己一些学习方向分享给大家。

一是通过多种方式为自己积累相关的知识和经验。比如参与一些项目,关于如何优化大模型的计算效率,并降低推理成本。

二是积极参与开源社区的讨论,跟踪相关领域的技术进展。通过阅读最新的论文和开源代码,去了解了当前大模型优化的前沿技术,并从一些专家那里获得了宝贵的指导,帮助他更好地理解这一领域中的技术挑战。

他也会持续关注新的场景融合,比如,探讨如何将大模型与云计算结合,特别是在云原生环境下如何提高资源调度效率,进一步提升大模型的训练和推理性能。

我们也相信在未来三年,孟同学在大模型的优化和应用落地方面会有更多的积累,并能为行业提供更加实用的技术解决方案。一说到孟同学都纷纷说,对,就是那个大佬!

当问到他对新入职的小伙伴有什么建议时,他说: “保持成长型思维,勇于突破自我边界。”

在职场初期,很多人会遇到不熟悉的工作和挑战,可能感到不安或迷茫,但这些正是成长的机会。孟同学在入职初期也经历了不少挑战,特别是在跨部门协作方面。刚开始时,他会对如何协调各部门的需求和资源感到不确定。

当时他参与的一个大模型平台项目,项目初期需要与多个部门沟通确认需求,每个部门的系统和流程都不相同,信息的对接和沟通也很复杂。为了快速推荐和落地,他主动向经验丰富的同学请教,逐步了解各团队的工作流程,并通过与各部门的同学逐一沟通,确保每个环节都能顺利衔接。

他说,“不必害怕犯错或显得不成熟,向有经验的同学请教能让我快速融入团队,学到更高效的工作方法。同时,我也学会了通过反思总结,不断找出自己的优点和不足,每完成一个任务后回顾自己在其中的表现,这让我在之后的工作中更加从容、不断提升。职场中的很多机会常常来源于那些需要学习新技能、走出舒适区的挑战。虽然这些任务看似困难,但正是通过解决这些困难,才能带来更大的成长空间。因此,保持开放的学习心态和积极迎接挑战,是职场新人最重要的品质,它不仅能帮助你在工作中不断进步,也为未来的职业发展打下坚实的基础。”

通过和孟同学的对话,我们看到的不仅是一个将“反复打磨”刻入日常的细节控,更是一个在时代快变中锚定自我进化节奏的长期主义者——他用行动验证:真正的成长从非宏大口号,而是把每个需求拆解为精进机会,将每次压力转化为认知升级的燃料,在“自驱”而非“他驱”的节奏中拓宽能力象限。

当组织与个体形成双向奔赴的成长型契约,那些被认真对待的代码、反复推敲的方案、深夜迭代的模型,终将沉淀为个人不可替代的价值坐标。你就只管往山顶走,走过的路自然都会变成我们的台阶。 你要坚信,时间从不辜负认真打磨自己的人。共勉!

往期回顾

1. 最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术

2. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

3. 得物业务参数配置中心架构综述

4. 得物增长兑换商城的构架演进

5. 得物自研DGraph4.0推荐核心引擎升级之路

文 / 得物技术

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

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

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

最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术

一、前言

MCP 全称 Model Context Protocol,是由 Anthropic公司在 2024 年 11 月推出一个开放协议,主要用于标准化应用程序向大语言模型提供上下文的方式。可以将 MCP 想象成 AI 应用程序的 USB-C 接口。就像 USB-C 为设备连接各种外设和配件提供了标准化方式一样,MCP 为 AI 模型连接不同的数据源和工具提供了标准化方式。

微信图片_2025-05-08_114916_097.png

近期 MCP 的热度持续上升,网上也是喷涌出大量相关文章,相信在不远的将来 MCP 将成为每个开发者必备的技能之一,非常值得投入时间学习一下。下面会通过简单的实践来带大家理解一下 MCP 的工作原理,以及展望下 MCP 在未来可能的一些应用场景。

二、MCP 基础架构

基础架构

在开始实践之前,还是简单介绍一下 MCP 的基本架构和一些基础组件:

微信图片_2025-05-08_114957_376.jpg

  • MCP Host:需要通过MCP访问数据的程序,例如 Claude Desktop、Cursor**、Cline等桌面工具。

    主要职责:接受&返回你的提问、跟模型交互、内置了 MCP Client,与服务器保持一对一连接的协议客户端。

  • MCP Server:轻量级程序,每个程序都通过标准化的模型上下文协议 (MCP) 提供特定功能。

    主要职责:能力暴露(操作本地文件&浏览器,访问数据库,访问远程服务)。

  • 本地数据源:MCP 服务器可以安全访问的数据库、本地文件、浏览器等。

  • 远程服务:MCP 服务器可以通过互联网(例如通过 API)连接到的外部系统。

工作流程

从用户提问,到最终完成任务的完整流程可参考下图:

图片

百闻不如一见,百见不如一练。下面我们手把手开发一个 MCP Server,并且通过 Cline 来使用它,实践过程中会容易帮助我们去理解 MCP。

三、MCP Server 开发&实践

准备MCP Client

这里我用的是 Cline,是 VSCode** 中的一个插件,直接在 VSCode 插件市场中搜索安装即可,其实这里的 Cline 在 MCP 的概念中是 MCP Host,只是 Host 里面内置了 MCP Client(负责跟模型&MCP Server 交互)。

其实更推荐使用 Claude,但是 Claude注册流程相对复杂一点,对网络环境要求也更高(需要科学上网)。

图片

安装好后,第一步就是需要配置大模型,这里我选择的是 DeepSeek。

需要自行购买 API Key(platform.deepseek.com/api_keys)

图片

然后就可以开始配置 MCP server 了,点击右上角的第二个图标。

图片

这里可以使用开源的 MCP Server,也可以使用自己开发的 MCP Server,下面我们尝试自己动手开发一个简单的 MCP Server。

开发MCP Server

想要开发一个 MCP Server,并不需要关心协议本身的一些细节,因为官方推出了各种语言的 SDK modelcontextprotocol.io/sdk/java/mc… ,通过 SDK 可以快速搭建一个 MCP Server,并且主流语言都针对 MCP 推出了自己的框架,Java 也不例外,这里我们选择使用 Spring 框架来搭建一个 MCP Server docs.spring.io/spring-ai/r…

引入依赖

<dependency>    <groupId>org.springframework.ai</groupId>    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId></dependency>

定义 Tools

这里我们定义一个发送飞书消息的工具类:

import org.springframework.ai.tool.annotation.Tool;import org.springframework.ai.tool.annotation.ToolParam;import org.springframework.stereotype.Service;import com.lark.oapi.Client;import com.lark.oapi.core.cache.LocalCache;import com.lark.oapi.core.enums.AppType;import com.lark.oapi.service.im.v1.enums.MsgTypeEnum;import com.lark.oapi.service.im.v1.enums.ReceiveIdTypeEnum;import com.lark.oapi.service.im.v1.model.CreateMessageReq;import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;import com.lark.oapi.service.im.v1.model.CreateMessageResp;import java.util.concurrent.TimeUnit;/** * @author xinyi */@Servicepublic class LarkService {    private final Client larkClient = feishuClient();    public Client feishuClient() {        return Client.newBuilder(System.getenv("larkAppId"),                   System.getenv("larkAppSecret")).appType(AppType.SELF_BUILT) // 设置app类型,默认为自建                .tokenCache(LocalCache.getInstance()) // 设置token缓存,默认为内存缓存                .requestTimeout(10, TimeUnit.SECONDS) // 设置httpclient 超时时间,默认永不超时                .logReqAtDebug(false)                .build();    }    @Tool(description = "用飞书给用户发消息")    public String sendLarkCardMessage(@ToolParam(description = "接收人邮箱") String receiveEmail,                                      @ToolParam(description = "飞书卡片内容(参考飞书文档要求的结构体)") String cardContent) throws Exception {        CreateMessageReq req = CreateMessageReq.newBuilder().receiveIdType(ReceiveIdTypeEnum.EMAIL.getValue())                .createMessageReqBody(CreateMessageReqBody.newBuilder()                        .receiveId(receiveEmail)                        .msgType(MsgTypeEnum.MSG_TYPE_INTERACTIVE.getValue())                        .content(cardContent)                        .build())                .build();        CreateMessageResp resp = larkClient.im().message().create(req);        return resp.getMsg();    }}

这里 Spring 会自动把@Tools 注解的方法按照 MCP 标准暴露出来,大模型会根据其中的描述来决策是否可以调用此方法。

启动类

import org.springframework.ai.tool.ToolCallbackProvider;import org.springframework.ai.tool.method.MethodToolCallbackProvider;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.annotation.Bean;@SpringBootApplicationpublic class McpServerApplication {    public static void main(String[] args) {        SpringApplication.run(McpServerApplication.class, args);    }    @Bean    public ToolCallbackProvider weatherTools(LarkService larkService) {        return MethodToolCallbackProvider.builder().toolObjects(larkService).build();    }}

打包

到这里一个简单的 MCP Server 就已经开发完成了,下面只需要执行 mvn clean package 打成可执行 jar 包就能配置到 Cline 中了。

图片

配置MCP Server

回到 VSCode 的 Cline 插件,点击第二个图标,然后点击下面的 Configure MCP Servers,然后开始编辑右侧的配置文件:

图片

这里的配置文件是 MCP 标准化的,下面基于我们这个 MCP Server 介绍下几个核心配置的含义:

 "mcpServers": {      "lark": {        "disabled": false,        "timeout": 60,        "command": "/Users/admin/Documents/jdk-17.jdk/Contents/Home/bin/java",        "args": [          "-Dspring.ai.mcp.server.stdio=true",          "-Dspring.main.web-application-type=none",          "-Dlogging.pattern.console=",          "-jar",          "/Users/admin/Documents/git/open-source/spring-ai-mcp-server-demo/target/spring-ai-mcp-server-demo-1.0-SNAPSHOT.jar"        ],        "env": {          "larkAppId": "xxx",          "larkAppSecret": "xxx"        },        "autoApprove": [          "sendLarkCardMessage"        ],        "transportType": "stdio"      },
  • mcpServers:JSON 配置跟 Key

  • lark:MCP Server 唯一标识&名称

  • command:启动 MCP Server 的命令(如 Java 就是 java -jar,Node 一般是 npx,Python 一般是 uvx)

  • args:执行命令后面的自定义参数

  • env:环境变量,用于配置一些可配置参数,比如密钥、外部 URL 等

这里配置好了后,如果右上角的点变成了绿色说明 MCP Server 加载成功,而且在下面还可以看到 MCP Server 提供的所有 Tools,以及每个 Tool 的参数跟描述。

图片

开始体验

点击右上角的+号开始聊天:给我发一条下午好的飞书卡片消息,附带一下今日的热点新闻。

图片

可以看到 Cline 调用了大模型开始思考,并且根据 MCP Server 提供的 Tools 开始选择发送消息接口并执行。

图片

而且如果第一次尝试失败,还会自动纠错,最后成功调用了我们 MCP Server 提供的 Tools,发送了一条消息给我。

图片图片

进阶体验

上面的例子我们只用到了一个 Tools,我们可以尝试组合多个 Tools&多个 MCP Server 来实现更复杂的任务,比如我们现在再开发一个可以操作 ES 的 MCP Server,然后打包后配置到 Cline 中。

@Tool(description = """        通用ES查询工具,参数示例:        path: 请求路径        method: HTTP请求方法 GET 或 POST        queryJson: 具体请求体        """)public String searchByQuery(        String path,        String method,        String queryJson) {    String url = String.format("%s/%s", System.getEnv("esBaseUrl"), path);    HttpEntity<String> request = buildEsRequest(queryJson);    ResponseEntity<String> response = restTemplate.exchange(            url, HttpMethod.valueOf(method), request, String.class);    return response.getBody();}

配置好后,在对话中发送:分析一下 es 集群目前的索引分布,重点分析一下哪些索引的分片设置不合理,最终整理后飞书发给我。

图片

然后会根据请求 ES 返回的结果,再次吐给模型进行分析。

图片

最终整理后通过飞书发送一份简单报告。

图片

联想一下

想象一下,如果组合一下飞书文档、浏览器操作、文件系统、发布系统对接等 MCP Server,一句话就可以让大模型从自动连接浏览器,打开飞书文档,分析需求,分析视觉稿,然后自己写代码,对比视觉稿,你就喝杯咖啡,静静的看着它工作。

顺带推荐一下常用的 MCP Client 以及一些现成的 MCP Server。

四、总结

相信大家通过上面的实践后,对 MCP 有了一个基本的认识,组合多个 MCP Server 的工作流可以自主完成非常复杂的任务,关键是这协议统一了连接标准,有大量现成的 MCP Server 可以即插即用,大幅降低建设成本。总之 MCP 协议的持续落地,让 AI 不再只是聊天工具,而是工业智能革命的万能操作平台,在未来潜力无限,想象无限,值得每一位开发者去学习并掌握它!

往期回顾

1. 得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践

2. 得物业务参数配置中心架构综述

3. 得物增长兑换商城的构架演进

4. 得物自研DGraph4.0推荐核心引擎升级之路

5. 大语言模型的训练后量化算法综述 | 得物技术

文 / 新一

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

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

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

大语言模型的训练后量化算法综述 | 得物技术

简介

在模型轻量化领域,量化是一种用于减少神经网络模型大小和计算量的技术,将模型参数(权重)或中间变量(激励)从高精度类型(FP32, FP16, BF16等)转换为低精度类型(int8, int4, fp8等)。 而近年来随着Transformer,MoE等架构的提出和大模型的兴起,使得神经网络模型能轻松突破几十亿甚至上万亿的规模参数,因此,我们需要一些适应于大模型的压缩技术,来降低模型的部署成本,并提升模型的推理效率。

从最初的GPTQ、AWQ等weight-only的量化算法开始,到现在LLM从训练、推理、轻量化、Agent等所有赛道都卷到飞起的时代,基于大模型的特性,在两年多时间里业内已有很多新的量化算法。 

概念

以下介绍一些模型量化中的概念。

量化

  • 量化感知训练(Quantization Aware Training, QAT):训练过程中插入伪量化算子,通过训练时统计输入输出的数据范围并动态调整量化参数,将量化过程结合到模型的训练过程中,适用于对模型精度要求较高的场景。

  • 训练后量化(Post Training Quantization, PTQ):模型训练完成后对其参数进行量化,通常只需要少量校验数据或不需要校验数据,简单高效,不需要训练,但通常相比QAT精度损失略大。

由于LLM通常训练成本巨大,所以PTQ在LLM中通常是主要的量化选择,本文后续主要介绍各种PTQ的方案。 

量化对象

  • Weight:即模型的权重,在LLM中主要指Linear算子的权重。权重量化可减少模型显存开销。

  • Activation:在模型前向计算过程中的输入输出变量,通常不会单独量化激励张量,而是结合权重量化一起。在LLM中激励矩阵的数值变化范围相比权重更大,有更多离群的异常值,因此相比权重量化更难。

  • KV Cache:除了权重和激活之外,在LLM的 KV Cache作为减少重复计算的特殊存在,会消耗不少的显存。 因此,KV Cache量化在LLM推理中减少显存开销,提升吞吐也很重要。

在LLM中,对Weight和Activation而言,通常有只量化权重的weight-only方法和weight & activation都量化的方法;另外为减少KV Cache的计算开销,也有对其进行量化。

细粒度

  • per-tensor量化:逐张量量化,或逐层量化,每个张量只有一个缩放因子。

  • per-channel 量化:逐通道量化,每个通道都有不同的缩放因子。

  • per-token 量化:主要对transformer中的激励矩阵而言,即逐行量化。在LLM中,常与权重per-channel 量化配合使用。

  • per-group:以组为单位,多个元素成组共享一个缩放因子,如GPTQ、AWQ常用的128个元素为一组进行量化,将通道划分为更小的子组,以实现更细粒度的精度控制。

其他维度

分类维度 类型 对比特点 适用范围
按是否需要额外校验数据 静态量化 不需要,通常速度较快。 常用于权重量化
动态量化 需要额外校验集对模型进行前向推理或后向传播,根据推理结果动态计算量化参数;相比静态量化速度较慢。 适用于权重量化和激励量化
按量化过程的时机 离线量化 在模型上线推理前,提前计算量化参数。 常用于权重量化和激励量化
在线量化 在推理过程中实时计算量化参数。 常用于LLM中的激励量化
按量化步长是否均匀 线性量化 量化步长固定,表示的范围均匀。计算复杂度低,硬件友好。 常用于基于通用GPU的量化方案
非线性量化 量化步长不固定,表示范围更灵活。精度损失更小,但计算复杂度高。对硬件支持要求更高。 用于基于专用芯片的量化方案
按量化范围是否对称 对称量化 量化数据范围以零值对称。零点值(zero-point)固定为0值,仅需考虑缩放(scale)参数。 用于权重量化和激励量化
非对称量化 量化数据范围为非对称。zero-point和scale参数都要计算。 权重量化和激励量化通常不会同时为非对称量化

量化方法摘要

GPTQ

GPTQ是一种weight-only的量化方法。它的特点是通过Hessian矩阵对每层权重做逐列量化,并在每列量化中通过反馈补偿之前的量化损失。它是LLM早期主要量化算法,因量化速度快和量化损失小,是早期在实践中被应用最广的算法。具体细节可参见之前的文章:
模型量化与量化在LLM中的应用

图片

GPTQ算法流程(图片来源:参考文献[1])

AWQ

AWQ(Activation-aware Weight Quantization) 也是一种weight-only的量化算法,也是早期主流的LLM量化算法,其特点是量化速度相较于GPTQ更快,且量化损失在多数量化方案和模型上相较于GPTQ也更小,到目前为止也是一种非常实用的量化方案。

AWQ出自深耕深度学习轻量化多年的HanSong团队,其主要原理是根据前向推理中的对应激励矩阵各个通道的数值,而非权重矩阵的通道数值来衡量权重矩阵各个通道的重要性,从而自动检索每个通道的缩放因子,并进而在优化损失函数中减小量化误差。具体细节也可参见之前的文章:模型量化与量化在LLM中的应用

图片

AWQ中的平滑过程(图片来源:参考文献[3])

HQQ

HQQ(Half-Quadratic Quantization)也是一种weight-only的量化方法,由其名称可知通过半二次优化的方法得到量化参数。相比AWQ和GTPQ,HQQ不依赖于校验数据集,不从最小化输出激励的角度优化,而是直接从权重本身优化量化前后的权重误差;而且其量化速度特别快,并且在低精度量化上有较好的量化误差。

优化目标如下,最小化原权重与量化反量化后的权重之间的误差,图片图片范数。

图片

  •  为量化参数(zero point和scale)

  • 图片  为量化、反量化过程。

  • 损失函数为图片范数,P<1, 相比于图片范数的均方差,图片 范数更关注权重数值中的长尾奇异值(outliers),以及矩阵的稀疏性,然而其非凸(non-convex)的特性需要优化函数做一定的转化。

优化过程

基于上述问题,引入一个额外变量图片让主优化函数分割成2个子优化问题;同时,为了方便使用迭代更新的过程解题,我们固定尺度参数图片,从而只优化零值图片

图片

通过交替优化的方法,可以写出如下子问题,以及超参的更新,

图片

sp1的形式是近端算子,对于图片范数,存在一个广义的阈值解如下,

图片

sp2可以通过量化公式代入,得到如下,

图片

并通过进一步简化(基于W的quantization grouping维度取均值),

图片

表现性能

HQQ的量化耗时非常短,以Llama2-70B为例,在A100上相比于GPTQ和AWQ,耗时分别缩短为1/50和1/25,同时也有着不逊色于前两者的量化精度损失;而Llama2-7B模型的量化耗时更是缩短到1分钟左右。 

图片

Llama2-7B量化:GPTQ, AWQ, HQQ三者的耗时对比

(图片来源:参考文献[6])

图片

Llama2-70B量化:GPTQ, AWQ, HQQ三者的耗时对比(图片来源:参考文献[6])

图片

HQQ 在group-wise量化模式下与GPTQ, AWQ等的性能对比(图片来源:参考文献[6])

SmoothQuant

SmoothQuant 是LLM量化领域首个对weight和activation做全量化,并能保障良好的量化损失,从而在实际中有广泛应用的量化算法,并以被Nvidia和Intel集成到各自的大模型量化工具TensorRT-LLM和Neural-Compressor中。SmoothQuant 也是由HanSong团队提出,因此也可在算法中看到相似的通道缩放操作。

该方法直接聚焦LLM量化困难的最主要原因,即transformer推理过程中的激活值中的异常值(Outliers)。激励矩阵中的异常值指的是绝对值比大多数的值大得多的元素值,异常值一直是量化领域的难点,是量化损失的重要来源,而LLM中的异常值尤难处理,因其通常持续存在于部分通道中,且量化过程中对其直接截断处理会对模型的生成能力造成重大影响。

图片

SmoothQuant中量化难度的迁移:激励矩阵中异常值的平滑(图片来源:参考文献[4])

该方法的核心是通过逐通道的缩放变换,使得Activation矩阵的绝对值幅度变得平滑,从而变得容易量化,而为了保障计算一致性,将反缩放因子作用到Weight中,稍微增加了Weight的量化难度;从而整体上使得模型的量化难度降低,且提高了量化精度。

量化过程

Transformer中常规的矩阵乘法表示为图片,SmoothQuant的矩阵乘法则表示如下,

图片

激励矩阵图片在列维度上每个元素除以图片, 权重矩阵图片在行维度上每个元素乘以图片,从而完成了对激励矩阵的平滑,以及保持整个乘法计算的一致性。

通道维度的缩放因子用对角矩阵图片表示,而如何对图片取值呢?作者提出了几种方案,

  • 一种是利用激励矩阵各个通道的绝对极值,即

    图片

  • 一种是利用权重矩阵各个通道的绝对极值,即

    图片

本质上,缩放因子的大小取值表达了我们要将激励矩阵量化难度的多少转移给权重矩阵。而以上的前者,容易将激活的量化难度向权重过度转移,从而导致权重量化难度大大增加;而后者会直接导致权重各通道的极值都相同,而激励依旧很难量化。

因而,一种平衡的方式如下,用α\alpha表示迁移强度,来控制激励量化难度迁移到权重的强度,图片

图片

而当图片时,下图表示了乘法计算中的缩放平滑过程。XW 在各自对应的通道计算绝对极大值,随后通过图片这两个向量计算得到缩放矩阵,再对XW 两个矩阵进行缩放变换,最后再对两个变换后的矩阵做乘法。 

图片

SmoothQuant矩阵乘法中的平滑过程示例(图片来源:参考文献[4])

表现性能

在量化模型的效果上,对比了同为Weight-activation量化的几种算法,SmoothQuant在多个数据集上的准确率表现突出。但作者没有对比同时代下的GPTQ、AWQ等weight-only的效果。

图片

SmoothQuant与W8A8和LLM.int8()的量化效果比较(图片来源:参考文献[4])

而在吞吐上,作者用CUTLASS实现了INT8乘法kernel,并将SmoothQuant集成到Pytorch之后,以W8A8方案为例,实现了在OPT模型上相比于原FP16模型在速度上1.56倍,以及在显存上1.96倍的优势。

图片

SmoothQuant经算子优化后与FP16和LLM.int8()的推理吞吐性能比较(图片来源:参考文献[4])

QuIP

在基于正交矩阵旋转优化大模型量化中的异常值(Outlier)问题的思路中,QuIP(Quantization with Incoherence Processing)是较早提出的一个方案。这种思路与SmoothQuant一样,都是在真正的量化步骤之前,通过对权重矩阵或激励矩阵做一定的前处理,使得该矩阵中的异常值改善或消失,使矩阵平滑,同时在整个前向推导中还能保持计算一致性。而与SmoothQuant直接对目标矩阵做尺度缩放不同,这种思路通常是通过对目标矩阵左乘、右乘正交矩阵,使得矩阵变得更容易量化。

该方案的主要主要亮点如下,

  • 一是分析矩阵中元素的绝对值分布,并定义了不相干性,将一个矩阵的量化难易程度具象化。

  • 二是提出了基于正交矩阵LDL分解的对权重矩阵的逐列量化方案,并证明了GPTQ也是该算法下的一种特殊情况。

  • 最后在低比特量化情况下,该算法证明了其性能优于之前的方案。 

矩阵的不相干性

作者定义了基于图片值的不相干性:当一个Hessian矩阵图片可以通过特征分解得到图片,且对所有的图片满足如下,

图片

那么我们说是的。 

而对于权重矩阵, 则定义其 如下, 

图片

以上定义中,矩阵的最大绝对值受限于图片值,而图片值越小,则越不相干,对应地,矩阵中的异常值就越少,也越容易量化。

量化过程

整体算法过程涉及到较为复杂的数学推导过程和大量定义和论证,其主要过程如下,

  • 第一步,对权重矩阵 图片做不相干性的前处理,使图片更容易量化,并作简单的量化处理;

  • 第二步,对Hessian矩阵(用图片计算,与GPTQ相同)做LDL分解;

  • 第三步,对图片进行逐列量化,每次量化当前列时,考虑前面所有已量化列的误差为反馈以缩小量化误差;

  • 第四步,逆不相干处理,以及反量化。

图片

QuIP算法的量化过程(图片来源:参考文献[5])

LDLQ

作者定义了一个基于LDL分解的最优化近似算法,自适应的近似过程可以是近似或随机(Near or Stochastic)。 根据以下公式逐层优化,

图片

作者定义了一个基于LDL分解的最优化近似算法,自适应的近似过程可以是近似或随机(Near or Stochastic)。 根据以下公式逐层优化,图片表示浮点权重,图片表示量化后的权重,图片表示输入矩阵,图片是其二阶矩阵,Hessian矩阵。

而对于每层Linear的图片,用如下形式作逐列量化,

图片

图片表示第k列权重,而图片表示第1到k-1列,图片表示量化后的第k列权重,Q表示对应的Near或Stochastic近似方法选择。图片表示某种序列的向量,也正是需要通过LDL分解求的校正项的系数。而整体的量化过程可以用矩阵的形式表示,用一个上三角矩阵表示LDL分解的系数矩阵,即图片 组成的矩阵,

图片

具体的不相干性处理和逆处理的算法过程可以参考论文中给出的细节。

图片

QuIP中不相干性的前处理和逆处理过程(图片来源:参考文献[5])

表现性能

下图是作者给出的对OPT模型权重层的处理前后,各层的元素值的不相干性的变化,可见在处理后,最大绝对值下降十分明显。

图片

OPT-2.7B模型在不相干性处理前后异常值数量的变化(图片来源:参考文献[5])

而在量化效果上,对比了同为weight-only的主流算法OPTQ(即GPTQ)在同比特情况下,多个验证集的准确率。在对Llama2-70b模型的低比特量化中,尤其是2-bit和3-bit, QuIP的效果明显,且没有崩坏。

图片

QuIP与OPTQ(GPTQ)在不同比特下的量化效果比较(图片来源:参考文献[5])

QuaRot

QuaRot(Quantization scheme based on Rotation)是基于旋转矩阵变换的一种量化方案,它的量化对象包括weight,activation以及KV cache。通过旋转矩阵,在保持一致性的前提下,去除中间变量的异常值,从而使量化更容易,这种模式应用于transformer中的Attention,KV cache和FFN中的激活值。

旋转矩阵

旋转变换利用的是正交矩阵先简单介绍一些相关的矩阵知识。

  • 正交矩阵图片是满足图片的方阵。

  • 旋转矩阵是正交矩阵。

  • Hadamard 矩阵是一个元素值都为{+1, -1}的正交矩阵。

  • Walsh-Hadamard矩阵是维度为图片 的Hadamard矩阵,

图片

图片图片是一个包含从{+1, -1}随机抽取的向量,可知图片也是正交矩阵。 

计算不变性

图片是一个权重矩阵,出现在Attention或FFN Block的左侧 (FFN中的图片图片,及Attention中的图片图片,那么可以将左侧乘以正交矩阵 图片 ,并通过将输出矩阵(FFN中的图片, 及Attention中的图片 )乘以图片来消除这种影响。

上述的计算不变性在当两个Block之间有RMSNorm时也是成立的。因为从概念上讲, RMSNorm对输入矩阵的每一行做归一化(其尺度缩放的参数会被吸收到就近的Linear权重),正交矩阵图片应用于 activation 矩阵不会影响范数。

图片

那么总的来说,对于一个Activation矩阵图片,右乘 图片,使得线性层的输入由图片变为了图片,被归一化之后送入下一个 Block,该Block 的输入权重现在是图片 ;即原本的图片 ,变成了图片, 输出不变,保持一致。 

量化过程

QuaRot总体分为2个阶段

  • 第1阶段,对transformer的前向过程进行旋转变换,具体是在Attention和FFN过程中插入离线Hadamard变换**和额外的在线Hadamard变换。
  • 第2阶段,利用现有的量化方法对weight进行量化,以及将量化过程加入前向过程使得对activation和cache进行在线量化。

第一阶段

第1阶段是对各个环节做Hadamard变换。 

图片

原Attention(包括RMSNorm): 实线箭头表示训练期间的变量流向,包括每个token的填充和推理

图片

原FFN(包括RMSNorm):门控前馈网络

图片

QuaRot版Attention:RMSNorm缩放alpha被吸收到输入矩阵,隐藏状态插入在线Hadamard变换进行旋转

图片

QuaRot版FFN:RMSNorm缩放alpha被吸收到输入矩阵,降采样Linear前插入在线Hadamard变换进行旋转。

QuaRot 量化前对transformer各个模块的旋转变换(图片来源:参考文献[7]

阶段1a: 权重调整,遵循计算不变性原理对权重做正交变换

对权重矩阵,例如图片, 首先,前面的LayerNorm 或 RMSNorm 的线性部分将被融合进来,再左乘随机Hadamard矩阵**图片,表示如下,

图片

其中图片表示归一化op被吸纳的线性部分,而对应输入的激励居住,则变为了图片

该操作对应Attention中的图片和FFN中的图片,而这样处理后对比处理前,激励不再包含异常值。

 

图片

旋转变换前后激励矩阵中异常值数量的变化(图片来源:参考文献[7])

阶段1b: 对FFN的输出插入在线Hadamard变换

该操作是针对下采样乘法图片的输入激励的异常值的处理。由上图可知插入了一个在线Hadamard变换算子,同时对下采样矩阵的参数做了补偿,使得图片

同时为了保障下一个Block的激励输入是带变换的,所以还需右乘一个图片,使得最终的变换形式是图片,保障FFN的输出为图片,作为下一模块的输入。 

阶段1c: 对Attention模块的注意力和Value的Hadamard变换

作者对注意力块同时应用了在线Hadamard变换和融入权重的离线Hadamard变换。 在计算注意力时,写成每个Head计算的维度,有如下形式,图片表示相应的Linear权重,

图片

其中,

  • 图片为softmax的输出,是一个维度为序列长度的方阵

  • 图片是单个Head的value矩阵,图片

  • 图片相乘后与图片相乘,上式表示逐Head的Attention模块输出图片的计算过程

首先对图片分别右乘和左乘,做Hadamard变换,带入上式,可知保持计算不变性。 

图片

图片分别有每个Head维度的图片concat而成,可以用单个Kronecker**乘法的形式表示对图片的变换,

图片

然后利用如下特性构建完整的Hadamard变换,

图片

  • 图片,先右乘了图片之后,再进行一次Hadamard Head操作(即图片图片表示注意力计算的输出),即相当于又右乘了图片,即总体右乘了 图片

  • 图片,先左乘了图片,再左乘了图片,所以总体左乘了图片

综上,所以总体上整个过程的设计保持了计算不变性。 

阶段1d: 对key的Hadamard变换

Key向量的计算也会收到异常值的影响,所以也需要引入Hadamard变换来消除这个问题。注意力矩阵图片 计算如下,

图片

其中,图片,是 输入Softmax时的缩放尺度,图片表示mask, 如最常用的Causal Mask,Pos 表示位置编码。 

由于Pos的存在妨碍了直接将Hadamard矩阵融合到图片中,因此也使用了在线Hadamard Head操作来旋转图片,对他们右乘图片

图片

其中的图片相当于变成了图片,整个计算过程保持了计算不变性。 

第2阶段

第2阶段是在变换后的真正量化过程。

阶段2a: 权重的量化

采用现成的GPTQ,或者更直接、更快速的RTN。

阶段2b: 激励的量化

对输入input进行per-token维度的在线量化,而其中RMSNorm依旧保持FP32的精度。

阶段2c: 缓存的量化

对kv cache直接量化到低比特并存储,并在需要计算时提取并重新反量化到FP16精度,计算乘法。而过程中Query保持FP16,  并参考了类似Flash Attention中的在线Softmax计算方式。

所以,结合上述细节和上图,我们可以讨论整个过程的数据流转。

在Attention过程中,FP16的输入图片右乘变换后,经过RMSNorm归一化,量化到INT4形式,并与左乘变换并量化后的权重做INT乘法运算,随后再反量化回FP16,其中图片经过位置编码(RoPE)计算,而图片经过变换并量化保存为cache,且在做MHA时反量化并变换回来,最后到输出Linear时再经变换和量化,与已变换并量化的权重相乘,最终再反量化为FP16输出图片

在FFN过程中,FP16的输入图片右乘变换后,经过RMSNorm归一化,量化到INT4形式,分别与左乘变换并量化后的上采样权重和门控权重做INT乘法运算,并反量化回FP16,做点乘;最后经变换和量化到INT4,与变换并量化后的下采样权重做乘法,最终再反量化为FP16输出图片

表现性能

在对权重、激励和缓存的全4-bits量化效果对比中,QuaRot相对于SmoothQuant, QmniQuant和QuIK,在Llama模型上有性能优势;且应用了group-wise后,对比Atom也有性能优势。 

图片

QuaRot与其他量化算法的性能比较(图片来源:参考文献[7])

SpinQuant

SpinQuant也是一种在利用正交旋转矩阵减少异常值的思路上的量化方法。该量化方案也是一个全量化方案,其量化对象也是所有的权重,激励和KV缓存。

该方案中,作者分析了不同随机矩阵变换下,多次量化效果的稳定性。用普通随机矩阵做旋转变换的量化过程的量化效果,最好与最差之间相比差距多大13个点,而随机 Hadamard 矩阵优于随机旋转矩阵,但也仍有6个点的不可忽略的方差。而作者提出的Cayley优化矩阵,如下图对比,则能将最终量化性能的方差明显缩小。 

图片

Llama2-7B 在不同随机旋转矩阵量化到W4A4模型的性能分布。不同随机旋转矩阵(普通随机,Hadamard和Cayley优化矩阵)之间的方差(图片来源:参考文献[8])

插入旋转矩阵

作者提出了针对不同复杂度而定制两种旋转策略。

下图是在完整的transformer block中插入不同旋转矩阵的概图,有四类旋转矩阵:图片,根据是否能合并,分为两类,

  • 图片 2个可合并的旋转矩阵:产生旋转不变的全精度网络。

  • 图片 2个在线的Hadamard旋转矩阵:进一步减少极端activation, kv-cache量化的异常值。

由此,作者提出了两种量化方案

  • SpinQuant(NoHad): 仅使用了离线旋转矩阵图片

  • SpinQuant(Had): 使用了图片

图片

SpinQuant整体的旋转变换(图片来源:参考文献[8])

R1R2

SpinQuant旋转矩阵的插入和应用与QuaRot大同小异。

由上图(a)(b)可知,图片和QuaRot中的1a一样,作用于每个Attention和FFN的输入处的激励矩阵,即Attention的Q、K、V Linear输入和FFN的上采样、门控Linear输入;具体到模块内部,其补偿矩阵图片$会被吸收到各种的权重矩阵中。

图片则是Head-wise地将注意力机制的输出乘以图片, 随后在输出output的投影矩阵图片乘以图片。这一操作类比于QuaRot中的1b,其旋转的计算一致性如下,

图片

R3R4

类似于QuaRot中的1c,在注意力机制中插入了额外的在线Hadamard变换(图片),以及在FFN的降采样Linear之前插入了在线Hadamard变换(图片),其旋转的计算一致性如下:

图片

注意力机制中value矩阵的旋转变换(图片来源:参考文献[8])

图片

FFN中下采样输入的变换(图片来源:参考文献[8])

Cayley优化旋转矩阵

本方案的一个主要贡献,是基于上述随机矩阵的方差分析,对旋转矩阵进一步做了基于最小化量化网络误差的优化。优化目标是上述的可合并的图片 ,而在线旋转图片依旧使用了Hadamard随机矩阵,这也是两种方案命名为NoHad和Had的原因。

基于图片优化过程的损失函数如下,

图片

这里,

  • 图片表示 Stiefel 流形,是正交矩阵的集合,{图片}。

  • 图片是基于校准集的比较量化前后的任务损失,可以是交叉熵,是一个关于{图片}的函数。图片 和图片分别是权重矩阵和输入激励矩阵。

为了优化上述函数,作者采用了一种叫Cayley SGD的梯度方法,这是一种Stiefel流形上的高效算法。其本质是一个迭代更新的优化过程, 在每次迭代中,旋转矩阵图片基于梯度更新, 

图片

其中定义 图片,是对矩阵图片的Cayley变换,图片是斜对称矩阵(图片)。 而图片由上述的损失函数的梯度 图片 的一个投影计算得到,

图片

通过矩阵图片的正交属性,推出Cayley变化得到的矩阵 图片的正交属性,从而保证更新后的旋转矩阵图片的正交属性。通过上述的梯度计算的迭代过程,可以求解优化图片 ,在这个过程中transformer的权重参数保持不变。

在具体实践中,作者基于WikiText2数据集作为校验集,用其中800个样本作前向推导,使用迭代更新次数为100次的Cayley SGD梯度优化结果作为新的旋转矩阵{图片},并在上述的随机矩阵量化结果分析中取得了最小方差和最优效果。 

表现性能

在量化性能上,基于Llama2系列与SmoothQuant、OmniQuant等方案作了比较,也与weight-only的算法GPTQ, AWQ, QuIP等做了比较,有更低的PPL(困惑度)和更好的准确度。

图片

量化效果对比(验证集:基于8个0-shot推理任务的平均准确度和基于WikiText2的PPL; 测试模型:Llama2)(图片来源:参考文献[8])

而且作者也对比了基于优化Cayley旋转矩阵和随机Hadamard矩阵的相同量化方案下的量化效果,体现了控制变量下的优化效果。 

图片

Llama3.2, Llama-3, Mistral等在8个0-shot任务下,Hadamard与Cayley优化矩阵的效果对比(图片来源:参考文献[8])

QQQ

QQQ(Quality Quattuor-Bit Quantization)是来自meituan的一个缝合了多种量化手段的方案。它吸收了自适应smooth技巧和Hessian-based权重量化算法,并重写了整型乘法的高效算子库,是一个针对weight和activation全量化的two-stage的量化算法。

图片

QQQ算法的二阶段量化流程(图片来源:参考文献[9])

量化过程

自适应平滑

通过通道维度的缩放操作,使得激励矩阵的异常值变得平滑,从而降低量化难度,这是启发自smoothquant算法,为求最优的平滑系数,构建了如下最小化量化前后输出误差的优化函数,

图片

图片 为element-wise的除法和乘法。

权重量化

基于Hessian的逐列的权重量化算法,则是借鉴自GPTQ,

图片

图片表示图片图片行, 图片表示Hessian矩阵。

W4A8

为支持和加速不同比特位的整型乘法,重写了W4A8的GEMM算子,融合了整型转换和反量化的过程,如下图,包含了per-channel和per-group 两种方案。

  • Per-channel: INT4的权重图片先通过精度转换变成INT8格式,再与INT8的激励图片 做GEMM, 最后反量化为FP16精度。

  • Per-group:  INT4的权重图片首先通过精度快速转换为FP16, 再加载group量化参数将权重反量化,随后再精度转换为INT8, 与INT8的激励做GEMM, 最后再反量化为FP16精度。

图片

W4A8的 per-channel权重量化模式(图片来源:参考文献[9])

图片

W4A8的 per-group权重量化模式(图片来源:参考文献[9])

表现性能

在量化效果上,以Llama2为例,基于Wikitext2的PPL和多个测试集的0-shot准确率和同等量化QoQ效果相当,而在PPL上与weight-only算法相比似乎稍有不足。 

图片

QQQ与其他算法的量化效果PPL对比(图片来源:参考文献[9])

图片

QoQ与其他算法的量化效果Zero-shot准确率对比(图片来源:参考文献[9])

而在推理性能上,通过基于高性能算子库Marlin重写了GEMM并集成到推理引擎vLLM上,

在Llama2-13B上相比于FP16,SmoothQuant和AWQ,有着2.24×, 2.10×, 1.59×的速度优势。

图片

QQQ量化后的模型与其他算法的推理吞吐性能比较(图片来源:参考文献[9])

QoQ

QoQ(Quattuor-Oct ̄o-Quattuor)是来自HanSong团队的W4A8KV4的低精度全量化方案。QoQ算法及其相关的Qserve推理系统,集成了包括量化过程和算子优化,与其说是量化算法,不如说是一套完整的端到端的模型轻量化推理引擎。 

作者在量化比特的选择上对比了Weight-only的W4A16方案和per-channel weight量化和per-token激励在线量化结合的W8A8方案。

  • 对于批处理较小的情况,LLM的GEMM主要是内存受限,W4A16有着更高的吞吐。

  • 当批次变大时,GEMM就变成了计算受限,由于INT8 Tensor Core具有更高吞吐量而使W8A8显得更快。

  • 而作者认为W4A8能兼顾两者,在内存密度和计算密度的场景下都能保持较高的吞吐。

  • 而在解码阶段,由于token的逐个生成特性,Attention的计算密度相对较低,因而KVcache的量化有助于解决内存密度问题,对KVCache选择W4,相比与W8能获得更高的性能。

  • 而对于更激进的W4A4,一方面由于准确性下降,另一方面也由于W4A4 GEMM在当前GPU架构(Ampere, Hopper)上并没有太显著的提升。

量化过程

QoQ的量化过程是众多量化算法中的技巧融合,通过不同手段来减小量化误差。 

渐进分组量化

图片

QoQ的渐进分组量化 (图片来源:参考文献[10])

渐进分组量化(Progressive Group Quantization)指的是对weight 先进行per-channel的INT8量化,再进行per-group的INT4量化。 

给定权重图片,先用per-channel对称量化至INT8形式,

图片

表示量化后得到的INT8的权重,图片是所使用的量化参数scale。

然后,对上述量化结果再使用per-grouup非对称量化至INT4形式,

图片

图片表示最终的无符号4-bit量化权重, 图片分别是对应的量化参数zero-point和scale。

当推理的前向过程计算W4A8 GEMM时,INT4的权重图片 被加载后,先反量化为INT8的权重图片,再执行W8A8的矩阵乘法。

另外,实际中,为了保护INT8反量化时饱和溢出,将INT8对称量化范围从[-127, 127]缩小到[-119, 119]。

平滑注意力

平滑注意力(SmoothAttention)借鉴了SmoothQuant中依靠通道缩放转移激励量化难度的思路,主要针对Key矩阵异常值较多且难量化的问题。

下图可视化了Value矩阵和经过RoPE的Key矩阵的元素值分布,可见Value矩阵的值较为平滑,而Key矩阵中有明显通道固定的异常值。 

图片

RoPE输出Key矩阵经smooth前后的异常值变化,以及Value矩阵的异常值变化(图片来源:参考文献[10])

借鉴SmoothQuant, 通过per-channel缩放因子缓解Key矩阵中的异常值范围,

图片

图片可以通过激励矩阵简单计算,图片, 而缩放强度超参图片可以取经验值0.5。由上图可见,通过平滑后Key矩阵的异常值得到明显缓解。

而实际中,通常缩放矩阵图片的补偿矩阵会融合到前一层的权重中去,而LLM中Attention的Query和Key通常会经过RoPE处理。RoPE在每个Head中将通道图片与通道图片配对。因此为了使SmoothQuant在RoPE中可交换,作者附加了一个硬约束条件,令图片,即

图片

图片

Qserve中的平滑缩放优化(图片来源:参考文献[10])

这样则可以通过图片 和 图片 将缩放矩阵的补偿矩阵融合到Query和Key的Linear层权重中去了。

旋转矩阵

同样,借鉴自QuaRot,QuIP等,使用Hadamard矩阵做旋转变换,来抑制输入激励矩阵的异常值。

图片

Qserve中的旋转矩阵优化(图片来源:参考文献[10])

通道重排

另外,参考AWQ, GPTQ等,提出了基于激励的通道重排序,使用激励矩阵逐通道的最大|图片|值,来表征通道显著性,重新排序通道,使得具有相似显著性的通道在同一个量化组中。

图片

Qserve中的通道重排优化(图片来源:参考文献[10])

Qserve吞吐优化

在通过各种量化技巧融合实现了W4A8KV4的量化流程后,为了保障其推理吞吐性能,作者又设计了一个Serving系统,命名为Qserve,将量化过程融合,设计GEMM kernel,  相当于一个高效的推理引擎。

下图是Qserve runtime示意图,其中所有的GEMM层都使用了W4A8输入并在INT8的TensorCore上执行,输出FP16格式,所有的Attention和LayerNorm都以FP16计算,且整体的LLM模块的输入输出都是FP16格式。 

图片

Qserve runtime推理流程中的精度变化(图片来源:参考文献[10])

  • 算子融合

    对于QKV投影和FFN第一个Linear,激励量化被融合到前面的 LayerNorm 中;FFN层第二个Linear的激励量化,则融合到前面的激活 Kernel 中。

  • KV-cache管理

    参考了vLLM、TensorRT-LLM等的PagedAttention模式,相比这些搜索引擎,Qserve采用了逐Head的动态管理模式,因为其需要存放量化参数,以及动态更新。

  • W4A8 GEMM

    GEMM是计算的主要开销,Qserve通过对权重重排、Per-channel反量化、Per-Group反量化等做了深度优化。

  • KV4** 缓存

    结合KV的量化和反量化优化整体的Attention流程耗时。

表现性能

在量化模型的PPL指标上,基于Llama,Mistral等模型,作者对比了很多量化算法,在同等量化条件下,QoQ有一定的优势,和QuaRot相当,而相比于Weight-only算法稍有不如;而在0-shot的准确率上优于Atom和QuaRot算法。 

图片

QoQ与其他算法的量化效果PPL对比(图片来源:参考文献[10])

图片

QoQ与其他算法的量化效果Zero-shot准确率对比(图片来源:参考文献[10])

在推理吞吐上,得益于其对Pipeline的深度优化,Qserve甚至表现得优于TRT-LLM这样的专业推理引擎。

图片

Qserve量化后的模型与其他算法的推理吞吐性能比较(图片来源:参考文献[10])

FP8

FP8是以8-bit位表示的一种低精度浮点格式,Nvidia 的GPU从Hopper架构的显卡开始支持FP8的训练和推理格式。FP8有2种格式,以下是与FP16和BF16的数据格式对比,

图片

FP8两种格式与FP16及BF16的比特位表示对比(图片来源:参考文献[13])

  • E4M3:包含1个符号位,4个指数位(exponent) 和 3个尾数位(mantissa),可以表示[-448, 448] 范围的数值和nan.

  • E5M2:包含1个符号位,5个指数位(exponent) 和 2个尾数位(mantissa). 可以表示[-57344, 57344],正负无穷(inf)和 nan. 

图片

FP8两种格式的具体表达范围(图片来源:参考文献[11])

符号位占一位,表示正负。

指数部分在浮点表示法当中,一般会减去一个偏移量,对于FP8 E4M3 而言,这个偏移量为-7,这使得指数的表示范围为[-7, 8]。对于 FP8 E5M2 而言,指数偏移量为 -15,指数表示范围为[-15, 16]。

底数从高位到低位,分别表示2的负k次幂;对于E4M3格式,使用3个比特表示底数,其分别对应2的负1, 2, 3次幂。对于E5M2格式,使用2个比特表示底数,分别对应2的负1, 2幂。底数表示时会额外加1,而当指数部分全为0时,则不额外加1。

浮点量化

相比于整型量化,浮点的量化属于非均匀量化,即浮点量化的步长是不固定的,由下图可知,相比于FP32到INT8的映射,浮点量化的步长随着指数部分的变大而变大。

图片

FP8与INT8量化的量化步长对比(图片来源:参考文献[12])

量化精度

FP8与INT8量化孰优孰劣,只能说各有长短。FP8 相比INT8,有更大的表示范围,但在一定范围内,其精度表达能力相较INT8为差。如下图,从高斯分布随机抽样1000万个数字,分别使用 FP8 E4M3, FP8 E5M2, INT8 完成量化。在三者的量化中,应用不同的缩放参数来调整量化效果,画出量化误差(用MSE表示)。可以看到,FP8之间,E4M3的量化效果要好于E5M2, 而在选取合适的量化参数范围内,INT8的量化效果要好于FP8,而FP8具有更好的兼容性,对缩放参数的选择相对不敏感,更适合不需要校验集的量化。

图片

FP8与INT8在不同量化参数下,对正态分布数据量化的精度损失对比(图片来源:参考文献[12])

总结

下面是对以上介绍的一些大模型量化方案的简要总结和对比,

量化算法名称 量化对象 特点和适用范围
GTPQ 权重 离线量化,支持4~8-bit,量化速度较快,支持模型较多,比较成熟
AWQ 权重 离线量化,支持4~8-bit,量化速度较快,支持模型较多,比较成熟
HQQ 权重 离线量化,支持1~8-bit,量化速度在所有算法中最快,量化精度与GTPQ,AWQ相当
SmoothQuant 权重、激励 在线量化,支持8-bit,量化速度较快,支持模型较多,比较成熟,推理吞吐较快
QuIP 权重 离线量化,支持2~4bit,量化速度较快,低精度(2-bit)下效果优于GPTQ
QuaRot   权重、激励、KV缓存 在线量化,支持4-bit, 8-bit,低精度(4-bit)下效果优于SmoothQuant 
SpinQuant 权重、激励、KV缓存 在线量化,支持4-bit, 8-bit,低精度(4-bit)下效果优于SmoothQuant,GPTQ,量化速度较快,但略慢于GPTQ
QQQ 权重、激励 在线量化,支持4-bit, 8-bit,推理吞吐较快
QoQ 权重、激励、KV缓存 在线量化,支持4-bit, 8-bit,推理吞吐较快
FP8 权重、激励、KV缓存 在线量化,支持FP8精度,依赖较新GPU,推理吞吐较快

综上,文章简要介绍了近期一些LLM后量化的算法和方案,当然还有众多算法未涉及和细讲,如SpQR,ZeroQuant, KIVI**,Atom, OmniQuant,AQLM等。

参考文献

  1. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers
  2. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration
  3. AWQ slides: hanlab.mit.edu/projects/aw…
  4. SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models
  5. QuIP: 2-Bit Quantization of Large Language Models With Guarantees
  6. HQQ: Half-Quadratic Quantization of Large Machine Learning Models.
  7. QuaRot: Outlier-Free 4-Bit Inference in Rotated LLMs
  8. SpinQuant: LLM quantization with learned rotations
  9. QQQ: Quality Quattuor-Bit Quantization for Large Language Models
  10. QServe: W4A8KV4 Quantization and System Co-design for Efficient LLM Serving
  11. Nvidia: FP8 Formats for Deep Learning
  12. FP8量化原理简介:zhuanlan.zhihu.com/p/574825662
  13. Nvidia Transformer Engine: Using FP8 with Transformer Engine 

往期回顾

1.如何合理规划Elasticsearch的索引|得物技术

2. DPP推荐引擎架构升级演进之路|得物技术

3.Cursor 在前端需求开发工作流中的应用|得物技术

4.得物 iOS 启动优化之 Building Closure

5.分布式数据一致性场景与方案处理分析|得物技术

文 / 旭囧

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

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

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

❌