普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月20日首页

一文解析得物自建 Redis 最新技术演进

作者 得物技术
2025年11月20日 14:42

一、前 言

自建 Redis 上线 3 年多以来,一直围绕着技术架构、性能提升、降低成本、自动化运维等方面持续进行技术演进迭代,力求为公司业务提供性能更高、成本更低的分布式缓存集群,通过自动化运维方式提升运维效率。

本文将从接入方式、同城双活就近读、Redis-server 版本与能力、实例架构与规格、自动化运维等多个方面分享一下自建 Redis 最新的技术演进。

二、规模现状

随着公司业务增长,自建 Redis 管理的 Redis 缓存规模也一直在持续增长,目前自建 Redis 总共管理 1000+集群,内存总规格 160T,10W+数据节点,机器数量数千台,其中内存规格超过 1T 的大容量集群数十个,单个集群最大访问 QPS 接近千万。

三、技术演进介绍

3.1 自建Redis系统架构

下图为自建Redis系统架构示意图:

自建 Redis 架构示意图

自建Redis集群由Redis-server、Redis-proxy、ConfigServer 等核心组件组成。

  • Redis-server 为数据存储组件,支持一主多从,主从多可用区部署,提供高可用、高性能的服务;
  • Redis-proxy 为代理组件,业务通过 proxy 可以像使用单点实例一样访问 Redis 集群,使用更简单,并且在Redis-proxy 上提供同区优先就近读、key 维度或者命令维度限流等高级功能;
  • ConfigServer 为负责 Redis 集群高可用的组件。

自建 Redis 接入方式支持通过域名+LB、service、SDK 直连(推荐)等多种方式访问 Redis 集群。

自建 Redis 系统还包含一个功能完善的自动化运维平台,其主要功能包括:

  • Redis 集群实例从创建、proxy 与 server 扩缩容、到实例下线等全生命周期自动化运维管理能力;
  • 业务需求自助申请工单与工单自动化执行;
  • 资源(包含 ECS、LB)精细化管理与自动智能分配能力、资源报表统计与展示;
  • ECS 资源定期巡检、自动均衡与节点智能调度;
  • 集群大 key、热 key 等诊断与分析,集群数据自助查询。

下面将就一些重要的最新技术演进进行详细介绍。

3.2 接入方式演进

自建 Redis 提升稳定性的非常重要的一个技术演进就是自研 DRedis SDK,业务接入自建 Redis 方式从原有通过域名+LB 的方式访问演进为通过 DRedis SDK 连接 proxy 访问。

LB接入问题

在自建 Redis 初期,为了方便业务使用,使用方式保持与云 Redis 一致,通过 LB 对 proxy 做负载均衡,业务通过域名(域名绑定集群对应 LB)访问集群,业务接入简单,像使用一个单点 Redis 一样使用集群,并且与云 Redis 配置方式一致,接入成本低。

随着自建 Redis 规模增长,尤其是大流量业务日渐增多,通过 LB 接入方式的逐渐暴露出很多个问题,部分问题还非常棘手:

  • 自建 Redis 使用的单个 LB 流量上限为5Gb,阈值比较小,对于一些大流量业务单个 LB 难以承接其流量,需要绑定多个LB,增加了运维复杂度,而且多个 LB 时可能会出现流量倾斜问题;
  • LB组件作为访问入口,可能会受到网络异常流量攻击,导致集群访问受损;
  • 由于Redis访问均是TCP连接,LB摘流业务会有秒级报错。

DRedis接入

自建Redis通过自研DRedis SDK,通过SDK直连 proxy,不再强依赖 LB,彻底解决 LB 瓶颈和稳定性风险问题,同时,DRedis SDK 默认优先访问同可用区 proxy,天然支持同城双活就近读。

DRedis SDK系统设计图如下所示:

Redis-proxy 启动并且获取到集群拓扑信息后,自动注册到注册中心;可通过管控白屏化操作向配置中心配置集群使用的 proxy 分组与权重、就近读规则等信息;DRedis SDK 启动后,从配置中心获取到 proxy 分组与权重、就近读规则,从注册中心获取到 proxy 节点信息,然后与对应 proxy 节点建立连接;应用通过 DRedis SDK 访问数据时,DRedis SDK 通过加权轮询算法获取一个 proxy 节点(默认优先同可用区)及对应连接,进行数据访问。

DRedis SDK并且对原生 RESP 协议进行了增强,添加了一部分自定义协议,支持业务灵活开启就近读能力,对于满足就近读规则的 key 访问、或者通过注解指定的就近读请求,DRedis SDK通过自定义协议信息,通知 proxy 在执行对应请求时,优先访问同可用区 server 节点。

DRedis SDK 目前支持 Java、Golang、C++(即将上线)三种开发语言。

  • Java SDK 基于 Redisson 客户端二次开发,后续还会新增基于 Jedis 二次开发版本,供业务灵活选择,并且集成到 fusion 框架中
  • Golang SDK 基于 go-Redis v9 进行二次开
  • C++ SDK 基于 brpc 二次开发

DRedis 接入优势

业务通过 DRedis SDK 接入自建 Redis,在稳定性、性能等方面都能得到大幅提升,同时能降低使用成本。

社区某应用升级后,业务 RT 下降明显,如下图所示:

DRedis 接入现状

DRedis SDK目前在公司内部大部分业务域的应用完成升级。

Java 和 Golang 应用目前接入上线超过300+

3.3 同城双活就近读

自建 Redis 同城双活采用中心写就近读的方案实现,可以降低业务多区部署时访问 Redis RT。

同城双活就近读场景下,业务访问 Redis 时,需要 SDK 优先访问同可用区proxy,proxy 优先访问同可用区 server节点,其中proxy优先访问同区 server 节点由 proxy 实现,但是在自研 DRedis SDK 之前,LB 无法自动识别应用所在同区的 proxy 并自动路由,因此需要借助service 的同区就近路由能力,同城双活就近读需要通过容器 proxy+service 接入。

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy。

service接入问题

目前,自建 Redis server 和 proxy 节点基本都是部署在 ECS 上,并且由于 server 节点主要消耗内存,而 proxy 节点主要消耗 CPU,因此默认采用 proxy + server 节点混部的方式,充分利用机器的 CPU 和内存,降低成本。

而为了支持同城双活就近读,需要在容器环境部署 proxy,并创建 service,会带来如下问题:

  • 运维割裂,运维复杂度增加,除了需要运维 ECS 环境部署节点,额外增加了容器环境部署方式。
  • 成本增加,容器环境 proxy 需要独立机器部署,无法与 server 节点混部,造成成本增加。
  • RT上升,节点 CPU 更高,从实际使用效果来看,容器环境 proxy 整体的 CPU 和响应 RT 都明显高于 ECS 环境部署的节点。
  • 访问不均衡,service 接入时,会出现连接和访问不均衡现象。
  • 无法定制化指定仅仅少量特定key 或者 key 前缀、指定请求开启就近读。

DRedis接入

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy;当同可用区可用 proxy 数量小于等于1个时,启用调用保护,DRedis会主动跨区访问其他可用区 proxy 节点。

通过service接入方式支持同城双活就近读,是需要在 proxy 上统一开启就近读配置,开启后,对全局读请求均生效,所有读请求都默认优先同区访问。

由于 Redis 主从复制为异步复制,主从复制可能存在延迟,理论上在备可用区可能存在读取到的不是最新数据

某些特定业务场景下,业务可能在某些场景能够接受就近读,但是其他一些场景需要保证强一致性,无法接受就近读,通过 service 接入方式时无法灵活应对这种场景。

DRedis SDK 提供了两种方式供这种场景下业务使用:

  • 支持指定 key 精确匹配或者 key 前缀匹配的方式,定向启用就近读。
  • Java 支持通过声明式注解(@NearRead)指定某次请求采用就近读;Golang 新增 80 个类似 xxxxNearby 读命令,支持就近读。

使用以上两种方式指定特定请求使用就近读时,无需 proxy 上统一配置同区优先就近读。默认情况下,所有读请求访问主节点,业务上对 RT 要求高、一致性要求低的请求可以通过以上两种方式指定优先同区就近读。

3.4 Redis-server版本与能力

在自建Redis 初期,由于业务在前期使用云Redis产品时均是使用Redis4.0 版本,因此自建 Redis 初期也是选择 Redis4.0 版本作为主版本,随着 Redis 社区新版本发布,结合当前业界使用的主流版本,自建Redis也新增了 Redis6.2 版本,并且将 Redis6.2 版本作为新集群默认版本。

不管是 Redis4.0 还是 Redis6.2 版本,均支持了多线程特性、实时热 key 统计能力、水平扩容异步迁移 slot 能力,存量集群随着日常资源均衡迁移调度,集群节点版本会自动升级到同版本的最新安装包。

  • 多线程特性

Redis6.2 版本支持 IO 多线程,在 Redis 处理读写业务请求数据时使用多线程处理,提高 IO 处理能力,自建 Redis 将多线程能力也移植到了 Redis4.0 版本,测试团队测试显示,开启多线程,读写性能提升明显。

多线程版本 VS 普通版本

多线程版本 VS 云产品5.0版本

  • 实时热 key 统计

自建 Redis4.0 和 Redis6.2 版本均支持 Redis 服务端实时热 key 统计能力,管控台白屏化展示,方便快速排查热 key 导致的集群性能问题。方案详细可阅读《基于Redis内核的热key统计实现方案》

  • 水平扩容异步迁移

自建 Redis 支持水平扩容异步数据迁移,解决大 key 无法迁移或者迁移失败的稳定性问题,支持多 key 并发迁移,几亿 key 数据在默认配置下水平扩容时间从平均 4 小时缩短到 10 分钟性能提升 20 倍,对业务RT影响下降 90% 以上

算法某实例 2.5 亿 key 水平扩容花费时间和迁移过程对业务 RT 影响

3.5 实例架构与规格

Redis单点主备模式

自建 Redis 实例默认均采用集群架构,但是通过 proxy 代理屏蔽集群架构细节,集群架构对业务透明,业务像使用一个单点 Redis 实例一样使用 Redis 集群。

但是集群架构下,由于底层涉及多个分片,不同 key 可能存在在不同分片,并且随着水平扩容,key所在分片可能会发生变化,因此,集群架构下,对于一些多 key 命令(如 eval、evalsha、BLPOP等)要求命令中所有 key 必须属于同一个slot。因此集群架构下,部分命令访问与单点还是有点差异。

实际使用中,有少数业务由于依赖了一些开源的三方组件,其中可能由于存储非常少量的数据,所以使用到 Redis 单点主备模式实例,因此,考虑到这种场景,自建 Redis 在集群架构基础上,也支持了Redis 单点主备模式可供选择。

一主多从规格

自建 Redis 支持一主多从规格用于跨区容灾,提供更快的 HA 效率,当前支持一主一从(默认),一主两从、一主三从 3 种副本规格,支持配置读写分离策略提升系统性能(一主多从规格下,开启读写分离,可以有多个分片承接读流量)

一主一从

一主两从

一主三从

  • 一主一从时默认主备可用区各部署一个副本(master在主可用区)
  • 一主两从时默认主可用区部署一主一从,备可用区部署一从副本
  • 一主三从时默认主可用区部署一主一从,备可用区部署两从副本

3.6 proxy限流

为了应对异常突发流量导致的业务访问性能下降,自建 Redis-proxy 支持限流能力

有部分业务可能存在特殊的已知大key,业务中正常逻辑也不会调用查询大 key 全量数据命令,如 hgetall、smembers 等,查询大 key 全量数据会导致节点性能下降,极端情况下会导致节点主从切换,因此,自建Redis 也支持配置命令黑名单,在特定的集群,禁用某些特定的命令

  • 支持 key 维度限流,指定 key 访问 QPS 阈值
  • 支持命令维度限流,指定命令访问 QPS 阈值
  • 支持命令黑名单,添加黑名单后,该实例禁用此命令

3.7 自动化运维

自建 Redis 系统还包含一个功能完善的自动化运维平台,一直以来,自建Redis一直在完善系统自动化运维能力,通过丰富的自动化运维能力,实现集群全生命周期自动化管理,资源管理与智能调度,故障自动恢复等,提高资源利用率、降低成本,提高运维效率。

  • 资源池自动化均衡调度

自建 Redis 资源池支持按内存使用率自动化均衡调度、按内存分配率自动化均衡调度、按 CPU 使用率均衡调度、支持指定机器凌晨迁移调度(隐患机器提前维护)等功能,均衡资源池中所有资源的负载,提高资源利用率。

  • 集群自动部署与下线

当业务提交集群申请工单审批通过后,判断是否支持自建,如符合自建则自动化进行集群部署和部署结果校验,校验集群可用性后自动给业务交付集群信息,整个过程高效快速。

业务提交集群下线工单后,自动检测是否满足下线条件,比如是否存在访问连接,如满足下线条件,则自动释放 proxy 资源,保留 7 天后自动回收 server 节点资源,在7 天内,如果存在特殊业务仍在使用的情况,还支持快速恢复使用。

  • 资源管理

对 ECS 机器资源和 LB 资源进行打标,根据特殊业务需要做不同资源池的隔离调度,支持在集群部署与扩容时,资源自动智能化分配。

  • 集群扩缩容

自建 Redis 支持 server 自动垂直扩容,业务申请集群时,可以选择是否开启自动扩容,如果开启自动扩容,当集群内存使用率达到80%时,系统会自动进行垂直扩容,对业务完全无感,快速应对业务容量上涨场景。

ecs-proxy,docker-proxy扩容,server节点的扩缩容也支持工单自动化操作,业务提交工单后,系统自动执行。

  • 工单自动化

当前80%以上的运维场景已完成工单自动化,如 Biz 申请、创建实例、密码申请、权限申请、删除key、实例升降配,集群下线等均完成工单自动化。业务提单审批通过后自动校验执行,执行完成后自动发送工单执行结果通知。

  • 告警自动化处理

系统会自动检测机器宕机事件,如发现机器宕机重启,会自动拉起机器上所有节点,快速恢复故障,提高运维效率。

关于自建 Redis 自动化运维能力提升详细设计细节,后续会专门分享,敬请期待。

四、总结

本文详细介绍了自建 Redis 最新技术演进,详细介绍了自研 DRedis SDK优势与目前使用现状,以及 DRedis 在同城双活就近读场景下,可以更精细化的控制部分请求采用优先同区就近读。

介绍了自建 Redis 目前支持最新的 Redis6.2版本,以及在 Redis4.0 和 Redis6.2 版本均支持多线程 IO 能力、实时热 key 统计能力、水平扩容异步迁移能力。自建 Redis 除了支持集群架构,也支持单点主备架构实例申请,同时支持一主多从副本规格,可以提供可靠性和读请求能力(读写分离场景下)。自建 Redis-proxy 也支持多种限流方式,包括 key 维度、命令维度等。

自建 Redis 自动化运维平台支持强大的自动化运维能力,提高资源利用率,降低成本,提高运维效率。

自建 Redis 经过长期的技术迭代演进,目前支持的命令和功能上完全对比云 Redis,同时,自建 Redis 拥有其他一些特色的能力与优势,比如不再依赖LB、支持自动垂直扩容、支持同区优先就近读等。

往期回顾

1. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

2. RN与hawk碰撞的火花之C++异常捕获|得物技术

3. 得物TiDB升级实践

4. 得物管理类目配置线上化:从业务痛点到技术实现

5. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

文 /竹径

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

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

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

昨天以前首页

Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

作者 得物技术
2025年11月18日 10:58

一、序 言

在分布式系统中,网络请求的可靠性直接决定了服务质量。想象一下,当你的支付系统因第三方API超时导致订单状态不一致,或因瞬时网络抖动造成用户操作失败,这些问题往往源于HTTP客户端缺乏完善的超时控制和重试策略。Golang标准库虽然提供了基础的HTTP客户端实现,但在高并发、高可用场景下,我们需要更精细化的策略来应对复杂的网络环境。

二、超时控制的风险与必要性

2024年Cloudflare的网络报告显示,78%的服务中断事件与不合理的超时配置直接相关。当一个HTTP请求因目标服务无响应而长时间阻塞时,不仅会占用宝贵的系统资源,更可能引发级联故障——大量堆积的阻塞请求会耗尽连接池资源,导致新请求无法建立,最终演变为服务雪崩。超时控制本质上是一种资源保护机制,通过设定合理的时间边界,确保单个请求的异常不会扩散到整个系统。

超时配置不当的两大典型风险:

  • DoS攻击放大效应:缺乏连接超时限制的客户端,在遭遇恶意慢响应攻击时,会维持大量半开连接,迅速耗尽服务器文件描述符。
  • 资源利用率倒挂:当ReadTimeout设置过长(如默认的0表示无限制),慢请求会长期占用连接池资源。Netflix的性能数据显示,将超时时间从30秒优化到5秒后,连接池利用率提升了400% ,服务吞吐量增长2.3倍。

三、超时参数示例

永远不要依赖默认的http.DefaultClient,其Timeout为0(无超时)。生产环境必须显式配置所有超时参数,形成防御性编程习惯。

以下代码展示如何通过net.Dialer配置连接超时和keep-alive策略:

transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,  // TCP连接建立超时
        KeepAlive: 30 * time.Second, // 连接保活时间
        DualStack: true,             // 支持IPv4/IPv6双栈
    }).DialContext,
    ResponseHeaderTimeout: 5 * time.Second, // 等待响应头超时
    MaxIdleConnsPerHost:   100,             // 每个主机的最大空闲连接
}
client := &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // 整个请求的超时时间
}

四、基于context的超时实现

context.Context为请求超时提供了更灵活的控制机制,特别是在分布式追踪和请求取消场景中。与http.Client的超时参数不同,context超时可以实现请求级别的超时传递,例如在微服务调用链中传递超时剩余时间。

4.1 上下文超时传递

如图所示,context通过WithTimeout或WithDeadline创建超时上下文,在请求过程中逐级传递。当父context被取消时,子context会立即终止请求,避免资源泄漏。

4.2 带追踪的超时控制

func requestWithTracing(ctx context.Context) (*http.Response, error) {
    // 从父上下文派生5秒超时的子上下文
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 确保无论成功失败都取消上下文
    
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return nil, fmt.Errorf("创建请求失败: %v", err)
    }
    
    // 添加分布式追踪信息
    req.Header.Set("X-Request-ID", ctx.Value("request-id").(string))
    
    client := &http.Client{
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout: 2 * time.Second,
            }).DialContext,
        },
        // 注意: 此处不设置Timeout,完全由context控制
    }
    
    resp, err := client.Do(req)
    if err != nil {
        // 区分上下文取消和其他错误
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("请求超时: %w", ctx.Err())
        }
        return nil, fmt.Errorf("请求失败: %v", err)
    }
    return resp, nil
}

关键区别:context.WithTimeout与http.Client.Timeout是叠加关系而非替代关系。当同时设置时,取两者中较小的值。

五、重试策略

网络请求失败不可避免,但盲目重试可能加剧服务负载,甚至引发惊群效应。一个健壮的重试机制需要结合错误类型判断、退避算法和幂等性保证,在可靠性和服务保护间取得平衡。

5.1 指数退避与抖动

指数退避通过逐渐增加重试间隔,避免对故障服务造成二次冲击。Golang实现中需加入随机抖动,防止多个客户端同时重试导致的波峰效应

以下是简单的重试实现示例:

type RetryPolicy struct {
    MaxRetries    int
    InitialBackoff time.Duration
    MaxBackoff    time.Duration
    JitterFactor  float64 // 抖动系数,建议0.1-0.5
}


// 带抖动的指数退避
func (rp *RetryPolicy) Backoff(attempt int) time.Duration {
    if attempt <= 0 {
        return rp.InitialBackoff
    }
    // 指数增长: InitialBackoff * 2^(attempt-1)
    backoff := rp.InitialBackoff * (1 << (attempt - 1))
    if backoff > rp.MaxBackoff {
        backoff = rp.MaxBackoff
    }
    // 添加抖动: [backoff*(1-jitter), backoff*(1+jitter)]
    jitter := time.Duration(rand.Float64() * float64(backoff) * rp.JitterFactor)
    return backoff - jitter + 2*jitter // 均匀分布在抖动范围内
}


// 通用重试执行器
func Retry(ctx context.Context, policy RetryPolicy, fn func() error) error {
    var err error
    for attempt := 0; attempt <= policy.MaxRetries; attempt++ {
        if attempt > 0 {
            // 检查上下文是否已取消
            select {
            case <-ctx.Done():
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            default:
            }
            
            backoff := policy.Backoff(attempt)
            timer := time.NewTimer(backoff)
            select {
            case <-timer.C:
            case <-ctx.Done():
                timer.Stop()
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            }
        }
        
        err = fn()
        if err == nil {
            return nil
        }
        
        // 判断是否应该重试
        if !shouldRetry(err) {
            return err
        }
    }
    return fmt.Errorf("达到最大重试次数 %d: %w", policy.MaxRetries, err)
}

5.2 错误类型判断

盲目重试所有错误不仅无效,还可能导致数据不一致。shouldRetry函数需要精确区分可重试错误类型:

func shouldRetry(err error) bool {
    // 网络层面错误
    var netErr net.Error
    if errors.As(err, &netErr) {
        // 超时错误和临时网络错误可重试
        return netErr.Timeout() || netErr.Temporary()
    }
    
    // HTTP状态码判断
    var respErr *url.Error
    if errors.As(err, &respErr) {
        if resp, ok := respErr.Response.(*http.Response); ok {
            switch resp.StatusCode {
            case 429, 500, 502, 503, 504:
                return true // 限流和服务器错误可重试
            case 408:
                return true // 请求超时可重试
            }
        }
    }
    
    // 应用层自定义错误
    if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrServiceUnavailable) {
        return true
    }
    
    return false
}

行业最佳实践:Netflix的重试策略建议:对5xx错误最多重试3次,对429错误使用Retry-After头指定的间隔,对网络错误使用指数退避(初始100ms,最大5秒)。

六、幂等性保证

重试机制的前提是请求必须是幂等的,否则重试可能导致数据不一致(如重复扣款)。实现幂等性的核心是确保多次相同请求产生相同的副作用,常见方案包括请求ID机制和乐观锁。

6.1 请求ID+Redis实现

基于UUID请求ID和Redis的幂等性检查机制,可确保重复请求仅被处理一次:

type IdempotentClient struct {
    redisClient *redis.Client
    prefix      string        // Redis键前缀
    ttl         time.Duration // 幂等键过期时间
}


// 生成唯一请求ID
func (ic *IdempotentClient) NewRequestID() string {
    return uuid.New().String()
}


// 执行幂等请求
func (ic *IdempotentClient) Do(req *http.Request, requestID string) (*http.Response, error) {
    // 检查请求是否已处理
    key := fmt.Sprintf("%s:%s", ic.prefix, requestID)
    exists, err := ic.redisClient.Exists(req.Context(), key).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等检查失败: %v", err)
    }
    if exists == 1 {
        // 返回缓存的响应或标记为重复请求
        return nil, fmt.Errorf("请求已处理: %s", requestID)
    }
    
    // 使用SET NX确保只有一个请求能通过检查
    set, err := ic.redisClient.SetNX(
        req.Context(),
        key,
        "processing",
        ic.ttl,
    ).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等锁失败: %v", err)
    }
    if !set {
        return nil, fmt.Errorf("并发请求冲突: %s", requestID)
    }
    
    // 执行请求
    client := &http.Client{/* 配置 */}
    resp, err := client.Do(req)
    if err != nil {
        // 请求失败时删除幂等标记
        ic.redisClient.Del(req.Context(), key)
        return nil, err
    }
    
    // 请求成功,更新幂等标记状态
    ic.redisClient.Set(req.Context(), key, "completed", ic.ttl)
    return resp, nil
}

关键设计:幂等键的TTL应大于最大重试周期+业务处理时间。例如,若最大重试间隔为30秒,处理耗时5秒,建议TTL设置为60秒,避免重试过程中键过期导致的重复处理。

6.2 业务层幂等策略

对于写操作,还需在业务层实现幂等逻辑:

  • 更新操作:使用乐观锁(如UPDATE ... WHERE version = ?)
  • 创建操作:使用唯一索引(如订单号、外部交易号)
  • 删除操作:采用"标记删除"而非物理删除

七、性能优化

高并发场景下,HTTP客户端的性能瓶颈通常不在于网络延迟,而在于连接管理和内存分配。通过合理配置连接池和复用资源,可显著提升吞吐量。

7.1 连接池配置

http.Transport的连接池参数优化对性能影响巨大,以下是经过生产验证的配置:

func NewOptimizedTransport() *http.Transport {
    return &http.Transport{
        // 连接池配置
        MaxIdleConns:        1000,  // 全局最大空闲连接
        MaxIdleConnsPerHost: 100,   // 每个主机的最大空闲连接
        IdleConnTimeout:     90 * time.Second, // 空闲连接超时时间
        
        // TCP配置
        DialContext: (&net.Dialer{
            Timeout:   2 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        
        // TLS配置
        TLSHandshakeTimeout: 5 * time.Second,
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: false,
            MinVersion:         tls.VersionTLS12,
        },
        
        // 其他优化
        ExpectContinueTimeout: 1 * time.Second,
        DisableCompression:    false, // 启用压缩
    }
}

Uber的性能测试显示,将MaxIdleConnsPerHost从默认的2提升到100后,针对同一API的并发请求延迟从85ms降至12ms,吞吐量提升6倍。

7.2 sync.Pool内存复用

频繁创建http.Request和http.Response会导致大量内存分配和GC压力。使用sync.Pool复用这些对象可减少90%的内存分配:

var requestPool = sync.Pool{
    New: func() interface{} {
        return &http.Request{
            Header: make(http.Header),
        }
    },
}


// 从池获取请求对象
func AcquireRequest() *http.Request {
    req := requestPool.Get().(*http.Request)
    // 重置必要字段
    req.Method = ""
    req.URL = nil
    req.Body = nil
    req.ContentLength = 0
    req.Header.Reset()
    return req
}


// 释放请求对象到池
func ReleaseRequest(req *http.Request) {
    requestPool.Put(req)
}

八、总结

HTTP请求看似简单,但它连接着整个系统的"血管"。忽视超时和重试,就像在血管上留了个缺口——平时没事,压力一来就大出血。构建高可靠的网络请求需要在超时控制、重试策略、幂等性保证和性能优化之间取得平衡。

记住,在分布式系统中,超时和重试不是可选功能,而是生存必需。

扩展资源:

往期回顾

  1. RN与hawk碰撞的火花之C++异常捕获|得物技术

  2. 得物TiDB升级实践

  3. 得物管理类目配置线上化:从业务痛点到技术实现

  4. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  5. RAG—Chunking策略实战|得物技术

文 /梧

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

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

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

得物TiDB升级实践

作者 得物技术
2025年11月11日 11:08

一、背 景

得物DBA自2020年初开始自建TiDB,5年以来随着NewSQL数据库迭代发展、运维体系逐步完善、产品自身能力逐步提升,接入业务涵盖了多个业务线和关键场景。从第一套TIDB v4.0.9 版本开始,到后来v4.0.11、v5.1.1、v5.3.0,在经历了各种 BUG 踩坑、问题调试后,最终稳定在 TIDB 5.3.3 版本。伴随着业务高速增长、数据量逐步增多,对 TiDB 的稳定性及性能也带来更多挑战和新的问题。为了应对这些问题,DBA团队决定对 TiDB 进行一次版本升级,收敛版本到7.5.x。本文基于内部的实践情况,从架构、新特性、升级方案及收益等几个方向讲述 TiDB 的升级之旅。

二、TiDB 架构

TiDB 是分布式关系型数据库,高度强兼容 MySQL 协议和 MySQL 生态,稳定适配 MySQL 5.7 和MySQL 8.0常用的功能及语法。随着版本的迭代,TiDB 在弹性扩展、分布式事务、强一致性基础上进一步针对稳定性、性能、易用性等方面进行优化和增强。与传统的单机数据库相比,TiDB具有以下优势:

  • 分布式架构,拥有良好的扩展性,支持对业务透明灵活弹性的扩缩容能力,无需分片键设计以及开发运维。
  • HTAP 架构支撑,支持在处理高并发事务操作的同时,对实时数据进行复杂分析,天然具备事务与分析物理隔离能力。
  • 支持 SQL 完整生态,对外暴露 MySQL 的网络协议,强兼容 MySQL 的语法/语义,在大多数场景下可以直接替换 MySQL。
  • 默认支持自愈高可用,在少数副本失效的情况下,数据库本身能够自动进行数据修复和故障转移,对业务无感。
  • 支持 ACID 事务,对于一些有强一致需求的场景友好,满足 RR 以及 RC 隔离级别,可以在通用开发框架完成业务开发迭代。

我们使用 SLB 来实现 TiDB 的高效负载均衡,通过调整 SLB 来管理访问流量的分配以及节点的扩展和缩减。确保在不同流量负载下,TiDB 集群能够始终保持稳定性能。在 TiDB 集群的部署方面,我们采用了单机单实例的架构设计。TiDB Server 和 PD Server 均选择了无本地 SSD 的机型,以优化资源配置,并降低开支。TiKV Server则配置在本地 SSD 的机型上,充分利用其高速读写能力,提升数据存储和检索的性能。这样的硬件配置不仅兼顾了系统的性能需求,又能降低集群成本。针对不同的业务需求,我们为各个组件量身定制了不同的服务器规格,以确保在多样化的业务场景下,资源得到最佳的利用,进一步提升系统的运行效率和响应速度。

三、TiDB v7 版本新特性

新版本带来了更强大的扩展能力和更快的性能,能够支持超大规模的工作负载,优化资源利用率,从而提升集群的整体性能。在 SQL 功能方面,它提升了兼容性、灵活性和易用性,从而助力复杂查询和现代应用程序的高效运行。此外,网络 IO 也进行了优化,通过多种批处理方法减少网络交互的次数,并支持更多的下推算子。同时,优化了Region 调度算法,显著提升了性能和稳定性。

四、TiDB升级之旅

4.1 当前存在的痛点

  • 集群版本过低:当前 TiDB 生产环境(现网)最新版本为 v5.3.3,目前官方已停止对 4.x 和 5.x 版本的维护及支持,TiDB 内核最新版本为 v8.5.3,而被用户广泛采用且最为稳定的版本是 v7.5.x。
  • TiCDC组件存在风险:TiCDC 作为增量数据同步工具,在 v6.5.0 版本以前在运行稳定性方面存在一定问题,经常出现数据同步延迟问题或者 OOM 问题。
  • 备份周期时间长:集群每天备份时间大于8小时,在此期间,数据库备份会导致集群负载上升超过30%,当备份时间赶上业务高峰期,会导致应用RT上升。
  • 集群偶发抖动及BUG:在低版本集群中,偶尔会出现基于唯一键查询的慢查询现象,同时低版本也存在一些影响可用性的BUG。比如在 TiDB v4.x 的集群中,TiKV 节点运行超过 2 年会导致节点自动重启。

4.2 升级方案:升级方式

TiDB的常见升级方式为原地升级和迁移升级,我们所有的升级方案均采用迁移升级的方式。

原地升级

  • 优势:方式较为简单,不需要额外的硬件,升级过程中集群仍然可以对外提供服务。
  • 劣势:该升级方案不支持回退、并且升级过程会有长时间的性能抖动。大版本(v4/v5 原地升级到 v7)跨度较大时,需要版本递增升级,抖动时间翻倍。

迁移升级

  • 优势:业务影响时间较短、可灰度可回滚、不受版本跨度的影响。
  • 劣势:搭建新集群将产生额外的成本支出,同时,原集群还需要部署TiCDC组件用于增量同步。

4.3 升级方案:集群调研

4.4 升级方案:升级前准备环境

4.5 升级方案:升级前验证集群

4.6 升级方案:升级中流量迁移

4.7 升级方案:升级后销毁集群

五、升级遇到的问题

5.1 v7.5.x版本查询SQL倾向全表扫描

表中记录数 215亿,查询 SQL存在合理的索引,但是优化器更倾向走全表扫描,重新收集表的统计信息后,执行计划依然为全表扫描。

走全表扫描执行60秒超时KILL,强制绑定索引仅需0.4秒。

-- 查询SQL
SELECT
  *
FROM
  fin_xxx_xxx
WHERE
  xxx_head_id = 1111111111111111
  AND xxx_type = 'XX0002'
  AND xxx_user_id = 11111111
  AND xxx_pay_way = 'XXX00000'
  AND is_del IN ('N', 'Y')
LIMIT
  1;


-- 涉及索引
KEY `idx_xxx` (`xxx_head_id`,`xxx_type`,`xxx_status`),

解决方案:

  • 方式一:通过 SPM 进行 SQL 绑定。
  • 方式二:调整集群参数 tidb_opt_prefer_range_scan,将该变量值设为 ON 后,优化器总是偏好区间扫描而不是全表扫描。

asktug.com/t/topic/104…

5.2 v7.5.x版本聚合查询执行计划不准确

集群升级后,在新集群上执行一些聚合查询或者大范围统计查询时无法命中有效索引。而低版本v4.x、5.x集群,会根据统计信息选择走合适的索引。

v4.0.11集群执行耗时:12秒,新集群执行耗时2分32.78秒

-- 查询SQL
select 
    statistics_date,count(1) 
from 
    merchant_assessment_xxx 
where 
    create_time between '2025-08-20 00:00:00' and '2025-09-09 00:00:00' 
group by 
    statistics_date order by statistics_date;


-- 涉及索引
KEY `idx_create_time` (`create_time`)

解决方案:

方式一:调整集群参数tidb_opt_objective,该变量设为 determinate后,TiDB 在生成执行计划时将不再使用实时统计信息,这会让执行计划相对稳定。

asktug.com/t/topic/104…

六、升级带来的收益

版本升级稳定性增强:v7.5.x 版本的 TiDB 提供了更高的稳定性和可靠性,高版本改进了SQL优化器、增强的分布式事务处理能力等,加快了响应速度和处理大量数据的能力。升级后相比之前整体性能提升40%。特别是在处理复杂 SQL 和多索引场景时,优化器的性能得到了极大的增强,减少了全表扫描的发生,从而显著降低了 TiKV 的 CPU 消耗和 TiDB 的内存使用。

应用平均RT提升44.62%

原集群RT(平均16.9ms)

新集群RT(平均9.36ms)

新集群平均RT提升50%,并且稳定性增加,毛刺大幅减少

老集群RT(平均250ms)

新集群RT(平均125ms)

提升TiCDC同步性能:新版本在数据同步方面有了数十倍的提升,有效解决了之前版本中出现的同步延迟问题,提供更高的稳定性和可靠性。当下游需要订阅数据至数仓或风控平台时,可以使用TiCDC将数据实时同步至Kafka,提升数据处理的灵活性与响应能力。

缩短备份时间:数据库备份通常会消耗大量的CPU和IO资源。此前,由于备份任务的结束时间恰逢业务高峰期,经常导致应用响应时间(RT)上升等问题。通过进行版本升级将备份效率提升了超过50%。

高压缩存储引擎:新版本采用了高效的数据压缩算法,能够显著减少存储占用。同时,通过优化存储结构,能够快速读取和写入数据,提升整体性能。相同数据在 TiDB 中的存储占用空间更低,iDB 的3副本数据大小仅为 MySQL(主实例数据大小)的 55%。

完善的运维体验:新版本引入更好的监控工具、更智能的故障诊断机制和更简化的运维流程,提供了改进的 Dashboard 和 Top SQL 功能,使得慢查询和问题 SQL 的识别更加直观和便捷,降低 DBA 的工作负担。

更秀更实用的新功能:TiDB 7.x版本提供了TTL定期自动删除过期数据,实现行级别的生命周期控制策略。通过为表设置 TTL 属性,TiDB 可以周期性地自动检查并清理表中的过期数据。此功能在一些场景可以有效节省存储空间、提升性能。TTL 常见的使用场景:

  • 定期删除验证码、短网址记录
  • 定期删除不需要的历史订单
  • 自动删除计算的中间结果

docs.pingcap.com/zh/tidb/v7.…

七、选择 TiDB 的原因

我们不是为了使用TiDB而使用,而是去解决一些MySQL无法满足的场景,关系型数据库我们还是优先推荐MySQL。能用分库分表能解决的问题尽量选择MySQL,毕竟运维成本相对较低、数据库版本更加稳定、单点查询速度更快、单机QPS性能更高这些特性是分布式数据库无法满足的。

  • 非分片查询场景:上游 MySQL 采用了分库分表的设计,但部分业务查询无法利用分片。通过自建 DTS 将 MySQL 数据同步到 TiDB 集群,非分片/聚合查询则使用 TiDB 处理,能够在不依赖原始分片结构的情况下,实现高效的数据查询和分析。
  • 分析 SQL 多场景:业务逻辑比较复杂,往往存在并发查询和分析查询的需求。通过自建 DTS 将 MySQL 数据同步到 TiDB,复杂查询在TiDB执行、点查在MySQL执行。TiDB支持水平扩展,其分布式计算和存储能力使其能够高效处理大量的并发查询请求。既保障了MySQL的稳定性,又提升了整体的查询能力。
  • 磁盘使用大场景:在磁盘使用率较高的情况下,可能会出现 CPU 和内存使用率低,但磁盘容量已达到 MySQL 的瓶颈。TiDB 能够自动进行数据分片和负载均衡,将数据分布在多个节点上, 缓解单一节点的磁盘压力,避免了传统 MySQL 中常见的存储瓶颈问题,从而提高系统的可扩展性和灵活性。
  • 数据倾斜场景:在电商业务场景上,每个电商平台都会有一些销量很好的头部卖家,数据量会很大。即使采取了进行分库分表的策略,仍难以避免大卖家的数据会存储在同一实例中,这样会导致热点查询和慢 SQL 问题,尽管可以通过添加索引或进一步分库分表来优化,但效果有限。采用分布式数据库能够有效解决这一问题。可以将数据均匀地分散存储在多个节点上,在查询时则能够并发执行,从而将流量分散,避免热点现象的出现。随着业务的快速发展和数据量的不断增长,借助简单地增加节点,即可实现水平扩展,满足海量数据及高并发的需求。

八、总结

综上所述,在本次 TiDB 集群版本升级到 v7.5.x 版本过程中,实现了性能和稳定性提升。通过优化的查询计划和更高效的执行引擎,数据读取和写入速度显著提升,大幅度降低了响应延迟,提升了在高并发操作下的可靠性。通过直观的监控界面和更全面的性能分析工具,能够更快速地识别和解决潜在问题,降低 DBA 的工作负担。也为未来的业务扩展和系统稳定性提供了强有力的支持。

后续依然会持续关注 TiDB 在 v8.5.x 版本稳定性、性能以及新产品特性带来应用开发以及运维人效收益进展。目前 TiDB 内核版本 v8.5.x 已经具备多模数据库 Data + AI 能力,在JSON函数、ARRAY 索引以及 Vector Index 实现特性。同时已经具备 Resource Control 资源管理能力,适合进行多业务系统数据归集方案,实现数据库资源池化多种自定义方案。技术研究方面我们数据库团队会持续投入,将产品最好的解决方案引入现网环境。

往期回顾

  1. 得物管理类目配置线上化:从业务痛点到技术实现

  2. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  3. RAG—Chunking策略实战|得物技术

  4. 告别数据无序:得物数据研发与管理平台的破局之路

  5. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

文 /岱影

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

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

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

得物管理类目配置线上化:从业务痛点到技术实现

作者 得物技术
2025年11月6日 15:16

一、引言

在电商交易领域,管理类目作为业务责权划分、统筹、管理核心载体,随着业务复杂性的提高,其规则调整频率从最初的 1 次 / 季度到多次 / 季度,三级类目的规则复杂度也呈指数级上升。传统依赖数仓底层更新的方式暴露出三大痛点:

  • 行业无法自主、快速调管理类目;
  • 业务管理类目规则调整,不支持校验类目覆盖范围是否有重复/遗漏,延长交付周期;
  • 规则变更成功后、下游系统响应滞后,无法及时应用最新类目规则。

本文将从技术视角解析 “管理类目配置线上化” 项目如何通过全链路技术驱动,将规则迭代周期缩短至 1-2 天。

二、业务痛点与技术挑战:为什么需要线上化?

2.1 效率瓶颈:手工流程与

高频迭代的矛盾

问题场景:业务方需线下通过数仓提报规则变更,经数仓开发、测试、BI需要花费大量精力校验确认,一次类目变更需 3-4 周左右时间才能上线生效,上线时间无法保证。

技术瓶颈:数仓离线同步周期长(T+1),规则校验依赖人工梳理,无法应对 “商品类目量级激增”。

2.2 质量风险:规则复杂度与

校验能力的失衡

典型问题:当前的管理类目映射规则,依赖业务收集提报,但从实际操作看管理三级类目映射规则提报质量较差(主要原因为:业务无法及时校验提报规则是否准确,是否穷举完善,是否完全无交叉),存在大量重复 / 遗漏风险。

2.3 系统耦合:底层变更对

下游应用的多米诺效应

连锁影响:管理类目规则变更会需同步更新交易后台、智能运营系统、商运关系工作台等多下游系统,如无法及时同步,可能会影响下游应用如商运关系工作台的员工分工范围的准确性,影响商家找人、资质审批等场景应用。

三、技术方案:从架构设计到核心模块拆解

3.1 分层架构:解耦业务与数据链路

3.2 核心模块技术实现

规则生命周期管理: 规则操作流程

提交管理类目唯一性校验规则

新增:id为空,则为新增

删除:当前db数据不在提交保存列表中

更新:名称或是否兜底类目或规则改变则发生更新【其中如果只有名称改变则只触发审批,不需等待数据校验,业务规则校验逻辑为将所有规则包含id,按照顺序排序拼接之后结果是否相等】

多级类目查询

构建管理类目树

/**
 * 构建管理类目树
 */
public List<ManagementCategoryDTO> buildTree(List<ManagementCategoryEntity> managementCategoryEntities) {
    Map<Long, ManagementCategoryDTO> managementCategoryMap = new HashMap<>();
    for (ManagementCategoryEntity category : managementCategoryEntities) {
        ManagementCategoryDTO managementCategoryDTO = ManagementCategoryMapping.convertEntity2DTO(category);
        managementCategoryMap.put(category.getId(), managementCategoryDTO);
    }
    
    // 找到根节点
    List<ManagementCategoryDTO> rootNodes = new ArrayList<>();
    for (ManagementCategoryDTO categoryNameDTO : managementCategoryMap.values()) {
        //管理一级类目 parentId是0
        if (Objects.equals(categoryNameDTO.getLevel(), ManagementCategoryLevelEnum.FIRST.getId()) && Objects.equals(categoryNameDTO.getParentId(), 0L)) {
            rootNodes.add(categoryNameDTO);
        }
    }
    // 构建树结构
    for (ManagementCategoryDTO node : managementCategoryMap.values()) {
        if (node.getLevel() > ManagementCategoryLevelEnum.FIRST.getId()) {
            ManagementCategoryDTO parentNode = managementCategoryMap.get(node.getParentId());
            if (parentNode != null) {
                parentNode.getItems().add(node);
            }
        }
    }
    return rootNodes;
}

填充管理类目规则



/**
 * 填充规则信息
 */
private void populateRuleData
(List<ManagementCategoryDTO> managementCategoryDTOS, List<ManagementCategoryRuleEntity> managementCategoryRuleEntities) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || CollectionUtils.isEmpty(managementCategoryRuleEntities)) {
        return;
    }
    List<ManagementCategoryRuleDTO> managementCategoryRuleDTOS =managementCategoryMapping.convertRuleEntities2DTOS(managementCategoryRuleEntities);
    // 将规则集合按 categoryId 分组
    Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap = managementCategoryRuleDTOS.stream()
            .collect(Collectors.groupingBy(ManagementCategoryRuleDTO::getCategoryId));
    // 递归填充规则到树结构
    fillRulesRecursively(managementCategoryDTOS, rulesByCategoryIdMap);


}


/**
 * 递归填充规则到树结构
 */
private static void fillRulesRecursively
(List<ManagementCategoryDTO> managementCategoryDTOS, Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || MapUtils.isEmpty(rulesByCategoryIdMap)) {
        return;
    }
    for (ManagementCategoryDTO node : managementCategoryDTOS) {
        // 获取当前节点对应的规则列表
        List<ManagementCategoryRuleDTO> rules = rulesByCategoryIdMap.getOrDefault(node.getId(), new ArrayList<>());
        node.setRules(rules);
        // 递归处理子节点
        fillRulesRecursively(node.getItems(), rulesByCategoryIdMap);
    }
}

状态机驱动:管理类目生命周期管理

超时机制 :基于时间阈值的流程阻塞保护

其中,为防止长时间运营处于待确认规则状态,造成其他规则阻塞规则修改,定时判断待确认规则状态持续时间,当时间超过xxx时间之后,则将待确认状态改为长时间未操作,放弃变更状态,并飞书通知规则修改人。

管理类目状态变化级联传播策略

类目生效和失效状态为级联操作。规则如下:

  • 管理二级类目有草稿状态时,不允许下挂三级类目的编辑;
  • 管理三级类目有草稿状态时,不允许对应二级类目的规则编辑;
  • 类目生效失效状态为级联操作,上层修改下层级联修改状态,如果下层管理类目存在草稿状态,则自动更改为放弃更改状态。

规则变更校验逻辑

当一次提交,可能出现的情况如下。一次提交可能会产生多个草稿,对应多个审批流程。

新增管理类目规则:

  • 一级管理类目可以直接新增(点击新增一级管理类目)
  • 二级管理类目和三级管理类目不可同时新增
  • 三级管理类目需要在已有二级类目基础上新增

只有名称修改触发直接审批,有规则修改需要等待数仓计算结果之后,运营提交发起审批。

交互通知中心:飞书卡片推送

  • 变更规则数据计算结果依赖数仓kafka计算结果回调。
  • 基于飞书卡片推送数仓计算结果,回调提交审批和放弃变更事件。

飞书卡片:

卡片结果

卡片操作结果

审批流程:多维度权限控制与飞书集成

提交审批的四种情况:

  • 名称修改
  • 一级类目新增
  • 管理类目规则修改
  • 生效失效变更

审批通过,将草稿内容更新到管理类目表中,将管理类目设置为生效中。

审批驳回,清空草稿内容。

审批人分配机制:多草稿并行审批方案

一次提交可能会产生多个草稿,对应多个审批流程。

审批逻辑

public Map<String, List<String>> buildApprover(
        ManagementCategoryDraftEntity draftEntity,
        Map<Long, Set<String>> catAuditorMap,
        Map<String, String> userIdOpenIdMap,
        Integer hasApprover) {
    
    Map<String, List<String>> nodeApprover = new HashMap<>();


    // 无审批人模式,直接查询超级管理员
    if (!Objects.equals(hasApprover, ManagementCategoryUtils.HAS_APPROVER_YES)) {
        nodeApprover.put(ManagementCategoryApprovalField.NODE_SUPER_ADMIN_AUDIT,
                queryApproverList(0L, catAuditorMap, userIdOpenIdMap));
        return nodeApprover;
    }
    
    Integer level = draftEntity.getLevel();
    Integer draftType = draftEntity.getType();
    boolean isEditOperation = ManagementCategoryDraftTypeEnum.isEditOp(draftType);
    
    // 动态构建审批链(支持N级类目)
    List<Integer> approvalChain = buildApprovalChain(level);
    for (int i = 0; i < approvalChain.size(); i++) {
        int currentLevel = approvalChain.get(i);
        Long categoryId = getCategoryIdByLevel(draftEntity, currentLevel);
        
        // 生成节点名称(如:NODE_LEVEL2_ADMIN_AUDIT)
        String nodeKey = String.format(
                ManagementCategoryApprovalField.NODE_LEVEL_X_ADMIN_AUDIT_TEMPLATE,
                currentLevel
        );
        
        // 编辑操作且当前层级等于提交层级时,添加本级审批人 【新增的管理类目没有还没有对应的审批人】
        if (isEditOperation && currentLevel == level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
        
        // 非本级审批人(上级层级)
        if (currentLevel != level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
    }
    
    return nodeApprover;
}


private List<Integer> buildApprovalChain(Integer level) {
    List<Integer> approvalChain = new ArrayList<>();
    if (level == 3) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 2) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 1) {
        approvalChain.add(1); // 管一审批人
        approvalChain.add(0); // 超管
    }
    return approvalChain;
}

3.3 数据模型设计

3.4 数仓计算逻辑

同步数据方式

方案一:

每次修改规则之后通过调用SQL触发离线计算

优势:通过SQL调用触发计算,失效性较高

劣势:ODPS 资源峰值消耗与SQL脚本耦合问题

  • 因为整个规则修改是三级类目维度,如果同时几十几百个类目触发规则改变,会同时触发几十几百个离线任务。同时需要大量ODPS 资源;
  • 调用SQL方式需要把当前规则修改和计算逻辑的SQL一起调用计算。

方案二:

优势:同时只会产生一次规则计算

劣势:实时性受限于离线计算周期

  • 实时性取决于离线规则计算的定时任务配置和离线数据同步频率,实时性不如直接调用SQL性能好
  • 不重不漏为当前所有变更规则维度

技术决策:常态化迭代下的最优解

考虑到管理类目规则平均变更频率不高,且变更时间点较为集中(非紧急场景占比 90%),故选择定时任务方案实现:

  • 资源利用率提升:ODPS 计算资源消耗降低 80%,避免批量变更时数百个任务同时触发的资源峰值;
  • 完整性保障:通过全量维度扫描确保规则校验无遗漏,较 SQL 触发方案提升 20% 校验覆盖率;
  • 可维护性优化:减少 SQL 脚本与业务逻辑的强耦合,维护成本降低 80%。

数据取数逻辑

生效中规则计算

草稿+生效中规格计算

如果是新增管理类目,直接参与计算。

如果是删除管理类目,需要将该删除草稿中对应的生效管理类目排除掉。

如果是更新:需要将草稿中的管理类目和规则替换生效中对应的管理类目和规则。

数仓实现

数据流程图

四、项目成果与技术价值

预期效率提升:从 “周级” 到 “日级” 的跨越

  • 管理一级 / 二级类目变更开发零成本,无需额外人力投入
  • 管理三级类目变更相关人力成本降低 100%,无需额外投入开发资源
  • 规则上线周期压缩超 90%,仅需 1 - 2 天即可完成上线

质量保障:自动化校验替代人工梳理

  • 规则重复 / 遗漏检测由人工梳理->自动化计算
  • 下游感知管理类目规则变更由人工通知->实时感知

技术沉淀:规则模型化能力

沉淀管理类目规则配置模型,支持未来四级、五级多级管理类目快速适配。

五、总结

未来优化方向:

  1. 规则冲突预警:基于AI预测高风险规则变更,提前触发校验
  2. 接入flink做到实时计算管理类目和对应商品关系

技术重构的本质是 “释放业务创造力”

管理类目配置线上化项目的核心价值,不仅在于技术层面的效率提升,更在于通过自动化工具链,让业务方从 “规则提报的执行者” 转变为 “业务策略的设计者”。当技术架构能够快速响应业务迭代时,企业才能在电商领域的高频竞争中保持创新活力。

往期回顾

  1. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  2. RAG—Chunking策略实战|得物技术

  3. 告别数据无序:得物数据研发与管理平台的破局之路

  4. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  5. Apex AI辅助编码助手的设计和实践|得物技术

文 /维山

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

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

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

大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

作者 得物技术
2025年11月4日 10:46

一、背 景

你是否曾在社区搜索时遇到这样的困扰:想找一双“平价学生党球鞋”,结果出现的多是限量联名款?或者输入“初冬轻薄通勤羽绒服”,却看到厚重登山款?这类“搜不准”的情况,正是搜索相关性技术要解决的核心问题——让搜索引擎更准确地理解用户意图,返回真正匹配的结果。今天,我们就来揭秘得物如何用大模型技术让搜索变得更“聪明”。

搜索相关性,即衡量搜索结果与用户查询的匹配程度,通俗来说就是“搜得准不准”。作为搜索体验的基石,良好的相关性能够帮助用户更顺畅地从种草走向决策,同时也对购买转化率和用户留存具有重要影响。

二、传统相关性迭代痛点

从算法层面看,搜索相关性模型需要计算用户查询与内容(包括下挂商卡)之间的相关程度。系统需要理解几十种用户意图,如品牌、系列、送礼、鉴别等,识别几十种商品属性,如人群、颜色、材质、款式,还要覆盖平台上数千个商品类目,从跑步鞋、冲锋衣到咖啡机、吹风机等等。

我们早期主要采用基于BERT的交互式模型,结合大量人工标注数据,来构建搜索相关性系统。然而,随着业务发展,传统方法在迭代过程中逐渐暴露出以下痛点:

  • 资源消耗大,标注成本高昂:模型效果严重依赖海量人工标注数据,需千万级的查询-商品配对样本。粗略估算,完成千万级数据标注,约需几十人全年无休投入,耗时费力且成本居高不下。
  • 扩展性不足,迭代响应缓慢:高度依赖人工标注的模式,导致模型难以灵活适应业务标准的频繁更新。每当新增商品类目或优化判断标准,往往需要重新标注,迭代周期长、响应速度慢。
  • 泛化能力有限,长尾场景表现不佳:模型对训练集中的常见品类效果尚可,但面对新品类或小众场景时表现明显下降。例如,用户从习惯搜索“鞋服”转向“旅行攻略”“美食景点”等场景时,搜索结果的相关性会大打折扣。

三、基于大模型的迭代流程

近年来,以GPT、Qwen为代表的大语言模型迅速发展,正在逐渐渗透和重塑搜索领域的各个环节。在搜索相关性任务上,大模型相比传统方法体现出三方面优势:

  • 理解能力更强,效果天花板显著提升:百亿甚至千亿级别的参数量,使大模型能够捕捉更复杂的语言表达和微妙语境,且具备不错的逻辑推理能力,这在多个权威评测中得到验证,为相关性效果突破提供了新的可能性。
  • 知识储备丰富,泛化能力大幅增强:基于海量互联网数据的预训练,让大模型内置了丰富的世界知识。面对未见过的新查询或内容类型,区别于小模型的“死记硬背”,大模型可以灵活的“举一反三”,提升系统在长尾场景下的鲁棒性。
  • 数据需求降低,迭代效率成倍提升:大模型本身就是一座“知识宝库”,通过提示词工程或少量样本微调,即可达到理想的业务效果。这降低了对大规模人工标注的依赖,为算法快速迭代奠定了基础。

基于这些特性,我们围绕大模型优化了相关性迭代的整个流程。首先是知识蒸馏新路径,传统BERT模型训练需要千万级人工标注,成本高周期长。现在,我们仅用万级数据训练大模型,再通过数据蒸馏的方式将其能力迁移至线上小模型。这一转变不仅提升了效果上限,也实现了算法的低成本快速迭代。

其次,我们将大模型深度融入“相关性问题发现 -> 解决”的闭环,覆盖新词诊断、badcase监控回流、GSB评估等环节。以每日badcase回流为例:对于低点查询,我们调用大模型进行相关性判断,经人工复核后进入线上bert模型训练池,形成持续优化闭环。这一流程重构,更大范围降低了对人工标注的依赖,提升了算法迭代效率。下图展示了新词生成 -> 相关性校验 -> 自动化归因 -> 人工标注 -> 样本增强的具体流程。

四、大模型建模搜索相关性

项目初期,大模型技术在搜索领域的应用尚处探索阶段,缺乏可借鉴的成熟方案。基于对算法原理与业务场景的理解,我们围绕“如何让大模型更接近人类的思考方式”这一目标,设计并实践了两项核心优化:

二阶段流程:我们观察到,人类判断相关性时通常遵循“先理解意图,再验证匹配”的认知过程。基于这一洞察,我们将端到端的相关性判断拆分为两个阶段。一阶段侧重理解用户查询,从中抽取出品牌、系列、适用人群等关键属性。二阶段则对内容进行属性解析,并逐一判断其与查询意图的一致性。最终综合多属性判断结果,输出相关性分档及对应依据。这一结构化的判断方式使模型大盘准确率从75%提升至80.95%,在理解能力上取得了可验证的进展。

R1慢思考:随着年初DeepSeek R1的发布,我们将其“慢思考”机制引入相关性建模,使模型能够生成思维链进行分步推理,例如:“用户搜索‘夏季运动鞋’→内容提及商品为跑步鞋→材质透气→符合夏季需求→判定相关”。在数据冷启阶段,我们调用开源推理模型,生成原始思考链,通过结果一致性校验&人工校验,过滤出少量高质cot推理数据。训练阶段,我们通过混合少量cot推理数据和大量常规数据的方式微调模型,使模型能将少量cot推理路径泛化到更多常规数据上。推理阶段,这种混合训练方式,也使模型能省略思维链的输出,同时保持分档准确性,从而在效果与效率之间取得平衡。该方法使大盘准确率从80.95%进一步提升至83.1%,中长尾场景准确率从76.98%大幅提升到81.45%,显示出良好的泛化能力。

以下是两个思维链示例:

基于大模型的技术演进并非一蹴而就,最初我们基于BERT训练数据,构建的初版相关性大模型效果有限,甚至略逊于线上BERT小模型基线(准确率 75% vs 75.2%)。通过后续一系列优化,如精细调整数据配比、引入课程学习等策略,模型效果逐步提升,最终大盘准确率提升约10个百分点,达到86.67%,验证了大模型在搜索相关性任务上的潜力。具体消融实验如下:

五、效果

经过两个季度的迭代优化,相关性大模型在效果上已稳定超过线上bert模型,在大盘测试集上,准确率提升11.47%,宏平均F1值提升16.21%。在样本量较少的档位上提升更为显著,2分档F1提升32.66%,1分档F1提升21.59%。目前,模型在NDCG和0分F1两个指标上仍有提升空间,这也将是下一阶段的优化重点。在中长尾场景下,大模型展现出更好的泛化能力,测试集准确率提升6.78%,宏平均F1提升25.72%,其中0分档F1提升达51.93%,表现全面优于线上基线模型。详情指标如下表:

六、落地

通过大模型标注千万级数据,并结合多版数据蒸馏策略进行A/B实验,线上相关性badcase率实现显著下降:大盘降低5.39个百分点,中长尾场景降低10.82个百分点,累计节约标注成本达百万级别。

离线评估方面,经过蒸馏后的线上BERT模型在大盘准确率上提升9.58%,宏平均F1提升10.91%;中长尾场景下准确率略有波动(-0.61%),但宏平均F1仍提升15.85%,体现出良好的泛化稳定性。

线上bert指标对比

后续方向

  • 当前大模型在分档能力上优于BERT,但在NDCG排序指标上仍有差距。我们正在探索基于生成式Listwise强化学习方法,建模内容间的偏序关系,以提升同一查询下的排序质量。
  • 基于大模型的数据蒸馏策略已逐步接近瓶颈。我们正尝试更高ROI的落地方案,包括logits蒸馏策略,并推进大模型直接承接部分线上流量的可行性验证。
  • 大模型本身的能力边界仍随开源基座模型和生成式搜推技术的发展而不断拓展,我们将持续跟进,探索效果上限的进一步突破。

七、结语

搜索相关性的优化,是一场没有终点的长跑。通过引入大模型技术,我们在理解用户意图、提升匹配精度上取得了阶段性进展,也为后续的迭代开辟了新的路径。未来,我们将紧跟大模型技术发展趋势,同时紧密结合业务场景,推动搜索体验向更智能、更精准的方向稳步演进。

往期回顾

  1. RAG—Chunking策略实战|得物技术

  2. 告别数据无序:得物数据研发与管理平台的破局之路

  3. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  4. Apex AI辅助编码助手的设计和实践|得物技术

  5. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

文 /若水、兰溪

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

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

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

RAG—Chunking策略实战|得物技术

作者 得物技术
2025年10月30日 16:36

一、背 景

在 RAG 系统中,即便采用性能卓越的 LLM 并反复打磨 Prompt,问答仍可能出现上下文缺失、事实性错误或拼接不连贯等问题。多数团队会频繁更换检索算法与 Embedding模型,但收益常常有限。真正的瓶颈,往往潜伏在数据入库之前的一个细节——文档分块(chunking)。不当的分块会破坏语义边界,拆散关键线索并与噪声纠缠,使被检索的片段呈现“顺序错乱、信息残缺”的面貌。在这样的输入下,再强大的模型也难以基于支离破碎的知识推理出完整、可靠的答案。某种意义上,分块质量几乎决定了RAG的性能上限——它决定知识是以连贯的上下文呈现,还是退化为无法拼合的碎片。

在实际场景中,最常见的错误是按固定长度生硬切割,忽略文档的结构与语义:定义与信息被切开、表头与数据分离、步骤说明被截断、代码与注释脱节,结果就是召回命中却无法支撑结论,甚至诱发幻觉与错误引用。相反,高质量的分块应尽量贴合自然边界(标题、段落、列表、表格、代码块等),以适度重叠保持上下文连续,并保留必要的来源与章节元数据,确保可追溯与重排可用。当分块尊重文档的叙事与结构时,检索的相关性与答案的事实一致性往往显著提升,远胜于一味更换向量模型或调参;换言之,想要真正改善 RAG 的稳健性与上限,首先要把“知识如何被切开并呈现给模型”这件事做好。

PS:本文主要是针对中文文档类型的嵌入进行实战。

二、什么是分块(Chunking)

分块是将大块文本分解成较小段落的过程,这使得文本数据更易于管理和处理。通过分块,我们能够更高效地进行内容嵌入(embedding),并显著提升从向量数据库中召回内容的相关性和准确性。

在实际操作中,分块的好处是多方面的。首先,它能够提高模型处理的效率,因为较小的文本段落更容易进行嵌入和检索。

其次,分块后的文本能够更精确地匹配用户查询,从而提供更相关的搜索结果。这对于需要高精度信息检索和内容生成的应用程序尤为重要。

通过优化内容的分块和嵌入策略,我们可以最大化LLM在各种应用场景中的性能。分块技术不仅提高了内容召回的准确性,还提升了整体系统的响应速度和用户体验。

因此,在构建和优化基于LLM的应用程序时,理解和应用分块技术是不可或缺的步骤。

分块过程中主要的两个概念:chunk_size块的大小,chunk_overlap重叠窗口。

三、为何要对内容做分块处理

  • 模型上下文窗口限制:LLM无法一次处理超长文本。分块的目的在于将长文档切成模型可稳定处理的中等粒度片段,并尽量对齐自然语义边界(如标题、段落、句子、代码块),避免硬切导致关键信息被截断或语义漂移。即便使用长上下文模型,过长输入也会推高成本并稀释信息密度,合理分块仍是必需的前置约束。
  • 检索的信噪比:块过大时无关内容会稀释信号、降低相似度判别力;块过小时语境不足、容易“只命中词不命中义”。合适的块粒度可在召回与精度间取得更好平衡,既覆盖用户意图,又不引入多余噪声。在一定程度上提升检索相关性的同时又能保证结果稳定性。
  • 语义连续性:跨段落或跨章节的语义关系常在边界处被切断。通过设置适度的 chunk_overlap,可保留跨块线索、减少关键定义/条件被“切开”的风险。对于强结构文档,优先让边界贴合标题层级与句子断点;必要时在检索阶段做轻量邻近扩展,以提升答案的连贯性与可追溯性,同时避免重复内容挤占上下文预算。

总之理想的分块是在“上下文完整性”和“信息密度”之间取得动态平衡:chunk_size决定信息承载量,chunk_overlap 用于弥补边界断裂并维持语义连续。只要边界对齐语义、粒度贴合内容,检索与生成的质量就能提升。

四、分块策略详解

4.1 基础分块

基于固定长度分块

  • 分块策略:按预设字符数 chunk_size 直接切分,不考虑文本结构。
  • 优点:实现最简单、速度快、对任意文本通用。
  • 缺点:容易破坏语义边界;块过大容易引入较多噪声,过小则会导致上下文不足。
  • 适用场景:结构性弱的纯文本,或数据预处理初期的基线方案。
from langchain_text_splitters import CharacterTextSplitter


splitter = CharacterTextSplitter(
    separator="",        # 纯按长度切
    chunk_size=600,      # 依据实验与模型上限调整
    chunk_overlap=90,    # 15% 重叠
)
chunks = splitter.split_text(text)
  • 参数建议(仅限中文语料建议)
    • chunk_size:300–800 字优先尝试;若嵌入模型最佳输入为 512/1024 tokens,可折算为约 350/700 中文字符起步。
    • chunk_overlap:10%–20% 起步;超过 30% 通常导致索引体积与检索开销显著上升,对实际性能起负作用,最后的效果并不会得到明显提升。

基于句子的分块

  • 分块策略:先按句子切分,再将若干句子聚合成满足chunk_size的块;保证最基本的语义完整性。
  • 优点:句子级完整性最好。对问句/答句映射友好。便于高质量引用。
  • 缺点:中文分句需特别处理。仅句子级切分可能导致块过短,需后续聚合。
  • 适用场景:法律法规、新闻、公告、FAQ 等以句子为主的文本。
  • 中文分句注意事项
    • 不要直接用 NLTK 英文 Punkt:无法识别中文标点,分句会失败或异常。
    • 可以直接使用以下内容进行分句:
      • 基于中文标点的正则:按“。!?;”等切分,保留引号与省略号等边界。
      • 使用支持中文的 NLP 库进行更精细的分句:
      • HanLP(推荐,工业级,支持繁多语言学特性)Stanza(清华/斯坦福合作,中文支持较好)spaCy + pkuseg 插件(或 zh-core-web-sm/med/lg 生态)
  • 示例(适配常见中文标点,基于正则的分句):
import re


def split_sentences_zh(text: str):
    # 在句末标点(。!?;)后面带可选引号的场景断句
    pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
    sentences = [m.group(0).strip() for m in pattern.finditer(text) if m.group(0).strip()]
    return sentences


def sentence_chunk(text: str, chunk_size=600, overlap=80):
    sents = split_sentences_zh(text)
    chunks, buf = [], ""
    for s in sents:
        if len(buf) + len(s) <= chunk_size:
            buf += s
        else:
            if buf:
                chunks.append(buf)
            # 简单重叠:从当前块尾部截取 overlap 字符与下一句拼接
            buf = (buf[-overlap:] if overlap > 0 and len(buf) > overlap else "") + s
    if buf:
        chunks.append(buf)
    return chunks


chunks = sentence_chunk(text, chunk_size=600, overlap=90)

HanLP 分句示例:

from hanlp_common.constant import ROOT
import hanlp


tokenizer = hanlp.load('PKU_NAME_MERGED_SIX_MONTHS_CONVSEG')  # 或句法/句子级管线
# HanLP 高层 API 通常通过句法/语料管线获得句子边界,具体以所用版本 API 为准
# 将句子列表再做聚合为 chunk_size

基于递归字符分块

  • 分块策略:给定一组由“粗到细”的分隔符(如段落→换行→空格→字符),自上而下递归切分,在不超出 chunk_size 的前提下尽量保留自然语义边界。
  • 优点:在“保持语义边界”和“控制块大小”之间取得稳健平衡,对大多数文本即插即用。
  • 缺点:分隔符配置不当会导致块粒度失衡,极度格式化文本(表格/代码)效果一般。
  • 适用场景:综合性语料、说明文档、报告、知识库条目。
import re
from langchain_text_splitters import RecursiveCharacterTextSplitter


separators = [
    r"\n#{1,6}\s",                 # 标题
    r"\n\d+(?:.\d+)*\s",          # 数字编号标题 1. / 2.3. 等
    "\n\n",                        # 段落
    "\n",                          # 行
    " ",                           # 空格
    "",                            # 兜底字符级
]
splitter = RecursiveCharacterTextSplitter(
    separators=separators,
    chunk_size=700,
    chunk_overlap=100,
    is_separator_regex=True,       # 告诉分割器上面包含正则
)
chunks = splitter.split_text(text)
  • 参数与分隔符建议(仅中文文档建议):
    • chunk_size:400–800 字符;如果内容更技术化、长句多时可适当上调该数值。
    • chunk_overlap:10%–20%。
    • separators(由粗到细,按需裁剪):
      • 章节/标题:正则 r"^#{1,6}\s"(Markdown 标题)、r"^\d+(.\d+)*\s"(编号标题)
      • 段落:"\n\n"
      • 换行:"\n"
      • 空格:" "
      • 兜底:""

总结

  • 调优流程
    • 固定检索与重排,只动分块参数。
    • 用验证集计算 Recall@k、nDCG、MRR、来源命中文档覆盖率、答案事实性(faithfulness)。
    • 观察块长分布:若长尾太长,适当收紧chunk_size 或增加粗粒度分隔符;若过短,放宽chunk_size 或降低分隔符优先级。
  • 重叠的成本与收益
    • 收益:缓解边界断裂,提升答案连贯性与可追溯性。
    • 成本:索引尺寸增长、召回重复块增多、rerank 负载提升。通常不建议超过 20%–25%。
  • 组合技巧
    • 先递归分块,再对“异常长句”或“跨段引用”场景加一点点额外 overlap。
    • 对标题块注入父级标题上下文,提高定位能力与可解释性。
  • 何时切换策略
    • 若问答频繁丢上下文或引用断裂:增大overlap或改用句子/结构感知策略。
    • 若召回含噪过多:减小 chunk_size 或引入更强的结构分隔符。

4.2 结构感知分块

利用文档固有结构(标题层级、列表、代码块、表格、对话轮次)作为分块边界,逻辑清晰、可追溯性强,能在保证上下文完整性的同时提升检索信噪比。

结构化文本分块

  • 分块策略
  • 以标题层级(H1–H6、编号标题)或语义块(段落、列表、表格、代码块)为此类型文档的天然边界,对过长的结构块再做二次细分,对过短的进行相邻合并。
  • 实施步骤
    • 解析结构:Markdown 用解析器remark/markdown-it-py或正则识别标题与语块;HTML用 DOMBeautifulSoup/Cheerio遍历 Hx、p、li、pre、table 等。
    • 生成章节:以标题为父节点,将其后的连续兄弟节点纳入该章节,直至遇到同级或更高层级标题。
    • 二次切分:章节超出 chunk_size时,优先按子标题/段落切,再不足时按句子或递归字符切分。
    • 合并短块:低于 min_chunk_chars 的块与相邻块合并,优先与同一父标题下的前后块。
    • 上下文重叠:优先用“结构重叠”(父级标题路径、前一小节标题+摘要),再辅以小比例字符overlap(10%–15%)。
    • 写入 metadata。
  • 示例代码
import re
from typing import List, Dict


heading_pat = re.compile(r'^(#{1,6})\s+(.*)$')  # 标题
fence_pat = re.compile(r'^```')                 # fenced code fence


def split_markdown_structure(text: str, chunk_size=900, min_chunk=250, overlap_ratio=0.1) -> List[Dict]:
    lines = text.splitlines()
    sections = []
    in_code = False
    current = {"level": 0, "title": "", "content": [], "path": []}
    
    path_stack = []  # [(level, title)]
    
    for ln in lines:
        if fence_pat.match(ln):
            in_code = not in_code
        m = heading_pat.match(ln) if not in_code else None
        if m:
            if current["content"]:
                sections.append(current)
            level = len(m.group(1))
            title = m.group(2).strip()


            while path_stack and path_stack[-1][0] >= level:
                path_stack.pop()
            path_stack.append((level, title))
            breadcrumbs = [t for _, t in path_stack]
            current = {"level": level, "title": title, "content": [], "path": breadcrumbs}
        else:
            current["content"].append(ln)
    
    if current["content"]:
        sections.append(current)
    
    # 通过二次拆分/合并将部分平铺成块
    chunks = []
    def emit_chunk(text_block: str, path: List[str], level: int):
        chunks.append({
            "text": text_block.strip(),
            "meta": {
                "section_title": path[-1] if path else "",
                "breadcrumbs": path,
                "section_level": level,
            }
        })
    
    for sec in sections:
        raw = "\n".join(sec["content"]).strip()
        if not raw:
            continue
        if len(raw) <= chunk_size:
            emit_chunk(raw, sec["path"], sec["level"])
        else:
            paras = [p.strip() for p in raw.split("\n\n") if p.strip()]
            buf = ""
            for p in paras:
                if len(buf) + len(p) + 2 <= chunk_size:
                    buf += (("\n\n" + p) if buf else p)
                else:
                    if buf:
                        emit_chunk(buf, sec["path"], sec["level"])
                    buf = p
            if buf:
                emit_chunk(buf, sec["path"], sec["level"])
    
    merged = []
    for ch in chunks:
        if not merged:
            merged.append(ch)
            continue
        if len(ch["text"]) < min_chunk and merged[-1]["meta"]["breadcrumbs"] == ch["meta"]["breadcrumbs"]:
            merged[-1]["text"] += "\n\n" + ch["text"]
        else:
            merged.append(ch)
    
    overlap = int(chunk_size * overlap_ratio)
    for ch in merged:
        bc = " > ".join(ch["meta"]["breadcrumbs"][-3:])
        prefix = f"[{bc}]\n" if bc else ""
        if prefix and not ch["text"].startswith(prefix):
            ch["text"] = prefix + ch["text"]
        # optional character overlap can在检索阶段用邻接聚合替代,这里略
    
    return merged
  • 参数建议(中文文档)
    • chunk_size:600–1000 字;技术文/长段落可取上限,继续适当增加。
    • min_chunk_chars:200–300 字(小于则合并)。
    • chunk_overlap:10%–15%;若使用“父级标题路径 + 摘要”作为结构重叠,可降至 5%–10%。

对话式分块

  • 分块策略
  • 以“轮次/说话人”为边界,优先按对话邻接对和小段话题窗口聚合。重叠采用“轮次重叠”而非单纯字符重叠,保证上下文流畅。
  • 适用场景
  • 客服对话、访谈、会议纪要、技术支持工单等多轮交流。
  • 检索期邻接聚合
  • 在检索阶段对对话块做“邻接扩展”:取被召回块前后各 1–2 轮上下文(或相邻块拼接)作为最终送审上下文,以提高回答连贯性与可追溯性。
  • 与重排协同
  • 可提升对“谁说的、在哪段说的”的判断力。
  • 示例代码:(按轮次滑动窗口分块)
from typing import List, Dict


def chunk_dialogue(turns: List[Dict], max_turns=10, max_chars=900, overlap_turns=2):
    """
    turns: [{"speaker":"User","text":"..." , "ts_start":123, "ts_end":130}, ...]
    """
    chunks = []
    i = 0
    while i < len(turns):
        j = i
        char_count = 0
        speakers = set()
        while j < len(turns):
            t = turns[j]
            uttr_len = len(t["text"])
            # 若单条超长,允许在句级二次切分(此处略),但不跨 speaker
            if (j - i + 1) > max_turns or (char_count + uttr_len) > max_chars:
                break
            char_count += uttr_len
            speakers.add(t["speaker"])
            j += 1
        
        if j > i:
            window = turns[i:j]
        elif i < len(turns):
            window = [turns[i]]
        else:
            break
        text = "\n".join([f'{t["speaker"]}: {t["text"]}' for t in window])
        meta = {
            "speakers": list(speakers),
            "turns_range": (i, j - 1),
            "ts_start": window[0].get("ts_start"),
            "ts_end": window[-1].get("ts_end"),
        }
        chunks.append({"text": text, "meta": meta})
        
        # 按轮次重叠回退
        if j >= len(turns):
            break
        next_start = i + len(window) - overlap_turns
        i = max(next_start, i + 1)  # 确保至少前进1步
    return chunks
  • 参数建议
    • max_turns_per_chunk:6–12 轮起步;语速快信息密度高可取 8–10。
    • max_chars_per_chunk:600–1000 字;若存在长段独白,优先句级再切,不跨说话人。
    • overlap_turns:1–2 轮;保证上一问下一答的连续性。
    • keep_pairing:不要拆开明显的问答对;若 chunk 临界,宁可扩一轮或后移切分点。

总结

  • 首选用结构边界做第一次切分,再用句级/递归策略做二次细分。
  • 优先使用“结构重叠”(父标题路径、上段标题+摘要、相邻发言)替代大比例字符重叠。
  • 为每个块写好 metadata,可显著提升检索质量与可解释性。
  • 对 PDF/HTML 先去噪(页眉页脚、导航、广告等),避免把噪声索引进库。

4.3 语义与主题分块

该方法不依赖文档的物理结构,而是依据语义连续性与话题转移来决定切分点,尤其适合希望“块内高度内聚、块间清晰分界”的知识库与研究类文本。

语义分块

  • 分块策略
    • 对文本先做句级切分,计算句子或短段的向量表示;
    • 当相邻语义的相似度显著下降(发生“语义突变”)时设为切分点。
  • 适用场景
    • 专题化、论证结构明显的文档:
    • 白皮书、论文、技术手册、FAQ 聚合页;
    • 需要高内聚检索与高可追溯性。
  • 使用流程
    • 句级切分:先用中文分句(标点/中文分句模型)得到句子序列。
    • 向量化:对每个句子编码,开启归一化(normalize)以便用余弦相似度。
    • 突变检测:
      • 简单粗暴的方法:sim(i, i-1) 低于阈值则切分。
      • 稳健的方法:与“前后窗口的均值向量”比较,计算新颖度 novelty = 1 - cos(emb_i, mean_emb_window),新颖度高于阈值则切分。
      • 平滑的方法:对相似度/新颖度做移动平均,降低抖动。
    • 约束与修正:设置最小/最大块长,避免过碎或过长,必要时进行相邻块合并。
  • 与检索/重排的协同
  • 召回时可做“邻接扩展”(把被命中的块前后各追加一两句),再做重排序。语义分块的高内聚可让 重排序更精准地区分相近候选。
  • 代码示例
from typing import List, Dict, Tuple
import numpy as np
from sentence_transformers import SentenceTransformer
import re


def split_sentences_zh(text: str) -> List[str]:
    # 简易中文分句,可替换为 HanLP/Stanza 更稳健的实现
    pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
    return [m.group(0).strip() for m in pattern.finditer(text) if m.group(0).strip()]


def rolling_mean(vecs: np.ndarray, i: int, w: int) -> np.ndarray:
    s = max(0, i - w)
    e = min(len(vecs), i + w + 1)
    return vecs[s:e].mean(axis=0)


def semantic_chunk(
    text: str,
    model_name: str = "BAAI/bge-m3",
    window_size: int = 2,
    min_chars: int = 350,
    max_chars: int = 1100,
    lambda_std: float = 0.8,
    overlap_chars: int = 80,
) -> List[Dict]:
    sents = split_sentences_zh(text)
    if not sents:
        return []
    
    model = SentenceTransformer(model_name)
    emb = model.encode(sents, normalize_embeddings=True, batch_size=64, show_progress_bar=False)
    emb = np.asarray(emb)
    
    # 基于窗口均值的“新颖度”分数
    novelties = []
    for i in range(len(sents)):
        ref = rolling_mean(emb, i-1, window_size) if i > 0 else emb[0]
        ref = ref / (np.linalg.norm(ref) + 1e-8)
        novelty = 1.0 - float(np.dot(emb[i], ref))
        novelties.append(novelty)
    novelties = np.array(novelties)
    
    # 相对阈值:μ + λσ
    mu, sigma = float(novelties.mean()), float(novelties.std() + 1e-8)
    threshold = mu + lambda_std * sigma
    
    chunks, buf, start_idx = [], "", 0
    def flush(end_idx: int):
        nonlocal buf, start_idx
        if buf.strip():
            chunks.append({
                "text": buf.strip(),
                "meta": {"start_sent": start_idx, "end_sent": end_idx-1}
            })
        buf, start_idx = "", end_idx
    
    for i, s in enumerate(sents):
        # 若超长则先冲洗
        if len(buf) + len(s) > max_chars and len(buf) >= min_chars:
            flush(i)
            # 结构化重叠:附加上一个块的尾部
            if overlap_chars > 0 and len(s) < overlap_chars:
                buf = s
                continue
        
        buf += s
        
        # 达到最小长度后遇到突变则切分
        if len(buf) >= min_chars and novelties[i] > threshold:
            flush(i + 1)
    
    if buf:
        flush(len(sents))
    
    return chunks
  • 参数调优说明(仅作参考)
    • 阈值的含义:语义变化敏感度控制器,越低越容易切、越高越保守。
    • 设定方式:
      • 绝对阈值:例如使用余弦相似度,若 sim < 0.75 则切分(需按语料校准)。
      • 相对阈值:对全篇的相似度/新颖度分布估计均值μ与标准差σ,使用 μ ± λσ 作为阈值,更稳健。
    • 初始的配置建议(仅限于中文技术/说明文档):
      • 窗口大小 window_size:2–4 句
      • 最小/最大块长:min_chunk_chars=300–400,max_chunk_chars=1000–1200
      • 阈值策略:novelty > μ + 0.8σ 或相似度 < μ - 0.8σ(先粗调后微调)
      • overlap:10% 左右或按“附加上一句”做轻量轮次重叠

主题的分块

  • 分块策略
  • 利用主题模型或聚类算法在“宏观话题”发生切换时进行切分,更多的关注章节级、段落级的主题边界。该类分块策略主要适合长篇、多主题材料。
  • 适用场景
    • 报告、书籍、长调研文档、综合评审;
    • 当文档内部确有较稳定的“话题块”。
  • 使用流程(最好用“句向量 + 聚类 + 序列平滑”而非纯 LDA)
    • 句级切分并编码:首先通过向量模型得到句向量,normalize。
    • 文档内或语料级聚类:
      • 文档内小规模:MiniBatchKMeans(k=3–8 先验)或 SpectralClustering。
      • 语料级统一主题:在大量文档上聚类(或用 HDBSCAN+UMAP),再将每篇文档的句子映射到最近主题中心。
    • 序列平滑与解码:
      • 对句子的主题标签做滑窗多数投票或一阶马尔可夫平滑,避免频繁抖动。
      • 当主题标签稳定变化并满足最小块长时,设为切分点。
    • 主题命名:
    • 用 KeyBERT/TF-IDF 在每个块内抽关键词,或用小模型生成一句话主题摘要,写入 metadata。
    • 约束:min/max_chars,保留代码/表格等原子块,必要时与结构边界结合使用。
  • 代码示例(KMeans 文档内聚类 + 序列平滑)
from typing import List, Dict
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans
import re


def split_sentences_zh(text: str) -> List[str]:
    pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
    return [m.group(0).strip() for m in pattern.finditer(text) if m.group(0).strip()]


def topic_chunk(
    text: str,
    k_topics: int = 5,
    min_chars: int = 500,
    max_chars: int = 1400,
    smooth_window: int = 2,
    model_name: str = "BAAI/bge-m3"
) -> List[Dict]:
    sents = split_sentences_zh(text)
    if not sents:
        return []
    
    model = SentenceTransformer(model_name)
    emb = model.encode(sents, normalize_embeddings=True, batch_size=64, show_progress_bar=False)
    emb = np.asarray(emb)
    
    km = KMeans(n_clusters=k_topics, n_init="auto", random_state=42)
    labels = km.fit_predict(emb)
    
    # 简单序列平滑:滑窗多数投票
    smoothed = labels.copy()
    for i in range(len(labels)):
        s = max(0, i - smooth_window)
        e = min(len(labels), i + smooth_window + 1)
        window = labels[s:e]
        vals, counts = np.unique(window, return_counts=True)
        smoothed[i] = int(vals[np.argmax(counts)])
    
    chunks, buf, start_idx, cur_label = [], "", 0, smoothed[0]
    def flush(end_idx: int):
        nonlocal buf, start_idx
        if buf.strip():
            chunks.append({
                "text": buf.strip(),
                "meta": {"start_sent": start_idx, "end_sent": end_idx-1, "topic": int(cur_label)}
            })
        buf, start_idx = "", end_idx
    
    for i, s in enumerate(sents):
        switched = smoothed[i] != cur_label
        over_max = len(buf) + len(s) > max_chars
        under_min = len(buf) < min_chars
        
        # 尝试延后切分,保证最小块长
        if switched and not under_min:
            flush(i)
            cur_label = smoothed[i]
        
        if over_max and not under_min:
            flush(i)
        
        buf += s
    
    if buf:
        flush(len(sents))
    
    return chunks
  • 一些参数对结果的影响
    • k(主题数):难以精准预设,可通过轮廓系数(silhouette)/肘部法初筛,再结合领域先验与人工校正。
    • HDBSCAN:min_cluster_size 影响较大,过小会碎片化,过大则合并不同话题。
    • min_topic_span_sents:如 5–8 句,防止标签抖动导致过密切分。
    • 小文档不宜用:样本太少时主题不可分,优先用语义分块或结构分块。

4.4 高级分块

小-大分块

  • 分块策略
  • 用“小粒度块”(如句子/短句)做高精度召回,定位到最相关的微片段;再将其“所在的大粒度块”(如段落/小节)作为上下文送入 LLM,以兼顾精确性与上下文完整性。
  • 使用流程
    • 构建索引(离线):
      • Sentence/短句索引(索引A):单位为句子或子句。
      • 段落/小节存储(存储B):保留原始大块文本与结构信息。
    • 检索(在线):
      • 用索引A召回 top_k_small 个小块(向量检索)。
      • 将小块按 parent_id 分组,计算组内分数(max/mean/加权),选出 top_m_big 个父块候选。
      • 对“查询-父块文本”做交叉编码重排,提升相关性排序的稳定性。
      • 上下文组装:在每个父块中高亮或优先保留命中小句附近的上下文(邻近N句或窗口字符 w),在整体 token 预算内拼接多块。
  • 代码示例(伪代码)
# 离线:构建小块索引,并保存 parent_id -> 大块文本 的映射
# 在线检索:
small_hits = small_index.search(embed(query), top_k=30)
groups = group_by_parent(small_hits)
scored_parents = score_groups(groups, agg="max")
candidates = top_m(scored_parents, m=3)


# 交叉编码重排
rerank_inputs = [(query, parent_text(pid)) for pid in candidates]
reranked = cross_encoder_rerank(rerank_inputs)


# 组装上下文:对每个父块,仅保留命中句及其邻近窗口,并加上标题路径
contexts = []
for pid, _ in reranked:
    hits = groups[pid]
    context = build_local_window(parent_text(pid), hits, window_sents=1)
    contexts.append(prefix_with_breadcrumbs(pid) + context)


final_context = pack_under_budget(contexts, token_budget=3000)    # 留出回答空间

父子段分块

  • 分块策略
  • 将文档按章节/段落等结构单元切成“父块”(Parent),再在每个父块内切出“子块”(通常为句子/短段或者笃固定块)。然后为“子块”建向量索引以做高精度召回。当检索时先召回子块,再按 parent_id 聚合并扩展到父块或父块中的局部窗口,兼顾最后召回内容的精准与上下文完整性。
  • 适用场景
    • 结构清晰的说明文、手册、白皮书、法规、FAQ 聚合页;
    • 需要“句级证据准确 + 段/小节级上下文完整”的问答。
  • 使用流程
    • 结构粗切(父块)
      • 按标题层级/段落/代码块切出父块。
      • 父块写入 breadcrumbs(H1/H2/...)、anchor、block_type、start/end_offset。
    • 精细切分(子块)
      • 在父块内部以句子/子句/固定块为单位切分(可用递归分块兜底),小比例 overlap(或附加上一句内容)。
      • 为每个子块记录child_offset、sent_index_range、parent_id。
    • 建索引与存储
      • 子块向量索引A:先编码,normalize 后建索引。
      • 父块存储B:保存原文与结构元信息,此处可以选建一个父块级向量索引用于粗排或回退。
    • 检索与组装
      • 用索引A召回 top_k_child 子块。
      • 按 parent_id 分组并聚合打分(max/mean/命中密度),选出 top_m_parent 父块候选。
      • 对 (query, parent_text 或 parent_window) 交叉编码重排。
      • 上下文裁剪:对每个父块仅保留“命中子块±邻近窗口”(±1–2 句或 80–200 字),加上标题路径前缀,控制整体 token 预算。
  • 打分与聚合策略
    • 组分数:score_parent = α·max(child_scores) + (1-α)·mean(child_scores) + β·coverage(命中子块数/父块子块总数)。
    • 密度归一化:density = sum(exp(score_i)) / length(parent_text),为避免长父块因命中多而“天然占优”。
    • 窗口合并:同一父块内相邻命中窗口若间距小于阈值则合并,减少重复与碎片。
  • 与“小-大分块”的关系
    • 小-大分块是检索工作流(小粒度召回→大粒度上下文);
    • 父子段分块是数据建模与索引设计(显式维护 parent–child 映射)。
    • 两者强相关、常配合使用:父子映射让小-大扩展更稳、更易去重与回链。
  • 示例
from typing import List, Dict, Tuple
import numpy as np
from sentence_transformers import SentenceTransformer




embedder = SentenceTransformer("BAAI/bge-m3")


def search_parent_child(query: str, top_k_child=40, top_m_parent=3, window_chars=180):
    q = embedder.encode([query], normalize_embeddings=True)[0]
    hits = small_index.search(q, top_k=top_k_child)  # 返回 [(child_id, score), ...]
    # 分组
    groups: Dict[str, List[Tuple[str, float]]] = {}
    for cid, score in hits:
        p = child_parent_id[cid]
        groups.setdefault(p, []).append((cid, float(score)))
    
    # 聚合打分(max + coverage)
    scored = []
    for pid, items in groups.items():
        scores = np.array([s for _, s in items])
        agg = 0.7 * scores.max() + 0.3 * (len(items) / (len(parents[pid]["sent_spans"]) + 1e-6))
        scored.append((pid, float(agg)))
    scored.sort(key=lambda x: x[1], reverse=True)
    candidates = [pid for pid, _ in scored[:top_m_parent]]
    
    # 为每个父块构造“命中窗口”
    contexts = []
    for pid in candidates:
        ptext = parents[pid]["text"]
        # 找到子块命中区间并合并窗口
        spans = sorted([(children[cid]["start"], children[cid]["end"]) for cid, _ in groups[pid]])
        merged = []
        for s, e in spans:
            s = max(0, s - window_chars)
            e = min(len(ptext), e + window_chars)
            if not merged or s > merged[-1][1] + 50:
                merged.append([s, e])
            else:
                merged[-1][1] = max(merged[-1][1], e)
        windows = [ptext[s:e] for s, e in merged]
        prefix = " > ".join(parents[pid]["meta"].get("breadcrumbs", [])[-3:])
        contexts.append((pid, f"[{prefix}]\n" + "\n...\n".join(windows)))
    
    # 交叉编码重排(此处用占位函数)
    reranked = cross_encoder_rerank(query, [c[1] for c in contexts])  # 返回 indices 顺序
    ordered = [contexts[i] for i in reranked]
    return ordered  # [(parent_id, context_text), ...]
  • 调参建议(仅作参考,具体需要按照实际来)
  • 调参顺序:先定父/子块长度 → 标定 top_k_child 与聚合权重 → 调整窗口大小与合并阈值 → 最后接入交叉编码重排并控制 token 预算。

代理式分块

  • 分块策略
  • 使用一个小温度、强约束的 LLM Agent 模拟“人类阅读与编排”,根据语义、结构与任务目标动态决定分块边界,并输出结构化边界信息与理由(rationale 可选,不用于检索)。
  • 适用场景
    • 高度复杂、长篇、非结构化且混合格式(文本+代码+表格)的文档;
    • 结构/语义/主题策略单独使用难以取得理想边界时。
  • 使用时的注意事项
    • 规则护栏:
      • 禁止在代码块、表格单元、引用块中间切分,对图片/公式作为原子单元处理。
      • 保持标题链路完整,强制最小/最大块长(min/max_chars / min/max_sents)。
    • 目标对齐:
    • 在系统提示中明确“为了检索问答/用于摘要/用于诊断”的目标,Agent 以任务优先级决定边界与上下文冗余度。
    • 结构化输出:
    • 要求输出 segments: [{start_offset, end_offset, title_path, reason}],不能接受自由文本。
    • 自检与回退:
    • Agent 产出的边界先过一遍约束校验器(如长度、原子块、顺序等),不符合规则的内容则自动回退到递归/句级分块。
    • 成本控制:
      • 长文分批阅读(分段滑动窗口);
      • 在每段末尾只输出边界草案,最终汇总并去重;
      • 温度低(≤0.3)、max_tokens 受控。
  • 示例:Agent 输出模式(伪 Prompt 片段)
系统:你是分块器。目标:为RAG检索创建高内聚、可追溯的块。规则:
1) 不得在代码/表格/公式中间切分;
2) 每块400-1000字;
3) 保持标题路径完整;
4) 尽量让“定义+解释”在同一块;
5) 输出JSON,含 start_offset/end_offset/title_path。


用户:<文档片段文本>
助手(示例输出):
{
  "segments": [
    {"start": 0, "end": 812, "title_path": ["指南","安装"], "reason": "完整步骤+注意事项"},
    {"start": 813, "end": 1620, "title_path": ["指南","配置"], "reason": "参数表与示例紧密相关"}
  ]
}
  • 集成的流程
    • 粗切:先用结构感知/递归策略获得初步块,降低 Agent 处理跨度。
    • Agent 精修:对“疑难块”(过长/多格式/主题混杂)调用 Agent 细化边界。
    • 质检:规则校验 + 语义稀疏度检测(块内相似度方差过大则再细分)。
    • 写入 metadata。

4.5 混合分块

单一策略难覆盖所有文档与场景。混合分块通过“先粗后细、按需细化”,在效率、可追溯性与答案质量之间取得稳健平衡。

  • 分块策略
  • 先用宏观边界(结构感知)做粗粒度切分,再对“过大或主题跨度大的块”应用更精细的策略(递归、句子、语义/主题)。查询时配合“小-大分块”/“父子段分块”的检索组装,以小精召回、以大保上下文。
  • 使用流程
    • 粗切(离线):按标题/段落/代码块/表格等结构单元切分,清理噪声(页眉页脚/导航)。
    • 细化(离线):对超长或密度不均的块,按规则选用递归/句子/语义分块二次细分。
    • 索引(离线):同时为“小块索引(句/子句)”与“大块存储(段/小节)”生成数据与metadata。
    • 检索(在线):小块高精度召回 → 按父块聚合与重排→ 在父块中抽取命中句邻域作为上下文,控制整体 token 预算。
  • 策略选择规则
    • 若块类型为代码/表格/公式:保持原子,不在中间切分,直接与其解释文字打包。
    • 若为对话:按轮次/说话人做对话式分块,overlap 使用“轮次重叠”。
    • 若为普通说明文/Markdown章节:
      • 长度 > max_coarse或句长方差高/标点稀疏:优先语义分块(句向量+突变阈值)。
      • 否则:递归字符分块(标题/段落/换行/空格/字符)保持语义边界。
    • 对过短块:与同一父标题相邻块合并,优先向后合并。
  • 质量-成本档位(仅供参考)
    • fast:仅结构→递归。overlap 5%–10%,不跑语义分块和主题分块
    • balanced(推荐):结构→递归,对异常块启用语义分块,小-大检索,overlap 10%左右
    • quality:在 balanced 基础上对疑难块启用 Agent 精修,更强的邻接扩展与rerank
  • 简洁调度器示例, 将结构粗切与若干细分器组合为一个“混合分块”入口,关键是类型判断与长度阈值控制。可以把前文已实现的结构/句子/语义/对话分块函数挂入此调度器。
from typing import List, Dict


def hybrid_chunk(
    doc_text: str,
    parse_structure,          # 函数:返回 [{'type': 'text|code|table|dialogue', 'text': str, 'breadcrumbs': [...], 'anchor': str}]
    recursive_splitter,       # 函数:text -> [{'text': str}]
    sentence_splitter,        # 函数:text -> [{'text': str}]
    semantic_splitter,        # 函数:text -> [{'text': str}]
    dialogue_splitter,        # 函数:turns(list) -> [{'text': str}],若无对话则忽略
    max_coarse_len: int = 1100,
    min_chunk_len: int = 320,
    target_len: int = 750,
    overlap_ratio: float = 0.1,
) -> List[Dict]:
    """
    返回格式: [{'text': str, 'meta': {...}}]
    """
    blocks = parse_structure(doc_text)  # 先拿到结构块
    chunks: List[Dict] = []
    
    def emit(t: str, meta_base: Dict):
        t = t.strip()
        if not t:
            return
        # 结构重叠前缀(标题路径)
        bc = " > ".join(meta_base.get("breadcrumbs", [])[-3:])
        prefix = f"[{bc}]\n" if bc else ""
        chunks.append({
            "text": (prefix + t) if not t.startswith(prefix) else t,
            "meta": meta_base
        })
    
    for b in blocks:
        t = b["text"]
        btype = b.get("type", "text")
        
        # 原子块:代码/表格
        if btype in {"code", "table", "formula"}:
            emit(t, {**b, "splitter": "atomic"})
            continue
        
        # 对话块
        if btype == "dialogue":
            for ck in dialogue_splitter(b.get("turns", [])):
                emit(ck["text"], {**b, "splitter": "dialogue"})
            continue
        
        # 普通文本:依据长度与“可读性”启用不同细分器
        if len(t) <= max_coarse_len:
            # 中短文本:递归 or 句子
            sub = recursive_splitter(t)
            # 合并过短子块
            buf = ""
            for s in sub:
                txt = s["text"]
                if len(buf) + len(txt) < min_chunk_len:
                    buf += txt
                else:
                    emit(buf or txt, {**b, "splitter": "recursive"})
                    buf = "" if buf else ""
            if buf:
                emit(buf, {**b, "splitter": "recursive"})
        else:
            # 超长文本:语义分块优先
            for ck in semantic_splitter(t):
                emit(ck["text"], {**b, "splitter": "semantic"})
    
    # 轻量字符重叠(可选)
    if overlap_ratio > 0:
        overlapped = []
        for i, ch in enumerate(chunks):
            overlapped.append(ch)
            if i + 1 < len(chunks) and ch["meta"].get("breadcrumbs") == chunks[i+1]["meta"].get("breadcrumbs"):
                # 在相邻同章节块间引入小比例重叠
                ov = int(len(ch["text"]) * overlap_ratio)
                if ov > 0:
                    head = ch["text"][-ov:]
                    chunks[i+1]["text"] = head + chunks[i+1]["text"]
        chunks = overlapped
    
    return chunks

五、结论

往期回顾

  1. 告别数据无序:得物数据研发与管理平台的破局之路

  2. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  3. Apex AI辅助编码助手的设计和实践|得物技术

  4. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

  5. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

文 /昆岚

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

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

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

告别数据无序:得物数据研发与管理平台的破局之路

作者 得物技术
2025年10月28日 11:08

一、背景介绍

为什么得物需要自建大数据研发与管理平台?

得物作为一家数据驱动型互联网企业,数据使用的效率、质量、成本,极大影响了公司的商业竞争力。而数据链路上最关键的系统是计算存储引擎和数据研发平台。其中计算存储引擎决定了数据的使用成本,数据研发平台则决定了数据的交付效率、数据质量以及数据架构合理性。

得物数据生产链路

过去整套大数据基础设施我们都使用云上商业化产品(下文简称“云平台”),但在各方面已远无法匹配上得物长期的业务发展。目前得物大数据面临着如下挑战:

因此24年技术部正式启动大数据系统自建,Galaxy数据研发与管理平台为其中一个重要项目,负责面向参与数据生产的用户,提供离线和实时数据的采集同步、研发运维、加工生产、数据资产管理以及安全合规的能力,满足业务长期发展对于数据架构、数据质量、数据交付效率的诉求。

二、产品功能架构

下图为整体产品功能架构,其中蓝色部分为当前已落地功能,灰色部分为规划中的功能。

Galaxy数据研发与管理平台产品功能架构

24年立项开始至今,我们主要专注在4个最核心能力的建设,分别为:数据研发套件、数据架构技术、数据质量技术、智能化数据研发。如果把数据研发平台比喻成一辆汽车,那么这四部分的定位如下图所示:

下文会对这些关键技术实现、落地进展以及Galaxy数据研发平台的架构演进,进行解析。

三、数据建设的“驾驶舱” - 数据研发套件

3.1 系统架构解析

数据研发套件包含数据研发IDE、数据资产系统、离线任务调度系统三部分,在平台中的定位类似于“汽车的驾驶舱”,为数据工程师提供丰富的工具集,控制全公司的数据流动与计算。整体系统架构如下图所示:

3.2 数据同步技术解析

数据同步也叫数据集成,它是Galaxy数据研发平台的核心组件之一,主要用于公司各种异构数据源与数据仓库进行数据传输,打通数据孤岛。它作为大数据链路加工的起点和终点,不仅用于数仓ODS层(Operational Data Store,保存原始数据)的入仓构建,也负责将数仓数据回流(出仓)到消费侧的各种数据源中。

离线数据同步

离线数据同步是最为主流的一种数据同步模式,以批量读写的形式将源表以全量或增量的形式周期性的写入目标表。目前Galaxy数据研发套件支持了多种类型数据源的离线同步,包括:

目前Galaxy数据研发套件的离线同步内核基于Spark Jar进行实现,下图为离线数据同步架构:

Galaxy数据研发套件离线数据同步架构

离线数据同步的具体实现执行流程(以MySQL同步至得物自建离线存储系统为例):

MySQL离线同步至得物自建离线存储系统的执行流程

实时数据同步

离线数据同步存在着一些局限性,主要有:

  • 对在线数据库压力大,即使是读库也可能影响线上部分业务的稳定性。而如果单独为数据入仓申请一个备库,又会带来较大的额外成本;
  • 大表同步时间长(可达7小时)此类任务基本无法保障下游重要数据产出的SLA;
  • 需要在短时间传输大量数据,对网络带宽依赖高;
  • 数据时效差,最快也是T+H的延迟,无法满足实时报表等对时效性敏感场景的需求。

因此需要实时数据同步的能力对此类场景进行补充。我们主要支持两种实时同步方案:

1)基于业务库binlog的的实时入仓

对比离线数据入仓,基于binlog的实时入仓可以避免对数据库造成压力,减少了对网络带宽的依赖,同时对于超大规模的表可以大幅缩短基线加工时长。但此方案依然需要(小时/天)将增量数据和全量数据做Merge处理和存储,这会产生冗余的计算和存储成本,且时效性也较差,因此本质上只能为离线数仓场景服务。

2)实时镜像同步

通过实时计算引擎Flink CDC将变更数据实时更新到存储系统中,保持数仓ODS表和来源数据库表的增全量同步,整体架构更加简单,并减少ODS层的批计算和冗余存储成本。目前规划通过Paimon、Iceberg等开放Lakehouse能力来实现离线存储系统的实时事务性更新。根据实际业务场景,也可以直接将数据实时写入StarRocks等支持更新的OLAP数据库中。

3.3 数据研发套件任务迁移方案解析

在过去得物的全部数据加工任务全部运行在云上数据平台,因此除了对齐产品能力外,我们还需要将数据加工任务从云平台“平滑”的迁移到Galaxy研发平台。

由于调度系统的故障风险极大,一旦异常很可能由于依赖错乱导致数据异常或停止调度导致的数据产出延迟。因此我们将Galaxy研发套件的平台层迁移和调度层迁移进行解耦,以便将调度系统的迁移节奏放缓。

首先进行风险较低的研发平台层迁移,让业务可以尽快上线,便于优化数据研发流程和数据资产管理能力。此阶段任务的调度依然运行在云平台。之后再进行调度层的迁移,这个阶段用户基本无感,完成后则彻底不再依赖云平台。

因此架构上一套研发平台需要同时适配两套调度系统(云任务调度+Galaxy自研调度系统 ),并支持逐步往自研Galaxy调度的平滑演进。

为了让调度迁移的过程需要如同“数据库主备切换”一样,尽量让用户无感,我们使用了影子节点的方案,以实现迁移流程的业务无感、可灰度、可回滚。影子节点本质是一个Shell任务,当调度系统启动它后,它会通过Rest API检测对方调度系统中实体节点的状态,并与它保持状态同步。通过影子节点,我们可以实现按照任意调度任务id进行灰度迁移,调度迁移本质就是将云平台的实体节点替换为影子节点。如下所示:

基于“影子节点”的双调度互通方案

3.4 功能建设与迁移进展

1)功能对齐与优化

目前Galaxy研发套件已完成与原云数据研发平台的主链路功能对齐,具备数据研发与资产管理的全套流程,同时还针自建Spark引擎查询和运维、在线数据入仓等方面进行定向优化。提效成果:

临时SQL查询性能优化

通过简化调用链路+Spark Driver预启动等查询加速技术,平均每个Query可以比原云数据研发平台固定节约35s+。

减少查询等待时间:290+人日/月

在线数据入仓自动化提效

通过工单申请即可实现MySQL数据入仓。自动帮用户创建同步数据源、增/全量ODS表、同步任务、增量Merge任务,并自动赋权以及数据初始化。根据用户调研和埋点分析,每个数据入仓需求可提效30min+。

提效效果:20+人日/月

2)业务迁移进展

目前我们已完成数据平台、数据挖掘、数据分析团队的全部任务迁移(占得物全域的44%),并完成了算法团队的POC。同时还将上述团队的临时取数业务迁移到了自建Spark引擎,从而实现云上商业版计算引擎的DEV资源缩容400+cu,总计可节省临时取数计算成本约2万+/月。

四、公司数据资产的“底盘”-数据架构技术

目前,公司业务用数越来越敏捷和频繁,而数据资产却没有做到“好找敢用”,大量的重复数据和数据烟囱也随之出现。这不仅导致大量数据二义性问题,同时也使计算存储成本难以控制。以离线数仓社区&交易的试点域为例,重复冗余表达到了54%,重复指标达到了35%。这本质上是缺乏数据架构体系的建设,数据架构是公司数据管理的“骨架”和“路线图”,它如同“汽车的底盘”,忽视数据架构可能导致数据的无序增长以及业务的决策错误。

4.1 Onedata数据架构方法论

及工具体系

Galaxy数据研发平台基于“Onedata”的数据架构方法论,建立了统一的数据采集和生产规范,使数据的新增更加合理、易用,提高数据的复用度、研发效率、交付质量,降低使用成本。这是一种“内啡肽”式的数据建设,前期需要花费一定时间进行数据模型的设计并遵守数据研发规范,但从业务的长远发展来看,这是必须要走的一步。

目前我们已在数据采集入仓和数据研发两个环节完成了数据架构能力建设,确保数据的入口(ODS)以及数据仓库的规范性,并再后续通过旁路数据治理的手段进行存量数据的规范化。如下图所示:

Onedata数据架构工具体系

融入了Onedata数据架构技术体系(红色部分)后的Galaxy数据研发平台架构如下图所示:

融入Onedata规范数据生产能力(红色部分)的

Galaxy研发平台技术架构

下文主要对两个关键模块,统一ODS自动化入仓平台、Onedata数据建模的实现方案进行解析。

4.2 统一ODS自动化采集入仓方案解析

ODS(Operational Data Store),为操作数据层,是整个数仓最基础的一层,是原始数据采集入仓的第一个环节。Onedata的核心理念之一是所有的数据采集有统一的规范和入口。因为随意的从在线库进行采集同步会导致大量重复的数据存储,以及过长浪费的表存储生命周期。

由于数据的采集入仓本身没有过度复杂的业务逻辑,因此Galaxy数据研发平台实现了自动化数据采集入仓能力,提供在线数据源到数仓ODS层的标准化采集和管理能力。无需研发代码的同时,产生的数据都是严格满足架构规范的。具体价值有:

  • 避免重复ODS数据存储
  • 通过库owner+数仓owner双重审批,避免不合理的数据入仓
  • 控制ODS表生命周期,避免存储成本浪费
  • 全流程自动化,提高ODS层数据研发效能

目前支持MySQL和TiDB的全量采集同步和增量采集同步。同时,开启自动更新模式的入仓任务,还会订阅来源MySQL表的变更消息,并自动更新同步任务。关键流程如下:

自动化数据入仓流程

4.3 规范数据建模与自动化指标研发方案解析

Onedata在数据研发环节,核心采用维度建模的理论。它构建了公司级的一致性维度、标准化的事实表以及可灵活分析的汇总表和无二义性的指标。并将数据进行清晰的分层,将公司内部分散、异构的数据整合成一套可信、可复用、可分析的数据资产。其主要价值有:

  • 保证维度和指标一致性:通过维度和业务过程的概念建模,确保维度表和事实表的全局唯一性;同时通过原子要素的指标建模,确保指标口径的全局无二义性。
  • 提升开发效率:数据工程师无需重复构建维度表和基础事实表,直接复用数仓公共层的成果;同时指标原子要素定义完成后,指标和汇总表的代码全部可以系统自动化生成和优化,大幅提高效率,也减少出错的可能性。
  • 增强数据可解释性:明确的表业务描述以及字段关联的指标和维度,以及清晰的星型/雪花模型关系,使数据的消费侧更方便的使用数据。
  • 事前治理:严格根据架构规范进行数据研发,禁止重复表的新增,约束数据表的生命周期、数据依赖等,避免事后运动式治理。

Onedata核心概念、建模流程以及配合工具如下图所示:

Onedata的数据建模流程

其中最为关键部分为“指标建模”。我们将指标的口径组成拆成了三部分组成:原子指标、业务限定、统计周期,同时在其物化到表上的时候再确定统计粒度。通过原子要素的组合定义指标可以确保同样的指标在公司全局只会有一个,以及标识出不同的汇总表、应用表中的指标是否为同一个。另外当原子口径发生了变化,系统也可以根据血缘关系找到受影响的指标和表,让owner进行握手确认,确保口径变更一致性。例如下图所示:

1个原子指标口径变更,影响了7个关联指标、

2张表的同步变更

从上图我们也可以看到,原子口径的变更影响非常大,即使可以基于血缘进行变更握手管控,人工修改逻辑也容易改错或遗留。因此我们实现了自动化指标代码生成的能力,基于原子口径自动化生成指标及其物化表的加工逻辑。

指标代码自动化生成方案

将指标按来源表分组,并将其的组成原子要素(原子指标+业务限定+统计周期+统计粒度)进行SQL逻辑的组装、优化、方言翻译,具体流程为:

数据建模与指标SQL生成案例:

1.数据建模

2.代码生成

3.代码优化 - 指标SQL优化规则

4.4 当前落地进展与效果

1)统一自动化ODS采集入仓

目前已实现通过工单申请的方式一键完成MySQL和TiDB的数据进行增/全量自动化采集入仓能力,无需人工编写代码即可实现规范的数据入仓。产品效果如下:

业务成果:

  • 业务域落地:目前已在得物内部各域全面落地统一ODS入仓能力。2025年Q3,得物全域新增的入仓任务93.6% 是通过Galaxy自动化采集入仓平台自动化生成的;
  • 表生命周期规范:25年新增ODS表生命周期定义率较24年Q4提升7.4倍,节约了大量离线存储;
  • ODS存储增量控制:通过源头规范数据入仓,配合数据治理团队使数仓ODS层存储季度增幅降低:32%->8%

2)规范建模与自动化指标代码生成

目前已完成数仓规划->概念建模->明细表维度建模->指标建模->指标代码自动化生成->汇总表代码自动化生成的Onedata规范建模研发全流程,产品效果如下:

业务成果:

  • 商家域数仓Onedata一期落地效果:完成了40+ 数据资产沉淀与规范化汰换改造,以及190+应用指标定义与上架,同时沉淀了100+公共派生指标。通过数据规范化重构、二义性问题的解决以及自动化代码生成的能力,可实现商家数据需求数仓开发效率提升40+%,每迭代线上需求吞吐量提升75%->90%。
  • 社区域数仓Onedata一期落地效果:完成1200+应用指标的定义与上架,实现100%无二义性。通过精品资产的规范建设与切换,通过复用公共层数据,实现5+万/月的成本下降。由于数据二义性的解决以及资产规范度的提高,实现数仓和分析师用于口径oncall和业务取数的人力成本减少约10+人日/月。

五、数据生产的“刹车片” - 数据质量技术

得物数仓发展至今,不仅用于高管决策以及数据报表的场景,同时和得物线上业务做了非常强的耦合,各域均存在P0级资损风险场景,例如:社区数仓的运营投放、算法数仓的新品商业化、交易数仓的费率折扣、营销域、用户域等等。这些数据直接应用于线上业务,任何的数据质量问题都可能导致公司、商家、用户的利益受损,以及业务对数仓的信心丢失。

然而过往数仓的数据交付只是停留在快速提供数据以发挥业务价值这一步,业务和研发对数据质量和稳定性保障重视度严重不够,并且没有明确生产变更和数据质量校验的SOP,同时也没有健全的工具体系支撑,全靠数据工程师的自我修养,导致历史上很多核心数据加工任务没有保障或者保障不全面,不断引发P级故障。

5.1 Galaxy的数据质量工具体系

数据质量的相关工具就如同“汽车的刹车片”,可以想象没有刹车在路上行驶是如何的危险。因此我们在Galaxy数据研发平台建设之初就同步进行了数据质量工具的开发。目前所建立起来的离线数仓质量加固SOP及配合的工具如下:

离线数仓质量加固SOP

目前重点建设的2个核心功能,分别为:数据质量校验规则,用于监控生产数据质量并进行及时阻断止血,避免下游数据污染;以及数据变更管控流水线,在数据生产变更的环节嵌入消费场景打标、自动化风险扫描、code review、自动化数据测试、发布审批等功能,以全面保障数据质量。融入了数据质量技术体系(红色部分)后的Galaxy数据研发平台架构如下图所示:

融入数据质量能力(红色部分)后的Galaxy数据研发平台架构

5.2 当前落地进展与效果

数据质量校验规则

Galaxy数据研发平台已经实现了完善的数据质量规则校验能力,用户在Galaxy数据研发IDE上面向数据标准进行高效的质量规则定义,系统会自动生成校验SQL随着任务的发布一起下发到调度系统中执行。同时支持了强规则(主路执行,数据异常阻断任务执行)和弱规则(旁路执行,数据异常进行告警) 两种规则运行场景,应对不同的场景诉求。产品效果如下所示:

场景覆盖方面已经实现了表非空校验、表波动校验、字段主键校验、字段非空校验、字段波动校验、字段枚举校验、自定义SQL校验等15种规则,覆盖了离线数仓100% 的校验场景。

同时通过批量导入、弱转强等提效工具帮助离线数仓团队在25年Q3新增了1200+质量规则,全量P0任务质量规则覆盖率达到96%,非P0任务86%。并结合发布管控流水线能力,实现了P0场景任务100%变更覆盖表级规则,且金额等高风险字段100%变更覆盖字段级规则

数据变更流水线

目前已经完成了完整的变更管控流水线的能力,主要功能包括:消费场景打标、静态风险扫描、Code Review、冒烟测试、数据探查、数据比对、发布审核。产品效果如下所示:

其中,场景打标方面,离线数仓末端任务(ADS和回流)98.3% 打标了数据消费场景,对于全链路分析数据重要性和消费场景起到了巨大的作用;变更管控方面,静态扫描节点已实现了48个风险扫描规则,覆盖了94% 的已知风险场景,当前系统自动化风险识别率98% (剩余为人工CR发现的问题),平均每双周可事前拦截600+起风险事件。

六、数据研发之路的“辅助驾驶”-智能化数据研发

过去10年,通过开源大数据组件的兴起,大幅度降低了企业构建大数据Infra的难度,在一定程度上实现了企业间的“数据平权” 。而在企业内部,由于数据同步、ETL研发调度、资产管理、数据治理等复杂的技术导致找数和用数门槛非常高,因此大部分场景都是提需求给数仓团队进行数据加工,那么数据团队的交付效率就变成了公司各业务线数据化经营决策的瓶颈

6.1 Galaxy的智能化演进路线

我们计划分3个阶段(L1~L3)建设Galaxy数据研发平台的智能化能力,来提升数据研发效率,降低业务自主进行数据研发的门槛,实现公司内部不同部门和岗位间的“数据平权” 。如下所示:

Galaxy数据研发智能化演进路径

当前我们处于L1的Copilot阶段,通过在数据研发流程中,旁路嵌入基于专家经验规则和大模型的智能SQL代码续写、智能任务诊断、智能SQL代码纠错与优化、 智能质量规则推荐等应用,辅助用户进行高效数据研发。嵌入Copilot后的Galaxy研发平台整体架构如下,主要关注红色部分

数据智能化L1阶段的Galaxy研发平台技术架构

下文主要对当前较为成熟的功能,智能SQL代码续写的实现方案进行解析。

6.2 智能SQL代码续写方案解析

SQL代码续写的重点在于工程链路,大模型上我们选择适合代码生成的小参数模型,当前使用了Qwen-2.5-coder,后续会进行其他模型的实验。系统流程如下:

智能SQL代码续写系统流程

关键模块功能描述:

6.3 当前落地进展与效果

目前Galaxy研发平台已经落地了智能代码续写、智能任务诊断、智能SQL纠错与优化3个Copilot应用,具体业务效果如下:

其中高活用户的智能代码续写功能开启率为98.5%,整体采纳率趋势和我们做的优化动作如下:

智能SQL续写采纳率趋势

(2025年04月25日~2025年09月09日)

七、后续规划

后续Galaxy数据研发平台会持续完善现有功能提升产品体验,同时在智能ETL Agent、Data Fabric、数据逻辑化三个前沿方向进行探索,通过技术先进性为公司数据业务带来更多的价值。

7.1 长期规划一:智能ETL Agent

核心目标:数据研发提效,并降低数据研发门槛

ETL Agent核心能力是需要将用户的自然语言业务需求翻译成数据表的SQL加工逻辑,其本质上就是“NL2SQL”的传统命题。然而,如果让大模型直接分析用户的问题,那么它需要尝试从底层混乱的物理表结构中生成目标SQL,这会将业务语义的复杂性完全压给大模型,导致同一指标因表结构理解偏差或字段映射错误而产生不同结果。

Galaxy的ETL Agent会采用“NL2Metric2SQL”的方案。通过大模型进行自然语言的解析,结合向量数据库的相似度匹配实现NL2Metric的能力,然后基于Onedata数据模型和指标语义层,将自然语言的用数需求准确翻译为指标原子要素(原子口径、业务限定、统计周期、统计维度),并自动构建ETL加工链路。如下图案例:

智能ETL Agent用户流程案例

这也是Galaxy智能化的L2阶段,这个阶段将数据研发分成了专家数据研发以及智能数据研发。专家模式依然按传统SQL任务进行数据研发。而智能研发则以自然语言形式的数据需求作为输入,通过提前将Onedata数据模型存储在RAG的向量数据库中,然后根据数据需求内容进行分词,按相似度从RAG中匹配出相关的指标要素构建出提示词,并请求大模型获得正确的指标要素。

实现智能ETL Agent后的智能化L2阶段Galaxy研发平台架构(红色部分为Agent相关模块)

7.2 长期规划二:Data Fabric

核心目标:减少非必要的离线数据存储成本

传统的数据集成(数据入仓)方案是通过离线或实时数据同步工具将公司内部各数据源的数据全量或增量地抽取、清洗、加载到一个中心化的数据仓库中。但这种方案在技术上存在三个问题:

  • 离线存储成本大:传统的数据集成方式,离线数仓的ODS层会拷贝全部所需在线数据的副本。然而其中很大一部分的数据仅用于短期分析,或用于对RT不敏感的查询场景,这些数据在离线数仓中物化存储的ROI极低,造成了大量存储成本浪费。
  • 数据搬迁成本大:随着业务的发展,公司的数据源可能分布在不同地域、不同云环境。周期性的将海量数据同步至中心化数仓,将产生巨大的网络带宽成本和入仓等待时间。同时入仓需要与数仓工程师进行需求沟通,也存在大量协作成本。
  • 数据一致性问题:数据同步有显著延迟,在离线同步的场景下,分析的数据会有天级延迟。

Data Fabric(数据编织)是一种全新的数据集成架构方案,核心理念是 “不移动数据,移动计算”。 技术实现方案上以外表的形式来封装源端表,通过统一的元数据系统,将源端表(外表)和离线表(内表)统一管理起来,使用起来对用户无感。在执行计算时,通过Spark引擎的跨源联邦查询能力,直接从各源端数据库(一般为备库或抽数库)将数据查询回来后进行分布式计算。下图展示了Data Fabric与传统数据集成的区别:

7.3 长期规划三:数据逻辑化

核心目标:计算存储成本降低,数据研发与运维提效

通过视图或参数化视图进行整条数据链路的构建,那么整条链路就完全不需要任何存储成本,计算成本也仅在视图查询时才发生。但这样会导致一个问题,当视图逻辑复杂,嵌套层级多时,查询效率非常低,且对相同视图的查询都需要重新计算。因此我们需要对一些关键的视图进行物化,物化后的视图,在查询时可以直接访问其物化表,实现查询性能的大幅提升。

数据逻辑化架构,会存在两层,上层为由用户定义的物理表以及虚拟视图组成的逻辑层,对用户感知;下层为物理表和系统自动生成的视图物化表组成的物理层,对用户不感知,具体如下图所示:

数据逻辑化的架构分层

数据逻辑化的关键技术之一为视图物化表的命中。当某个视图存在物化表时,需要将对应查询范围的数据直接翻译成物化表的查询,而不去展开视图查询,以提升查询性能。技术链路如下图所示:

数据逻辑化的视图物化命中改写链路

另一项关键技术为视图的物化策略与回收策略。系统需要定期通过算法识别出在满足产出时效的前提下,整体计算和存储成本最低的物化方案。例如下方案例:

数据逻辑化的视图物化与回收策略

目前全域优化场景简单且有效的算法有遗传算法、模拟退火算法等。通过评估在一定存储成本限制下,哪些视图的物化组合,可以使用整体计算cost最低。

将数据虚拟化技术和ETL Agent能力结合,我们可以实现系统自托管的智能数据研发,即Galaxy智能化的L3阶段。

往期回顾

  1. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  2. Apex AI辅助编码助手的设计和实践|得物技术

  3. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

  4. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

  5. 线程池ThreadPoolExecutor源码深度解析|得物技术

文 /宇贤

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

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

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

从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

作者 得物技术
2025年10月23日 16:45

一、背 景

预发环境一个后台服务admin突然启动失败,异常如下:



org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'timeoutNotifyController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'spuCheckDomainServiceImpl': Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:598)
        at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:376)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1404)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:592)
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:515)
        at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:320)
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
        at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:847)
        at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:877)
        at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549)
        at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:141)
        at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:744)
        at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:391)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:312)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1215)
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1204)
        at com.shizhuang.duapp.commodity.interfaces.admin.CommodityAdminApplication.main(CommodityAdminApplication.java:100)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:51)
        at org.springframework.boot.loader.PropertiesLauncher.main(PropertiesLauncher.java:578)

错误日志中明确写道:“Bean has been injected into other beans ... in its raw version as part of a circular reference, but has eventually been wrapped. ”这不仅仅是一个简单的循环依赖错误。它揭示了一个更深层次的问题:当循环依赖遇上Spring的AOP代理(如@Transactional事务、自定义切面等),Spring在解决依赖的时,不得已将一个“半成品”(原始Bean)注入给了其他30多个Bean。而当这个“半成品”最终被“包装”(代理)成“成品”时,先前那些持有“半成品”引用的Bean们,使用的却是一个错误的版本。

这就像在组装一个精密机器时,你把一个未经质检的零件提前装了进去,等质检完成后,机器里混用着新旧版本的零件,最终的崩溃也就不可避免。

本篇文章将带你一起:

  • 熟悉spring容器的循环依赖以及Spring容器如何解决循环依赖,创建bean相关的流程。
  • 深入解读这条复杂错误日志背后的每一个关键线索;
  • 提供紧急止血方案;
  • 分享如何从架构设计上避免此类问题的实践心得。

二、相关知识点简介

2.1 循环依赖

什么是Bean循环依赖?

循环依赖:说白是一个或多个对象实例之间存在直接或间接的依赖关系,这种依赖关系构成了构成一个环形调用,主要有如下几种情况。

第一种情况:自己依赖自己的直接依赖

第二种情况:两个对象之间的直接依赖

前面两种情况的直接循环依赖比较直观,非常好识别,但是第三种间接循环依赖的情况有时候因为业务代码调用层级很深,不容易识别出来。

循环依赖场景

构造器注入循环依赖:

@Service
public class A {public A(B b) {}}
@Service
public class B {public B(A a) {}}

结果:项目启动失败抛出异常BeanCurrentlyInCreationException

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:339)
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:215)
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:318)
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)

构造器注入构成的循环依赖,此种循环依赖方式无论是Singleton模式还是prototype模式都是无法解决的,只能抛出BeanCurrentlyInCreationException异常表示循环依赖。原因是Spring解决循环依赖依靠的是Bean的“中间态”这个概念,而中间态指的是已经实例化,但还没初始化的状态。而完成实例化需要调用构造器,所以构造器的循环依赖无法解决。

Singleton模式field属性注入(setter方法注入)循环依赖:

这种方式是我们最为常用的依赖注入方式:

@Service
public class A {
    @Autowired
    private B b;
    }
@Service
public class B {
    @Autowired
    private A a;
    }

结果:项目启动成功,正常运行

prototype field属性注入循环依赖:

prototype在平时使用情况较少,但是也并不是不会使用到,因此此种方式也需要引起重视。

@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
public class A {
    @Autowired
    private B b;
    }
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
public class B {
    @Autowired
    private A a;
    }

结果:需要注意的是本例中启动时是不会报错的(因为非单例Bean默认不会初始化,而是使用时才会初始化),所以很简单咱们只需要手动getBean()或者在一个单例Bean内@Autowired一下它即可。

// 在单例Bean内注入
    @Autowired
    private A a;

这样子启动就报错:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'mytest.TestSpringBean': Unsatisfied dependency expressed through field 'a'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'a': Unsatisfied dependency expressed through field 'b'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'b': Unsatisfied dependency expressed through field 'a'; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:596)
        at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:374)

如何解决?可能有的小伙伴看到网上有说使用@Lazy注解解决:

    @Lazy
    @Autowired
    private A a;

此处负责任的告诉你这样是解决不了问题的(可能会掩盖问题),@Lazy只是延迟初始化而已,当你真正使用到它(初始化)的时候,依旧会报如上异常。

对于Spring循环依赖的情况总结如下:

  • 不能解决的情况:构造器注入循环依赖,prototype field属性注入循环依赖
  • 能解决的情况:field属性注入(setter方法注入)循环依赖

Spring如何解决循环依赖

Spring 是通过三级缓存和提前曝光的机制来解决循环依赖的问题。

三级缓存

三级缓存其实就是用三个 Map 来存储不同阶段 Bean 对象。

一级缓存
private final Map<StringObject> singletonObjects = new ConcurrentHashMap<>(256);
二级缓存
private final Map<StringObjectearlySingletonObjects = new HashMap<>(16);
//三级缓存
private final Map<StringObjectFactory<?>> singletonFactories = new HashMap<>(16)
  • singletonObjects:用于存放完全初始化好的 bean,从该缓存中取出的 bean 可以直接使用
  • earlySingletonObjects:提前曝光的单例对象的cache,存放原始的 bean 对象(尚未填充属性),用于解决循环依赖。
  • singletonFactories:单例对象工厂的cache,存放 bean 工厂对象,用于解决循环依赖。

三级缓存解决循环依赖过程

假设现在我们有ServiceA和ServiceB两个类,这两个类相互依赖,代码如下:

@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
    }


@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA ;
    }

下面的时序图说明了spring用三级缓存解决循环依赖的主要流程:

为什么需要三级缓存?

这是一个理解Spring容器如何解决循环依赖的核心概念。三级缓存是Spring为了解决循环依赖的同时,又能保证AOP代理的正确性而设计的精妙机制。

为了理解为什么需要三级缓存,我们一步步来看。

如果没有缓存(Level 0)

假设有两个Bean:ServiceA  和 ServiceB,它们相互依赖。

Java

@Component
public class ServiceA  {
    @Autowired
    private ServiceB serviceB;
}
@Component
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

创建过程(无缓存)

  • 开始创建 ServiceA -> 发现 ServiceA 需要 ServiceB -> 开始创建 ServiceB
  • 开始创建 ServiceB -> 发现 ServiceB 需要 ServiceA -> 开始创建 ServiceA
  • 开始创建 ServiceA -> 发现 ServiceA 需要 ServiceB -> ... 无限循环,StackOverflowError

结论:无法解决循环依赖,直接死循环。

如果只有一级缓存(Singleton Objects)

一级缓存存放的是已经完全创建好、初始化完毕的Bean。

问题:在Bean的创建过程中(比如在填充属性 populateBean 时),ServiceA还没创建完,它本身不应该被放入"已完成"的一级缓存。但如果ServiceB需要ServiceA,而一级缓存里又没有ServiceA的半成品,ServiceB就无法完成创建。这就回到了上面的死循环问题。

结论:一级缓存无法解决循环依赖。

如果使用二级缓存

二级缓存的核心思路是:将尚未完全初始化好的“早期引用”暴露出来。

现在我们有:

  • 一级缓存(成品库) :存放完全准备好的Bean。
  • 二级缓存(半成品库) :存放刚刚实例化(调用了构造方法),但还未填充属性和初始化的Bean的早期引用。

创建过程(二级缓存):

开始创建ServiceA

  • 实例化ServiceA(调用ServiceA的构造方法),得到一个ServiceA的原始对象。
  • 将ServiceA的原始对象放入二级缓存(半成品库)。
  • 开始为ServiceA填充属性 -> 发现需要ServiceB。

开始创建ServiceB

  • 实例化ServiceB(调用B的构造方法),得到一个ServiceB的原始对象。
  • 将ServiceB的原始对象放入二级缓存。
  • 开始为ServiceB填充属性 -> 发现需要ServiceA。

ServiceB从二级缓存中获取A

  • ServiceB成功从二级缓存中拿到了ServiceA的早期引用(原始对象)。
  • ServiceB顺利完成了属性填充、初始化等后续步骤,成为一个完整的Bean。
  • 将完整的ServiceB放入一级缓存(成品库),并从二级缓存移除ServiceB。

ServiceA继续创建:

  • ServiceA拿到了创建好的ServiceB,完成了自己的属性填充和初始化。
  • 将完整的ServiceA放入一级缓存(成品库),并从二级缓存移除ServiceA。

问题来了:如果ServiceA需要被AOP代理怎么办?

如果A类上加了 @Transactional 等需要创建代理的注解,那么最终需要暴露给其他Bean的应该是ServiceA的代理对象,而不是ServiceA的原始对象。

在二级缓存方案中,ServiceB拿到的是A的原始对象。但最终ServiceA完成后,放入一级缓存的是ServiceA的代理对象。这就导致了:

  • ServiceB里面持有的ServiceA是原始对象。
  • 而其他地方注入的ServiceA是代理对象。
  • 这就造成了不一致!如果通过ServiceB的ServiceA去调用事务方法,事务会失效,因为那是一个没有被代理的原始对象。

结论:二级缓存可以解决循环依赖问题,但无法正确处理需要AOP代理的Bean。

三级缓存的登场(Spring的终极方案)

为了解决代理问题,Spring引入了第三级缓存。它的核心不是一个直接存放对象(Object)的缓存,而是一个存放 ObjectFactory(对象工厂) 的缓存。

三级缓存的结构是:Map<String, ObjectFactory<?>> singletonFactories

创建过程(三级缓存,以ServiceA需要代理为例):

  • 开始创建ServiceA
  • 实例化ServiceA,得到ServiceA的原始对象。
  • 三级缓存添加一个ObjectFactory。这个工厂的getObject()方法有能力判断ServiceA是否需要代理,并返回相应的对象(原始对象或代理对象)
  • 开始为ServiceA填充属性 -> 发现需要ServiceB。
  • 开始创建B
  • 实例化ServiceB。
  • 同样向三级缓存添加一个ServiceB的ObjectFactory。
  • 开始为ServiceB填充属性 -> 发现需要ServiceA。
  • ServiceB从缓存中获取ServiceA
  • ServiceB发现一级缓存没有ServiceA,二级缓存也没有ServiceA。
  • ServiceB发现三级缓存有A的ObjectFactory。
  • B调用这个工厂的getObject()方法。此时,Spring会执行一个关键逻辑:
  • 如果ServiceA需要被代理,工厂会提前生成ServiceA的代理对象并返回。
  • 如果ServiceA不需要代理,工厂则返回A的原始对象。
  • 将这个早期引用(可能是原始对象,也可能是代理对象) 放入二级缓存,同时从三级缓存移除A的工厂。
  • ServiceB拿到了ServiceA的正确版本的早期引用。

后续步骤:

  • ServiceB完成创建,放入一级缓存。
  • ServiceA继续用ServiceB完成创建。在ServiceA初始化的最后,Spring会再次检查:如果ServiceA已经被提前代理了(即在第3步中),那么就直接返回这个代理对象;如果没有,则可能在此处创建代理(对于不需要解决循环依赖的Bean)。
  • 最终,将完整的ServiceA(代理对象)放入一级缓存,并清理二级缓存。

总结:为什么需要三级缓存?

需要三级缓存,是因为Spring要解决一个复杂问题:在存在循环依赖的情况下,如何确保所有Bean都能拿到最终形态(可能被AOP代理)的依赖对象,而不是原始的、未代理的对象。 三级缓存通过一个ObjectFactory将代理的时机提前,完美地解决了这个问题。二级缓存主要是为了性能优化而存在的。

spring三级缓存为什么不能解决

@Async注解的循环依赖问题

这触及了 Spring 代理机制的一个深层次区别。@Async注解的循环依赖问题确实比@Transactional 更复杂,三级缓存无法完全解决。让我们深入分析原因。

2.2 Spring创建Bean主要流程

为了容易理解 Spring 解决循环依赖过程,我们先简单温习下 Spring 容器创建 Bean 的主要流程。

从代码看Spring对于Bean的生成过程,步骤还是很多的,我把一些扩展业务代码省略掉:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
          throws BeanCreationException {
    if (mbd.isSingleton()) {
      instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
    }
    // Bean初始化第一步:默认调用无参构造实例化Bean
    // 如果是只有带参数的构造方法,构造方法里的参数依赖注入,就是发生在这一步
    if (instanceWrapper == null) {
      instanceWrapper = createBeanInstance(beanName, mbd, args);
    }


    //判断Bean是否需要提前暴露对象用来解决循环依赖,需要则启动spring三级缓存
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
       isSingletonCurrentlyInCreation(beanName));
   if (earlySingletonExposure) {
     if (logger.isTraceEnabled()) {
       logger.trace("Eagerly caching bean '" + beanName +
             "' to allow for resolving potential circular references");
      }
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}


    // Initialize the bean instance.
    Object exposedObject = bean;
    try {
      // bean创建第二步:填充属性(DI依赖注入发生在此步骤)
      populateBean(beanName, mbd, instanceWrapper);
      // bean创建第三步:调用初始化方法,完成bean的初始化操作(AOP的第三个入口)
      // AOP是通过自动代理创建器AbstractAutoProxyCreator的postProcessAfterInitialization()
//方法的执行进行代理对象的创建的,AbstractAutoProxyCreator是BeanPostProcessor接口的实现
      exposedObject = initializeBean(beanName, exposedObject, mbd);




   if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
     if (earlySingletonReference != null) {
        if (exposedObject == bean) {
          exposedObject = earlySingletonReference;
        }
        else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
          String[] dependentBeans = getDependentBeans(beanName);
          Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
          for (String dependentBean : dependentBeans) {
             if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
                actualDependentBeans.add(dependentBean);
             }
          }
          if (!actualDependentBeans.isEmpty()) {
             throw new BeanCurrentlyInCreationException(beanName,
                   "Bean with name '" + beanName + "' has been injected into other beans [" +
                   StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
                   "] in its raw version as part of a circular reference, but has eventually been " +
                   "wrapped. This means that said other beans do not use the final version of the " +
                   "bean. This is often the result of over-eager type matching - consider using " +
                   "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
          }
       }
    }
}


    } catch (Throwable ex) {
      // ...
    }
    // ...
    return exposedObject;
    }

从上述代码看出,整体脉络可以归纳成 3 个核心步骤:

  • 实例化Bean:主要是通过反射调用默认构造函数创建 Bean 实例,此时Bean的属性都还是默认值null。被注解@Bean标记的方法就是此阶段被调用的。
  • 填充Bean属性:这一步主要是对Bean的依赖属性进行填充,对@Value、@Autowired、@Resource注解标注的属性注入对象引用。
  • 调用Bean初始化方法:调用配置指定中的init方法,如 xml文件指定Bean的init-method方法或注解 @Bean(initMethod = "initMethod")指定的方法。

三、案例分析

3.1 代码分析

以下是我简化后的类之间大体的依赖关系,工程内实际的依赖情况会比这个简化版本复杂一些。

@RestController
public class OldCenterSpuController {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}
@RestController
public class TimeoutNotifyController {
    @Resource
    private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}
@Component
public class NewSpuApplyCheckServiceImpl {
    @Resource
    private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}
@Component
@Slf4j
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

从代码看,主要是SpuCheckDomainServiceImpl和NewSpuApplyCheckServiceImpl 构成了一个依赖环。而我们从正常启动的bean加载顺序发现首先是从OldCenterSpuController开始加载的,具体情况如下所示:

OldCenterSpuController 
    ↓ (依赖)
NewSpuApplyCheckServiceImpl 
    ↓ (依赖)  
SpuCheckDomainServiceImpl 
    ↓ (依赖)
NewSpuApplyCheckServiceImpl 

异常启动的情况bean加载是从TimeoutNotifyController开始加载的,具体情况如下所示:

TimeoutNotifyController 
    ↓ (依赖)
SpuCheckDomainServiceImpl 
    ↓ (依赖)  
NewSpuApplyCheckServiceImpl 
    ↓ (依赖)
SpuCheckDomainServiceImpl 

同一个依赖环,为什么从OldCenterSpuController 开始加载就可以正常启动,而从TimeoutNotifyController 启动就会启动异常呢?下面我们会从现场debug的角度来分析解释这个问题。

3.2 问题分析

在相关知识点简介里面知悉到spring用三级缓存解决了循环依赖问题。为什么后台服务admin启动还会报循环依赖的问题呢?

要得到问题的答案,还是需要回到源码本身,前面我们分析了spring的创建Bean的主要流程,这里为了更好的分析问题,补充下通过容器获取Bean的。

在通过spring容器获取bean时,底层统一会调用doGetBean方法,大体如下:

protected <T> T doGetBean(final String name, @Nullable final Class<T> requiredType,
       @Nullable final Object[] args, boolean typeCheckOnly) throws BeansException {
    
    final String beanName = transformedBeanName(name);
    Object bean;
    
    // 从三级缓存获取bean
    Object sharedInstance = getSingleton(beanName);
    if (sharedInstance != null && args == null) {
       bean = getObjectForBeanInstance(sharedInstance, name, beanName, null);
    }else {
     if (mbd.isSingleton()) {
       sharedInstance = getSingleton(beanName, () -> {
       try {
         //如果是单例Bean,从三级缓存没有获取到bean,则执行创建bean逻辑
          return createBean(beanName, mbd, args);
       }
       catch (BeansException ex) {
          destroySingleton(beanName);
          throw ex;
       }
    });
    bean = getObjectForBeanInstance(sharedInstance, name, beanName, mbd);
  }   
 }

从doGetBean方法逻辑看,在spring从一二三级缓存获取bean返回空时,会调用createBean方法去场景bean,createBean方法底层主要是调用前面我们提到的创建Bean流程的doCreateBean方法。

注意:doGetBean方法里面getSingleton方法的逻辑是先从一级缓存拿,拿到为空并且bean在创建中则又从二级缓存拿,二级缓存拿到为空 并且当前容器允许有循环依赖则从三级缓存拿。并且将对象工厂移到二级缓存,删除三级缓存

doCreateBean方法如下:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
          throws BeanCreationException {
    if (mbd.isSingleton()) {
      instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
    }
    // Bean初始化第一步:默认调用无参构造实例化Bean
    // 如果是只有带参数的构造方法,构造方法里的参数依赖注入,就是发生在这一步
    if (instanceWrapper == null) {
      instanceWrapper = createBeanInstance(beanName, mbd, args);
    }


    //判断Bean是否需要提前暴露对象用来解决循环依赖,需要则启动spring三级缓存
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
       isSingletonCurrentlyInCreation(beanName));
   if (earlySingletonExposure) {
     if (logger.isTraceEnabled()) {
       logger.trace("Eagerly caching bean '" + beanName +
             "' to allow for resolving potential circular references");
      }
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}


    // Initialize the bean instance.
    Object exposedObject = bean;
    try {
      // bean创建第二步:填充属性(DI依赖注入发生在此步骤)
      populateBean(beanName, mbd, instanceWrapper);
      // bean创建第三步:调用初始化方法,完成bean的初始化操作(AOP的第三个入口)
      // AOP是通过自动代理创建器AbstractAutoProxyCreator的postProcessAfterInitialization()
//方法的执行进行代理对象的创建的,AbstractAutoProxyCreator是BeanPostProcessor接口的实现
      exposedObject = initializeBean(beanName, exposedObject, mbd);




   if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
     if (earlySingletonReference != null) {
        if (exposedObject == bean) {
          exposedObject = earlySingletonReference;
        }
        else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
          String[] dependentBeans = getDependentBeans(beanName);
          Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
          for (String dependentBean : dependentBeans) {
             if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
                actualDependentBeans.add(dependentBean);
             }
          }
          if (!actualDependentBeans.isEmpty()) {
             throw new BeanCurrentlyInCreationException(beanName,
                   "Bean with name '" + beanName + "' has been injected into other beans [" +
                   StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
                   "] in its raw version as part of a circular reference, but has eventually been " +
                   "wrapped. This means that said other beans do not use the final version of the " +
                   "bean. This is often the result of over-eager type matching - consider using " +
                   "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
          }
       }
    }
}


    } catch (Throwable ex) {
      // ...
    }
    // ...
    return exposedObject;
    }

将doGetBean和doCreateBean的逻辑转换成流程图如下:

从流程图可以看出,后台服务admin启动失败抛出UnsatisfiedDependencyException异常的必要条件是存在循环依赖,因为不存在循环依赖的情况bean只会存在单次加载,单次加载的情况bean只会被放进spring的第三级缓存。

而触发UnsatisfiedDependencyException异常的先决条件是需要spring的第一二级缓存有当前的bean。所以可以知道当前bean肯定存在循环依赖。在存在循环依赖的情况下,当前bean被第一次获取(即调用doGetBean方法)会缓存进spring的第三级缓存,然后会注入当前bean的依赖(即调用populateBean方法),在当前bean所在依赖环内其他bean都不在一二级缓存的情况下,会触发当前bean的第二次获取(即调用doGetBean方法),由于第一次获取已经将Bean放进了第三级缓存,spring会将Bean从第三级缓存移到二级缓存并删除第三级缓存。

最终会回到第一次获取的流程,调用初始化方法做初始化。最终在初始化有对当前bean做代理增强的并且提前暴露到二级缓存的对象有被其他依赖引用到,而且allowRawInjectionDespiteWrapping=false的情况下,会导致抛出UnsatisfiedDependencyException,进而导致启动异常。

注意:在注入当前bean的依赖时,这里spring将Bean从第三级缓存移到二级缓存并删除第三级缓存后,当前bean的依赖的其他bean会从二级缓存拿到当前bean做依赖。这也是后续抛异常的先决条件

结合admin有时候启动正常,有时候启动异常的情况,这里猜测启动正常和启动异常时bean加载顺序不一致,进而导致启动正常时当前Bean只会被获取一次,启动异常时当前bean会被获取两次。为了验证猜想,我们分别针对启动异常和启动正常的bean获取做了debug。

debug分析

首先我们从启动异常提取到以下关键信息,从这些信息可以知道是spuCheckDomainServiceImpl的加载触发的启动异常。所以我们这里以spuCheckDomainServiceImpl作为前面流程分析的当前bean。

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'timeoutNotifyController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'spuCheckDomainServiceImpl': Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.

然后提前我们在doCreateBean方法设置好spuCheckDomainServiceImpl加载时的条件断点。我们先debug启动异常的情况。最终断点信息如下:

从红框1里面的两个引用看,很明显调initializeBean方法时spring有对spuCheckDomainServiceImpl做代理增强。导致initializeBean后返回的引用和提前暴露到二级缓存的引用是不一致的。这里spuCheckDomainServiceImpl有二级缓存是跟我们前面分析的吻合,是因为spuCheckDomainServiceImpl被获取了两次,即调了两次doGetBean。

从红框2里面的actualDependentBeans的set集合知道提前暴露到二级缓存的引用有被其他33个bean引用到,也是跟异常提示的bean列表保持一致的。

这里spuCheckDomainServiceImpl的加载为什么会调用两次doGetBean方法呢?

从调用栈分析到该加载链如下:

TimeoutNotifyController  ->spuCheckDomainServiceImpl-> newSpuApplyCheckServiceImpl-> ... ->spuCheckDomainServiceImpl

TimeoutNotifyController注入依赖时第一次调用doGetBean获取spuCheckDomainServiceImpl时,从一二三级缓存获取不到,会调用doCreateBean方法创建spuCheckDomainServiceImpl。

首先会将spuDomainServiceImpl放进spring的第三级缓存,然后开始调populateBean方法注入依赖,由于在循环中间的newSpuApplyCheckServiceImpl是第一次获取,一二三级缓存都获取不到,会调用doCreateBean去创建对应的bean,然后会第二次调用doGetBean获取spuCheckDomainServiceImpl,这时spuCheckDomainServiceImpl在第一次获取已经将bean加载到第三级缓存,所以这次spring会将bean从第三级缓存直接移到第二级缓存,并将第三级缓存里面的spuCheckDomainServiceImpl对应的bean删除,并直接返回二级缓存里面的bean,不会再调doCreateBean去创建spuCheckDomainServiceImpl。最终完成了循环中间的bean的初始化后(这里循环中间的bean初始化时依赖到的bean如果有引用到spuCheckDomainServiceImpl会调用doGetBean方法从二级缓存拿到spuCheckDomainServiceImpl提前暴露的引用),会回到第一次调用doGetBean获取spuCheckDomainServiceImpl时调用的doCreateBean方法的流程。继续调initializeBean方法完成初始化,然后将初始化完成的bean返回。最终拿初始化返回的bean引用跟二级缓存拿到的bean引用做对比,发现不一致,导致抛出UnsatisfiedDependencyException异常。

那么这里为什么spuCheckDomainServiceImpl调用initializeBean方法完成初始化后与提前暴露到二级缓存的bean会不一致呢?

看spuCheckDomainServiceImpl的代码如下:

@Component
@Slf4j
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource
    private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

发现SpuCheckDomainServiceImpl类有使用到 @Validated注解。查阅资料发现 @Validated的实现是通过在initializeBean方法里面执行一个org.springframework.validation.beanvalidation.MethodValidationPostProcessor后置处理器实现的,MethodValidationPostProcessor会对SpuCheckDomainServiceImpl做一层代理。导致initializeBean方法返回的spuCheckDomainServiceImpl是一个新的代理对象,从而最终导致跟二级缓存的不一致。

debug视图如下:

那为什么有时候能启动成功呢?什么情况下能启动成功?

我们继续debug启动成功的情况。最终观察到spuCheckDomainServiceImpl只会调用一次doGetBean,而且从一二级缓存拿到的spuCheckDomainServiceImpl提前暴露的引用为null,如下图:

这里为什么spuCheckDomainServiceImpl只会调用一次doGetBean呢?

首先我们根据调用栈整理到当前加载的引用栈:

oldCenterSpuController-> newSpuApplyCheckServiceImpl-> ... ->spuCheckDomainServiceImpl -> newSpuApplyCheckServiceImpl

根据前面启动失败的信息我们可以知道,spuCheckDomainServiceImpl处理依赖的环是:

spuCheckDomainServiceImpl ->newSpuApplyCommandServiceImpl-> ... ->spuCheckDomainServiceImpl

失败的情况我们发现是从spuCheckDomainServiceImpl开始创建的,现在启动正常的情况是从newSpuApplyCheckServiceImpl开始创建的。

创建 newSpuApplyCheckServiceImpl时,发现它依赖环中间这些bean会依次调用doCreateBean方法去创建对应的bean。

调用到spuCheckDomainServiceImpl时,由于是第一次获取bean,也会调用doCreateBean方法创建bean,然后回到创建spuCheckDomainServiceImpl的doCreateBean流程,这里由于没有将spuCheckDomainServiceImpl的三级缓存移到二级缓存,所以不会导致抛出UnsatisfiedDependencyException异常,最终回到newSpuApplyCheckServiceImpl的doCreateBean流程,由于newSpuApplyCheckServiceImpl在调用initializeBean方法没有做代理增强,所以也不会导致抛出UnsatisfiedDependencyException异常。因此最后可以正常启动。

这里我们会有疑问?类的创建顺序由什么决定的呢?

通常不同环境下,代码打包后的jar/war结构、@ComponentScan的basePackages配置细微差别,都可能导致Spring扫描和注册Bean定义的顺序不同。Java ClassLoader加载类的顺序本身也有一定不确定性。如果Bean定义是通过不同的配置类引入的,配置类的加载顺序会影响其中所定义Bean的注册顺序。

那是不是所有的类增强在有循环依赖时都会触发UnsatisfiedDependencyException异常呢?

并不是,比如@Transactional就不会导致触发UnsatisfiedDependencyException异常。让我们深入分析原因。

核心区别在于代理创建时机不同。

@Transactional的代理时机如下:

// Spring 为 @Transactional 创建代理的流程1. 实例化原始 Bean
2. 放入三级缓存(ObjectFactory)
3. 当发生循环依赖时,调用 ObjectFactory.getObject()
4. 此时判断是否需要事务代理,如果需要则提前创建代理
5. 将代理对象放入二级缓存,供其他 Bean 使用

@Validated的代理时机:

// @Validated 的代理创建在生命周期更晚的阶段1. 实例化原始 Bean
2. 放入三级缓存(ObjectFactory)
3. 当发生循环依赖时,调用 ObjectFactory.getObject()
4.  ❌ 问题:此时 @Validated 的代理还未创建!
5. 其他 Bean 拿到的是原始对象,而不是异步代理对象

问题根源:@Transactional的代理增强是在三层缓存生成时触发的, @Validated的增强是在初始化bean后通过后置处理器做的代理增强。

3.3 解决方案

短期方案

  • 移除SpuCheckDomainServiceImpl类上的Validated注解
  • @lazy 解耦
    • 原理是发现有@lazy 注解的依赖为其生成代理类,依赖代理类,只有在真正需要用到对象时,再通过getBean的逻辑去获取对象,从而实现了解耦。

长期方案

严格执行DDD代码规范

这里是违反DDD分层规范导致的循环依赖。

梳理解决历史依赖环

通过梳理修改代码解决历史存在的依赖环。我们内部实现了一个能检测依赖环的工具,这里简单介绍一下实现思路,详情如下。

日常循环依赖环:实战检测工具类解析

在实际项目中,即使遵循了DDD分层规范和注入最佳实践,仍有可能因业务复杂或团队协作不充分而引入循环依赖。为了在开发阶段尽早发现这类问题,我们可以借助自定义的循环依赖检测工具类,在Spring容器启动后自动分析并报告依赖环。

功能概述:

  • 条件启用:通过配置circular.dependecy.analysis.enabled=true开启检测;
  • 依赖图构建:扫描所有单例Bean,分析其构造函数、字段、方法注入及depends-on声明的依赖;
  • 循环检测算法:使用DFS遍历依赖图,识别所有循环依赖路径;
  • 通知上报:检测结果通过飞书机器人发送至指定接收人(targetId)。

简洁代码结构如下:

@Component
@ConditionalOnProperty(value = "circular.dependency.analysis.enabled", havingValue = "true")
public class TimingCircularDependencyHandler extends AbstractNotifyHandler<NotifyData>
    implements ApplicationContextAwareBeanFactoryAware {
    
    @Override
    public Boolean handler(NotifyData data) {
        dependencyGraph = new HashMap<>();
        handleContextRefresh(); // 触发依赖图构建与检测
        return Boolean.TRUE;
    }
    
    private void buildDependencyGraph() {
        // 遍历所有Bean,解析其依赖关系
        // 支持:构造器、字段、方法、depends-on
    }
    
    private void detectCircularDependencies() {
        // 使用DFS检测环,记录所有循环路径
        // 输出示例:循环依赖1: A -> B -> C -> A
    }
}

四、总结

循环依赖暴露了代码结构的设计缺陷。理论上应通过分层和抽象来避免,但在复杂的业务交互中仍难以杜绝。虽然Spring利用三级缓存等机制默默解决了这一问题,使程序得以运行,但这绝不应是懈怠设计的借口。我们更应恪守设计原则,从源头规避循环依赖,构建清晰、健康的架构。

往期回顾

1. Apex AI辅助编码助手的设计和实践|得物技术

2. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

3. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

4. 线程池ThreadPoolExecutor源码深度解析|得物技术

5. 基于浏览器扩展 API Mock 工具开发探索|得物技术

文 /鲁班

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

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

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

Apex AI辅助编码助手的设计和实践|得物技术

作者 得物技术
2025年10月21日 17:22

一、背景

Apex以vscode插件为主要载体,接入SSO认证、打通CursorRules知识库、Webview远程UI、实现无感安装MCP、创建智能体、使用智能体等能力,帮助实现提示词撰写效率的提升,降低了使用过程的费力度。通过知识库、智能体等可实现在保障代码质量同时,进一步提升AI代码生成占比。

除了功能层面的能力,想必大家对Apex内部实现原理应该也很感兴趣,如何打通知识库、智能体使用时,MCP为什么自动安装了,下面将从技术实现角度,剖析Apex 如何将“AI 能力”工程化落地到 Cursor 开发流程中。了解Apex是如何激活装配、打通SSO认证,同步 Cursor Rules 知识库、通过远程dist包实现webview UI渲染,并提供智能体能力,实现无感更新,消息如何编排,如何识别大仓还是独立应用等。

二、架构设计总览

Apex 以插件为主控Webview 承载 UI 与业务交互,服务层聚合认证、工程上下文、CursorRules知识库、埋点等能力,MCP 以“配置即工具”的方式进行边界的扩展。实现三端(插件-前端-服务端)通过版本编排解耦vscode插件的迭代周期,在安全(鉴权)、可观测(日志与活跃情况)、工程落地(规则知识库与智能体模板)之间取得平衡。

三、功能设计&落地

3.1 激活与装配流程

为实现插件的稳定启动,在插件注册过程中,出现异常失败也不污染后续状态,通过事件注册按需加载,避免了冷启动插件带来的抖动问题。

要点流程介绍:

  • 版本检查先行:防止低版本 Cursor 带来功能不兼容。
  • 早期守卫 workspaceRoot,避免在无工作区时继续初始化产生隐式 NPE。
  • 服务单例初始化顺序:ProjectService(工程上下文)→RuleSyncService(规则聚合/监听)→ StorageService(持久能力)→ AuthService.initialize()(异步认证获取 token 与 userInfo)。
  • 鉴权失败直接中断,避免后续埋点与 webview 的脏状态。
  • 命令注册分散在此处,MessageHandler负责Webview 指令编排。

// ...
VersionChecker.checkVersion();
const root = workspaceRoot()
if (!root) return;  // 早期守卫
ProjectService.getInstance();   // 工程上下文
const auth = AuthService.getInstance();
const logger = LoggerService.getInstance(context);
const user = await auth.initialize()
if (!user) return;  // 认证失败即止损
logger.watch();   // 可观测
// 注册 Webview/命令/规则监听 ...

3.2 认证与安全

(AuthService+Storage)

通过接入SSO实现可以单点登录闭环 + 本地仅存最少信息,降低泄漏面、失败可针对进行快速反馈。后期记录用户维度智能体的使用、记录用户的关键行为埋点,从而进一步实现及时私聊沟通解决告警出现的问题,另外,针对用户的使用习惯和使用情况可进行针对性的分析和需求收集。

令牌获取流程(嵌入端口监听 + 浏览器回调):

async initialize(){
  const saved = await storage.getAuthToken();
  const token = saved?.trim()? saved : await login();
  return await validateToken(token) ?? await login();
}

令牌回调服务器(端口探测 + CORS + 路由校验):

import http from 'http';
import * as vscode from 'vscode';
const LOWCODE_PLATFORM_API = 'https://xxx.yyy.zzz/ddd';
const PORT = 9527;


const findAvailablePort = (startPortnumberendPortnumber = startPort + 10): Promise<number> => {
 //  端口监听逻辑
};


export const requestToken = async () => {
  // 验证token有效性
}
  • 数据结构与算法设计:
    • 端口探测算法:线性递增(最多 10 次),失败上抛;简单可靠,代价可接受。
    • CORS 与 OPTIONS 预检处理;路由严格校验zzz/ddd,仅取请求头 accesstoken。
    • 超时控制(5 分钟)避免悬挂。
  • 持久化:
// ... 省略若干
export class StorageService {
    // ...
    public async saveAuthToken(token: string): Promise<void> {
        await this.secureStorage.saveSecret(
            StorageService.CACHE_KEYS.AUTH_TOKEN,
            token
        );
    }
    public async getAuthToken(): Promise<string | undefined> {
        return await this.secureStorage.getSecret(
            StorageService.CACHE_KEYS.AUTH_TOKEN
        );
    }
    public async deleteAuthToken(): Promise<void> {
        await this.secureStorage.deleteSecret(
            StorageService.CACHE_KEYS.AUTH_TOKEN
        );
    }
    public async clearAll(): Promise<void> {
        for (const key of Object.values(StorageService.CACHE_KEYS)) {
           // 遍历清除key值
        }
    }
}
  • 在 secrets 中存敏感数据,安全性较高;clearAll() 同时清理多个状态缓存值,防止残留。

3.3 规则知识库工程化

(RuleSyncService)

通过Gtlab维护远程知识库文档,实现知识库聚合,模板/规则“一键对齐”,多包仓不会出现杂乱和上下文丢失等情况。大仓模式下实现批量并发拉取,非大仓模式下实现兜底向上拉取能力。

知识库规则拉取至各应用逻辑

模板拉取过程:GitLab 分批并发,chunk 化

export async function fetchTemplateFiles(
    projectId: number,
    templatePath: string,
    branch: string
): Promise<Array<{ pathstringcontentstring }>> {
    // git接口获取文件并进行分发同步
}
  • 数据结构:数组分块 + Promise.all 并发,有效权衡吞吐与限流风险(每批 5 个)。
  • 模板写入(按类型路由到 .cursor/rules/basic.mdc、notepads 或工程根):
export async function writeTemplatesToDisk(files, templatePath, targetRoot, type?) {
    for (const file of files) {
        // 处理模板写入
    }
    return true;
}
  • .gitignore 同步策略(追加不重复的规则):
export const syncGitIgnoreToPath = async (targetPath: string, ignoreRules: string[]) => {
    // 追加ignore逻辑
};

子应用规则同步至逻辑

监听与同步策略:

public startWatcher() {
  const pattern = new vscode.RelativePattern(this.workspaceRoot'*/**/.cursor/rules/*.mdc');
  this.watcher = vscode.workspace.createFileSystemWatcher(pattern);
  
  this.watcher.onDidCreate(uri => {
    // 同步变更mdc
  });
  
  this.watcher.onDidChange(uri => {
    // 同步变更mdc
  });
  
  this.watcher.onDidDelete(uri => {
    // 移除指定mdc
  });
}

规则改写:

export function writeRelativePathToContent(contentstring, relativePathstring) {
    // 相对路径注入 + 目标文件聚合为 .sync.mdc
}
  • 算法说明:
    • 提取 mdc 头中的 globs,对不含路径分隔符的 glob 自动加 /**/,再拼接相对路径前缀,确保规则定位到子包内。
    • 默认兜底 **/.,覆盖子目录所有文件,提升易用性。
  • 目标文件命名:
private getTargetPath(fsPath: string) {
  // 针对同步过来的所有mdc文件进行重命名
}
  • 将子仓的规则扁平化同步到根目录 .cursor/rules/*.sync.mdc,避免分散规则导致的遗漏。

3.4 远程 webview 与版本编排

(Webview+VersionChecker+Trace)

由于Apex插件的更新需要手动通过dx vs更新,修复问题或有新功能无法实时进行更新,新版本有问题无法回滚及时止损。

Apex通过DNF接口获取当前远程版本webVersion、coreVersion,对比当前local加载版本,实现无感更新或回退。

  • 重新加载方式:直接拉取最新版本。
  • Tab重新点开,实时检测最新版本,点击更新按钮实现更新。

远程加载逻辑(支持本地调试、回退远端 CDN):

const v = await fetchWebVersion() || 'latest';
const local = useLocal() && await ping('http://localhost:9527/...');
const js = local ? mapToExternal(local) : cdn(`@apex-plugin/web@${v}`);
return htmlWith(js, csp());
  • 关键要点:
    • 从DNF后端接口获取webVersion,优先 USE_LOCAL且本地可访问则映射本地端口。
    • 动态 CSP(当前较宽松,含'unsafe-eval'),满足构建产物运行,未来会按资源域名白名单收紧。
    • 版本编排:Web UI 可独立灰度;插件端仅负责加载版本号对应的资源。

Cursor 版本下限拦截:

export class VersionChecker {
  private static readonly MIN_VERSION = "0.46.0";
  
  public static async checkVersion() {
    const isCursor = vscode.env.appName.toLowerCase().includes("cursor");
    if (!isCursor) {
      return;
    }
    // ... 读取本机 Cursor 安装目录,比较版本,小于阈值弹升级引导
  }
  // compareVersions(v1, v2) 三段位点比较
}

版本注入的位置(可用于埋点/展示):TraceService.setPluginVersion(v)+ getPluginVersion() 默认读取core package.json。建议在宿主(packages/plugin)激活时注入真实发布版本,确保埋点准确。

3.5 项目服务(ProjectService):

Monorepo识别、模板拉取与写入

通过识别应用是否是大仓应用,便于后期进行不同类型应用的业务逻辑处理,如知识库同步在大仓下和单仓下的不同分支逻辑是不同的。大仓下子仓的规则会被自动聚合到顶层.cursor/rules下,实现规则命中率显著提升。

Monorepo 识别与项目列表收集方式:

public isMonorepo(): boolean {
    try {
        // 通过判断是否有`pnpm-workspace`判断是否是大仓
        // 获取 package.json 中 workspaces 进行判断是否大仓
    } catch (error) {
        console.error('判断monorepo失败:', error);
        return false;
    }
}

3.6 埋点与活跃情况记录

(LoggerService+UsageRecorder)

通过组合心跳与焦点事件,确保用户离开窗口也能形成完整闭环记录,便于后期进行用户行为画像等分析。

事件监听与活跃态判定:

start() {
  // 开启事件监听记录
  this.eventList = [
    // 多个事件记录绑定...
  ];
}
startInterval() {
  if (!this.walkClocker) {
    this.walkClocker = setInterval(() => { this.reportUsage(); }, this.walkInterval);
  }
}
  • walkInterval=30s 的心跳,配合窗口焦点事件强制上报一次,确保离开窗口时记录“单次活跃时长”。

埋点上报示例:

const reportTimenote = async () => {
  // 记录用户活跃情况、包含分支、版本、仓库等等信息
}

性能与可靠性:

  • 事件监听广泛但回调轻量(更新时间 + 定时器驱动),无重 IO。
  • 远端上报失败未中断主流程,可再考虑指数退避与采样策略。

3.7 Webview 消息编排

(MessageHandler)

MessageHandler 作为 Webview 与服务层的协调者,不承载复杂业务逻辑,单一职责,实现路由 Webview 消息到服务层,支持失败统一回发,前后端协作清晰,便于后期扩展和灰度,有良好的可维护性。

  • 获取插件/核心版本:handleGetPluginVersion(从 TraceService.getPluginVersion() + package.json.version)。
  • 生成规则:handleGenerateRule→.gitignore 同步 + 模板拉取写入+ AI 角色写入 + README 打开。
  • MCP 相关:handleHandleServerConfig、checkMcpList、fetchInstalledMcpList、initRuleMcpConfig。
  • 导入提示词:handleImportPrompt写入 .cursor/notepads/note-*.private.md 并辅助插入到 Composer。

四、总结&展望

Apex通过 RuleSync 与 ProjectService 实现CursorRules规则模板一键同步,依托配置化 MCP 加速工具集成和能力提升,以安全令牌与白名单机制强化治理,并借助 UsageRecorder 与 TraceService 提供可观测性,全面支持高效、安全、可控的使用交付与版本去迭代化管理。

Apex 的核心在于“把 AI 真正落在工程实践之中”,以插件为载体打通认证、上下文、CursorRules规则和Cursor;以 MCP 为能力边界实现“配置即扩展”;以可观测为保障推动插件能力持续演进。通过“单例化、配置化、远程化、工程化”的设计原则,让团队在享受 AI 编码效率的同时,最大限度保持工程可控与可治理。

但是受限于智能体执行需要手动触发,开发者可能会存在遗忘执行的情况等,下一步计划智能体执行支持命令行触发,预期试行添加到 git hook中commit提交代码后自动执行,避免遗忘,提升Apex更多可玩性。

#Agent #MCP #智能体

往期回顾

1. 从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

2. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

3. 线程池ThreadPoolExecutor源码深度解析|得物技术

4. 基于浏览器扩展 API Mock 工具开发探索|得物技术

5. 破解gh-ost变更导致MySQL表膨胀之谜|得物技术

文 /凯

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

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

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

从 JSON 字符串到 Java 对象:Fastjson 1.2.83 全程解析|得物技术

作者 得物技术
2025年10月16日 11:52

一、概述

Fastjson 是阿里巴巴开源的高性能 JSON 序列化处理库,其主要以处理小数据时速度最快而著称,功能全面。Fastjson1.X版本目前已停止维护,被Fastjson2.X代替,但1.X版本国内被广泛使用,通过学习其技术架构,剖析架构上优缺点,对技术人员提升软件设计工程实践能力很有价值。

首先我们对“序列化 / 反序列化”概念上建立直观认识,把Java对象转化为JSON格式的字符串的过程叫做序列化操作,反之则叫反序列化。如果把“序列化 / 反序列化”放到整个计算机系统的坐标系里,可以把它看成一次数据的“跨边界搬家”。

对象在“内存世界”里活得很好,但只要一离开进程地址空间(网络、磁盘、数据库、浏览器、异构语言),就必须先打成包裹(序列化),到对岸再拆包裹(反序列化)。

二、核心模块架构

从高层次视图看Fastjson框架的结构,主要可以分为用户接口层、配置管理层、序列化引擎、反序列化引擎和安全防护层。其中用户接口提供了门面类用户编码直接与门面类交互,降低使用复杂度;配置管理层允许用户对框架行为进行配置;序列化引擎是序列化操作的核心实现;反序列引擎是反序列化操作的核心实现;安全模块解决框架安全问题,允许用户针对安全问题设置黑白名单等安全检查功能。下图为Fastjson模块关系图:

模块关系图

三、项目结构

com.alibaba.fastjson/
├── JSON.java                    # 核心入口类
├── annotation/                  # 注解定义
├── asm/                         # ASM字节码精简库
├── parser/                      # 解析器模块
│   ├── DefaultJSONParser.java  # 默认JSON解析器
│   ├── JSONLexer.java          # 词法分析器接口
│   ├── JSONScanner.java        # 词法分析器实现
│   └── deserializer/           # 反序列化器
├── serializer/                  # 序列化器模块
│   ├── JSONSerializer.java     # JSON序列化器
│   ├── SerializeConfig.java    # 序列化配置
│   └── ObjectSerializer.java   # 对象序列化器接口
├── spi/                         # SPI扩展机制
├── support/                     # 框架支持
└── util/                        # 工具类

3.1 项目结构说明

主要可以划分为以下几个核心模块(包):

com.alibaba.fastjson (核心 API 与数据结构)

  • 关键类 :
    • JSON.java: 整个库的门面(Facade),提供了最常用、最便捷的静态方法,如 toJSONString() (序列化), parseObject() (反序列化为对象), parseArray() (反序列化为数组)。通常它是用户最先接触到的类。
    • JSONObject.java: 继承自java.util.HashMap,用于表示 JSON 对象结构( {key: value} )。
    • JSONArray.java: 继承自java.util.ArrayList,用于表示 JSON 数组结构 ( [value1, value2] )。

com.alibaba.fastjson.serializer (序列化模块)

此模块负责将 Java 对象转换为 JSON 格式的字符串

  • 关键类 :
    • JSONSerializer.java: 序列化的核心调度器。它维护了序列化的上下文信息,如对象引用、循环依赖检测、特性( SerializerFeature )开关等,并驱动整个序列化过程。
    • SerializeWriter.java: 一个高度优化的 Writer 实现,专门用于生成 JSON 字符串。它内部使用 char[] 数组来拼接字符串,避免了 String 的不可变性带来的性能损耗,是 Fastjson 高性能写入的关键
    • JavaBeanSerializer.java: 默认的 JavaBean 序列化器。在未启用 ASM 优化时,它通过反射获取对象的属性( getter 方法)并将其序列化。
    • ASMSerializerFactory.java: 性能优化的核心 。它使用 ASM 字节码技术在运行时动态生成序列化器类,这些类直接调用 getter 方法并操作SerializeWriter,避免了反射的性能开销。
    • ObjectSerializer.java: 序列化器接口。用户可以通过实现此接口来为特定类型提供自定义的序列化逻辑。
    • SerializeConfig.java: 序列化配置类。它维护了 Java 类型到 ObjectSerializer 的缓存。 SerializeConfig.getGlobalInstance() 提供了全局唯一的配置实例。
    • SerializerFeature.java: 序列化特性枚举。定义了各种序列化行为的开关,例如 WriteMapNullValue (输出 null 值的字段)、 DisableCircularReferenceDetect (禁用循环引用检测) 等。

com.alibaba.fastjson.parser (反序列化模块)

此模块负责将 JSON 格式的字符串解析为 Java 对象。

  • 关键类 :
    • DefaultJSONParser.java: 反序列化的核心调度器。它负责解析 JSON 字符串的整个过程,管理 JSONLexer进行词法分析,并根据 Token (如 { , } , [ , ] , string , number 等)构建 Java 对象。
    • JSONLexer.java / JSONLexerBase.java: JSON 词法分析器。它负责扫描输入的 JSON 字符串,将其切割成一个个有意义的 Token ,供 DefaultJSONParser 使用。
    • JavaBeanDeserializer.java: 默认的 JavaBean 反序列化器。在未启用 ASM 优化时,它通过反射创建对象实例并设置其属性值。
    • ASMDeserializerFactory.java: 与序列化类似,它动态生成反序列化器字节码,直接调用 setter 方法或直接对字段赋值,避免了反射。
    • ObjectDeserializer.java: 反序列化器接口。用户可以实现此接口来自定义特定类型的反序列化逻辑。
    • ParserConfig.java: 反序列化配置类。维护了 Java 类型到 ObjectDeserializer 缓存,并负责管理 ASM 生成的类的加载。
    • Feature.java: 反序列化特性枚举,用于控制解析行为。

com.alibaba.fastjson.annotation (注解模块)

提供了一系列注解,允许用户通过声明式的方式精细地控制序列化和反序列化的行为。

  • 关键注解 :
    • @JSONField: 最核心的注解,可用于字段或方法上,用于自定义字段名、格式化、序列化/反序列化顺序、是否包含等。
    • @JSONType: 可用于类上,用于配置该类的序列化器、反序列化器、特性开关等。

3.2 项目结构小结

Fastjson 框架在架构设计体现了“关注点分离”的原则,将序列化、反序列化、API、工具类等清晰地划分到不同的模块中。整个框架具有高度的可扩展性,用户可以通过 ObjectSerializer / ObjectDeserializer接口和丰富的注解来满足各种复杂的定制化需求。

四、核心源码分析

为了更直观说明框架实现原理,本文对部分展示的源代码进行了删减,有些使用了伪代码,如需了解更多实现细节请读者阅读项目源码(github.com/alibaba/fas…)

整体上Fastjson通过统一的门面API(JSON.toJSONString/parseObject)调用核心控制器(JSONSerializer/DefaultJSONParser),利用ASM字节码生成反射机制,配合SerializeWriter/JSONLexer进行高效的Java对象与JSON字符串间双向转换,同时提供配置缓存、循环引用检测AutoType安全防护等优化机制。下图为框架处理数据流:

数据流

4.1 序列化原理介绍

序列化步骤主要包括:序列化器查找→JavaBean字段解析→字段值转换和JSON字符串构建等过程。下图为序列化处理时序图:

序列化时序图

序列化入口与初始化

使用JSON.toJSONString()入口,将person对象转换为JSON字符串。

Person person = new Person();
String json = JSON.toJSONString(person);

用户调用toJSONString方法进行对象序列化操作,JSON.java包含了多个toJSONString重载方法,共同完成核心类初始化:SerializeConfig,SerializeWriter,JSONSerializer。

//用户不指定SerializeConfig,默认私有全局配置
public static String toJSONString(Object object, SerializeFilter[] filters, 
                                  SerializerFeature... features) {
   return toJSONString(objectSerializeConfig.globalInstance, filters, nullDEFAULT_GENERATE_FEATURE, features);
}


public static String toJSONString(Object object, 
                                      SerializeConfig config, 
                                      SerializeFilter[] filters, 
                                      String dateFormat, 
                                      int defaultFeatures, 
                                      SerializerFeature... features) {
    SerializeWriter out = new SerializeWriter((Writernull, defaultFeatures, features);
    try {
        JSONSerializer serializer = new JSONSerializer(out);
        //省略其他代码...
        serializer.write(object);  // 核心序列化调用
        return out.toString();
    } finally {
        out.close();
    }
}

序列化控制流程

JSONSerializer.write()核心逻辑

write方法的逻辑比较简单,首先处理null值,然后根据类型查找序列器(ObjectSerializer),最后将序列化逻辑委派给序列化器处理。

public final void write(Object object) {
    //如何序列化对象为null,直接写入"null"字符串
    if (object == null) {
        out.writeNull();
        return;
    }


    Class<?> clazz = object.getClass();
    ObjectSerializer writer = getObjectWriter(clazz);  // 类型识别与序列化器选择


    try {
        writer.write(thisobjectnullnull0);  // 委托给具体序列化器
    } catch (IOException e) {
        throw new JSONException(e.getMessage(), e);
    }
}

类型识别与序列化器策略

框架采用策略化模式将不同类型序列化逻辑封装成不同的序列化器:

  • 基础类型 : 使用专门的Codec(如StringCodec、IntegerCodec)
  • 集合类型 : 使用ListSerializer、MapSerializer等
  • JavaBean : 使用JavaBeanSerializer或ASM动态生成的序列化器
  • 枚举类型 : 使用EnumSerializer

SerializeConfig.getObjectWriter方法负责序列化器查找工作:



public ObjectSerializer getObjectWriter(Class<?> clazz, boolean create) {
    // 第一步:缓存查找
    ObjectSerializer writer = get(clazz);
    if (writer != null) {
        return writer;
    }


    // 第二步:SPI扩展加载(当前线程类加载器)
    try {
        final ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        for (Object o : ServiceLoader.load(AutowiredObjectSerializer.class, classLoader)) {
            if (!(o instanceof AutowiredObjectSerializer)) {
                continue;
            }
            AutowiredObjectSerializer autowired = (AutowiredObjectSerializer) o;
            for (Type forType : autowired.getAutowiredFor()) {
                put(forType, autowired);
            }
        }
    } catch (ClassCastException ex) {
        // skip
    }


    writer = get(clazz);
    if (writer == null) {
        // 第三步:SPI扩展加载(JSON类加载器)
        final ClassLoader classLoader = JSON.class.getClassLoader();
        if (classLoader != Thread.currentThread().getContextClassLoader()) {
            // 重复SPI加载逻辑...
        }
    }


    // 第四步:模块扩展
    for (Module module : modules) {
        writer = module.createSerializer(this, clazz);
        if (writer != null) {
            put(clazz, writer);
            return writer;
        }
    }


    // 第五步:内置类型匹配
    if (writer == null) {
        String className = clazz.getName();
        Class<?> superClass;


        if (Map.class.isAssignableFrom(clazz)) {
            put(clazz, writer = MapSerializer.instance);
        } else if (List.class.isAssignableFrom(clazz)) {
            put(clazz, writer = ListSerializer.instance);
        } else if (Collection.class.isAssignableFrom(clazz)) {
            put(clazz, writer = CollectionCodec.instance);
        } else if (Date.class.isAssignableFrom(clazz)) {
            put(clazz, writer = DateCodec.instance);
        } else if (clazz.isEnum()) {
            // 枚举处理逻辑
        } else if (clazz.isArray()) {
            // 数组处理逻辑
        } else {
            // 第六步:JavaBean序列化器创建
            if (create) {
                writer = createJavaBeanSerializer(clazz);
                put(clazz, writer);
            }
        }
    }


    return writer;
}

JavaBean序列化处理

JavaBeanSerializer的write方法实现了Java对象序列化处理核心逻辑:

方法签名分析:

protected void write(JSONSerializer serializer, //JSON序列化器,提供序列化上下文和输出流
                      Object object//待序列化的Java对象
                      Object fieldName, //字段名称,用于上下文追踪
                      Type fieldType, //字段类型信息
                      int features, //序列化特性标志位
                      boolean unwrapped //是否展开包装,用于嵌套对象处理
    ) throws IOException

序列化流程概览:

// 1. 空值检查和循环引用处理
if (object == null) {
    out.writeNull();
    return;
}


if (writeReference(serializer, object, features)) {
    return;
}


// 2. 字段序列化器选择
final FieldSerializer[] getters;
if (out.sortField) {
    getters = this.sortedGetters;
} else {
    getters = this.getters;
}


// 3. 上下文设置和格式判断
SerialContext parent = serializer.context;
if (!this.beanInfo.beanType.isEnum()) {
    serializer.setContext(parent, object, fieldName, this.beanInfo.features, features);
}


// 4.遍历属性序列化器,完成属性序列化
for (int i = 0; i < getters.length; ++i) {
    FieldSerializer fieldSerializer = getters[i];
    // 获取属性值
    Object propertyValue = this.processValue(serializer, fieldSerializer.fieldContext, object, fieldInfoName,
                                        propertyValue, features);
    // 写入属性值                                    
    fieldSerializer.writeValue(serializer, propertyValue);
}

循环引用检测:

JavaBeanSerializerwriteReference 方法执行循环引用检测,Fastjson使用$ref占位符处理循环引用问题,防止对象循环引用造成解析查询栈溢出。

public boolean writeReference(JSONSerializer serializer, Object object, int fieldFeatures) {
    SerialContext context = serializer.context;
    int mask = SerializerFeature.DisableCircularReferenceDetect.mask;


    // 检查是否禁用循环引用检测
    if (context == null || (context.features & mask) != 0 || (fieldFeatures & mask) != 0) {
        return false;
    }


    // 检查对象是否已存在于引用表中
    if (serializer.references != null && serializer.references.containsKey(object)) {
        serializer.writeReference(object);  // 写入引用标记
        return true;
    }
    return false;
}

上下文管理与引用追踪:

序列化采用DFS(深度优先)算法遍历对象树,使用 IdentityHashMap<Object, SerialContext> references 来追踪对象引用:

  • setContext: 建立序列化上下文,记录对象层次关系
  • containsReference: 检查对象是否已被序列化
  • popContext: 序列化完成后清理上下文
protected IdentityHashMap<ObjectSerialContext> references  = null;
protected SerialContext                          context;
//使用链表建立序列化上下文引用链,记录对象层次关系
public void setContext(SerialContext parent, Object objectObject fieldName, int features, int fieldFeatures) {
    if (out.disableCircularReferenceDetect) {
        return;
    }
    //构建当前上下文到parent上下文引用链
    this.context = new SerialContext(parent, object, fieldName, features, fieldFeatures);
    if (references == null) {
        references = new IdentityHashMap<ObjectSerialContext>();
    }
    this.references.put(object, context);
}
//检查对象是否已被序列化,防止重复序列化
public boolean containsReference(Object value) {
    if (references == null) {
        return false;
    }
    SerialContext refContext = references.get(value);
    if (refContext == null) {
        return false;
    }
    if (value == Collections.emptyMap()) {
        return false;
    }
    Object fieldName = refContext.fieldName;
    return fieldName == null || fieldName instanceof Integer || fieldName instanceof String;
}
//清理上下文,将当前序列化上下文指向父亲节点
public void popContext() {
    if (context != null) {
        this.context = this.context.parent;
    }
}

字段值转换与序列化

FieldSerializer.writeValue()核心逻辑

FieldSerializer 的writeValue方法实现了字段值的序列化操作:

public void writeValue(JSONSerializer serializer, Object propertyValue) throws Exception {
    // 运行时类型识别
    Class<?> runtimeFieldClass = propertyValue != null ? 
        propertyValue.getClass() : this.fieldInfo.fieldClass;


    // 查找属性类型对应的序列化器
    ObjectSerializer fieldSerializer = serializer.getObjectWriter(runtimeFieldClass);


    // 处理特殊格式和注解
    if (format != null && !(fieldSerializer instanceof DoubleSerializer)) {
        serializer.writeWithFormat(propertyValue, format);
        return;
    }


    // 委托给具体序列化器处理
    fieldSerializer.write(serializer, propertyValue, fieldInfo.name, 
                         fieldInfo.fieldType, fieldFeatures);
}

不同类型的序列化策略

基础类型序列化 :

  • 直接调用SerializeWriter的对应方法(writeInt、writeString等)

复杂对象序列化 :

  • 递归调用JSONSerializer.write()方法
  • 维护序列化上下文和引用关系
  • 应用过滤器和特性配置

ASM定制化序列化器加速,下文会进行详细讲解。

  • 为序列化的类动态生成定制化的序列化器,避免反射调用开销

JSON字符串构建

SerializeWriter.java采用线程本地缓冲机制,提供高效的字符串构建:

//用于存储存JSON字符串
private final static ThreadLocal<char[]> bufLocal         = new ThreadLocal<char[]>();
//将字符串转换为UTF-8字节数组
private final static ThreadLocal<byte[]> bytesBufLocal    = new ThreadLocal<byte[]>();
  • 字符缓冲区 : 线程本地char[]数组减少内存分配,避免频繁创建临时数组对象。
  • 动态扩容 : 根据内容长度自动调整缓冲区大小。

bufLocal初始化创建2048字符的缓冲区,回收阶段当缓冲区大小不超过 BUFFER_THRESHOLD (128KB)时,将其放回ThreadLocal缓存,超过阈值的大缓冲区不缓存,避免内存占用过大。

bytesBufLocal专门用于UTF-8编码转换过程,初始缓冲区大小:8KB(1024 * 8),根据字符数量估算所需字节数(字符数 × 3),只有不超过 BUFFER_THRESHOLD 的缓冲区才会被缓存。

4.2 序列化小结

Fastjson通过JSON.toJSONString()门面API调用JSONSerializer控制器,利用ASM字节码生成的高性能序列化器或反射机制遍历Java对象字段,配合SerializeWriter将字段名和值逐步写入缓冲区构建JSON字符串。

4.3 反序列化流程

虽然“序列化”与“反序列化”在概念上是对偶的(Serialize ↔ Deserialize),但在实现层面并不严格对偶,反序列化实现明显比序列化复杂。核心步骤包括:反序列化器查找→ 反序列流程控制→词法分析器(Tokenizer) → 安全检查→反射/ASM 字段填充等,下图为处理时序图:

反序列化入口与反序列化器选择

反序列化从 JSON.java的parseObject方法开始:

// JSON.java - 反序列化入口
public static <T> parseObject(String text, Class<T> clazz, int features) {
    if (text == null) {
        return null;
    }
    DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);
    T value = (T) parser.parseObject(clazz);
    parser.handleResovleTask(value);
    parser.close();
    return value;
}

查找反序列化器

在 DefaultJSONParser.java 中选择合适的反序列化器:

// DefaultJSONParser.java - 反序列化器选择
public <T> T parseObject(Type typeObject fieldName) {
    int token = lexer.token();
    if (token == JSONToken.NULL) {
        lexer.nextToken();
        return (T) TypeUtils.optionalEmpty(type);
    }
    //从缓存中查找反序列化器
    ObjectDeserializer deserializer = config.getDeserializer(type);


    try {
        if (deserializer.getClass() == JavaBeanDeserializer.class) {
            return (T) ((JavaBeanDeserializer) deserializer).deserialze(thistype, fieldName, 0);
        } else {
            return (T) deserializer.deserialze(thistype, fieldName);
        }
    } catch (JSONException e) {
        throw e;
    } catch (Throwable e) {
        throw new JSONException(e.getMessage(), e);
    }
}

ParserConfig.java 负责获取对应类型的反序列化器:

// ParserConfig.java - 反序列化器获取
public ObjectDeserializer getDeserializer(Type type) {
    ObjectDeserializer deserializer = this.deserializers.get(type);
    if (deserializer != null) {
        return deserializer;
    }
    //通过Class查找
    if (type instanceof Class<?>) {
        return getDeserializer((Class<?>) typetype);
    }
    //通过泛型参数查找
    if (type instanceof ParameterizedType) {
        Type rawType = ((ParameterizedTypetype).getRawType();
        if (rawType instanceof Class<?>) {
            return getDeserializer((Class<?>) rawType, type);
        } else {
            return getDeserializer(rawType);
        }
    }


    return JavaObjectDeserializer.instance;
}

反序列化控制流程

JavaBeanDeserializer.java 的deserialze实现了反序列化主要处理流程。

// JavaBeanDeserializer.java - 类型识别与字段匹配
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName, int features, int[] setFlags) {
    // 1.特殊类型快速处理
    if (type == JSON.class || type == JSONObject.class) {
        return (T) parser.parse();
    }
    //2.初始化核心组件
    final JSONLexer lexer = parser.lexer;
    //3.反序列化上下文管理
    ParseContext context = parser.getContext();
    if (object != null && context != null) {
       context = context.parent;
    }
    ParseContext childContext = null;
    //保存解析后字段值
    Map<String, Object> fieldValues = null;
    // JSON关键字分支预处理
    if (token == JSONToken.RBRACE) {
        lexer.nextToken(JSONToken.COMMA);
        if (object == null) {
          object = createInstance(parser, type);
        }
        return (T) object;
    }
    //处理其他JSON关键字
    ...


    //4.字段解析主循环
    for (int fieldIndex0, notMatchCount = 0;; fieldIndex++) {
        boolean customDeserializerfalse;
        //这是一个性能优化的设计,通过预排序和索引访问来提高字段匹配的效率,
        //通常情况下JSON串按字段定义顺序排列,因此能快速命中
        if (fieldIndex < sortedFieldDeserializers.length && notMatchCount < 16) {
            fieldDeserializer = sortedFieldDeserializers[fieldIndex];
            fieldInfo = fieldDeserializer.fieldInfo;
            fieldClass = fieldInfo.fieldClass;
            fieldAnnotation = fieldInfo.getAnnotation();
            if (fieldAnnotation != null && fieldDeserializer instanceof DefaultFieldDeserializer) {
              customDeserializer = ((DefaultFieldDeserializer) fieldDeserializer).customDeserilizer;
            }
         }
         Object fieldValue = null;


         if (fieldDeserializer != null) {
            char[] name_chars = fieldInfo.name_chars;
            //指定了自定义发序列化器,后续使用自定义序列化器处理
            if (customDeserializer && lexer.matchField(name_chars)) {
                        matchFieldtrue;
             // 基本类型快速路径匹配
             } else if (fieldClass == int.class || fieldClass == Integer.class) {
                //词法分析,解析int值
                int intVal = lexer.scanFieldInt(name_chars);
                if (intVal == 0 && lexer.matchStat == JSONLexer.VALUE_NULL) {
                    fieldValue = null;
                } else {
                    fieldValue = intVal;
                }
                if (lexer.matchStat > 0) {
                    matchFieldtrue;
                    valueParsedtrue;
                } else if (lexer.matchStat == JSONLexer.NOT_MATCH_NAME) {
                    //增加计算,记录未命中次数以调整匹配策略
                    notMatchCount++;
                    continue;
                }


           } else if(...){
           //省略其他基础类型处理  
           }
         }
         // 快速匹配失败,动态扫描字段名,通过符号表优化:返回的字符串可能是符号表中的缓存实例
         if (!matchField) {
            key = lexer.scanSymbol(parser.symbolTable);
            // $ref 引用处理
            if ("$ref" == key && context != null) {
                handleReferenceResolution(lexer, parser, context)
            }
            // @type 类型处理
            if ((typeKey != null && typeKey.equals(key))
                            || JSON.DEFAULT_TYPE_KEY == key) {
              //AutoType安全检查
              config.checkAutoType(typeName, expectClass, lexer.getFeatures());
              handleTypeNameResolution(lexer, parser, config, beanInfo, type, fieldName);
            }


         }
    }


    // 5.如果对象为空,则创建对象实例
    if (object == null && fieldInfo == null) {
        object = createInstance(parser, type);
        if (object == null) {
            return null;
        }
    }


    //6. 字段值设置
    for (Map.Entry<String, Object> entry : fieldValues.entrySet()) {
        FieldDeserializer fieldDeserializer = getFieldDeserializer(entry.getKey());
        if (fieldDeserializer != null) {
            fieldDeserializer.setValue(object, entry.getValue());
        }
     }


    return (T) object;
}

字符串解析阶段(词法分析)

JSONLexerBase内部维护词法解析状态机,实现词法分析核心逻辑,下面展示了Integer值类型处理源码:

    public int scanFieldInt(char[] fieldName) {
        matchStat = UNKNOWN;
        // 1. 字段名匹配阶段
        if (!charArrayCompare(fieldName)) {
            matchStat = NOT_MATCH_NAME;
            return 0;
        }
        
        int offset = fieldName.length;
        char chLocal = charAt(bp + (offset++));
        // 2. 负号处理
        final boolean negative = chLocal == '-';
        if (negative) {
            chLocal = charAt(bp + (offset++));
        }
        // 3. 数字解析核心算法
        int value;
        if (chLocal >= '0' && chLocal <= '9') {
            value = chLocal - '0';
            for (;;) {
                chLocal = charAt(bp + (offset++));
                if (chLocal >= '0' && chLocal <= '9') {
                    value = value * 10 + (chLocal - '0');// 十进制累加
                } else if (chLocal == '.') {
                    matchStat = NOT_MATCH; // 拒绝浮点数
                    return 0;
                } else {
                    break;
                }
            }
             // 4. 溢出检测
            if (value < 0 //
                    || offset > 11 + 3 + fieldName.length) {
                if (value != Integer.MIN_VALUE //
                        || offset != 17 //
                        || !negative) {
                    matchStat = NOT_MATCH;
                    return 0;
                }
            }
        } else {
            matchStat = NOT_MATCH;
            return 0;
        }
         // 5. JSON 结束符处理
        if (chLocal == ',') {
            bp += offset;
            this.ch = this.charAt(bp);
            matchStat = VALUE;
            token = JSONToken.COMMA;
            return negative ? -value : value;
        }
        
        if (chLocal == '}') {
             // ... 处理对象结束和嵌套结构
            chLocal = charAt(bp + (offset++));
            if (chLocal == ',') {
                token = JSONToken.COMMA;
                bp += offset;
                this.ch = this.charAt(bp);
            } else if (chLocal == ']') {
                token = JSONToken.RBRACKET;
                bp += offset;
                this.ch = this.charAt(bp);
            } else if (chLocal == '}') {
                token = JSONToken.RBRACE;
                bp += offset;
                this.ch = this.charAt(bp);
            } else if (chLocal == EOI) {
                token = JSONToken.EOF;
                bp += (offset - 1);
                ch = EOI;
            } else {
                matchStat = NOT_MATCH;
                return 0;
            }
            matchStat = END;
        } else {
            matchStat = NOT_MATCH;
            return 0;
        }
        
        return negative ? -value : value;
    }

类型安全检查(AutoType检查)

ParserConfig.java 中的checkAutoType方法对反序列化类型做黑白名单检查。

// ParserConfig.java - AutoType安全检查
public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {
    if (typeName == null) {
        return null;
    }
    
    if (typeName.length() >= 192 || typeName.length() < 3) {
        throw new JSONException("autoType is not support. " + typeName);
    }
    
    String className = typeName.replace('$''.');
    Class<?> clazz = null;
    
    final long BASIC = 0xcbf29ce484222325L;
    final long PRIME = 0x100000001b3L;
    
    final long h1 = (BASIC ^ className.charAt(0)) * PRIME;
    // hash code编码匹配性能优化
    if (h1 == 0xaf64164c86024f1aL) { 
        throw new JSONException("autoType is not support. " + typeName);
    }
    if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
        throw new JSONException("autoType is not support. " + typeName);
    }
    
    final long h3 = (((((BASIC ^ className.charAt(0)) 
                        * PRIME) 
                        ^ className.charAt(1)) 
                        * PRIME) 
                        ^ className.charAt(2)) 
                        * PRIME;
    
    if (autoTypeSupport || expectClass != null) {
        long hash = h3;
        for (int i = 3; i < className.length(); ++i) {
            hash ^= className.charAt(i);
            hash *= PRIME;
            if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
                throw new JSONException("autoType is not support. " + typeName);
            }
            if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
                clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
    }


    // ... 更多安全检查逻辑
    return clazz;
}

对象实例化过程

JavaBeanDeserializer.java中的createInstance方法创建对象实例:

// JavaBeanDeserializer.java - 对象实例化
protected Object createInstance(DefaultJSONParser parser, Type type) {
    if (type instanceof Class) {
        if (clazz.isInterface()) {
        // 接口类型使用Java反射创建实例
            Class<?> clazz = (Class<?>) type;
            ClassLoader loader = Thread.currentThread().getContextClassLoader();
            final JSONObject obj = new JSONObject();
            Object proxy = Proxy.newProxyInstance(loader, new Class<?>[] { clazz }, obj);
            return proxy;
        }
    }
    
    if (beanInfo.defaultConstructor == null && beanInfo.factoryMethod == null) {
        return null;
    }
    
    Object object;
    try {
    //通过构造器创建实例
        Constructor<?> constructor = beanInfo.defaultConstructor;
        if (beanInfo.defaultConstructorParameterSize == 0) {
            object = constructor.newInstance();
        } else {
            ParseContext context = parser.getContext();
            if (context == null || context.object == null) {
                throw new JSONException("can't create non-static inner class instance.");
            }


            final Class<?> enclosingClass = constructor.getDeclaringClass().getEnclosingClass();
            object = constructor.newInstance(context.object);
        }
    } catch (JSONException e) {
        throw e;
    } catch (Exception e) {
        throw new JSONException("create instance error, class " + clazz.getName(), e);
    }


    return object;
}

FieldDeserializer.java中的setValue方法通过反射实现字段设置:

// FieldDeserializer.java - 属性赋值的核心实现
public void setValue(Object objectObject value) {
    if (value == null && fieldInfo.fieldClass.isPrimitive()) {
        return;
    } else if (fieldInfo.fieldClass == String.class
            && fieldInfo.format != null
            && fieldInfo.format.equals("trim")) {
        value = ((String) value).trim();
    }
    
    try {
        Method method = fieldInfo.method;
        if (method != null) {
            if (fieldInfo.getOnly) {
                // 处理只读属性的特殊情况
                if (fieldInfo.fieldClass == AtomicInteger.class) {
                    AtomicInteger atomic = (AtomicInteger) method.invoke(object);
                    if (atomic != null) {
                        atomic.set(((AtomicInteger) value).get());
                    }
                } else if (Map.class.isAssignableFrom(method.getReturnType())) {
                    Map map = (Map) method.invoke(object);
                    if (map != null) {
                        map.putAll((Map) value);
                    }
                } else {
                    Collection collection = (Collection) method.invoke(object);
                    if (collection != null && value != null) {
                        collection.clear();
                        collection.addAll((Collection) value);
                    }
                }
            } else {
                // 通过setter方法赋值
                method.invoke(object, value);
            }
        } else {
            // 通过字段直接赋值
            final Field field = fieldInfo.field;
            if (field != null) {
                field.set(object, value);
            }
        }
    } catch (Exception e) {
        throw new JSONException("set property error, " + clazz.getName() + "#" + fieldInfo.name, e);
    }
}

4.4 反序列化小结

Fastjson通过JSON.parseObject()门面API调用DefaultJSONParser控制器,利用JSONLexer进行词法分析解析JSON字符串,经过AutoType安全检查后使用ASM字节码生成动态反序列化器或反射机制创建Java对象实例并逐字段赋值。

五、特性讲解

5.1 ASM性能优化

ASM 是 fastjson 类似于 JIT,在运行时把「反射调用」翻译成「直接字段访问 + 方法调用」的字节码,从而把序列化/反序列化性能提升 20% 以上,当然随着JVM对反射性能的优化性能差正在逐渐被缩小。下图是作者使用工具类读取的动态序列化/反序列化器源码片段。

5.2  AutoType机制

AutoType是 fastjson 的“动态多态还原”方案:

序列化时把具体子类名字写进 "@type",反序列化时先加载类 → 再调 setter → 完成还原。

 速度上“指针引用”即可定位序列化器,功能上靠 @type 字段把被擦除的泛型/接口/父类重新映射回具体实现。

在未开启AutoType机制情况下,在将store对象序列化成JSON串后,再反序列化为对象时由于字段的类型为接口无法转换成具体的Dog类型示例;开启AutoType机制后,序列化时将类型一并写入到JSON串内,后续进行反序列化时可以根据这个类型还原成具体的类型实例。

interface Animal {}


class Dog implements Animal {
    private String name;
    private double weight;


    //省略getter,setter
}


class PetStore {
    private Animal animal;
}




public static void main(String[] args) {
    Animal dog = new Dog("dodi"12);
    PetStore store = new PetStore(dog);
    String jsonString = JSON.toJSONString(store);
    PetStore petStore = JSON.parseObject(jsonString, PetStore.class);
    Dog parsedDog = (Dog) petStore.getAnimal();
}

public static void main(String[] args) {
    Animal dog = new Dog("dodi"12);
    PetStore store = new PetStore(dog);
    String jsonString = JSON.toJSONString(store, SerializerFeature.WriteClassName);
    PetStore petStore = JSON.parseObject(jsonString, PetStore.class);
    Dog parsedDog = (Dog) petStore.getAnimal();
}

AutoType 让 fastjson 在反序列化时根据 @type 字段动态加载任意类,这一“便利”却成为攻击者远程代码执行的快捷通道:通过把JdbcRowSetImpl等 JNDI 敏感类写进 JSON,服务端在调用 setter 的瞬间就会向外部 LDAP/RMI 服务器拉取恶意字节码,完成 RCE;而官方长期依赖“黑名单”堵漏,导致 1.2.25→1.2.80 出现 L 描述符、Throwable 二次反序列化、内部类等连续绕过,形成“补丁-绕过-再补丁”的猫鼠游戏, 虽然在1.2.68 引入 safeMode 但为了兼容性需要使用者手动开启 ,而且实现也不够健壮,开启safeMode仍有利用代码漏洞绕过检查风险,后续版本对safeMode加固并对已知安全漏洞清零,直到最新1.2.83版本安全问题也不能说彻底解决。

5.3 流式解析

Fastjson 提供一套 Streaming API,核心类JSONReader /JSONWriter,行业内惯称「流式解析」或「增量解析」,主要用于处理JSON大文件解析。技术上流式解析采用“拉模式(pull parsing)”,底层维护 8 KB 滑动缓冲,词法分析器(Tokenizer)把字节流切成 token 流,语法状态机根据 token 类型驱动反序列化器(ObjectReader)即时产出 Java 对象,对象一旦交付给用户代码处理后,内部引用立即释放。这种方式内存中不会保存所有对象,对象处理完即被丢弃,因此可以处理数据量远大于内存的数据,而不会出现OOM。下面是使用流式解析的示例代码:

// 依赖:com.alibaba:fastjson:1.2.83
try (JSONReader reader = new JSONReader(
        new InputStreamReader(
                new FileInputStream("huge-array.json"), StandardCharsets.UTF_8))) {
    reader.startArray();                 // 告诉解析器:根节点是 []
    while (reader.hasNext()) {           // 拉取下一条
        Order order = reader.readObject(Order.class); // 瞬时对象
        processOrder(order);//业务处理
        orderRepository.save(order);     // 立即落盘,内存即可回收
    }
    reader.endArray();
}

六、总结

Fastjson核心特性在于高速序列化/反序列化,利用ASM在运行时生成字节码动态创建解析器,减少反射;AutoType字段支持多态,却带来反序列化RCE风险,建议关闭AutoType,开启safeMode。选型建议:在选择JSON序列化框架时对于非极端性能要求推荐Jackson,或者使用Fastjson2,其改用LambdaMetafactory替换ASM,性能再提升30%,默认关闭AutoType安全性有保证。

参考资料:

往期回顾

1. 用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

2. 线程池ThreadPoolExecutor源码深度解析|得物技术

3. 基于浏览器扩展 API Mock 工具开发探索|得物技术

4. 破解gh-ost变更导致MySQL表膨胀之谜|得物技术

5. MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

文 /剑九

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

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

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

基于浏览器扩展 API Mock 工具开发探索|得物技术

作者 得物技术
2025年9月23日 10:13

一、前 言

在日常开发过程中,偶尔会遇到后端接口未完成或者某个环境出现问题需要根据接口返回来复现等等场景。刚好最近在学习浏览器插件的相关知识,并在此背景下开发了一款基于浏览器插件的 Mock 工具。该工具专注于 API 请求拦截和数据模拟,旨在帮助开发者提升开发效率,能够解决一些问题。

二、浏览器插件介绍

什么是浏览器插件

浏览器插件(Extensions 或 Add-ons)是一类运行于浏览器进程中的轻量级功能增强模块,其核心价值在于通过标准化接口实现对浏览器内核能力的深度整合与定制。根据 Mozilla 开发者文档定义,插件本质上是“能够修改和增强浏览器能力的应用程序”,Firefox、Chrome 等主流浏览器均采用 WebExtensions API 这一跨浏览器技术构建插件生态。与网页应用(Web App)需依赖浏览器标签页运行、原生应用(Native App)需独立安装的特性不同,插件以轻量化部署为显著特征——无需复杂设置即可直接在浏览器环境内运行,同时具备标签控制、网络请求拦截、本地存储访问等网页应用无法实现的底层能力。

Manifest V3 架构

Manifest V3 对浏览器插件的底层架构进行了颠覆性重构,主要体现在背景执行机制、网络请求控制和代码安全模型三个核心维度。以下从技术实现与设计动机角度进行全面对比:

关键架构变革:V3 采用"静态声明+内核级处理"模式替代 V2 的"动态脚本+插件自主控制"模式,通过浏览器内核直接介入关键流程(如网络拦截),在性能与安全性之间取得平衡。

核心配置差异示例

背景执行模块配置

// V2 持久化背景页配置"
background": {
  "scripts": ["background.js"],  
  "persistent": true             
}


// V3 Service Worker 配置
"background": {
   "service_worker": "background.js",  
   "type": "module"                   
}

网络请求规则配置

V3 需在 Manifest 中声明 DNR 权限及规则文件:

// V3 declarativeNetRequest 配置
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"],
"declarative_net_request": {
   "rule_resources": [
       {"id": "ruleset_1","enabled": true,"path": "rules.json"  }
    ]
}

三、Mock 插件实现的基本原理

请求拦截的核心原理

由于 Manifest V3 移除了webRequest API 的阻塞能力,declarativeNetRequest又无法重定向到自定义数据,于是方案上选择的是在页面上下文中重写原生 API,目前重写了fetch跟XMLHttpRequest。

// injected.js - 在页面环境中重写fetch
const originalFetch = window.fetch;
window.fetch = async function(url, options = {}) {
  // 1. 发送拦截请求到content script
  const response = await sendMockRequest(url, options);


  // 2. 如果有匹配的Mock规则,返回Mock数据
  if (response.shouldMock) {
    return new Response(
      JSON.stringify(response.mockData),
      {
        status: response.status,
        headers: response.headers
      }
    );
  }


  // 3. 否则执行原始请求
  return originalFetch.call(this, url, options);
};

脚本注入

通过多重注入策略 + 动态检测的方式实现, 直接引入会触发 Content Security Policy 阻止内联脚本执行。

// content.js
async function injectScript() {
  // 策略1: 内联脚本注入
  try {
    await injectInlineScript();
    if (await checkScriptActivation()) return;
  } catch (e) {
    console.warn('内联注入失败:', e);
  }


  // 策略2: 外部脚本注入
  try {
    await injectExternalScript();
    if (await checkScriptActivation()) return;
  } catch (e) {
    console.warn('外部注入失败:', e);
  }


  // 策略3: 最简单注入方式
  await attemptSimpleInjection();
}

四、整体实现架构与流程

项目结构设计

chrome-mock/
├── build-script/          # rollup 进行构建
├── static/                # 静态文件引入,如mockjs
├── debug/                 # 本地调试相关的页面
├── manifest.json          # 插件配置
├── background.js          # 核心逻辑处理
├── content.js            # 脚本注入和消息中转
├── injected.js           # 请求拦截实现
├── options.html          # 管理界面
├── options.js            # 管理界面逻辑
├── options.css           # 管理界面样式
├── popup.html            # 弹窗界面
├── popup.js              # 弹窗逻辑
├── popup.css             # 弹窗样式
└── icons/                # 图标资源

相关功能介绍

创建 Mock 规则

创建 Mock 规则提供了多种方式:

  • 如果是快速联调的场景,可以用得物API管理平台导入的方式。
  • 在排查某个环境问题,需要根据服务端接口返回数据复现的时候,可以通过在管理规则页面手动新建。

方法一:快捷操作弹窗配置

  • 打开 Mock 管理页面
    • 点击浏览器工具栏中的 Mock Frog 图标;
    • 选择"管理规则"或直接访问扩展选项页。
  • 添加新规则

方式一:复制粘贴得物API管理平台地址,点击导入,自动生成。这个弹窗比较小适合自动导入的场景,也可以点击上面 popup 的管理规则再进行操作。

如:https:/apiManage.test.com/project/interface?id=3xxxx&projectId=5xxxx

本地调用可能会有代理增加了前缀,系统会自动在路径前添加通配符 *

方式二:复制接口地址到匹配 URL 输入框中自行配置数据,这一步建议到管理规则中才施展的开。

支持根据接口平台的 JSON Schema 生成对应类型的 Mock 数据。

Mock 效果演示:

,时长00:14

方法二:管理规则页配置

由于 popup 界面大小有限,点击其他区域弹窗也会关闭,提供了一个比较详细的管理页可以修改添加规则。

规则列表:

新建 Mock 规则:

页面白名单管理

作用:针对所有页面都进行拦截可能会导致页面卡顿,影响整体性能,于是增加了白名单控制,控制 Mock 功能在哪些场景下才生效。同时也提供了全局生效模式,方便快速操作。

使用方法:

  • 开启白名单功能
    • 进入扩展设置页面
    • 找到"页面白名单"配置
    • 启用"使用页面白名单"
  • 添加白名单域名
支持格式:
- http://localhost:3000        # 完整URL
- *.example.com               # 通配符域名
- dev.company.com             # 具体域名
- http://dev.company.com      # 带协议的域名

请求日志

作用:记录所有 Mock 请求的详细信息,便于调试。

整体流程

五、总结与收获

本次插件开发过程中,探索了浏览器扩展插件的能力,学习了 Manifest V3 的新知识,掌握了浏览器插件开发相关技术点。随之而来带来的思考便是在 AI 的浪潮下,浏览器插件是否能有更多的可能。

另外,本次很多功能点的实现也依赖了 Cursor,探索浏览器插件的同时也深入的学习了Cursor的应用,感受到了Cursor从 0 到 1 搭建过程中的速度和效率。但是在后续需要对功能点进行改造时,使用效率并不是高,如何建立规则和组织话术,让 AI 更加规范、高效地辅助开发是一个值得深思的问题,当然通过 rules 是能够解决大部分问题。

六、未来展望

功能升级:在复杂业务场景中,往往需要Mock的接口都比较多。如果能监听页面所有请求,然后提供通过选中某个历史请求即可Mock接口的方式,就能快速实现接口的Mock。

能力拓展:探索在浏览器插件上结合 AI 能力的应用,基于规则跟用户的要求更快速的生成 Mock 数据。

往期回顾

1. 破解gh-ost变更导致MySQL表膨胀之谜|得物技术

2. MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

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

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

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

文 /段壹

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

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

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

破解gh-ost变更导致MySQL表膨胀之谜|得物技术

作者 得物技术
2025年9月18日 14:09

一、问题背景

业务同学在 OneDBA 平台进行一次正常 DDL 变更完成后(变更内容跟此次问题无关),发现一些 SQL 开始出现慢查,同时变更后的表比变更前的表存储空间膨胀了几乎 100%。经过分析和流程复现完整还原了整个事件,发现了 MySQL 在平衡 B+tree 页分裂方面遇到单行记录太大时的一些缺陷,整理分享。

为了能更好的说明问题背后的机制,会进行一些关键的“MySQL原理”和“当前DDL变更流程”方面的知识铺垫,熟悉的同学可以跳过。

本次 DDL 变更后带来了如下问题:

  • 变更后,表存储空间膨胀了几乎 100%;
  • 变更后,表统计信息出现了严重偏差;
  • 变更后,部分有排序的 SQL 出现了慢查。

现在来看,表空间膨胀跟统计信息出错是同一个问题导致,而统计信息出错间接导致了部分SQL出现了慢查,下面带着这些问题开始一步步分析找根因。

二、索引结构

B+tree

InnoDB 表是索引组织表,也就是所谓的索引即数据,数据即索引。索引分为聚集索引和二级索引,所有行数据都存储在聚集索引,二级索引存储的是字段值和主键,但不管哪种索引,其结构都是 B+tree 结构。

一棵 B+tree 分为根页、非叶子节点和叶子节点,一个简单的示意图(from Jeremy Cole)如下:

由于 InnoDB B+tree 结构高扇区特性,所以每个索引高度基本在 3-5 层之间,层级(Level)从叶子节点的 0 开始编号,沿树向上递增。每层的页面节点之间使用双向链表,前一个指针和后一个指针按key升序排列。

最小存储单位是页,每个页有一个编号,页内的记录使用单向链表,按 key 升序排列。每个数据页中有两个虚拟的行记录,用来限定记录的边界;其中最小值(Infimum)表示小于页面上任何 key 的值,并且始终是单向链表记录列表中的第一个记录;最大值(Supremum)表示大于页面上任何 key 的值,并且始终是单向链表记录列表中的最后一条记录。这两个值在页创建时被建立,并且在任何情况下不会被删除。

非叶子节点页包含子页的最小 key 和子页号,称为“节点指针”。

现在我们知道了我们插入的数据最终根据主键顺序存储在叶子节点(页)里面,可以满足点查和范围查询的需求。

页(page)

默认一个页 16K 大小,且 InnoDB 规定一个页最少能够存储两行数据,这里需要注意规定一个页最少能够存储两行数据是指在空间分配上,并不是说一个页必须要存两行,也可以存一行。

怎么实现一个页必须要能够存储两行记录呢? 当一条记录 <8k 时会存储在当前页内,反之 >8k 时必须溢出存储,当前页只存储溢出页面的地址,需 20 个字节(行格式:Dynamic),这样就能保证一个页肯定能最少存储的下两条记录。

溢出页

当一个记录 >8k 时会循环查找可以溢出存储的字段,text类字段会优先溢出,没有就开始挑选 varchar 类字段,总之这是 InnoDB 内部行为,目前无法干预。

建表时无论是使用 text 类型,还是 varchar 类型,当大小 <8k 时都是存储在当前页,也就是在 B+tree 结构中,只有 >8k 时才会进行溢出存储。

页面分裂

随着表数据的变化,对记录的新增、更新、删除;那么如何在 B+tree 中高效管理动态数据也是一项核心挑战。

MySQL InnoDB 引擎通过页面分裂和页面合并两大关键机制来动态调整存储结构,不仅能确保数据的逻辑完整性和逻辑顺序正确,还能保证数据库的整体性能。这些机制发生于 InnoDB 的 B+tree 索引结构内部,其具体操作是:

  • 页面分裂:当已满的索引页无法容纳新记录时,创建新页并重新分配记录。
  • 页面合并:当页内记录因删除/更新低于阈值时,与相邻页合并以优化空间。

深入理解上述机制至关重要,因为页面的分裂与合并将直接影响存储效率、I/O模式、加锁行为及整体性能。其中页面的分裂一般分为两种:

  • 中间点(mid point)分裂:将原始页面中50%数据移动到新申请页面,这是最普通的分裂方法。
  • 插入点(insert point)分裂:判断本次插入是否递增 or 递减,如果判定为顺序插入,就在当前插入点进行分裂,这里情况细分较多,大部分情况是直接插入到新申请页面,也可能会涉及到已存在记录移动到新页面,有有些特殊情况下还会直接插入老的页面(老页面的记录被移动到新页面)。

表空间管理

InnoDB的B+tree是通过多层结构映射在磁盘上的,从它的逻辑存储结构来看,所有数据都被有逻辑地存放在一个空间中,这个空间就叫做表空间(tablespace)。表空间由段(segment)、区(extent)、页(page)组成,搞这么多手段的唯一目的就是为了降低IO的随机性,保证存储物理上尽可能是顺序的。

三、当前DDL变更机制

在整个数据库平台(OneDBA)构建过程中,MySQL 结构变更模块是核心基础能力,也是研发同学在日常业务迭代过程中使用频率较高的功能之一。

主要围绕对表加字段、加索引、改属性等操作,为了减少这些操作对线上数据库或业务的影响,早期便为 MySQL 结构变更开发了一套基于容器运行的无锁变更程序,核心采用的是全量数据复制+增量 binlog 回放来进行变更,也是业界通用做法(内部代号:dw-osc,基于 GitHub 开源的 ghost 工具二次开发),主要解决的核心问题:

  • 实现无锁化的结构变更,变更过程中不会阻挡业务对表的读写操作。
  • 实现变更不会导致较大主从数据延迟,避免业务从库读取不到数据导致业务故障。
  • 实现同时支持大规模任务变更,使用容器实现使用完即销毁,无变更任务时不占用资源。

变更工具工作原理简单描述 (重要)

重点:

简单理解工具进行 DDL 变更过程中为了保证数据一致性,对于全量数据的复制与 binlog 回放是并行交叉处理,这种机制它有一个特点就是【第三步】会导致新插入的记录可能会先写入到表中(主键 ID 大的记录先写入到了表),然后【第二步】中复制数据后写入到表中(主键 ID 小的记录后写入表)。

这里顺便说一下当前得物结构变更整体架构:由于变更工具的工作原理需消费大量 binlog 日志保证数据一致性,会导致在变更过程中会有大量的带宽占用问题,为了消除带宽占用问题,开发了 Proxy 代理程序,在此基础之上支持了多云商、多区域本地化变更。

目前整体架构图如下:

四、变更后,表为什么膨胀?

原因说明

上面几个关键点铺垫完了,回到第一个问题,这里先直接说明根本原因,后面会阐述一下排查过程(有同学感兴趣所以分享一下,整个过程还是耗费不少时间)。

在『结构变更机制』介绍中,我们发现这种变更机制它有一个特点,就是【第三步】会导致新插入的记录可能会先写入到表中(主键 ID 大的记录先写入到了表),然后【第二步】中复制数据后写入到表中(主键 ID 小的记录)。这种写入特性叠加单行记录过大的时候(业务表单行记录大小 5k 左右),会碰到 MySQL 页分裂的一个瑕疵(暂且称之为瑕疵,或许是一个 Bug),导致了一个页只存储了 1 条记录(16k 的页只存储了 5k,浪费 2/3 空间),放大了存储问题。

流程复现

下面直接复现一下这种现象下导致异常页分裂的过程:

CREATE TABLE `sbtest` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `pad` varchar(12000),
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

然后插入两行 5k 大小的大主键记录(模拟变更时 binlog 回放先插入数据):

insert into sbtest values (10000, repeat('a',5120));
insert into sbtest values (10001, repeat('a',5120));

这里写了一个小工具打印记录对应的 page 号和 heap 号。

# ./peng
[pk:10000] page: 3 -> heap: 2
[pk:10001] page: 3 -> heap: 3

可以看到两条记录都存在 3 号页,此时表只有这一个页。

继续开始顺序插入数据(模拟变更时 copy 全量数据过程),插入 rec-1:

insert into sbtest values (1, repeat('a',5120));
# ./peng
[pk:1] page: 3 -> heap: 4
[pk:10000] page: 3 -> heap: 2
[pk:10001] page: 3 -> heap: 3

插入 rec-2:

insert into sbtest values (2, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 5 -> heap: 3

可以看到开始分裂了,page 3 被提升为根节点了,同时分裂出两个叶子节点,各自存了两条数据。此时已经形成了一棵 2 层高的树,还是用图表示吧,比较直观,如下:

插入 rec-3:

insert into sbtest values (3, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 5 -> heap: 3

示意图如下:

插入 rec-4:

insert into sbtest values (4, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 6 -> heap: 2

这里开始分裂一个新页 page 6,开始出现比较复杂的情况,同时也为后面分裂导致一个页只有 1 条数据埋下伏笔:

这里可以看到把 10001 这条记录从 page 5 上面迁移到了新建的 page 6 上面(老的 page 5 中会删除 10001 这条记录,并放入到删除链表中),而把当前插入的 rec-4 插入到了原来的 page 5 上面,这个处理逻辑在代码中是一个特殊处理,向右分裂时,当插入点页面前面有大于等于两条记录时,会设置分裂记录为 10001,所以把它迁移到了 page 6,同时会把当前插入记录插入到原 page 5。具体可以看 btr_page_get_split_rec_to_right 函数。

/* 这里返回true表示将行记录向右分裂:即分配的新page的hint_page_no为原page+1 */
ibool btr_page_get_split_rec_to_right(
/*============================*/
        btr_cur_t*        cursor,
        rec_t**           split_rec)
{
  page_t*        page;
  rec_t*        insert_point;
  
  // 获取当前游标页和insert_point
  page = btr_cur_get_page(cursor);
  insert_point = btr_cur_get_rec(cursor);
  
  /* 使用启发式方法:如果新的插入操作紧跟在同一页面上的前一个插入操作之后,
     我们假设这里存在一个顺序插入的模式。 */
  
  // PAGE_LAST_INSERT代表上次插入位置,insert_point代表小于等于待插入目标记录的最大记录位置
  // 如果PAGE_LAST_INSERT=insert_point意味着本次待插入的记录是紧接着上次已插入的记录,
  // 这是一种顺序插入模式,一旦判定是顺序插入,必然反回true,向右分裂
  if (page_header_get_ptr(page, PAGE_LAST_INSERT) == insert_point) {
    // 1. 获取当前insert_point的page内的下一条记录,并判断是否是supremum记录
    // 2. 如果不是,继续判断当前insert_point的下下条记录是否是supremum记录
    // 也就是说,会向后看两条记录,这两条记录有一条为supremum记录,
    // split_rec都会被设置为NULL,向右分裂
    rec_t*        next_rec;
    next_rec = page_rec_get_next(insert_point);
    
    if (page_rec_is_supremum(next_rec)) {
    split_at_new:
      /* split_rec为NULL表示从新插入的记录开始分裂,插入到新页 */
      *split_rec = nullptr;
    } else {
      rec_t* next_next_rec = page_rec_get_next(next_rec);
      if (page_rec_is_supremum(next_next_rec)) {
        goto split_at_new;
      }
      
      /* 如果不是supremum记录,则设置拆分记录为下下条记录 */


      /* 这样做的目的是,如果从插入点开始向上有 >= 2 条用户记录,
         我们在该页上保留 1 条记录,因为这样后面的顺序插入就可以使用
         自适应哈希索引,因为它们只需查看此页面上的记录即可对正确的
         搜索位置进行必要的检查 */
      
      *split_rec = next_next_rec;
    }
    
    return true;
  }
  
  return false;
}

插入 rec-5:

insert into sbtest values (5, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:5] page: 7 -> heap: 3
[pk:10000] page: 7 -> heap: 2
[pk:10001] page: 6 -> heap: 2

开始分裂一个新页 page 7,新的组织结构方式如下图:

此时是一个正常的插入点右分裂机制,把老的 page 5 中的记录 10000 都移动到了 page 7,并且新插入的 rec-5 也写入到了 page 7 中。到此时看上去一切正常,接下来再插入记录在当前这种结构下就会产生异常。

插入 rec-6:

insert into sbtest values (5, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:5] page: 7 -> heap: 3
[pk:6] page: 8 -> heap: 3
[pk:10000] page: 8 -> heap: 2
[pk:10001] page: 6 -> heap: 2

此时也是一个正常的插入点右分裂机制,把老的 page 7 中的记录 10000 都移动到了 page 8,并且新插入的 rec-6 也写入到了 page 8 中,但是我们可以发现 page 7 中只有一条孤零零的 rec-5 了,一个页只存储了一条记录。

按照代码中正常的插入点右分裂机制,继续插入 rec-7 会导致 rec-6 成为一个单页、插入 rec-8 又会导致 rec-7 成为一个单页,一直这样循环下去。

目前来看就是在插入 rec-4,触发了一个内部优化策略(具体优化没太去研究),进行了一些特殊的记录迁移和插入动作,当然跟记录过大也有很大关系。

排查过程

有同学对这个问题排查过程比较感兴趣,所以这里也整理分享一下,简化了一些无用信息,仅供参考。

表总行数在 400 百万,正常情况下的大小在 33G 左右,变更之后的大小在 67G 左右。

  • 首先根据备份恢复了一个数据库现场出来。
  • 统计了业务表行大小,发现行基本偏大,在 4-7k 之间(一个页只存了2行,浪费1/3空间)。
  • 分析了变更前后的表数据页,以及每个页存储多少行数据。
    • 发现变更之前数据页大概 200 百万,变更之后 400 百万,解释了存储翻倍。
    • 发现变更之前存储 1 行的页基本没有,变更之后存储 1 行的页接近 400 百万。

基于现在这些信息我们知道了存储翻倍的根本原因,就是之前一个页存储 2 条记录,现在一个页只存储了 1 条记录,新的问题来了,为什么变更后会存储 1 条记录,继续寻找答案。

  • 我们首先在备份恢复的实例上面进行了一次静态变更,就是变更期间没有新的 DML 操作,没有复现。但说明了一个问题,异常跟增量有关,此时大概知道跟变更过程中的 binlog 回放特性有关【上面说的回放会导致主键 ID 大的记录先写入表中】。
  • 写了个工具把 400 百万数据每条记录分布在哪个页里面,以及页里面的记录对应的 heap 是什么都记录到数据库表中分析,慢长等待跑数据。

  • 数据分析完后通过分析发现存储一条数据的页对应的记录的 heap 值基本都是 3,正常应该是 2,意味着这些页并不是一开始就存一条数据,而是产生了页分裂导致的。
  • 开始继续再看页分裂相关的资料和代码,列出页分裂的各种情况,结合上面的信息构建了一个复现环境。插入数据页分裂核心函数。
    • btr_cur_optimistic_insert:乐观插入数据,当前页直接存储
    • btr_cur_pessimistic_insert:悲观插入数据,开始分裂页
    • btr_root_raise_and_insert:单独处理根节点的分裂
    • btr_page_split_and_insert:分裂普通页,所有流程都在这个函数
    • btr_page_get_split_rec_to_right:判断是否是向右分裂
    • btr_page_get_split_rec_to_left:判断是否是向左分裂

heap

heap 是页里面的一个概念,用来标记记录在页里面的相对位置,页里面的第一条用户记录一般是 2,而 0 和 1 默认分配给了最大最小虚拟记录,在页面创建的时候就初始化好了,最大最小记录上面有简单介绍。

解析 ibd 文件

更快的方式还是应该分析物理 ibd 文件,能够解析出页的具体数据,以及被分裂删除的数据,分裂就是把一个页里面的部分记录移动到新的页,然后删除老的记录,但不会真正删除,而是移动到页里面的一个删除链表,后面可以复用。

五、变更后,统计信息为什么差异巨大?

表统计信息主要涉及索引基数统计(也就是唯一值的数量),主键索引的基数统计也就是表行数,在优化器进行成本估算时有些 SQL 条件会使用索引基数进行抉择索引选择(大部分情况是 index dive 方式估算扫描行数)。

InnoDB 统计信息收集算法简单理解就是采样叶子节点 N 个页(默认 20 个页),扫描统计每个页的唯一值数量,N 个页的唯一值数量累加,然后除以N得到单个页平均唯一值数量,再乘以表的总页面数量就估算出了索引总的唯一值数量。

但是当一个页只有 1 条数据的时候统计信息会产生严重偏差(上面已经分析出了表膨胀的原因就是一个页只存储了 1 条记录),主要是代码里面有个优化逻辑,对单个页的唯一值进行了减 1 操作,具体描述如下注释。本来一个页面就只有 1 条记录,再进行减 1 操作就变成 0 了,根据上面的公式得到的索引总唯一值就偏差非常大了。

static bool dict_stats_analyze_index_for_n_prefix(
    ...
    // 记录页唯一key数量
    uint64_t n_diff_on_leaf_page;
    
    // 开始进行dive,获取n_diff_on_leaf_page的值
    dict_stats_analyze_index_below_cur(pcur.get_btr_cur(), n_prefix,
                                       &n_diff_on_leaf_page, &n_external_pages);
    
    /* 为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。
    一次是某个页面的最后一个值,一次是另一个页面的第一个值。请考虑以下示例:
    Leaf level:
    page: (2,2,2,2,3,3)
    ... 许多页面类似于 (3,3,3,3,3,3)...
    page: (3,3,3,3,5,5)
    ... 许多页面类似于 (5,5,5,5,5,5)...
    page: (5,5,5,5,8,8)
    page: (8,8,8,8,9,9)
    我们的算法会(正确地)估计平均每页有 2 条不同的记录。
    由于有 4 页 non-boring 记录,它会(错误地)将不同记录的数量估计为 8 条
    */ 
    if (n_diff_on_leaf_page > 0) {
      n_diff_on_leaf_page--;
    }
    
    // 更新数据,在所有分析的页面上发现的不同键值数量的累计总和
    n_diff_data->n_diff_all_analyzed_pages += n_diff_on_leaf_page;
)

可以看到PRIMARY主键异常情况下统计数据只有 20 万,表有 400 百万数据。正常情况下主键统计数据有 200 百万,也与表实际行数差异较大,同样是因为单个页面行数太少(正常情况大部分也只有2条数据),再进行减1操作后,导致统计也不准确。

MySQL> select table_name,index_name,stat_value,sample_size from mysql.innodb_index_stats where database_name like 'sbtest' and TABLE_NAME like 'table_1' and stat_name='n_diff_pfx01';
+-------------------+--------------------------------------------+------------+-------------+
| table_name        | index_name                                 | stat_value | sample_size |
+-------------------+--------------------------------------------+------------+-------------+
| table_1           | PRIMARY                                    |     206508 |          20 |
+-------------------+--------------------------------------------+------------+-------------+
11 rows in set (0.00 sec)

优化

为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。

这里应该是可以优化的,对于主键来说是不是可以判断只有一个字段时不需要进行减1操作,会导致表行数统计非常不准确,毕竟相邻页不会数据重叠。

最低限度也需要判断单个页只有一条数据时不需要减1操作。

六、统计信息与慢SQL之间的关联关系?

当前 MySQL 对大部分 SQL 在评估扫描行数时都不再依赖统计信息数据,而是通过一种 index dive 采样算法实时获取大概需要扫描的数据,这种方式的缺点就是成本略高,所以也提供有参数来控制某些 SQL 是走 index dive 还是直接使用统计数据。

另外在SQL带有 order by field limit 时会触发MySQL内部的一个关于 prefer_ordering_index 的 ORDER BY 优化,在该优化中,会比较使用有序索引和无序索引的代价,谁低用谁。

当时业务有问题的慢 SQL 就是被这个优化干扰了。

# where条件
user_id = ? and biz = ? and is_del = ? and status in (?) ORDER BY modify_time limit 5


# 表索引
idx_modify_time(`modify_time`)
idx_user_biz_del(`user_id`,`biz`, `is_del`)

正常走 idx_user_biz_del 索引为过滤性最好,但需要对 modify_time 字段进行排序。

这个优化机制就是想尝试走 idx_modify_time 索引,走有序索引想避免排序,然后套了一个公式来预估如果走 idx_modify_time 有序索引大概需要扫描多少行?公式非常简单直接:表总行数 / 最优索引的扫描行数 * limit。

  • 表总行数:也就是统计信息里面主键的 n_rows
  • 最优索引的扫描行数:也就是走 idx_user_biz_del 索引需要扫描的行数
  • limit:也就是 SQL 语句里面的 limit 值

使用有序索引预估的行数对比最优索引的扫描行数来决定使用谁,在这种改变索引的策略下,如果表的总行数估计较低(就是上面主键的统计值),会导致更倾向于选择有序索引。

但一个最重要的因素被 MySQL 忽略了,就是实际业务数据分布并不是按它给的这种公式来,往往需要扫描很多数据才能满足 limit 值,造成慢 SQL。

七、如何临时解决该问题?

发现问题后,可控的情况下选择在低峰期对表执行原生 alter table xxx engine=innodb 语句, MySQL 内部重新整理了表空间数据,相关问题恢复正常。但这个原生 DDL 语句,虽然变更不会产生锁表,但该语句无法限速,同时也会导致主从数据较大延迟。

为什么原生 DDL 语句可以解决该问题?看两者在流程上的对比区别。

alter table xxx engine=innodb变更流程 当前工具结构变更流程
1. 建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。
  1. 拷贝全量数据:将目标表中的全量数据同步至临时表。
  2. 增量DML临时存储在一个缓冲区内。
  3. 全量数据复制完成后,开始应用增量DML日志。
  4. 切换新旧表:重命名原表作为备份,再用临时表替换原表。
  5. 变更完成 | 1. 创建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。
  6. 拷贝全量数据:将目标表中的全量数据同步至临时表。
  7. 解析Binlog并同步增量数据: 将目标表中的增量数据同步至临时表。
  8. 切换新旧表:重命名原表作为备份,再用临时表替换原表。
  9. 变更完成 |

可以看出结构变更唯一不同的就是增量 DML 语句是等全量数据复制完成后才开始应用,所以能修复表空间,没有导致表膨胀。

八、如何长期解决该问题?

关于业务侧的改造这里不做过多说明,我们看看从变更流程上面是否可以避免这个问题。

既然在变更过程中复制全量数据和 binlog 增量数据回放存在交叉并行执行的可能,那么如果我们先执行全量数据复制,然后再进行增量 binlog 回放是不是就可以绕过这个页分裂问题(就变成了跟 MySQL 原生 DDL 一样的流程)。

变更工具实际改动如下图:

这样就不存在最大记录先插入到表中的问题,丢弃的记录后续全量复制也同样会把记录复制到临时表中。并且这个优化还能解决需要大量回放 binlog 问题,细节可以看看 gh-ost 的 PR-1378。

九、总结

本文先介绍了一些关于 InnoDB 索引机制和页溢出、页分裂方面的知识;介绍了业界通用的 DDL 变更工具流程原理。

随后详细分析了变更后表空间膨胀问题根因,主要是当前变更流程机制叠加单行记录过大的时候(业务表单行记录大小 5k 左右),会碰到 MySQL 页分裂的一个瑕疵,导致了一个页只存储了 1 条记录(16k 的页只存储了 5k,浪费 2/3 空间),导致存储空间膨胀问题。

最后分析了统计信息出错的原因和统计信息出错与慢 SQL 之间的关联关系,以及解决方案。

全文完,感谢阅读。

往期回顾

  1. MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

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

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

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

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

文 / 东青

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

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

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

MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

作者 得物技术
2025年9月16日 14:17

一、前 言

本文核心介绍,为何业界会有这样的说法?—— “MySQL单表存储数据量最好别超过千万级别”

当然这里是有前提条件的,也是我们最常使用到的:

  • InnoDB存储引擎;
  • 使用的是默认索引数据结构——B+树;
  • 正常普通表数据(列数量控制在几个到一二十个,普通字段类型及长度)。

接下来咱们就探究一下原因,逐步揭开答案。

二、MySQL是如何存储数据的?

核心结构:B+树 + 16KB数据页

这里如下,建一张普通表user:

CREATE TABLE `user` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(100NOT NULL DEFAULT '' COMMENT '名字',
  `age` int(11NOT NULL DEFAULT '0' COMMENT '年龄',
  PRIMARY KEY (`id`),
  KEY `idx_age` (`age`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

数据页(Page)

介绍

InnoDB存储的最小单位,固定为16KB 。每页存储表数据(行记录)、索引、元信息等。数据加载到内存时以页为单位,减少磁盘I/O次数。

页的结构

假设我们有这么一张user数据表。其中id是唯一主键。这看起来的一行行数据,为了方便,我们后面就叫它们record吧。这张表看起来就跟个excel表格一样。excel的数据在硬盘上是一个xx.excel的文件。而上面user表数据,在硬盘上其实也是类似,放在了user.ibd文件下。含义是user表的innodb data文件,又叫表空间。虽然在数据表里,它们看起来是挨在一起的。但实际上在user.ibd里他们被分成很多小份的数据页,每份大小16k。类似于下面这样。

ibd文件内部有大量的页,我们把视角聚焦一下,放到页上面。整个页16k,不大,但record这么多,一页肯定放不下,所以会分开放到很多页里。并且这16k,也不可能全用来放record对吧。因为record们被分成好多份,放到好多页里了,为了唯一标识具体是哪一页,那就需要引入页号(其实是一个表空间的地址偏移量)。同时为了把这些数据页给关联起来,于是引入了前后指针,用于指向前后的页。这些都被加到了页头里。页是需要读写的,16k说小也不小,写一半电源线被拔了也是有可能发生的,所以为了保证数据页的正确性,还引入了校验码。这个被加到了页尾。那剩下的空间,才是用来放我们的record的。而record如果行数特别多的话,进入到页内时挨个遍历,效率也不太行,所以为这些数据生成了一个页目录,具体实现细节不重要。只需要知道,它可以通过二分查找的方式将查找效率从O(n) 变成O(lgn)

 

从页到索引—B+树索引

如果想查一条record,我们可以把表空间里每一页都捞出来(全表扫描),再把里面的record捞出来挨个判断是不是我们要找的。行数量小的时候,这么操作也没啥问题。行数量大了,性能就慢了,于是为了加速搜索,我们可以在每个数据页里选出主键id最小的record,而且只需要它们的主键id和所在页的页号。组成新的record,放入到一个新生成的一个数据页中,这个新数据页跟之前的页结构没啥区别,而且大小还是16k。但为了跟之前的数据页进行区分。数据页里加入了页层级(page level) 的信息,从0开始往上算。于是页与页之间就有了上下层级的概念,就像下面这样。

突然页跟页之间看起来就像是一棵倒过来的树了。也就是我们常说的B+ 树索引。最下面那一层,page level 为0,也就是所谓的叶子结点,其余都叫非叶子结点。上面展示的是两层的树,如果数据变多了,我们还可以再通过类似的方法,再往上构建一层。就成了三层的树。

  • 聚簇索引:数据按主键组织成一棵B+树。叶子节点存储完整行数据 ,非叶子节点存储主键值+指向子页的指针(类似目录)。
  • 二级索引:叶子节点存储主键值,查询时需回表(根据主键回聚簇索引查数据)。
  • 行格式:如COMPACT格式,行数据包含事务ID、回滚指针、列值等信息。行大小影响单页存储的行数

存入数据如下

比如表数据已存在id为1-10的数据存储,简单比方如下:

然后需要插入id=11的数据:

  • 加载1号数据页入内存,分析判定;
  • id=11的数据大于id=10,那么锁定页号5,判定5号页是否还可以存下数据11;
  • 可以存下,将id=11的数据写入到5号页中。

关键原理总结

所有数据通过B+树有序组织,数据存储在数据页上,页与页之间以双向链表连接,非叶子节点提供快速定位路径,叶子节点存储实际的数据。 

三、MySQL是如何查询到数据的?

上面我们已经介绍了MySQL中使用页存储数据,以及B+树索引数据的结构,那现在我们就可以通过这样一棵B+树加速查询。

**举个例子:select ***

from table where id = 5

比方说我们想要查找行数据5。会先从顶层页的record们入手。record里包含了主键id和页号(页地址)

如下图所示,左边2号页最小id是1,向右3号页最小id是4,然后4号页最小是7,最后5号页最小是10。

那id=5的数据如果存在,5大于4小于7,那必定在3号页里面。于是顺着的record的页地址就到了3号数据页里,于是加载3号数据页到内存。在数据页里找到id=5的数据行,完成查询。

另外需要注意的是,上面的页的页号并不是连续的,它们在磁盘里也不一定是挨在一起的。这个过程中查询了2个页(1号跟3号),如果这三个页都在磁盘中(没有被提前加载到内存中),那么最多需要经历两次磁盘IO查询,它们才能被加载到内存中。(如果考虑1号如果是root常驻内存,那么需要磁盘IO一次即可定位到)。

查询步骤总结

以聚簇索引搜索为例(假设id是主键):

  • 从根页开始搜索 :

加载根页(常驻内存)到Buffer Pool,根据指针找到下一层节点。

  • 逐层定位叶子节点 :

在非叶子节点页(存储主键+指针)中二分查找 ,定位id=5所在范围的子页(如页A)。

重复此过程,直到叶子节点页。

  • 叶子节点二分查找 :

在叶子页内通过主键二分查找定位到行记录,返回完整数据。

I/O次数分析 :

  • 树高为3时:根页 + 中间页 + 叶子页 = 3次磁盘I/O (若页不在内存中)。
  • B+树矮胖特性 :3层即可支撑千万级数据(接下来分析),是高效查询的基础。

四、2000万这个上限值如何算出来的?

在我们清楚了MySQL是如何存储及查询数据后,那么2000万这个数值又是如何得来的呢?超过2000万比如存储一亿数据会如何?

B+树承载的记录数量

从上面的结构里可以看出B+树的最末级叶子结点里放了record数据。而非叶子结点里则放了用来加速查询的索引数据。也就是说同样一个16k的页,非叶子节点里每一条数据都指向一个新的页,而新的页有两种可能。

  • 如果是末级叶子节点的话,那么里面放的就是一行行record数据。
  • 如果是非叶子节点,那么就会循环继续指向新的数据页。

假设

  • 非叶子节点内指向其他内存页的指针数量为x(非叶子节点指针扇出值)
  • 叶子节点内能容纳的record数量为y(叶子节点单页行数)
  • B+树的层数为z(树高)

那这棵B+树放的行数据总量等于 (x ^ (z-1)) * y

核心公式:单表最大行数 = 非叶节点扇出指针数 ^ (树高-1) × 单页行数

非叶子节点指针扇出值—x 怎么算?

我们回去看数据页的结构。

非叶子节点里主要放索引查询相关的数据,放的是主键和指向页号。

  • 主键假设是bigint(8Byte),而页号在源码里叫FIL_PAGE_OFFSET(4Byte),那么非叶子节点里的一条数据是12Byte左右。
  • 整个数据页16k, 页头页尾那部分数据全加起来大概128Byte,加上页目录毛估占1k吧。那剩下的15k除以12Byte,等于1280,也就是可以指向x=1280页。

我们常说的二叉树指的是一个结点可以发散出两个新的结点。m叉树一个节点能指向m个新的结点。这个指向新节点的操作就叫扇出(fanout) 。而上面的B+树,它能指向1280个新的节点,恐怖如斯,可以说扇出非常高了。

单页行数—y的计算

叶子节点和非叶子节点的数据结构是一样的,所以也假设剩下15kb可以发挥。

叶子节点里放的是真正的行数据。假设一条行数据1kb,所以一页里能放y=15行

行总数计算

回到 (x ^ (z-1)) * y 这个公式。

已知x=1280,y=15。

假设B+树是两层,那z=2。则是(1280 ^ (2-1)) * 15 ≈ 2w

假设B+树是三层,那z=3。则是 (1280 ^ (3-1)) * 15 ≈ 2.5kw

这个2.5kw,就是我们常说的单表建议最大行数2kw的由来。 毕竟再加一层,数据就大得有点离谱了。三层数据页对应最多三次磁盘IO,也比较合理。

  • 临界点 :当行数突破约2000万时,树高可能从3层变为4层:
  • 树高=4时:最大行数 ≈ 1280^3 × 15 结果已超过百亿(远大于2000万)
  • 性能断崖 :树高从3→4,查询I/O次数从3次增至4次 (多一次磁盘寻址),尤其在回表查询、高并发、深分页时性能骤降。

行数超一亿就慢了吗?

上面假设单行数据用了1kb,所以一个数据页能放个15行数据。

如果我单行数据用不了这么多,比如只用了250byte。那么单个数据页能放60行数据。

那同样是三层B+树,单表支持的行数就是 (1280 ^ (3-1)) * 60 ≈ 1个亿。

你看我一个亿的数据,其实也就三层B+树,在这个B+树里要查到某行数据,最多也是三次磁盘IO。所以并不慢。

B树承载的记录数量

我们都知道,现在MySQL的索引都是B+树,而有一种树,跟B+树很像,叫B树,也叫B-树

它跟B+树最大的区别在于,B+树只在末级叶子结点处放数据表行数据,而B树则会在叶子和非叶子结点上都放。

于是,B树的结构就类似这样:

B树将行数据都存在非叶子节点上,假设每个数据页还是16kb,掐头去尾每页剩15kb,并且一条数据表行数据还是占1kb,就算不考虑各种页指针的情况下,也只能放个15条数据。数据页扇出明显变少了

计算可承载的总行数的公式也变成了一个等比数列

15 + 15^2 +15^3 + ... + 15^z

其中z还是层数的意思。

为了能放2kw左右的数据,需要z>=6。也就是树需要有6层,查一次要访问6个页。假设这6个页并不连续,为了查询其中一条数据,最坏情况需要进行6次磁盘IO

而B+树同样情况下放2kw数据左右,查一次最多是3次磁盘IO

磁盘IO越多则越慢,这两者在性能上差距略大。

为此,B+树比B树更适合成为MySQL的索引

五、总结:生死博弈的核心

B+树叶子和非叶子结点的数据页都是16k,且数据结构一致,区别在于叶子节点放的是真实的行数据,而非叶子结点放的是主键和下一个页的地址。

B+树一般有两到三层,由于其高扇出,三层就能支持2kw以上的数据,且一次查询最多1~3次磁盘IO,性能也还行。

存储同样量级的数据,B树比B+树层级更高,因此磁盘IO也更多,所以B+树更适合成为MySQL索引。

索引结构不会影响单表最大行数,2kw也只是推荐值,超过了这个值可能会导致B+树层级更高,影响查询性能。

单表最大值还受主键大小和磁盘大小限制。

16KB页与B+树的平衡 :页大小限制了单页行数和指针数,B+树通过多阶平衡确保低树高。

2000万不是绝对 :若行小于1KB(如只存ID),上限可到5000万+;若行较大(如含大字段),可能500万就性能下降。

优化建议:

  • 控制单行大小(避免TEXT/BLOB直接入表)。
  • 分库分表:单表接近千万级时提前规划。
  • 冷热分离:历史数据归档。

本质:通过页大小和B+树结构,MySQL在磁盘I/O和内存效率之间取得平衡。超出平衡点时,性能从“平缓下降”变为“断崖下跌”。

六、拓展问题

为啥设计单页大小16k?

MySQL索引采用的是B+树数据结构,每个叶子节点(叶子块)存储一个索引条目的信息。而MySQL使用的是页式存储(Paged storage)技术,将磁盘上的数据划分为一个个固定大小的页面,每个页面包含若干个索引条目。

为了提高索引查询效率和降低磁盘I/O的频率,MySQL设置了16KB的单页大小。这是因为在MySQL中:

  • 内存大小限制:MySQL的索引需要放在内存中进行查询,如果页面过大,将导致索引无法完全加载到内存中,从而影响查询效率。
  • 磁盘I/O限制: 当需要查询一个索引时,MySQL需要把相关的页面加载到内存中进行处理,如果页面过大,将增加磁盘I/O的开销,降低查询效率。
  • 索引效率限制:在B+树数据结构中,每个叶子节点存储着一个索引条目,因此如果每个页面能够存放更多索引条目,就可以减少B+树结构的深度,从而提高索引查询效率。

综上所述,MySQL索引单页大小设置为16KB可以兼顾内存大小、磁盘I/O和索引查询效率等多方面因素,是一种比较优化的方案。需要注意的是,对于某些特殊的应用场景,可能需要根据实际情况对单页大小进行调整。

字符串怎么做索引?

在MySQL中,可以通过B+树索引结构对字符串类型的列进行排序。具体来说,当使用B+树索引进行排序时,MySQL会根据字符串的字典序(Lexicographic Order)进行排序。

字典序是指将字符串中的每个字符依次比较,直到找到不同的字符为止。如果两个字符串在相同的位置上具有不同的字符,则以这两个字符的ASCII码值比较大小,并按照升序或降序排列。例如,字符串"abc"和"def"比较大小时,先比较'a'和'd'的ASCII码,因为'd'的ASCII码大于'a',所以"def"大于"abc"。

需要注意的是,如果对长字符串进行排序,可能会影响索引查询的性能,因此可以考虑使用前缀索引或全文索引来优化。同时,在实际开发中,还需要注意选择适当的字符集和排序规则,以确保排序结果正确和稳定。

中文字符串怎么做索引?

中文字符串排序在MySQL中可以使用多种方式,最常见的有以下两种:

  • 按拼音排序:对于中文字符串,可以按照拼音进行排序。可以使用拼音排序插件,如pinyin或zhuyin插件,来实现中文字符串按照拼音进行排序。这些插件会将中文字符串转换为拼音或注音后,再进行排序。

例如,先安装pinyin插件:

INSTALL PLUGIN pinyin SONAME 'ha_pinyin.so';

然后创建对应的索引并按拼音排序:

CREATE INDEX idx_name_pinyin ON mytable(name) USING BTREE WITH PARSER pinyin;
SELECT * FROM mytable ORDER BY name COLLATE pinyin;
  • 按Unicode码点排序:可以使用UTF-8字符集,并选择utf8mb4_unicode_ci排序规则,在使用此排序规则时,MySQL会按照Unicode码点进行排序,适合于较为通用的中文字符串排序需求。

例如:

CREATE INDEX idx_name_unicode ON mytable(name) USING BTREE;
SELECT * FROM mytable ORDER BY name COLLATE utf8mb4_unicode_ci;

需要注意的是,不同的排序方式可能会对性能产生影响,因此需要根据具体需求选择合适的排序方式,并进行必要的测试和验证。同时,在进行中文字符串排序时,还需要考虑到中文字符的复杂性,例如同音字、繁简体等问题,以确保排序结果正确和稳定。

索引字段的长度有限制吗?

在MySQL中,索引的长度通常是由三个因素决定的:数据类型、字符集和存储引擎。不同的数据类型、字符集和存储引擎所支持的最大索引长度也有所不同。

一般情况下,索引的长度不应该超过存储引擎所支持的最大索引长度。在InnoDB存储引擎中,单个索引所能包含的最大字节数为767个字节(前缀索引除外)。如果索引的长度超过了最大长度,则会导致创建索引失败。因此,在设计表结构时,需要根据索引列的数据类型和字符集等因素,合理设置索引长度,以充分利用索引的优势。

对于字符串类型的索引,还需要注意以下几点:

  • 对于UTF-8字符集,每个字符占用1-4个字节,因此索引长度需要根据实际情况进行计算。例如,一个VARCHAR(255)类型的列在utf8mb4字符集下的最大长度为255*4=1020个字节。
  • 可以使用前缀索引来减少索引的大小,提高索引查询效率。在创建前缀索引时需要指定前缀长度。例如,可以在创建索引时使用name(10)来指定name列的前10个字符作为索引。
  • 在使用全文索引对字符串进行搜索时,MySQL会将文本内容分割成单个词汇后建立倒排索引。在建立索引时需要考虑到中英文分词的问题,以确保全文索引的准确性和查询效率。

综上所述,索引的长度需要根据数据类型、字符集和存储引擎等多个因素进行综合考虑,并合理设置索引长度,以提高索引查询效率和利用率。

往期回顾

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

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

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

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

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

文 / 太空

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

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

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

❌
❌