普通视图

发现新文章,点击刷新页面。
昨天以前首页

TypeScript 数组去重的 20 种实现方式,学会用不同思路解决问题

作者 刀法如飞
2026年5月8日 19:16

TypeScript 数组去重的 20 种实现方式,用不同思路解决问题

数组去重是最常见的编程算法,非常简单,但也可以有很多的实现方案。TypeScript 在 JavaScript 的基础上加了静态类型,让通用工具函数可以用泛型写一次、对所有可比较类型可用。本文整理 TS 数组去重的 20 种写法,按 5 个策略分类。AI时代,可以不手写代码了,但需要知道代码背后的原理,这样才能更好地指导AI编程。

为什么性能差异这么大?

最简单的写法,新建一个数组,把不在结果里的添加进去。

function unique<T>(arr: T[]): T[] {
  const result: T[] = []
  for (const item of arr) {
    // includes 是 O(n) 线性扫描,整体则是 O(n²)
    if (!result.includes(item)) {
      result.push(item)
    }
  }
  return result
}

本文源码:github.com/microwind/a…*

问题在于每次 includes 都要全量扫一遍 result,复杂度是 O(n²)。

优化思路:换一种判重方式

  • Set / Map O(1) 查询:new Set(arr)
  • 排序 O(n log n):相同元素相邻后扫一遍
  • filter + 闭包:在函数式管道里携带"已见"状态
  • JSON 序列化:处理对象、嵌套数组等不可哈希元素
  • 递归:换种表达方式,本质仍是上面的思路

TS 相比 JS 的优势

  • 泛型 <T>:写一次、对所有类型类型安全可用
  • 类型约束T extends string | number 限定基本类型,避免对象误用 Object 字面量
  • 编译期校验:传入错误类型立即报错,不会在运行时才崩

推荐方案

需求 代码 性能 保序
一行最简 [...new Set<T>(arr)] O(n)
函数式 + Set arr.filter(x => !seen.has(x) && seen.add(x)) O(n)
按字段去重 [...new Map(arr.map(x => [x.id, x])).values()] O(n)
对象数组 JSON.stringify 作为 Set 的键 O(n×m)

第1类:基础循环(方法1-6)

策略原理:不用任何内置数组方法,纯靠下标、嵌套循环、indexOf 这种"原始"手段完成去重。每一步判重都是 O(n),整体 O(n²)。

适用场景:教学、面试手撕。生产代码不建议使用。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[取下一个元素]
    B --> C{遍历结果数组<br/>是否已存在?}
    C -->|否| D[push 追加]
    C -->|是| E[跳过]
    D --> F([继续])
    E --> F
    F --> B

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#3A86FF,color:#fff,stroke:#2b63c4,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class B,D,E step
    class C check
// 方法1:双循环索引比较——i 与左侧每个 j 比对
static unique1<T>(arr: T[]): T[] {
  const result: T[] = []
  for (let i = 0, l = arr.length; i < l; i++) {
    for (let j = 0; j <= i; j++) {
      if (arr[i] === arr[j]) {
        // i === j 表示前面没有相同值,是首次出现
        if (i === j) result.push(arr[i])
        break
      }
    }
  }
  return result
}

// 方法2:新建数组 + includes 检查
static unique2<T>(arr: T[]): T[] {
  const result: T[] = []
  for (const item of arr) {
    // includes 是 O(n) 线性扫描,每个元素都要扫描一次,整体是 O(n²)
    if (!result.includes(item)) {
      result.push(item)
    }
  }
  return result
}

// 方法3:从后往前原地 splice
static unique3<T>(arr: T[]): T[] {
  let l = arr.length
  while (l-- > 0) {
    // 从后往前遍历,避免删除后索引变化导致跳过元素
    // 每个元素都要扫描一次,整体是 O(n²)
    for (let i = 0; i < l; i++) {
      if (arr[l] === arr[i]) {
        arr.splice(l, 1)
        break
      }
    }
  }
  return arr
}

// 方法4:从前往后原地 splice(删后面相同项)
static unique4<T>(arr: T[]): T[] {
  let l = arr.length
  for (let i = 0; i < l; i++) {
    // 从前往后遍历,每个元素都要扫描一次,整体是 O(n²)
    for (let j = i + 1; j < l; j++) {
      if (arr[i] === arr[j]) {
        arr.splice(j, 1)
        j--; l--
      }
    }
  }
  return arr
}

// 方法5:forEach + indexOf
// indexOf 返回首次出现下标,等于当前下标即首次
static unique5<T>(arr: T[]): T[] {
  const result: T[] = []
  // forEach 是 O(n) 线性扫描,每个元素都要扫描一次
  arr.forEach((item, i) => {
    if (arr.indexOf(item) === i) result.push(item)
  })
  return result
}

// 方法6:双重 while 倒序 splice
static unique6<T>(arr: T[]): T[] {
  let l = arr.length
  while (l-- > 0) {
    let i = l
    // 从后往前遍历,每个元素都要扫描一次,整体是 O(n²)
    while (i-- > 0) {
      if (arr[l] === arr[i]) {
        arr.splice(l, 1)
        break
      }
    }
  }
  return arr
}

所有泛型方法的 T 不需要额外约束——=== 比较对所有 TS 类型都有效(虽然引用类型只比指针)。


第2类:内置数组方法(方法7-11)

策略原理:JavaScript 数组自带 filterreduceforEach 等高阶方法,可以把"判重 + 收集"写成函数式风格。注意 indexOf / includes 仍是 O(n),需要用 Set<T> 闭包才能压到 O(n)。

适用场景:现代 TS 工程的常态写法。可读性高,链式组合方便。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[filter / reduce 管道]
    B --> C{选择策略}
    C -->|indexOf 判重| D["O(n²)"  但简洁]
    C -->|Set 闭包判重| E["O(n)" 推荐使用]
    C -->|对象键| F["O(n)" 但有类型陷阱]
    D --> G([新数组])
    E --> G
    F --> G

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#0F3460,color:#fff,stroke:#0a2647,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,G start
    class B,D,E,F step
    class C check
// 方法7:filter + indexOf 一行经典
// indexOf 返回首次出现下标,等于当前下标即首次出现,用 filter 过滤出首次出现的元素
static unique7<T>(arr: T[]): T[] {
  return arr.filter((item, i) => arr.indexOf(item) === i)
}

// 方法8:filter + Set 闭包——推荐写法
// Set.add 返回 Set 自身(truthy),结合短路 && 实现首次见到才返回 true
static unique8<T>(arr: T[]): T[] {
  const seen = new Set<T>()
  return arr.filter(item => !seen.has(item) && !!seen.add(item))
}

// 方法9:reduce 累加(用数组)
// 函数式风格,但 includes 仍是 O(n²)
// 注意 reduce 的泛型参数 T[],初始值为 [] as T[]
static unique9<T>(arr: T[]): T[] {
  // 用 reduce 累加数组,每次判断是否已存在,不存在则添加
  return arr.reduce<T[]>((acc, item) => {
    if (!acc.includes(item)) acc.push(item)
    return acc
  }, [])
}

// 方法10:reduce + Set 闭包——O(n) 函数式
static unique10<T>(arr: T[]): T[] {
  const seen = new Set<T>()
  // 用 reduce 累加数组,每次判断是否已存在,不存在则添加
  return arr.reduce<T[]>((acc, item) => {
    if (!seen.has(item)) {
      seen.add(item)
      acc.push(item)
    }
    return acc
  }, [])
}

// 方法11:Object + typeof 键
// 用 typeof + value 拼成字符串作为对象键,避免 1 与 '1' 冲突
// 类型约束 T extends string | number | boolean 限制为基本类型
static unique11<T extends string | number | boolean>(arr: T[]): T[] {
  const obj: Record<string, true> = {}
  // 用 filter 过滤出首次出现的元素,用 typeof + value 拼成字符串作为对象键
  return arr.filter(item => {
    const key = typeof item + String(item)
    return Object.prototype.hasOwnProperty.call(obj, key)
      ? false
      : (obj[key] = true)
  })
}

TS 加分项T extends string | number | boolean 限定调用方只能传基本类型数组,对象数组在编译期就会报错——避免运行时陷阱。


第3类:集合容器(方法12-14)

策略原理:ES6 引入的 SetMap 用 SameValueZero 算法判等,键唯一且 O(1),是 JS/TS 里最自然的去重工具。Object 字面量虽然也能当哈希用,但有"键自动字符串化""数字键被引擎重排"等坑。

适用场景:日常项目首选 Set;需要保留 value 选 Map;只在小数据或特殊兼容场景才用 Object

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B{选择容器}
    B -->|Set 'T'| C[键唯一<br/>保持插入顺序]
    B -->|Map 'K, V'| D[键唯一<br/>值可携带类型]
    B -->|Object 'Record'| E[键自动字符串化<br/>有重排陷阱]
    C --> F([转回数组])
    D --> F
    E --> F

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#8338EC,color:#fff,stroke:#5e27a8,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class C,D,E step
    class B check
// 方法12:new Set 转数组——一行经典
// Set<T> 用 SameValueZero 比较,NaN 也能正确去重
static unique12<T>(arr: T[]): T[] {
  return [...new Set(arr)]
}

// 方法13:Map<T, T> + keys
// 适合"按键去重,值携带其他信息"的场景
static unique13<T>(arr: T[]): T[] {
  const map = new Map<T, T>()
  // 用 Map<T, T> + keys 转数组,保持插入顺序
  arr.forEach(item => map.set(item, item))
  return [...map.keys()]
}

// 方法14:Object 字面量哈希——T extends string | number 防误用
// 注意:1 与 '1' 会被合并;数字键会被引擎按升序重排
static unique14<T extends string | number>(arr: T[]): T[] {
  const obj = {} as Record<string, T>
  // 用 Object 字面量哈希,键自动字符串化,数字键会被引擎按升序重排
  for (const item of arr) obj[String(item)] = item
  return Object.values(obj)
}

TS 类型提醒Map<K, V> 的两个泛型参数让你显式声明键值类型,比 JS 的 new Map() 更安全。如果按业务字段去重,可以写 new Map<number, User>() 表明键是 id(number),值是 User。


第4类:排序后去重(方法15-17)

策略原理:先 sort 让相同元素相邻,再扫一遍删除相邻相同项。复杂度由排序决定,O(n log n)。优点是不需要额外的哈希结构,"相邻判等"是最便宜的判重方式;缺点是会破坏原顺序。

适用场景:输出本就需要排序、不在意原顺序。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[sort<br/>相同元素相邻]
    B --> C{相邻是否相同?}
    C -->|是| D[splice/skip]
    C -->|否| E[保留]
    D --> F([结果])
    E --> F

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#FF6B6B,color:#fff,stroke:#cc4444,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class B,D,E step
    class C check
// 方法15:sort + splice 升序去重(仅 number[])
// JS sort 不传比较函数会按字符串排序,必须传 (a, b) => a - b
static unique15(arr: number[]): number[] {
  arr.sort((a, b) => a - b)
  let l = arr.length
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  while (l-- > 1) {
    if (arr[l] === arr[l - 1]) arr.splice(l, 1)
  }
  return arr
}

// 方法16:sort + filter 相邻判重
static unique16(arr: number[]): number[] {
  arr.sort((a, b) => a - b)
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  return arr.filter((item, i) => i === 0 || item !== arr[i - 1])
}

// 方法17:经典双指针(LeetCode 26)
// 排序后原地双指针,O(1) 额外空间
static unique17(arr: number[]): number[] {
  if (arr.length === 0) return arr
  arr.sort((a, b) => a - b)
  let slow = 0
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  for (let fast = 1; fast < arr.length; fast++) {
    if (arr[fast] !== arr[slow]) {
      arr[++slow] = arr[fast]
    }
  }
  return arr.slice(0, slow + 1)
}

泛化排序的难点:要让排序方法也支持任意 T,得让调用方传 compareFn: (a: T, b: T) => number——参考 Array.prototype.sort 的设计。这里为简明起见限定为 number[]


第5类:递归与特殊(方法18-20)

策略原理:递归用自调用替代循环,是函数式思维的体现,主要用于教学。JSON.stringify 把对象映射为字符串,是处理"不可哈希元素"(对象数组、嵌套数组)的常见招数。

适用场景:递归——教学;JSON——对象数组按整体结构去重。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([数组 length=n]) --> B{length <= 1?}
    B -->|是| C([返回])
    B -->|否| D[检查末尾元素<br/>是否在前面出现]
    D --> E{重复?}
    E -->|是| F[丢弃末尾]
    E -->|否| G[保留末尾]
    F --> H[递归 length-1]
    G --> H
    H --> A

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#118AB2,color:#fff,stroke:#0b5f7a,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,C start
    class D,F,G,H step
    class B,E check
// 方法18:递归原地删除
static unique18<T>(arr: T[], length: number): T[] {
  if (length <= 1) return arr
  const last = length - 1
  // 从后往前遍历,检查末尾元素是否在前面出现
  for (let i = last - 1; i >= 0; i--) {
    if (arr[last] === arr[i]) {
      arr.splice(last, 1)
      break
    }
  }
  return UniqueArray.unique18(arr, length - 1)
}

// 方法19:递归拼接返回(不修改原数组)
static unique19<T>(arr: T[], length: number): T[] {
  if (length <= 1) return arr.slice(0, length)
  const last = length - 1
  const lastItem = arr[last]
  let isRepeat = false
  // 从后往前遍历,检查末尾元素是否在前面出现
  for (let i = last - 1; i >= 0; i--) {
    if (lastItem === arr[i]) {
      isRepeat = true
      break
    }
  }
  const head = UniqueArray.unique19(arr, length - 1)
  return isRepeat ? head : head.concat(lastItem)
}

// 方法20:JSON 字符串判重——处理对象数组
// 把对象序列化成字符串作为 Set 的键,能去重 {id:1} 这类结构
static unique20<T>(arr: T[]): T[] {
  const seen = new Set<string>()
  const result: T[] = []
  // 遍历数组,把每个对象序列化成字符串作为 Set 的键
  for (const item of arr) {
    const key = JSON.stringify(item)
    if (!seen.has(key)) {
      seen.add(key)
      result.push(item)
    }
  }
  return result
}

// 用法示例:
// UniqueArray.unique20<{id: number}>([{id: 1}, {id: 2}, {id: 1}])
// => [{id: 1}, {id: 2}]

JSON 的两个限制:① 字段顺序不同的对象会被认为不同({a:1,b:2}{b:2,a:1});② undefined、函数、循环引用会丢失或抛错。


选择指南

%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 15, 'padding': 5}}}%%
graph TD
    Start(["数组去重"]) --> Need{"是否需要保序?"}

    Need -->|不需要| Fast["看数据特征"]
    Need -->|需要| Ordered["保留原顺序"]

    Fast --> Q1{"数据形态"}
    Q1 -->|顺便要排序| Sort["sort + Set"]
    Q1 -->|纯基本类型| Set1["[...new Set(arr)]"]

    Ordered --> Q2{"侧重点"}
    Q2 -->|代码简洁| Set2["[...new Set(arr)]<br/>一行解决"]
    Q2 -->|函数式 O(n)| FilterSet["filter + Set 闭包"]
    Q2 -->|按字段去重| MapByKey["Map + keyFn"]
    Q2 -->|对象数组| JSON["JSON.stringify + Set"]

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a
    classDef decision fill:#FE8B57,color:#fff,stroke:#141b2d
    classDef fast fill:#3A86FF,color:#fff,stroke:#2b63c4
    classDef ordered fill:#8338EC,color:#fff,stroke:#5e27a8
    classDef method fill:#0f3460,color:#fff,stroke:#0a2647

    class Start start
    class Need,Q1,Q2 decision
    class Fast fast
    class Ordered ordered
    class Sort,Set1,Set2,FilterSet,MapByKey,JSON method
类别 时间复杂度 是否保序 主要场景
基础循环 O(n²) 教学、面试手撕
内置数组方法 O(n) ~ O(n²) 函数式风格
集合容器 O(n) 看具体类 日常项目首选
排序后去重 O(n log n) 顺便要排序
递归 / JSON 视实现 看实现 教学 / 对象数组

实际项目里怎么选

绝大多数情况一行就够:

// 保序、O(n)、写法最短,工程首选
const result = [...new Set<T>(arr)]

// 或函数式风格,O(n)
const seen = new Set<T>()
const result = arr.filter(x => !seen.has(x) && !!seen.add(x))

按业务字段去重(最常用):

interface User {
  id: number
  name: string
}

// 按 id 去重
const result = [...new Map(users.map(u => [u.id, u])).values()]

对象数组按整体结构去重:

const seen = new Set<string>()
const result = arr.filter(x => {
  const key = JSON.stringify(x)
  return !seen.has(key) && !!seen.add(key)
})

需要排序:

const result = [...new Set(arr)].sort((a, b) => a - b)

带业务逻辑的去重

实际工作里经常遇到这样的情况:遇到重复时不能简单丢弃,要按某个规则做处理。比如:

  • id 去重,但要保留分数最高的那条记录
  • 去重的同时累加重复次数
  • 数值在某个区间内才参与去重

这类需求 Set 直接搞不定,需要把"判重"和"处理"两步拆开来写。TS 里通常用泛型 Map<K, V> + 合并函数:

/**
 * 带业务规则的去重。
 *
 * @param data 原数据
 * @param keyFn 从元素提取去重键
 * @param onDup 遇到重复时如何合并 (旧值, 新值) -> 新代表值
 */
function uniqueBy<T, K>(
  data: T[],
  keyFn: (item: T) => K,
  onDup?: (oldVal: T, newVal: T) => T,
): T[] {
  // Map 保证遍历顺序与首次出现顺序一致
  const chosen = new Map<K, T>()
  for (const item of data) {
    const key = keyFn(item)
    if (!chosen.has(key)) {
      chosen.set(key, item)
    } else if (onDup) {
      chosen.set(key, onDup(chosen.get(key)!, item))
    }
  }
  return [...chosen.values()]
}

例 1:按 id 去重,保留分数最高的:

interface Student {
  id: number
  name: string
  score: number
}

const students: Student[] = [
  { id: 1, name: '张三', score: 90 },
  { id: 1, name: '张三', score: 95 },   // 同 id,分数更高
  { id: 2, name: '李四', score: 85 },
]

const result = uniqueBy(
  students,
  s => s.id,
  (oldS, newS) => newS.score > oldS.score ? newS : oldS,
)
// [{id:1, score:95, ...}, {id:2, score:85, ...}]

例 2:去重同时统计频次:

const counts = new Map<string, number>()
for (const item of data) {
  counts.set(item, (counts.get(item) ?? 0) + 1)
}
// counts.keys() 是保序的去重结果

例 3:区间过滤——只对 [0, 100] 区间内的值去重,区间外原样保留:

const seen = new Set<number>()
const result: number[] = []
for (const x of data) {
  if (x >= 0 && x <= 100) {
    if (seen.has(x)) continue
    seen.add(x)
  }
  result.push(x)
}

这三个例子是同一种思路:把判重与业务规则分开。判重用 Set/Map 保证 O(n),规则部分留给回调或显式分支处理。


对象数组去重的几种 TS 写法

TS 比 JS 的优势在于——可以为每种去重写法显式标注键类型,编译器会帮你检查:

写法 1:按字段去重(最常见)

interface User {
  id: number
  name: string
}

// new Map<id类型, 值类型>
const result: User[] = [
  ...new Map<number, User>(users.map(u => [u.id, u])).values()
]

写法 2:按多字段组合

const result: User[] = [
  ...new Map<string, User>(
    users.map(u => [`${u.id}|${u.name}`, u])
  ).values()
]

写法 3:按整体结构(用 JSON)

const seen = new Set<string>()
const result = arr.filter(x => {
  const key = JSON.stringify(x)
  return !seen.has(key) && !!seen.add(key)
})

写法 4:写一个通用的 uniqueBy(推荐)

function uniqueBy<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
  const seen = new Set<K>()
  return arr.filter(item => {
    const key = keyFn(item)
    return !seen.has(key) && !!seen.add(key)
  })
}

// 使用
const unique = uniqueBy(users, u => u.id)

TS 类型小贴士

Set 的泛型参数:永远显式标注 Set<T>,避免推断成 Set<unknown>

const seen = new Set<number>()      // ✓ 类型明确
const seen = new Set()              // ✗ Set<unknown>

reduce 的初始值:泛型推断有时会推成原数组类型,需要显式标注:

// ✗ 类型错误:推断 acc 为 number 而非 number[]
arr.reduce((acc, x) => [...acc, x], [])

// ✓ 用 reduce<T[]> 或 [] as T[]
arr.reduce<number[]>((acc, x) => [...acc, x], [])

Set.add 返回类型Set<T>.add 返回 Set<T>(truthy),用 && 短路时需要 !! 转布尔:

// JS 里直接 && 即可,TS 严格模式下需 !!
return !seen.has(item) && !!seen.add(item)

总结

工程应用选择:

  • 默认用 [...new Set<T>(arr)]:保序、一行、O(n)、类型安全
  • 函数式用 arr.filter(x => !seen.has(x) && !!seen.add(x))
  • 按字段去重用通用泛型 uniqueBy<T, K>(arr, keyFn)
  • 对象整体去重用 JSON.stringify 作为键
  • 顺便排序用 [...new Set(arr)].sort((a, b) => a - b)
  • 业务规则干预用 Map<K, V> + 合并函数

核心思路:

  1. 同一个问题可以从多个角度切入
  2. 选对数据结构往往比写更聪明的代码更重要
  3. O(n²) 与 O(n) 在数据变大时是几百倍的实际差距
  4. 不要过度优化——能用 new Set 就别绕弯
  5. TS 的泛型让通用工具函数写一次、对所有类型可用,比 JS 更值得封装

更多算法

不同语言算法实现:github.com/microwind/a…

AI编程知识库:microwind.github.io

TDD实战-会议室冲突检测的红绿重构循环

作者 花满溪
2026年5月7日 17:49

本文通过一个真实的"三个会议交叉重叠"场景,完整展示测试驱动开发(TDD)的"红-绿-重构"循环。你将从零开始,亲眼见证测试如何驱动代码设计、暴露边缘情况、最终产出健壮的实现。

第一步:需求分析

业务场景

会议室预约系统允许用户将多个会议分配到同一间会议室。当会议时间发生重叠时,系统需要自动检测冲突并标红。本次要实现的策略是冲突均标红:只要两个会议的时间有重叠,两者都标记为冲突。

聚焦一个场景:三个会议交叉重叠

假设有三个会议,时间如下:

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,但 Meeting 1 和 Meeting 3 不重叠——它们首尾相接(09:00 = 09:00)。

用时间轴线表示如下:

Meeting 1  ████████░░░░░░░░░░░░
Meeting 2  ░░░░████████░░░░░░░░
Meeting 3  ░░░░░░░░░███████████
           08:00    09:00    10:00

测试用例

对应该场景,我们列出了 7 个测试用例,覆盖三种操作类型:

操作类型一:添加会议

用例名称 操作 预期结果
1A 2A 3A => 1x 2x 3x 三个会议依次分配到 A 全部标红

操作类型二:移出会议

用例名称 操作 预期结果
-> 1 null => 2x 3x 移出 1 2、3 冲突,1 正常
-> 2 null => 移出 2 全部正常
-> 3 null => 1x 2x 移出 3 1、2 冲突,3 正常

操作类型三:移动到其他会议室

用例名称 操作 预期结果
-> 1 B => 2x 3x 1 移到 B 2、3 冲突,1 正常
-> 2 B => 2 移到 B 全部正常
-> 3 B => 1x 2x 3 移到 B 1、2 冲突,3 正常

关键洞察: "移出 2"这个用例——当"桥梁"会议被移除后,1 和 3 不重叠,冲突全部消失。这是整个场景中最关键的设计洞察。


第二步:搭建测试环境

首先安装 Vitest:

pnpm add -D vitest

package.json 中添加测试脚本:

{
  "scripts": {
    "test": "vitest",
  }
}

第三步:红——编写第一个失败的测试

TDD 的第一步是:先写一个会失败的测试,明确描述你期望的最小行为。

我们在 test/three-cross.spec.js 中开始:

import { describe, it, expect } from 'vitest'
import { handleRoomChange } from '../index'

describe('三个会议交叉重叠 - 冲突均标红', () => {

    it('无会议时,添加第一个会议应该无冲突', () => {
        const meeting1 = {
            id: 1, name: 'Meeting 1',
            start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
            date: '2021-01-01', isConflict: false, roomId: null
        }

        meeting1.roomId = 'A'
        handleRoomChange(meeting1)

        expect(meeting1.isConflict).toBe(false)
    })
})

运行测试:

pnpm test

输出:

 FAIL  test/three-cross.spec.js
  TypeError: handleRoomChange is not a function

测试失败,符合预期——因为 handleRoomChange 还不存在。这正是"红"阶段:我们需要创建这个模块。


第四步:绿——编写最小化代码通过测试

创建一个最简的方法,让它刚好通过当前测试:

// index.js
  handleRoomChange(meeting: Meeting): void {
    meeting.isConflict = false
  }

这个实现很简单——它无条件地把 isConflict 设为 false最小化代码意味着用最少的工作让当前测试变绿

运行测试:

✓ 三个会议交叉重叠 - 冲突均标红
  ✓ 无会议时,添加第一个会议应该无冲突

绿了。


第五步:红——添加第二个测试,暴露新行为

现在写第二个测试——添加两个时间重叠的会议,两者都应被标记为冲突:

// 在同一个 describe 块中添加
it('两个重叠的会议应都被标记为冲突', () => {
    const meeting1 = {
        id: 1, name: 'Meeting 1',
        start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }
    const meeting2 = {
        id: 2, name: 'Meeting 2',
        start: '2021-01-01 08:30:00', end: '2021-01-01 09:30:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }

    meeting1.roomId = 'A'
    handleRoomChange(meeting1)
    meeting2.roomId = 'A'
    handleRoomChange(meeting2)

    expect(meeting1.isConflict).toBe(true)
    expect(meeting2.isConflict).toBe(true)
})

运行:

 FAIL  test/three-cross.spec.ts
  ✓ 无会议时,添加第一个会议应该无冲突
  ✗ 两个重叠的会议应都被标记为冲突
    AssertionError: expected false to be true

红。 现在的代码把所有会议都设为 false,显然不对。


第六步:绿——让第二个测试通过

现在需要真正实现冲突检测逻辑了。但我们仍然只做刚好够用的代码:

import dayjs from 'dayjs'

let meetingsMap = {}

function hasConflict(m1, m2) {
    return dayjs(m1.end).isAfter(dayjs(m2.start))
        && dayjs(m2.end).isAfter(dayjs(m1.start))
}
export function handleRoomChange(meeting) {
    const { date, roomId } = meeting;
    if (!meetingsMap[date]) meetingsMap[date] = {}
    if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
    const roomMeetings = meetingsMap[date][roomId]
    roomMeetings.push(meeting)

    // 暴力检测所有会议两两之间的冲突
    for (let i = 0; i < roomMeetings.length; i++) {
        for (let j = i + 1; j < roomMeetings.length; j++) {
            if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                roomMeetings[i].isConflict = true
                roomMeetings[j].isConflict = true
            }
        }
    }
}

关键设计决策分析:

  1. 为什么用 meetingsMap 因为需要按日期和会议室维度持久化会议列表,才能做两两比较。
  2. 为什么用 dayjs 时间比较需要精确到秒,用字符串比较不可靠,dayjs 是项目已有的日期库。
  3. 冲突条件m1 未结束 && m2 已开始——经典的区间重叠判断。
  4. 嵌套循环:最简单直观的两两比较方式,O(n²) 复杂度,但对于一个会议室通常只有几个会议的场景完全够用。

运行测试:

✓ 无会议时,添加第一个会议应该无冲突
✓ 两个重叠的会议应都被标记为冲突

两个测试都通过了。


第七步:红——用核心场景驱动设计

现在到了我们真正关心的场景:三个会议交叉重叠。先标记所有会议:

it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
    const meetings = [
        { id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
          date: '2021-01-01', isConflict: false, roomId: null },
    ]

    meetings.forEach(m => { m.roomId = 'A'; 
    handleRoomChange(m)
    })

    expect(meetings[0].isConflict).toBe(true)  // 1-2 重叠
    expect(meetings[1].isConflict).toBe(true)  // 2-3 重叠
    expect(meetings[2].isConflict).toBe(true)  // 2-3 重叠
})

运行测试,通过了!因为嵌套循环已经处理了所有两两关系。

现在加上关键用例——移出会议

it('1 A 2 A 3 A -> 2 null =>', () => {
        const meetings = [
            {
                id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
        ]

        meetings.forEach(m => { m.roomId = 'A'; handleRoomChange(m) })

        // 移出 Meeting 2
        meetings[1].prevRoomId = meetings[1].roomId
        meetings[1].roomId = null
        handleRoomChange(meetings[1])

        expect(meetings[0].isConflict).toBe(false)  // 1 和 3 不重叠
        expect(meetings[1].isConflict).toBe(false)  // 已移出
        expect(meetings[2].isConflict).toBe(false)  // 3 和 1 不重叠
    })

运行:

 FAIL  ✗ 1 A 2 A 3 A -> 2 null =>
  AssertionError: expected true to be false

红! 问题在于:当我们移出 Meeting 2 时,之前的 handleRoomChange 只会往列表里加会议,从没考虑过移除。Meeting 1 的 isConflict 仍然停留在 true,没有被重新计算。

现在,移出需求驱动我们设计一个新的能力。


第八步:绿——实现移除和重算

我们需要区分"添加"和"移除"两种情况。设计 handleRoomChange 的分支逻辑:

export function clearMap() {
    meetingsMap = {}
}

export function handleRoomChange(meeting) {
    const { prevRoomId, roomId, date } = meeting

    // 先从旧会议室移除
    if (prevRoomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][prevRoomId]) meetingsMap[date][prevRoomId] = []
        const roomMeetings = meetingsMap[date][prevRoomId]
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    // 再添加到新会议室
    if (roomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
        const roomMeetings = meetingsMap[date][roomId]
        roomMeetings.push(meeting)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    } else {
        meeting.isConflict = false
    }
}

这个步骤:

  • 每个测试用例都需要将 meetingsMap 重置。
  • 每次添加或移除会议后,都重新计算整个会议室的状态。虽然看起来做了重复计算,但胜在简单且正确——会议室里的会议数量极少,性能不是问题,正确性才是。

运行测试:

✓ 两个重叠的会议应都被标记为冲突
✓ 1 A 2 A 3 A => 1 x 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>

所有测试通过。


第九步:红——继续添加剩余场景

测完核心逻辑,快速补全剩余用例:

it('1 A 2 A 3 A -> 1 null => 2 x 3 x', () => {
    // 移出 1 → 2 和 3 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

it('1 A 2 A 3 A -> 3 null => 1 x 2 x', () => {
    // 移出 3 → 1 和 2 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(true)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(false)
})

it('1 A 2 A 3 A -> 1 B => 2 x 3 x', () => {
    // 1 移到 B → A 会议室只剩 2 和 3,它们冲突
    // 1 在 B 会议室只有自己,无冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

由于 handleRoomChange 已经同时处理了移除和添加,这些用例都直接通过:

1 A 2 A 3 A -> 1 null => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>
✓ 1 A 2 A 3 A -> 3 null => 1 x 2 x
✓ 1 A 2 A 3 A -> 1 B => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 B =>
✓ 1 A 2 A 3 A -> 3 B => 1 x 2 x

7 个测试,全部通过。


第十步:重构——在安全网下优化代码

测试全部通过后,我们站在一个安全的位置审视代码。

问题 1:面向过程的代码重构

import dayjs from 'dayjs'

export default class AllConflictManager {
    meetingsMap = {}

    clear() {
        this.meetingsMap = {}
    }

    handleRoomChange(meeting) {
        const { prevRoomId, roomId } = meeting
        if (prevRoomId) {
            this.removeMeetingFromRoom(meeting, prevRoomId)
        }
        if (roomId) {
            this.addMeetingToRoom(meeting, roomId)
        } else {
            meeting.isConflict = false
        }
    }

    addMeetingToRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        roomMeetings.push(meeting)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    getRoomMeetings(date, roomId) {
        if (!this.meetingsMap[date]) this.meetingsMap[date] = {}
        if (!this.meetingsMap[date][roomId]) this.meetingsMap[date][roomId] = []
        return this.meetingsMap[date][roomId]
    }

    removeMeetingFromRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    findAllConflicts(roomMeetings) {
        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (this.hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        return conflictIds
    }

    updateConflictStatus(roomMeetings, conflictIds) {
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    hasConflict(m1, m2) {
        return dayjs(m1.end).isAfter(dayjs(m2.start)) && dayjs(m2.end).isAfter(dayjs(m1.start))
    }
}

问题 2:测试中重复的测试数据

每个测试用例都重复定义了三个 meeting 对象。提取到公共数据:

// data/three-cross.json
[
    { "id": 1, "name": "Meeting 1", "start": "2021-01-01 08:00", "end": "2021-01-01 09:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 2, "name": "Meeting 2", "start": "2021-01-01 08:30", "end": "2021-01-01 09:30",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 3, "name": "Meeting 3", "start": "2021-01-01 09:00", "end": "2021-01-01 10:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null }
]

问题 3:Manager 实例创建和重置重复

提取到 test-utils.js

import AllConflictManager from '../index'

const manager = new AllConflictManager()

export function resetMeetings(meetings) {
    meetings.forEach(m => {
        m.isConflict = false
        m.prevRoomId = null
        m.roomId = null
    })
    manager.clear()
}

export function assignToRoom(meeting, roomId) {
    meeting.roomId = roomId
    manager.handleRoomChange(meeting)
}

export function removeMeetingFromRoom(meeting) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = null
    manager.handleRoomChange(meeting)
}

export function moveMeeting(meeting, newRoomId) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = newRoomId
    manager.handleRoomChange(meeting)
}

问题 4:测试代码精简

重构后的测试文件干净很多:

import { describe, it, expect, beforeEach } from 'vitest'
import { resetMeetings, assignToRoom, removeMeetingFromRoom, moveMeeting } from './test-utils'
import meetings from '../data/three-cross.json';

describe('三个会议交叉重叠 - 冲突均标红', () => {
    beforeEach(() => {
        resetMeetings(meetings)
        assignToRoom(meetings[0], 'A')
        assignToRoom(meetings[1], 'A')
        assignToRoom(meetings[2], 'A')
    })

    it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
        expect(meetings[0].isConflict).toBe(true)
        expect(meetings[1].isConflict).toBe(true)
        expect(meetings[2].isConflict).toBe(true)
    })
    // ...其余用例
})

重构后运行测试:

 Test Files  1 passed (1)
      Tests  7 passed (7)

全部通过。 我们可以在安全网的保护下自信地说:重构没有破坏任何功能。


最终代码全景

源码骨架

AllConflictManager
├── meetingsMap            ← 按 date → roomId 的二维存储
├── clear()                ← 重置状态
├── handleRoomChange()     ← 入口:先移除旧房间,再添加新房间
├── addMeetingToRoom()     ← 添加 + 重算冲突
├── removeMeetingFromRoom()← 移除 + 重算冲突
├── findAllConflicts()     ← O(n²) 两两比较
├── updateConflictStatus() ← 批量更新标记
└── hasConflict()          ← 区间重叠判断

测试覆盖

7 个用例覆盖 3 类操作:
  ┌─ 添加会议(1 个)
  ├─ 移出会议(3 个)
  └─ 移动会议(3 个)

回顾:TDD 如何驱动了设计

让我们回顾整个过程中,测试是如何驱动设计决策的:

测试驱动了接口设计

第一个测试只验证"添加无冲突会议",导致最初实现只是一个空壳方法。当第二个测试需要"检测重叠"时,才被迫引入 meetingsMap 存储和 hasConflict 方法。

测试驱动了移除逻辑

移出会议的测试迫使我们设计 handleRoomChange 的分支结构(先移除旧房间,再添加新房间)。如果只是按直觉写"添加"逻辑,永远不会考虑到移除后的状态重算。有了移除逻辑后,发现测试用例仍无法通过,meetingsMap 存储需重置。

测试驱动了冲突重算策略

当移出 Meeting 2 后,Meeting 1 的 isConflict 仍然为 true——这个失败迫使我们意识到:每次状态变更后都需要完整重算,而不是增量更新recalculateConflicts 方法从这里诞生。

测试驱动了数据抽取

测试代码中的重复数据——7 个用例写了 21 个 meeting 对象——驱动我们将测试数据提到 JSON 文件。这既是重构,也是一种设计信号:测试数据应该与测试逻辑分离。


总结:TDD 的节奏感

回顾完整的红-绿-重构循环:

RED (写测试)      →  1. 定义行为期望
                     2. 确认当前代码做不到
                     ↓
GREEN (写代码)    →  3. 用最直接的方式让测试通过
                     4. 确信结果正确
                     ↓
REFACTOR (优化)   →  5. 在测试保护下改进代码质量
                     6. 测试依然通过
                     ↓
(回到第 1 步,覆盖下一个场景)

这个循环的核心价值在于节奏感:每一步都有明确的目标,每一步的结果都可以立即验证。没有模糊区间,没有"感觉应该没问题"——只有绿色的通过和红色的失败。

当你习惯了这个节奏后,你会发现编码的过程不再是"写完再看",而是一个持续获得正向反馈的过程。每个测试从红变绿的那一刻,都在告诉你:你又推进了一步。

你不需要一次设计出完美的架构。你只需要写一个会失败的测试,然后让它通过。然后重复。最终,好的设计会自己浮现出来。

测试用例

两个重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 10:00
时间关系:Meeting 1 和 Meeting 2 互相重叠
用例名称 操作步骤 预期结果
1 A 2 A => 2 x Meeting 1 分配到 A,Meeting 2 分配到 A Meeting 1 正常,Meeting 2 冲突
1 A 2 A -> 1 null => 上述基础上,Meeting 1 移出 两个会议都正常
1 A 2 A -> 1 B => 上述基础上,Meeting 1 移到 B 两个会议都正常
1 A 2 A -> 2 null => 上述基础上,Meeting 2 移出 两个会议都正常
1 A 2 A -> 2 B => 上述基础上,Meeting 2 移到 B 两个会议都正常

三个全重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:30
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
时间关系:三个会议两两之间都互相重叠
用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => 1x 3 x Meeting 2 移出 Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => 1 x 3 x Meeting 2 移到 B Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

三个会议 - 交叉重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,Meeting 1 与 Meeting 3 不重叠(首尾相接)

用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => Meeting 2 移出 三个会议都正常
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => Meeting 2 移到 B 三个会议都正常
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

四个会议 - 链式重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
Meeting 4 09:30 10:30

时间关系:链式重叠,1-2 重叠,2-3 重叠,3-4 重叠,1-3、1-4、2-4 不重叠

用例名称 操作步骤 预期结果
1 A 2 A 3 A 4 A => 1 x 2 x 3 x 4 x 初始状态 Meeting 1、 2、3、4 冲突
1 A 2 A 3 A 4 A -> 1 null => 2 x 3 x 4 x Meeting 1 移出 Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常
1 A 2 A 3 A 4 A -> 2 null => 3 x 4 x Meeting 2 移出 Meeting 1、2 正常,Meeting 3、4冲突
1 A 2 A 3 A 4 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 null => 1 x 2 x 3 x Meeting 4 移出 Meeting 4 正常,Meeting 1、2、3 冲突
1 A 2 A 3 A 4 A -> 1 B => 2 x 3 x 4 x Meeting 1 移到 B Meeting 1 正常,Meeting 2、3、4 冲突
1 A 2 A 3 A 4 A -> 2 B => 3 x 4 x Meeting 2 移到 B Meeting 1、2 正常,Meeting 3、4 冲突
1 A 2 A 3 A 4 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 B => 1 x 2 x 3 x Meeting 4 移到 B Meeting4 正常,Meeting 1、2、3 冲突

LeetCode 72. 编辑距离:动态规划经典题解

作者 Wect
2026年5月3日 11:23

刷LeetCode中等题时,编辑距离绝对是动态规划的经典代表作——它看似复杂,三种操作(插入、删除、替换)让人无从下手,但只要理清状态定义和转移逻辑,就能轻松破解。今天就带大家一步步拆解这道题,从题意分析到代码实现,把每一个细节讲透。

一、题目解读:到底要解决什么问题?

题目很简洁:给定两个单词word1和word2,返回将word1转换成word2所使用的最少操作数

允许的三种操作(对一个单词进行):

  • 插入一个字符:比如word1是“abc”,word2是“abdc”,可以在word1的b和c之间插入d,完成转换。

  • 删除一个字符:比如word1是“abcd”,word2是“abc”,删除word1的d即可。

  • 替换一个字符:比如word1是“abc”,word2是“adc”,将word1的b替换成d即可。

核心难点:三种操作可以任意组合,如何找到“最少步骤”?—— 动态规划的核心就是“最优子结构”,把大问题拆成小问题,逐个解决。

二、动态规划思路拆解(核心部分)

动态规划解题,关键就3步:定义dp数组、确定边界条件、推导状态转移方程。我们一步步来:

1. 定义dp数组

设word1的长度为n,word2的长度为m,定义dp[i][j]:将word1前i个字符(word1[0..i-1])转换成word2前j个字符(word2[0..j-1])所需的最少操作数。

为什么是i-1和j-1?因为dp数组的下标从0开始,而字符串的下标也从0开始,dp[0][0]表示两个空串的转换,dp[1][1]才对应两个单词的第一个字符,这样定义更直观,避免下标混乱。

2. 确定边界条件

边界情况就是“其中一个单词为空串”的场景:

  • 如果word1为空(i=0),那么要转换成word2前j个字符,只能不断插入j个字符,所以dp[0][j] = j。

  • 如果word2为空(j=0),那么要转换成空串,只能不断删除word1前i个字符,所以dp[i][0] = i。

比如dp[0][3] = 3(空串转成3个字符,插入3次),dp[2][0] = 2(2个字符转成空串,删除2次)。

3. 推导状态转移方程

这是最关键的一步,我们分两种情况讨论:word1的第i个字符(word1[i-1])和word2的第j个字符(word2[j-1])是否相等。

情况1:word1[i-1] == word2[j-1]

此时,两个字符已经匹配,不需要做任何操作,最少操作数就等于“word1前i-1个字符转word2前j-1个字符”的操作数,即:

dp[i][j] = dp[i-1][j-1]

比如word1是“abc”,word2是“adc”,当i=3、j=3时,word1[2] = c,word2[2] = c,此时dp[3][3] = dp[2][2]。

情况2:word1[i-1] != word2[j-1]

此时需要进行插入、删除、替换三种操作中的一种,取这三种操作的最少步骤即可,对应三个方向的状态:

  • 删除操作:删除word1的第i个字符,此时dp[i][j] = dp[i-1][j] + 1(删除1次,加上之前的操作数)。

  • 插入操作:在word1的第i个字符后插入一个和word2[j-1]相同的字符,此时dp[i][j] = dp[i][j-1] + 1(插入1次,加上之前的操作数)。

  • 替换操作:将word1的第i个字符替换成word2[j-1],此时dp[i][j] = dp[i-1][j-1] + 1(替换1次,加上之前的操作数)。

所以,状态转移方程为:

dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + 1)

三、完整代码实现(TypeScript)

结合上面的思路,直接上代码,每一行都加了详细注释,对应我们拆解的逻辑:

function minDistance(word1: string, word2: string): number {
  let n = word1.length; // word1的长度
  let m = word2.length; // word2的长度

  // 边界情况:有一个字符串为空串,操作数就是另一个字符串的长度
  if (n * m == 0) {
    return n + m;
  }

  // 初始化dp数组:n+1行(word1的0~n个字符),m+1列(word2的0~m个字符)
  const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1));

  // 边界状态初始化:word2为空时,dp[i][0] = i(删除i次)
  for (let i = 0; i < n + 1; i++) {
    dp[i][0] = i;
  }
  // 边界状态初始化:word1为空时,dp[0][j] = j(插入j次)
  for (let j = 0; j< m + 1; j++) {
    dp[0][j] = j;
  }

  // 填充dp数组:遍历所有i和j,计算每个dp[i][j]的值
  for (let i = 1; i < n + 1; i++) {
    for (let j = 1; j < m + 1; j++) {
      // 左:删除操作,dp[i-1][j] + 1
      let left = dp[i - 1][j] + 1;
      // 下:插入操作,dp[i][j-1] + 1
      let down = dp[i][j - 1] + 1;
      // 左下:替换/不操作,先取dp[i-1][j-1]
      let left_down = dp[i - 1][j - 1];
      // 字符不相等时,替换操作需要+1
      if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
        left_down += 1;
      }
      // 取三种操作的最小值,赋值给dp[i][j]
      dp[i][j] = Math.min(left, Math.min(down, left_down));
    }
  }

  // dp[n][m]就是word1完整转word2的最少操作数
  return dp[n][m];
};

四、代码解析与易错点提醒

1. 易错点1:边界条件的判断

当n*m == 0时,说明其中一个字符串长度为0,此时直接返回n+m即可,不用再初始化dp数组,节省时间。比如word1为空,word2长度为5,返回5(插入5次)。

2. 易错点2:dp数组的初始化

dp数组的长度是n+1和m+1,因为要包含“0个字符”的情况,比如dp[0][0] = 0(两个空串,无需操作)。

3. 易错点3:字符串下标与dp数组下标的对应

dp[i][j]对应word1[0..i-1]和word2[0..j-1],所以取字符时要用word1.charAt(i-1)和word2.charAt(j-1),千万别写成i和j,否则会越界。

五、总结:这道题的核心价值

编辑距离是动态规划的经典应用,它的核心思想是“用子问题的最优解推导原问题的最优解”。这道题的关键不是记住代码,而是理解:

  • 如何定义dp数组,让它能表示“子问题的最优解”;

  • 如何根据题意,找到边界条件(极端情况);

  • 如何分析不同场景,推导状态转移方程。

学会这道题后,再遇到类似的“最少操作”“最优路径”类动态规划题,思路会清晰很多。比如字符串匹配、最长公共子序列等,都能用到类似的思维。

生成式召回在得物的落地技术分享与思考

作者 得物技术
2026年4月16日 10:17

一、背景

推荐系统在提升用户体验的同时,也面临着信息茧房、兴趣收敛和内容同质化的挑战。随着用户与系统交互的深入,"推荐→用户反馈→再推荐"的闭环会逐渐强化用户的少数主兴趣,导致推荐结果趋同,降低用户的新鲜感与满意度。

生成式AI技术的快速发展为推荐系统带来了新的机遇。与传统的判别式匹配范式不同,生成式召回通过预测用户下一个可能点击的内容,实现从"匹配已知"到"预测潜在"的范式转变。在得物社区这一潮流生活方式平台上,用户对内容多样性和新颖性的需求尤为突出,这为生成式召回的探索提供了天然的场景。

基于此背景,得物启动了生成式召回方向的一期探索,旨在为下一代智能推荐系统的构建积累经验,探索推荐系统的 scaling-law 规律。

传统召回方法的局限性与生成式召回的动机

传统判别式ANN召回的局限性

  • 时序信息建模不足:难以有效捕捉用户行为序列中的长期兴趣、短期偏好及其动态演变过程。
  • 兴趣多样性受限: 基于历史行为的匹配范式容易收敛到少数高频兴趣点,难以拓展用户的兴趣广度。
  • 匹配范式天花板:判别式兴趣建模受限于已有历史数据,难以预测用户未来的、潜在的兴趣方向。
  • 兴趣融合能力弱:各兴趣点通常独立建模,缺乏对用户多兴趣间协同关系的端到端建模能力。

生成式召回的核心优势

  • Next-Token Prediction 范式:通过预测用户下一个可能点击的内容,实现端到端的用户兴趣融合建模。
  • 引导式召回机制:为生成式模型提供可控的、结构化的召回条件,确保召回内容的相关性与业务目标一致性。
  • 时序依赖建模:基于 Transformer 架构,自然捕捉用户行为序列中的时序依赖关系。
  • 兴趣预测能力:不仅能匹配已知兴趣,还能基于历史行为模式,预测用户的潜在兴趣方向。
  • 端到端优化:从用户行为序列直接生成召回结果,减少中间环节的信息损失。
  • 具有 scaling-law 规律:随着样本与模型规模的提升,能大幅提高模型的表达能力,提高线上的推荐效果。

二、技术方案

得物生成式召回系统采用 Generative Model 与 Rerank Model 联合训练的端到端设计,实现了生成与排序的协同优化。

image.png

Generative Model设计细节

生成式模型基于 Transformer 架构实现 Next-Token 生成任务,主要特征包括:

  • 主序列特征:使用用户图文和视频的有效点击序列,以及对应的一 / 二 / 三级类目序列,截断最近 100 个行为;
  • 首位 User Token 生成策略:联合训练辅助双塔模型产出首位 user_token,通过梯度隔离机制,确保生成任务与双塔任务的独立优化;
  • 模型参数配置:采用当前 DeepRec 框架可承受的最大参数规模,配置为 n_layers=3,n_heads=4,dim=64,并加入 position embedding,增强时序建模能力。

Rerank Model设计细节

重排模型与生成式模型联合训练,通过多任务学习提升召回精度:

  • 联合训练机制:通过召回目标同时训练 rerank 模型的 item 塔与 user 塔,与 Generative Model 共享底层特征表示;
  • 多任务梯度平衡:设计合理的损失权重分配策略,确保生成任务与重排任务的梯度协同优化。

推理过程:从一级类目生成到精准召回

生成式召回在线上推理时遵循"生成→向量化→检索→重排"的四步流程,兼顾了生成式模型的预测能力与向量检索的效率。

一级类目生成

推理过程首先通过生成式模块的 Decoder 生成 Top-K 一级类目。经过离线 recall@100 参数搜索对比,确定 K=4 为最优配置,在召回效果与计算成本间取得平衡。生成的一级类目作为后续步骤的 “硬条件” 向量,为多兴趣建模提供结构化引导。

多兴趣向量构造

基于生成的 K 个一级类目,通过条件双塔的 user_tower 分别得到图文和视频的 K 个用户兴趣向量。这一设计实现了兴趣解耦,每个兴趣向量专注于特定类目下的用户偏好,避免了传统单向量表示中的兴趣混淆问题。

ANN召回与Rerank排序

各兴趣向量分别进行 ANN 向量检索,从候选池中召回相关 item。召回结果再由 Rerank 模型进行精细化打分排序,最终通过蛇形 Merge 策略将多个兴趣通道的结果融合,作为最终召回列表输出。

三、实验效果

为验证生成式召回的实际效果,我们在得物社区进行了严格的AB测试。结果也带来了社区线上指标的显著提升。验证了生成式算法的在得物落地的可行性,并预示着更大的探索潜力。

核心消费指标显著提升

生成式召回在多个核心消费指标上取得显著正向效果:

指标名称 相对提升(%) 显著性
人均推荐有效VV +0.41% 显著
社区DAU均时长(秒) +0.37% 显著
人均推荐总时长(秒) +0.45% 显著
推荐曝光UV人均内容VV +0.39% 显著

多样性指标改善

除了消费深度,生成式召回在兴趣广度拓展上也表现突出:

多样性指标 相对提升(%) 显著性
人均点击一级类目数 +0.18% 显著
人均点击三级类目数 +0.23% 显著
人均曝光三级类目数 +0.19% 显著

未来工程优化方向

基于一期实践经验,后续工程优化将聚焦于:

  • 框架迁移:从 DeepRec 迁移至 DeepSea-Torch 框架,支持更大参数量与稀疏特征;
  • 架构升级:探索 One-Rec 框架落地,统一生成式与判别式召回范式;
  • 推理加速:研究模型压缩、量化等推理优化技术,进一步降低服务延迟;
  • 成本优化:通过训练策略改进和资源调度优化,降低单位效果的成本。

四、总结与展望

得物生成式召回一期实践表明,通过 “生成预测 + 引导召回” 的技术路径,可以在可控成本下,同时实现用户消费深度与兴趣广度的双重提升,为下一代智能推荐系统的构建提供了重要参考。本次实践成功验证了生成式召回在工业级推荐场景的可行性与有效性。通过 Generative Model 与 Rerank Model 的联合训练架构,实现了从判别式匹配到生成式预测的成功范式迁移。技术方案在保持推荐相关性的同时,显著提升了兴趣探索能力,为打破信息茧房提供了新的技术路径。

当前方案以一级类目作为生成目标,这是考虑到类目体系的稳定性和可解释性。下一步将基于社区样本训练 Item Embedding,并将 Item Token 离散化与用户 Next-Token 生成任务联合训练。这一演进将实现从粗粒度到细粒度的兴趣预测,提升召回的精准度。

模型能力升级

通过框架迁移大幅提升模型参数量,支持大规模稀疏特征,探索更强大的生成式模型架构。具体方向包括:

  • 扩展上下文窗口:从当前的 100 行为扩展到更长序列,更好建模用户长期兴趣;
  • 改进注意力机制:研究稀疏注意力、线性注意力等高效注意力变体,平衡效果与效率。

与LLM结合的可能性

借鉴得物在基于大语言模型的新颖性推荐上的经验,生成式召回可与 LLM 知识增强结合。LLM 的世界知识可以帮助识别用户潜在但未明确表达的兴趣,而生成式模型则负责将这些兴趣转化为可执行的召回策略,形成知识增强的生成式召回新范式。

多模态与跨域生成

探索利用多模态信息生成更丰富的用户兴趣表示,并尝试跨业务域的生成式兴趣迁移。在得物的业务生态中,社区内容与电商商品之间存在天然关联,通过跨域生成式召回,可以实现从内容兴趣到商品需求的自然过渡,提升业务协同价值。

往期回顾

1.立正请站好:一个组件复用 Skill 的工程化实践|得物技术

2.财务数仓 Claude AI Coding 应用实战|得物技术 

3.日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

4.Redis 自动化运维最佳实践|得物技术

5.Claude在得物App数仓的深度集成与效能演进

文 /流煜曦

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

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

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

大禹平台:流批一体离线Dump平台的设计与应用|得物技术

作者 得物技术
2026年3月19日 10:47

一、前言

大禹平台是一个离线 Dump 平台。在不同的场景都有自己的 Dump 流程,我们这里的 Dump 特指在搜索、推荐、广告(后续简称 “搜推广”)的场景中,将异构数据源加工处理后给到索引平台做索引的流程。

Dump 流程有如下一些特点:

  • 多源异构的数据:包括 MySQL、ODPS、HBase 和 Kafka 等各种数据源。
  • 多样化的输出:输出支持搜推广引擎构建倒排索引、Summary 服务构建 kv/kkv 索引等。
  • 流批数据结合:一般会有全量和增量,需要保证处理逻辑一致,增量能达到秒级更新。
  • 数据处理能力:例如多表 Join、UDF、Filter 等,以方便业务的开发和接入。

离线 Dump 流程

二、项目背景

现状

当前 dump 开发模式

如上图是当前常见的 Dump 开发模式,采用了流批分离架构:流处理通过 DTS 订阅 binlog,由 Flink 消费主表变更事件并反查关联表构建宽表,实现增量更新;批处理则将 MySQL 数据抽取至 ODPS,通过 Spark 处理多源数据并按业务逻辑拼接,最终输出 ODPS 表。这种架构存在以下问题:

当前 dump 开发的问题

目标

依托社区搜索核心场景,构建流批一体化的新质 Dump 架构,实现以下三大核心能力突破:

  • 工程效率: 基于可视化 DAG 编排工具,提供低代码开发能力,通过拖拽式界面实现复杂任务流程的快速搭建与迭代,显著降低开发门槛。
  • 数据质量: 基于流批一体架构,通过统一逻辑开发范式实现流批数据同源同构,从根本上提升数据准确性与可靠性。
  • 稳定性保障: 通过引入镜像表和状态大宽表,提高了数据的查询效率,系统性降低对源库的反查压力,确保系统长期稳定运行。

二、大禹平台介绍

平台设计

系统架构

平台架构

如上图是大禹平台技术架构,底层依赖公司的 DJob Cron 定时任务、Flink/Spark 流批计算能力以及多种存储系统;上层为平台支持的搜推广多种场景业务。

大禹平台分为管理平台与后台系统两部分。管理平台完成处理逻辑的 DAG 开发和相关 Debug、回归验证、监控大盘等能力;后台系统将管理平台的配置转为执行任务,然后依托流批框架生成 Flink/Spark 执行实例,通过调度引擎完成全流程任务执行。

如下图是新版 Dump 流程,将 Dump 拆分为三个阶段:镜像阶段、宽表阶段、导出阶段,以及流、批两种处理模式。新版流程处理过程有如下优化:

  • MySQL 镜像至 HBase: 平台将任务依赖的 MySQL 数据统一同步至 HBase 构建镜像层,实现与上游 RDS 解耦。有效规避多任务并发反查导致的数据库压力,支持跨任务共享复用 HBase 镜像表,显著提升数据源稳定性与资源利用率。
  • Binlog 订阅平台化: 将 RDS Binlog 订阅流程深度内嵌,自动完成 DTS 订阅创建与 Kafka 资源申请,封装为标准化服务。开发者无需关注底层链路,一键配置即可获取实时变更流,降低接入复杂度,保障流式数据可靠性。
  • 状态大宽表消除反查: 基于 HBase 构建持久化状态大宽表,完整记录字段中间状态。任务处理时直接读取状态数据,彻底规避冗余反查逻辑,简化开发流程。

新版 Dump 流程

调度引擎

大禹平台利用得物 DJob Cron 自建调度系统,通过搭建多个 Cron Job 轮训的方式,完成对任务分阶段的处理。

Cron Job 构建调度系统

一个执行实例的全流程

执行框架

在镜像、宽表、导出三个阶段,分别都有对应 Spark 和 Flink 处理框架。其中,镜像阶段完成 MySQL 数据同步,导出阶段完成状态宽表到引擎数据源的导出流程,宽表阶段是具体的业务逻辑实现。

宽表 Spark 框架逻辑: 任务严格遵循 DAG 拓扑顺序,依次执行各算子节点(数据源→业务逻辑→导出)的数据处理流水线,最终通过 BulkLoad 方式将结果高效写入 HBase。

宽表阶段 Spark 框架逻辑

宽表 Flink 框架逻辑: 消费非维表节点的增量,依据节点依赖关系进行拓扑排序后依次执行各节点计算逻辑,将产出字段更新至状态宽表,并实时同步至下游导出链路。

宽表阶段 Flink 框架逻辑

流批一体保障数据质量

平台采用统一的 DAG 编排引擎,将流处理与批处理任务抽象为相同的计算拓扑,从架构层面保障数据源头的天然一致性,彻底规避因不同环境下开发导致的数据偏差风险。同时,平台内置标准化的 UDF(用户自定义函数)开发模板与运行时框架:开发者只需专注业务逻辑实现,编写的 UDF 代码经一次注册,即可无缝嵌入流式与批量处理流程,真正实现 “一次开发、流批复用”,显著提升开发效率,降低维护成本,保障 Dump 开发从数据源头到处理逻辑各环节的流批一致性。

平台通过定义 AlgoDumpUDF 方法类,完成消息类型封装,用户可以利用 UDF 实现数据过滤和驱动删除等逻辑。

public abstract class AlgoDumpUDF implements UDFFunction, Serializable {
    //消息类型 add/delete/drop 三种
    public AlgoDumpMessageType algoDumpMessageType = 
    AlgoDumpMessageType.MESSAGE_TYPE_ADD;
    @Override
    public AlgoDumpMessageType getStatus() {
        return algoDumpMessageType;
    }


    //调用该方法实现增量驱动删除
    @Override
    public void delete(Object key, String reason) {
        this.algoDumpMessageType = AlgoDumpMessageType.MESSAGE_TYPE_DELETE;
    }
    //调用该方法实现增量过滤
    @Override
    public void drop(Object key, String reason) {
        this.algoDumpMessageType = AlgoDumpMessageType.MESSAGE_TYPE_DROP;
    }
    /**
     * 用户重写该方法完成业务逻辑开发
     */
    public void process() throws Exception {
    }
}

CASE示例:用户通过重写process()方法, 实现自己的业务逻辑,实现时可以利用drop方法把无效数据过滤,利用delete方法实现对下游索引发送删除消息。

public class MyUdf extends AlgoDumpUDF implements Serializable {
    public  Tuple2<String, String> process(String id, String taskname) 
    throws Exception {
        //过滤消息
        if(StringUtils.isBlank(id)) {
            this.drop(id, "drop by id null");
        }


        //驱动增量删除消息
        if(id.equals(0)) {
            this.delete(id, "delete by id = 0");
        }


        //用户写具体业务逻辑
        String a1 = "";
        if (taskname.equals("dddddd")) {
            a1 = "ddd";
        }
        String b1 = "test";
        return new Tuple2<>(a1, b1);
    }
}

小全量模式加速数据Dump

大禹支持任务实例按照大全量和小全量两种模式运行,针对部分频繁更新部分字段需求的任务可实现快速加载。

  • 大全量: 对数据源执行全量同步重建,生成全新的状态大宽表,并同步刷新流批处理链路,实现数据基准的彻底更新与端到端一致性保障。
  • 小全量: 基于现有状态大宽表,仅针对批处理来源字段加载最新数据源快照,经处理后通过 BulkLoad 高效写入 HBase;依托 HBase 多版本特性实现新旧数据平滑切换,确保批处理数据增量更新过程中查询服务零中断、数据时效性与业务连续性兼得。

小全量模式

任务复用支持数据分层管理

大禹平台支持任务产出的双重应用:既可对接计算引擎(如 CEngine),亦可作为公共数据被下游任务高效复用。平台通过标准化的 MirrorOut(导出)与 MirrorIn(接入)算子构建清晰的数据复用链路 —— 上游任务将公共数据配置为 MirrorOut 导出,下游任务通过 MirrorIn 算子一键引用,无需重复开发与数据搬运,实现数据资产的即产即用、任务依赖的显式管理,显著提升开发效率与数据复用性。

任务复用

管理平台

任务开发与运维

管理平台提供一站式任务开发生命周期管理,涵盖任务创建、可视化流程编排、实例调度与资源管控等核心环节;其中,Dump 任务通过可视化编排实现业务配置——用户仅需拖拽算子节点、配置参数,即可直观构建数据处理逻辑,显著提升开发效率与配置准确性。

如下图,通过拖拽算子的方式,可以直观地构建 dump 任务的流程图,实现便捷高效的开发体验。

图画编排式开发任务

执行实例以可视化流程图形式完整呈现任务执行全流程,每个节点清晰展示输入参数与输出结果,并支持对指定节点进行手动重试或终止操作,便于问题定位与流程干预。

执行实例状态

辅助工具

数据回归验证

平台提供流批数据回归验证能力,支持模板化配置与一键复用,高效保障数据质量与业务稳定性。

  • 批量回归: 多版本批数据快速比对,一键校验全量一致性,适用于版本迭代验证;
  • 流式回归: 基于索引表增量变更抽样,对指定时间窗口内实时数据进行跨索引一致性校验,精准定位流式链路异常。

创建批数据回归任务

创建流数据回归任务

数据Debug

大禹平台构建了覆盖全链路的数据运维干预能力,确保数据处理的可靠性与灵活性。

  • 组图配置: 支持对源端组图配置进行主动干预与调整,实现配置策略的快速生效。
  • Dump流程: 支持Dump构建流程的调控,实现对全链路流程的问题快速定位,保障数据产出的稳定性与高效性。
  • 在线索引: 提供线上索引数据的实时干预能力,支持对增量数据进行修正,确保索引内容的及时性与准确性。

四、业务场景实践

社区搜索倒排表链路

如下图所示,社区搜索倒排表 Dump 任务以动态内容为核心实体,融合动态实时内容流、天级统计特征及商品多维特征,通过流批一体处理生成高时效的倒排索引宽表。

社区搜索倒排宽表链路

穿搭精选推荐链路

如下图所示,穿搭精选推荐 Dump 任务以动态-商品关系为核心主表,融合动态维度的多源流批特征数据(如内容特征基础表、内容审核表、天级离线统计特征表等),利用DAG 编排构建动态-商品的大宽表。

穿搭精选推荐链路

五、未来规划

平台能力持续增强

  • 算子体系完善: 基于业务场景持续增强关键算子(如维表动态更新、Service 服务化算子、UDTF 部署优化等)和优化调度流程,强化数据处理灵活性;
  • 性能深度优化: 引入任务剪枝、智能倾斜治理等策略,提升资源利用率与执行效率;
  • 可观测性升级: 构建覆盖全局大盘与任务粒度的监控体系,完善资源消耗追踪、Debug 与全链路 Trace 能力,夯实平台稳定性与运维支撑基础。

深化协同共建,释放平台价值

  • 纵向提效: 聚焦索引构建效率攻坚,与索引平台深度协同重构数据同步链路。以社区搜索大宽表为例,当前同步耗时近3小时,通过消除冗余中间状态、精简处理流程,可以实现索引构建端到端提速,显著压缩数据准备周期。
  • 横向赋能: 平台能力已在社区域多业务场景完成验证,后续可以联动其他业务场景共建;同时平台的子功能也具有通用能力,可将数据回归验证、索引监控大盘等高复用能力模块化开放,赋能各业务线“即插即用”,加速技术资产沉淀与跨域协同创新。

大禹未来规划

往期回顾

1.基于 Cursor Agent 的流水线 AI CR 实践|得物技术

2.从IDE到Terminal:适合后端宝宝体质的Claude Code工作流|得物技术

3.AI编程能力边界探索:基于 Claude Code 的 Spec Coding 项目实战|得物技术

4.搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

5.得物社区搜推公式融合调参框架-加乘树3.0实战

文 /野雨

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

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

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

Matrix获取卡顿堆栈 (Point Stack)

作者 KangJX
2026年3月17日 17:23

matrix 是腾讯微信团队开源的一套移动端性能监控与分析框架,核心目标是帮助开发者定位、解决移动端(iOS/Android)应用的性能问题,是微信内部大规模验证过的成熟工具,本文通过阅读源码,详细介绍了针对卡顿日志获取的核心原理。

Matrix 通过周期性采集主线程堆栈并保存在循环数组中,在检测到卡顿时,使用 Point Stack 算法找出最有可能导致卡顿的堆栈。

核心思想

时间流逝
   ↓
每 50ms 采集一次主线程堆栈
   ↓
保存到循环数组(固定大小,如 20 个)
   ↓
检测到卡顿时
   ↓
分析循环数组,找出 Point Stack(最可能导致卡顿的堆栈)
   ↓
生成卡顿报告

设计目标

目标 实现方式
及时性 每 50ms 采集一次,不错过卡顿过程
完整性 保存一个周期内的所有堆栈(通常 20 个)
准确性 通过算法找出真正导致卡顿的堆栈
高效性 固定大小循环数组,避免内存膨胀
低开销 CPU 占用 < 3%,不影响用户体验

核心时间参数

三个关键参数

// 1. RunLoop 超时阈值(卡顿判定阈值)
static useconds_t g_RunLoopTimeOut = 2000000;  // 2000ms = 2秒
// 作用:超过此时间判定为卡顿

// 2. 检查周期(单次采集周期)
static useconds_t g_CheckPeriodTime = 1000000;  // 1000ms = 1秒
// 作用:一轮堆栈采集的总时间,通常为超时阈值的一半

// 3. 堆栈采集间隔
static useconds_t g_PerStackInterval = 50000;  // 50ms
// 作用:每次堆栈采集之间的时间间隔

参数关系

┌────────────────────────────────────────────────┐
│ g_RunLoopTimeOut (2秒) - 卡顿判定阈值          │
└────────────────────────────────────────────────┘
                    │
                    ├─ 一半
                    ↓
┌────────────────────────────────────────────────┐
│ g_CheckPeriodTime (1秒) - 检查周期             │
└────────────────────────────────────────────────┘
                    │
                    ├─ 除以
                    ↓
┌────────────────────────────────────────────────┐
│ g_PerStackInterval (50ms) - 堆栈间隔           │
└────────────────────────────────────────────────┘
                    ↓
┌────────────────────────────────────────────────┐
│ g_MainThreadCount = 1000ms / 50ms = 20         │
│ (循环数组大小 = 一个周期内采集的堆栈数量)      │
└────────────────────────────────────────────────┘

时间轴示意

时间线(以2秒卡顿为例):

T=0ms      开始监控
|
T=50ms     采集第1个堆栈 ← S0
|
T=100ms    采集第2个堆栈 ← S1
|
T=150ms    采集第3个堆栈 ← S2
|
...
|
T=950ms    采集第19个堆栈 ← S18
|
T=1000ms   采集第20个堆栈 ← S19  ← 完成一轮采集
|          ↓
|          检查是否卡顿(检查RunLoop执行时间)
|          如果未卡顿,进入下一轮采集
|
T=1050ms   采集第21个堆栈 ← S20(覆盖S0)
|
...
|
T=2000ms   检查发现 RunLoop 执行超过 2秒
|          ↓
|          触发卡顿检测
|          ↓
|          分析循环数组中的 20 个堆栈
|          ↓
|          找出 Point Stack(最可能导致卡顿的堆栈)
|          ↓
|          生成卡顿报告

堆栈获取流程

整体流程图

┌──────────────────────────────────────────┐
│ 监控线程主循环                            │
│ while (YES) {                            │
│   check();            // 检测卡顿         │recordCurrentStack(); // 采集堆栈       │
│ }                                        │
└──────────────────────────────────────────┘
                    ↓
┌──────────────────────────────────────────┐
│ recordCurrentStack() 方法                │
│                                          │
│ 外层循环:遍历检查周期                    │
│   nTotalCnt = m_nIntervalTime /         │
│               g_CheckPeriodTime         │
│   通常 = 1000ms / 1000ms = 1次          │
│                                          │
│   内层循环:在一个周期内多次采集          │
│     intervalCount = g_CheckPeriodTime / │
│                     g_PerStackInterval  │
│     通常 = 1000ms / 50ms = 20次         │
│                                          │
│     每次循环:                            │
│       1. usleep(50ms)  // 等待          │2. 获取主线程堆栈                   │
│       3. 添加到循环数组                   │
└──────────────────────────────────────────┘

详细步骤

步骤 1:初始化
// 在 WCBlockMonitorMgr 的 start 方法中
- (void)start {
    // 计算循环数组大小
    g_MainThreadCount = g_CheckPeriodTime / g_PerStackInterval;
    // 例如:1000ms / 50ms = 20
    
    // 创建主线程堆栈处理器
    m_pointMainThreadHandler = [[WCMainThreadHandler alloc] 
                                 initWithCycleArrayCount:g_MainThreadCount];
    
    // g_MainThreadCount = 20
    // 意味着循环数组可以保存 20 个堆栈
}
步骤 2:周期性采集
- (void)recordCurrentStack {
    // ================================================================
    // 外层循环:决定执行几个检查周期
    // ================================================================
    // 正常情况:m_nIntervalTime = 1000ms
    // 退火情况:m_nIntervalTime = 2000ms, 3000ms, 5000ms...
    unsigned long nTotalCnt = m_nIntervalTime / g_CheckPeriodTime;
    
    for (int nCnt = 0; nCnt < nTotalCnt && !m_bStop; nCnt++) {
        // 记录本轮开始时间(用于检测系统挂起)
        gettimeofday(&m_recordStackTime, NULL);
        
        if (g_MainThreadHandle) {
            // ========================================================
            // 内层循环:在一个检查周期内多次采集
            // ========================================================
            // intervalCount = 1000ms / 50ms = 20
            int intervalCount = g_CheckPeriodTime / g_PerStackInterval;
            
            for (int index = 0; index < intervalCount && !m_bStop; index++) {
                // 1️⃣ 等待 50ms
                usleep(g_PerStackInterval);  // 50000 微秒 = 50ms
                
                // 2️⃣ 分配内存
                size_t stackBytes = sizeof(uintptr_t) * g_StackMaxCount;
                uintptr_t *stackArray = (uintptr_t *)malloc(stackBytes);
                if (stackArray == NULL) {
                    continue;  // 内存分配失败,跳过本次
                }
                
                // 3️⃣ 初始化
                __block size_t nSum = 0;
                memset(stackArray, 0, stackBytes);
                
                // 4️⃣ 获取主线程堆栈
                [WCGetMainThreadUtil
                 getCurrentMainThreadStack:^(NSUInteger pc) {
                     stackArray[nSum] = (uintptr_t)pc;  // 保存程序计数器
                     nSum++;
                 }
                 withMaxEntries:g_StackMaxCount        // 最大100个栈帧
                 withThreadCount:g_CurrentThreadCount];
                
                // 5️⃣ 添加到循环数组
                [m_pointMainThreadHandler addThreadStack:stackArray 
                                           andStackCount:nSum];
                // 注意:stackArray 的所有权转移给 m_pointMainThreadHandler
            }
        }
        
        // ============================================================
        // 检测是否被系统挂起
        // ============================================================
        struct timeval tvCur;
        gettimeofday(&tvCur, NULL);
        unsigned long long diff = [WCBlockMonitorMgr diffTime:&m_recordStackTime 
                                                      endTime:&tvCur];
        
        if (diff > DETECTION_THREAD_JUDGE_SUSPEND_THRESHOLD) {
            // 实际消耗时间 > 10秒,说明被挂起了
            gettimeofday(&g_tvRun, NULL);  // 更新时间,避免误报
            MatrixInfo(@"挂起后运行,差值 %llu", diff);
            return;
        }
    }
}
步骤 3:获取主线程堆栈(底层实现)
// WCGetMainThreadUtil 内部使用 backtrace
+ (void)getCurrentMainThreadStack:(StackCallback)callback 
                   withMaxEntries:(size_t)maxEntries
                  withThreadCount:(NSUInteger)threadCount {
    // 1. 获取主线程
    thread_t mainThread = pthread_mach_thread_np(pthread_main_thread_np());
    
    // 2. 暂停主线程(非常短暂,微秒级)
    thread_suspend(mainThread);
    
    // 3. 获取线程状态
    _STRUCT_MCONTEXT machineContext;
    mach_msg_type_number_t state_count = THREAD_STATE_COUNT;
    kern_return_t kr = thread_get_state(mainThread,
                                        THREAD_STATE,
                                        (thread_state_t)&machineContext.__ss,
                                        &state_count);
    
    // 4. 回溯堆栈
    if (kr == KERN_SUCCESS) {
        uintptr_t backtraceBuffer[maxEntries];
        size_t backtraceLength = ksbt_backtraceLength(&machineContext);
        
        // 遍历堆栈帧
        for (size_t i = 0; i < backtraceLength && i < maxEntries; i++) {
            uintptr_t pc = ksbt_framePointer(&machineContext, i);
            callback(pc);  // 回调传递每个栈帧的地址
        }
    }
    
    // 5. 恢复主线程
    thread_resume(mainThread);
}

循环数组存储机制

数据结构设计

@interface WCMainThreadHandler {
    // ================================================================
    // 循环数组配置
    // ================================================================
    int m_cycleArrayCount;  // 数组大小,例如 20
    
    // ================================================================
    // 循环数组(核心存储结构)
    // ================================================================
    uintptr_t **m_mainThreadStackCycleArray;  // 二维数组
    // 第一维:堆栈索引 [0, 19]
    // 第二维:堆栈地址数组 uintptr_t[]
    
    size_t *m_mainThreadStackCount;  // 每个堆栈的深度
    // 例如:[50, 48, 52, ..., 45]
    
    uint64_t m_tailPoint;  // 尾指针,指向下一个写入位置
    
    // ================================================================
    // 分析数据(用于 Point Stack 算法)
    // ================================================================
    size_t *m_topStackAddressRepeatArray;  // 栈顶地址连续重复次数
    // 例如:[0, 1, 2, 0, 1, 0, ...]
    
    int *m_mainThreadStackRepeatCountArray;  // Point Stack 地址总重复次数
    // 动态分配,在找到 Point Stack 后创建
}

循环数组可视化

初始化状态(m_cycleArrayCount = 5):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │NULL │NULL │NULL │NULL │NULL │
       └─────┴─────┴─────┴─────┴─────┘
          ↑
     m_tailPoint = 0


添加第 1 个堆栈(S0):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S0  │NULL │NULL │NULL │NULL │
       └─────┴─────┴─────┴─────┴─────┘
               ↑
          m_tailPoint = 1


添加第 2-5 个堆栈:

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S0  │ S1  │ S2  │ S3  │ S4  │
       └─────┴─────┴─────┴─────┴─────┘
          ↑
     m_tailPoint = 0 (回绕)


添加第 6 个堆栈(S5,覆盖 S0):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S5  │ S1  │ S2  │ S3  │ S4  │
       └─────┴─────┴─────┴─────┴─────┘
               ↑
          m_tailPoint = 1

时间顺序:S1 → S2 → S3 → S4 → S5(最新)

添加堆栈的实现

- (void)addThreadStack:(uintptr_t *)stackArray 
         andStackCount:(size_t)stackCount {
    if (stackArray == NULL) {
        return;
    }
    
    pthread_mutex_lock(&m_threadLock);
    
    // ================================================================
    // 1. 将堆栈写入循环数组
    // ================================================================
    
    // 如果当前位置已有堆栈,先释放旧的
    if (m_mainThreadStackCycleArray[m_tailPoint] != NULL) {
        free(m_mainThreadStackCycleArray[m_tailPoint]);
    }
    
    // 保存新堆栈
    m_mainThreadStackCycleArray[m_tailPoint] = stackArray;
    m_mainThreadStackCount[m_tailPoint] = stackCount;
    
    // ================================================================
    // 2. 统计栈顶地址连续重复次数(核心!)
    // ================================================================
    
    // 计算上一个位置的索引
    uint64_t lastTailPoint = (m_tailPoint + m_cycleArrayCount - 1) % m_cycleArrayCount;
    
    // 获取上一个堆栈的栈顶地址
    uintptr_t lastTopStack = 0;
    if (m_mainThreadStackCycleArray[lastTailPoint] != NULL) {
        lastTopStack = m_mainThreadStackCycleArray[lastTailPoint][0];
    }
    
    // 获取当前堆栈的栈顶地址
    uintptr_t currentTopStackAddr = stackArray[0];
    
    // 比较栈顶地址
    if (lastTopStack == currentTopStackAddr) {
        // 栈顶地址相同,累加重复次数
        size_t lastRepeatCount = m_topStackAddressRepeatArray[lastTailPoint];
        m_topStackAddressRepeatArray[m_tailPoint] = lastRepeatCount + 1;
    } else {
        // 栈顶地址不同,重置重复次数
        m_topStackAddressRepeatArray[m_tailPoint] = 0;
    }
    
    // ================================================================
    // 3. 移动尾指针
    // ================================================================
    
    m_tailPoint = (m_tailPoint + 1) % m_cycleArrayCount;
    
    pthread_mutex_unlock(&m_threadLock);
}

栈顶地址重复次数统计示例

假设连续采集到以下堆栈(简化为栈顶地址):

时间: T0   T50  T100 T150 T200 T250 T300 T350 T400
索引:  0    1    2    3    4    5    6    7    8
堆栈: S0   S1   S2   S3   S4   S5   S6   S7   S8
栈顶: A    B    C    C    C    C    C    D    D

m_topStackAddressRepeatArray 的值:
     [0,   0,   0,   1,   2,   3,   4,   0,   1]
      ↑    ↑    ↑    ↑    ↑    ↑    ↑    ↑    ↑
      A    B    C   CCCCD   D重
     首次  首次 首次  复2345  首次  复2

分析:
- S6(索引6)的栈顶地址 C 连续重复了 4 次(从 S2S6- 说明主线程在函数 C 上停留了 5 × 50ms = 250ms
- S6 就是最有可能导致卡顿的堆栈(Point Stack

什么是 Point Stack?

Point Stack(关键堆栈) 是指在一个检查周期内,最有可能导致卡顿的主线程堆栈

一、核心数据结构

1. 循环数组配置

变量名 类型 说明
m_cycleArrayCount int 循环数组大小(例如:20)
m_tailPoint uint64_t 循环数组尾指针,指向下一个写入位置
pthread_mutex_t m_threadLock 线程锁,保护循环数组的并发访问

循环数组原理:

数组大小 = 检查周期 / 堆栈间隔
例如:1000ms / 50ms = 20

索引:    0    1    2    3    4    ...   19
       ┌────┬────┬────┬────┬────┬ ─ ─ ┬────┐
堆栈:   │ S0 │ S1 │ S2 │    │    │     │    │
       └────┴────┴────┴─▲──┴────┴ ─ ─ ┴────┘
                        │
                   m_tailPoint

当数组满时,从头开始覆盖(FIFO)

2. 堆栈存储数组(二维数组)

变量名 类型 维度 说明
m_mainThreadStackCycleArray uintptr_t ** [cycleArrayCount][stackDepth] 堆栈地址二维数组
m_mainThreadStackCount size_t * [cycleArrayCount] 每个堆栈的深度数组

数据结构示意:

m_mainThreadStackCycleArray:
  [0] → [0x1000, 0x2000, 0x3000, ...]  // 第0个堆栈,深度=3
  [1] → [0x1000, 0x2000, 0x3000, ...]  // 第1个堆栈,深度=3
  [2] → [0x1000, 0x2000, 0x4000, ...]  // 第2个堆栈,深度=3
  ...
  [19] → NULL                          // 尚未写入

m_mainThreadStackCount:
  [0] = 3   // 第0个堆栈深度
  [1] = 3   // 第1个堆栈深度
  [2] = 3   // 第2个堆栈深度
  ...
  [19] = 0  // 尚未写入

3. 栈顶重复次数数组

变量名 类型 说明
m_topStackAddressRepeatArray size_t * 每个堆栈的栈顶地址连续重复次数

用途: 找出 Point Stack(栈顶重复次数最多的堆栈)

数据示例:

假设连续采集的堆栈栈顶地址:
索引:    0      1      2      3      4
栈顶:    A      A      A      B      B

m_topStackAddressRepeatArray:
       [0]    [1]    [2]    [3]    [4]
       0      1      2      0      1

解释:
- 索引0: 第一次出现A,重复0- 索引1: 第二次出现A(与前一个相同),重复1- 索引2: 第三次出现A(与前一个相同),重复2- 索引3: 出现B(改变了),重复0- 索引4: 第二次出现B(与前一个相同),重复1次

结果:索引2的重复次数最多(2次),所以索引2Point Stack

4. Point Stack地址重复次数数组

变量名 类型 说明
m_mainThreadStackRepeatCountArray int * Point Stack中每个地址的总重复次数(动态分配)

用途: 统计 Point Stack 中每个地址在所有堆栈中的总出现次数,识别热点函数

数据示例:

假设有4个堆栈,Point Stack是索引2Stack 0:        Stack 1:        Stack 2(Point):  Stack 3:
0x1000          0x1000          0x1000          0x1000
0x2000          0x2000          0x2000          0x2000
0x3000          0x3000          0x3000          0x4000
0x4000          0x5000          0x6000

Point Stack (索引2) 的地址:
  [0] = 0x1000
  [1] = 0x2000
  [2] = 0x3000

统计结果 m_mainThreadStackRepeatCountArray:
  [0] = 4   // 0x1000 在4个堆栈中都出现
  [1] = 4   // 0x2000 在4个堆栈中都出现
  [2] = 3   // 0x3000 在3个堆栈中出现

符号化后:
  [0] main           (4次) ← 所有堆栈都有,基础函数
  [1] viewDidLoad    (4次) ← 所有堆栈都有,入口函数
  [2] heavyWork      (3次) ← 75%的时间在这里,瓶颈!⚠️

image.png

算法流程

总体流程图

开始
  ↓
1. 查找最大重复次数
  ↓
2. 按时间顺序找出第一个等于最大值的堆栈索引
  ↓
3. 复制 Point Stack
  ↓
4. 计算 Point Stack 中每个地址的总重复次数
  ↓
5. 创建 KSStackCursor 并返回
  ↓
结束

步骤 1:查找最大重复次数

目的: 找出 m_topStackAddressRepeatArray 中的最大值。

size_t maxValue = 0;
BOOL trueStack = NO;

// 第一次遍历:只找最大值(不记录索引)
for (int i = 0; i < m_cycleArrayCount; i++) {
    size_t currentValue = m_topStackAddressRepeatArray[i];
    if (currentValue >= maxValue) {
        maxValue = currentValue;
        trueStack = YES;
    }
}

if (!trueStack) {
    return NULL;  // 没有有效堆栈
}

步骤 2:找出 Point Stack 的索引

目的: 按时间顺序(从新到旧)找第一个重复次数等于 maxValue 的堆栈。

size_t currentIndex = (m_tailPoint + m_cycleArrayCount - 1) % m_cycleArrayCount;

// 第二次遍历:按时间顺序(从新到旧)
for (int i = 0; i < m_cycleArrayCount; i++) {
    // 计算真实索引
    int trueIndex = (m_tailPoint + m_cycleArrayCount - i - 1) % m_cycleArrayCount;
    
    // 找到第一个等于最大值的
    if (m_topStackAddressRepeatArray[trueIndex] == maxValue) {
        currentIndex = trueIndex;
        break;  // 找到最新的,立即停止
    }
}

索引计算公式:

trueIndex = (m_tailPoint + m_cycleArrayCount - i - 1) % m_cycleArrayCount

参数说明:
- m_tailPoint: 下一个要写入的位置
- i: 遍历变量(0 = 最新,1 = 次新,...)
- m_cycleArrayCount: 数组大小(如20)

例子:
假设 m_tailPoint = 1, m_cycleArrayCount = 5

i=0: trueIndex = (1+5-0-1) % 5 = 0  → 最新堆栈
i=1: trueIndex = (1+5-1-1) % 5 = 4  → 次新堆栈
i=2: trueIndex = (1+5-2-1) % 5 = 3  → 第三新堆栈
i=3: trueIndex = (1+5-3-1) % 5 = 2  → 第四新堆栈
i=4: trueIndex = (1+5-4-1) % 5 = 1  → 最旧堆栈(空)

步骤 3:复制 Point Stack

size_t stackCount = m_mainThreadStackCount[currentIndex];
size_t pointThreadSize = sizeof(uintptr_t) * stackCount;
uintptr_t *pointThreadStack = (uintptr_t *)malloc(pointThreadSize);

// 复制堆栈地址
for (size_t idx = 0; idx < stackCount; idx++) {
    pointThreadStack[idx] = m_mainThreadStackCycleArray[currentIndex][idx];
}

步骤 4:计算地址总重复次数

三层循环统计:

// 分配重复次数数组
m_mainThreadStackRepeatCountArray = (int *)malloc(stackCount * sizeof(int));
memset(m_mainThreadStackRepeatCountArray, 0, stackCount * sizeof(int));

// 外层循环:遍历 Point Stack 的每个地址
for (size_t i = 0; i < stackCount; i++) {
    uintptr_t targetAddress = m_mainThreadStackCycleArray[currentIndex][i];
    
    // 中层循环:遍历循环数组中的每个堆栈
    for (int innerIndex = 0; innerIndex < m_cycleArrayCount; innerIndex++) {
        size_t innerStackCount = m_mainThreadStackCount[innerIndex];
        
        // 内层循环:遍历当前堆栈的每个地址
        for (size_t idx = 0; idx < innerStackCount; idx++) {
            // 比较是否匹配
            if (targetAddress == m_mainThreadStackCycleArray[innerIndex][idx]) {
                m_mainThreadStackRepeatCountArray[i] += 1;
            }
        }
    }
}

算法分析:

  • 时间复杂度:O(n × m × k)
    • n = Point Stack 深度(通常 < 100)
    • m = 循环数组大小(通常 20)
    • k = 平均堆栈深度(通常 < 50)
  • 实际数据量很小,性能可接受

步骤 5:创建 KSStackCursor

KSStackCursor *pointCursor = (KSStackCursor *)malloc(sizeof(KSStackCursor));
kssc_initWithBacktrace(pointCursor, pointThreadStack, (int)stackCount, 0);
return pointCursor;

作用: 将原始堆栈数组包装成 KSCrash 能使用的标准格式。


至于堆栈的获取,可以参考我的另一篇文章ARM64 调用栈回溯原理

iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

作者 iceiceiceice
2026年3月5日 18:13

iOS PDF 阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

目标读者:有 PDFKit 使用经验的 iOS 开发者。
本文重点:几何分块算法、段落识别逻辑、跨栏语义合并三个核心难点。


背景:段评是什么,难在哪里

杂志类 App 有一个常见需求——用户长按某段正文,划出一段话,然后对这段话写评论。这个交互在微信读书、Kindle 里都很成熟,但它们针对的是结构化的电子书格式(ePub、MOBI),正文结构天然清晰。

PDF 没有这种结构。一份杂志 PDF 在底层只有一堆带坐标的"文字片段"(glyph run),没有段落、没有栏、没有语义层次。PDFKit 提供的 PDFSelectionselectionsByLine 能给你"行",但它不知道哪些行属于同一个段落,也不知道这一页有几栏。

因此,段评的核心问题是:给定用户选中的一行文字,如何还原它所在的完整自然段?

这个问题比想象中复杂,主要难点有三个:

  1. 几何噪声:PDF 的行坐标存在浮点误差,标题、页码、图注混杂其中,必须过滤。
  2. 多栏布局:杂志常见双栏、三栏排版,阅读顺序不是简单地从上到下。
  3. 跨栏断段:一个自然段可能从左栏末尾延续到右栏开头,PDFKit 对此一无所知。

XLPDFParagraphEngine 的设计思路,就是用纯几何方法逐层解决这三个问题。


整体架构:四层流水线

整个引擎的入口是:

/// 自定义PDFView里面获取menu
+ (NSString *)paragraphTextFromSelection:(PDFSelection *)selection
                                document:(PDFDocument *)document;

它的内部执行路径是一条清晰的四层流水线:

PDFSelection
    │
    ▼
① buildLinesFromSelection     — 行提取 + 噪声过滤
    │
    ▼
② buildBlocksFromLinesIteratively  — 几何连通分块
    │
    ▼
③ readingOrderForBlock         — 列识别 + 段落切分
    │
    ▼
④ mergeSemanticContinuousBlocks — 跨栏语义合并
    │
    ▼
paragraphTextFromLines         — 拼接文本输出

每一层解决一个独立问题,下面逐层展开。


第一层:行提取与噪声过滤

PDFKit 的 selectionsByLine 会把选区内的每一行作为独立的 PDFSelection 返回,这是我们的原始数据源。但原始数据有大量噪声需要清理。

/// 获取所有lines
+ (NSArray<XLPDFLine *> *)buildLinesFromPage:(PDFPage *)page document:(PDFDocument *)document {
    CGRect pageRect = [page boundsForBox:kPDFDisplayBoxMediaBox];
    PDFSelection *pageSelection = [page selectionForRect:pageRect];
    return [self buildLinesFromBaseSelection:pageSelection document:document];
}

/// 获取选中的lines
+ (NSArray<XLPDFLine *> *)buildLinesFromSelection:(PDFSelection *)selection document:(PDFDocument *)document {
    return [self buildLinesFromBaseSelection:selection document:document];
}

+ (NSArray<XLPDFLine *> *)buildLinesFromBaseSelection:(PDFSelection *)baseSelection document:(PDFDocument *)document {

    NSMutableArray<XLPDFLine *> *lines = [NSMutableArray array];

    NSArray<PDFPage *> *pages = baseSelection.pages;

    for (PDFSelection sel in baseSelection.selectionsByLine) {

        NSString *text = sel.string;
        if (text.length == 0) continue;

        // 找到当前行所属 page
        PDFPage *linePage = nil;
        CGRect rect = CGRectZero;

        for (PDFPage *page in pages) {
            rect = [sel boundsForPage:page];
            if (!CGRectIsEmpty(rect)) {
                linePage = page;
                break;
            }
        }

        if (!linePage) continue;

        if (CGRectIsEmpty(rect)) continue;

        // ========= 公共过滤逻辑 =========

        CGFloat width = CGRectGetWidth(rect);

        CGFloat height = CGRectGetHeight(rect);

        // 过滤竖排
        if (text.length > 1 && height > width * 2.0) continue;
        // 过滤异常高度
        CGRect pageRect = [linePage boundsForBox:kPDFDisplayBoxMediaBox];
        CGFloat pageHeight = CGRectGetHeight(pageRect);
        if (height > pageHeight * 0.05) continue;

  
        NSString *trimText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

        if (trimText.length == 0) continue;

        // 过滤纯数字编号(01、02、1、2、一、二 等页码/序号)
        NSString *numberPattern = @"^\\s*[零一二三四五六七八九十百\\d]+[、.]?\\s*$";
        NSPredicate *numberPredicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", numberPattern];
        if ([numberPredicate evaluateWithObject:trimText]) continue;

        // ========= 构建模型 =========
        XLPDFLine *line = [XLPDFLine new];
        line.selection = sel;
        line.page = linePage;
        line.rect = rect;
        line.text = trimText;

        NSInteger pageIndex = [document indexForPage:linePage];
        line.pageIndex = pageIndex == NSNotFound ? -1 : pageIndex;
        line.font = [**self** dominantFontFromSelection:sel]; // 提取该行的主体字体
        [lines addObject:line];
    }

    return lines;
}

这里的过滤策略针对杂志 PDF 的典型噪声:

  • 竖排文字:部分杂志有竖向装饰文字,height > width * 2.0 可以有效识别并排除。
  • 异常高度:正文行高通常不超过页面高度的 5%,超出这个比例的往往是大图、横幅或装饰元素。
  • 页码与序号:正则 ^[零一二三四五六七八九十百\d]+[、.]?$ 可以匹配中英文页码和列表编号,避免它们干扰后续分段判断。

过滤完成后,每一行被封装成 XLPDFLine 模型,携带 textrectpagepageIndex 等属性,供后续层使用。


第二层:几何连通分块

拿到干净的行列表后,下一个问题是:这一页上有几个独立的文字区域?

杂志版式复杂,一页上可能同时存在主正文区、侧边栏、图注、引言框等多个互不相连的文字区域。如果不先区分这些区域,段落识别就会跨区混淆。

引擎使用了一个经典的**几何连通图(Connected Components)**算法:

将每一行的 rect 向外膨胀(inflate)半个行高
如果两行膨胀后的 rect 有交叉 → 认为它们"连通"
对所有行做图的连通分量遍历 → 每个连通分量就是一个 Block

膨胀量选择行高的 50%,是一个关键的经验值设定:

+ (BOOL)linesConnected:(XLPDFLine *)a other:(XLPDFLine *)b {
    CGFloat insetA = a.rect.size.height * 0.5;
    CGFloat insetB = b.rect.size.height * 0.5;
    CGRect ra = CGRectInset(a.rect, -insetA, -insetA);
    CGRect rb = CGRectInset(b.rect, -insetB, -insetB);
    return CGRectIntersectsRect(ra, rb);
}

为什么不用固定像素值?因为杂志里的字号差异很大——正文可能是 10pt,大标题可能是 36pt。固定像素膨胀会导致小字号的脚注与正文粘连,或者大标题与相邻栏文字误连。用行高比例膨胀,让每行的"感知范围"与自身字号成正比,鲁棒性更好。

连通分量的遍历使用迭代 DFS:

+ (NSArray *)buildBlocksFromLinesIteratively:(NSArray *)lines {
    NSMutableArray *remaining = [lines mutableCopy];
    NSMutableArray *resultBlocks = [NSMutableArray array];

    while (remaining.count > 0) {
        NSArray *block = [self buildSingleBlockFromLines:remaining];
        [resultBlocks addObject:block];
        [remaining removeObjectsInArray:block];
    }
    return resultBlocks;
}

每次从剩余行中任取一行作为起点,BFS/DFS 扩展出整个连通分量,然后从剩余集合中移除,直到所有行都被分配完毕。


第三层:阅读顺序还原与段落切分

每个 Block 内部可能还有多列(例如一个双栏正文区,在几何上是一个连通分量)。这一层先识别列,再在每列内部切分段落。

3.1 列识别:X 轴区间合并

+ (NSArray<XLPDFLine *> *)readingOrderForBlock:(NSArray<XLPDFLine *> *)block {
 
    NSArray *ranges = [self xRangesFromBlock:block]; // 投影到X轴:x+w
    NSArray *columnRanges = [self mergeXRanges:ranges]; // 算出有多少列
    NSArray *columns = [self splitBlock:block intoColumns:columnRanges]; // 划入列里
    NSMutableArray *result = [NSMutableArray array];
    NSInteger paragraphIndex = 0;

    // 列里面直接按照Y排序即可
    for (NSArray<XLPDFLine *> *column in columns) {
        // 分段
        NSArray *ordered = [self readingOrderForColumnByIndentOnly:column paragraphStartIndex:&paragraphIndex];
        [result addObjectsFromArray:ordered];
    }

    return result;
}

具体做法:把 Block 内每一行的 [minX, maxX] 区间收集起来,按 minX 排序后做区间合并(sweep line),相互重叠或相接的区间合并为一个列边界。最终得到若干互不重叠的列区间,每一行按其中心 X 坐标归入对应的列。

这个方法的优势是完全不依赖任何先验知识,无论一页有几栏、栏宽是否均等,都能正确识别。

3.2 段落切分:三条几何规则

列识别完成后,列内的行按 Y 坐标从上到下排好序。接下来要判断相邻两行是否属于同一段落,引擎使用了三条互补的规则:

+ (NSArray<XLPDFLine *> *)readingOrderForColumnByIndentOnly:(NSArray<XLPDFLine *> *)column paragraphStartIndex:(NSInteger *)paragraphIndex {

    NSArray<XLPDFLine *> *sorted = [self sortLinesByYDescending:column];
    CGFloat baseMinX = [self baseMinXForColumn:sorted];
    CGFloat baseMaxX = [self baseMaxXForColumn:sorted];
    CGFloat columnWidth = baseMaxX - baseMinX;

    [sorted enumerateObjectsUsingBlock:^(XLPDFLine * _Nonnull line, NSUInteger idx, BOOL * _Nonnull stop) {

        if (idx > 0) {

            XLPDFLine *prevLine = sorted[idx - 1];

            // 条件1:当前行首行缩进
            CGFloat indent = CGRectGetMinX(line.rect) - baseMinX;
            BOOL currentLineIsHead = indent > 10.0;

            // 条件2:上一行是尾行(右侧留白超过列宽 10%)
            CGFloat prevLineTrailingGap = baseMaxX - CGRectGetMaxX(prevLine.rect);
            BOOL prevLineIsTail = prevLineTrailingGap > columnWidth * 0.1;

            // 条件3:行间距超过行高阈值
            CGFloat gap = CGRectGetMinY(prevLine.rect) - CGRectGetMaxY(line.rect);
            CGFloat lineHeight = CGRectGetHeight(line.rect);
            BOOL hasLargeGap = gap > lineHeight * 0.8;

            if (currentLineIsHead || prevLineIsTail || hasLargeGap) {
                (*paragraphIndex)++;
            }
        }

        line.paragraphIndex = *paragraphIndex;
    }];

    return sorted;

}

规则1(首行缩进) 是中文排版最常见的段落标记,10pt 的阈值约等于一个汉字的宽度。

规则2(末行留白) 是规则1的补充:段落末行通常不会写满整行。15% 的阈值过滤掉因行尾标点导致的微小留白,同时能识别出明显的短尾行。注意这里使用的 baseMaxX 是列内所有行 maxX 的中位数,而不是最大值,这样对行尾有标点突出的情况更鲁棒。

规则3(行间距) 用于处理无缩进、无留白但通过空行分隔的段落风格(英文排版常见)。

三条规则取 OR,任意一条满足就认为新段落开始,paragraphIndex 递增。


第四层:跨栏语义合并(最难的部分)

前三层解决了单个 Block 内部的问题,但杂志双栏排版有一个特殊情况:

一个自然段从左栏末尾开始,写满后在右栏顶部继续。

这两部分在几何上属于不同的 Block(左栏和右栏不连通),但语义上是同一个段落。这就是跨栏语义合并问题。

4.1 判断标准:段尾 + 段首

合并的充要条件是:左栏某 Block 的最后一行是段尾行(Tail) ,同时右栏某 Block 的第一行是段首行(Head) ,且两者字号一致。

段尾判断:末行写满(右侧留白 < 列宽10%)且没有句末标点(。!?;…等):

+ (BOOL)isTailBlock:(NSArray *)block {
    XLPDFLine *lastLine = block.lastObject;
    CGFloat trailingGap = columnMaxX - CGRectGetMaxX(lastLine.rect);
    BOOL noTrailingGap  = trailingGap <= columnWidth * 0.1;
    BOOL noEndingSymbol = ![self lineEndsWithParagraphSymbol:lastLine];
    return noTrailingGap && noEndingSymbol;
}

段尾判断:判断一行是否以句末标点结尾(处理引号包裹和英文小数)

+ (BOOL)lineEndsWithParagraphSymbol:(XLPDFLine *)line {

    NSCharacterSet *endingSymbols = [NSCharacterSet characterSetWithCharactersInString:@"。!?;.!?;…"];
    NSCharacterSet *wrapperSet =
    [NSCharacterSet characterSetWithCharactersInString:@"”’\"'))】》〉 "];
    NSString *trimmed = [line.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

    if (trimmed.length == 0) return NO;

    NSInteger index = (NSInteger)trimmed.length - 1;

    // 跳过包裹字符
    while (index >= 0 &&
           [wrapperSet characterIsMember:[trimmed characterAtIndex:index]]) {
        index--;
    }

    if (index < 0) return NO;

    unichar c = [trimmed characterAtIndex:index];

    // 英文小数点不算句末(3.14)
    if (c == '.' && index > 0 &&
        [[NSCharacterSet decimalDigitCharacterSet]
         characterIsMember:[trimmed characterAtIndex:index - 1]]) {
        return NO;
    }

  
    return [endingSymbols characterIsMember:c];
}

段首判断:首行无明显缩进(缩进量 ≤ 10pt),说明这一行是接续上一栏的内容,而不是新段落的起点:

+ (BOOL)isHeadBlock:(NSArray *)block {
    XLPDFLine *firstLine = block.firstObject;
    CGFloat indent = CGRectGetMinX(firstLine.rect) - columnMinX;
    return indent <= 10.0;
}

解决多栏PDF的跨列语义连续问题

/// blocks先要进行font过滤,保留主体字体的block进行跨列连续合并
/// 先对所有blocks里面的pageLines进行分列(大概2、3列)
/// 将所有block归列,每一列N个block
/// 每一列的从后往前找可能是段尾的block
/// 从第二列内部blocks遍历从前往后判断是否是段首
/// 合并段位和段首,处理blockIndex、paragraphIndex
+ (NSArray<NSArray<XLPDFLine *> *> *)mergeSemanticContinuousBlocks:(NSArray<NSArray<XLPDFLine *> *> *)blocks {

    if (blocks.count < 2) return blocks;
    // 直接从 blocks 展平生成 pageLines
    NSMutableArray<XLPDFLine *> *pageLines = [NSMutableArray array];
    for (NSArray<XLPDFLine *> *block in blocks) {
        [pageLines addObjectsFromArray:block];
    }
    NSArray<NSValue *> *columnRanges = [self detectColumnRanges:pageLines];

    if (columnRanges.count < 2) return blocks;

    // 按列分组(保持列内Y排序)
    NSMutableArray<NSMutableArray *> *columns = [NSMutableArray array];
    for (NSInteger i = 0; i < columnRanges.count; i++) {
        [columns addObject:[NSMutableArray array]];
    }

    for (NSArray<XLPDFLine *> *block in blocks) {

        NSInteger colIdx = [self columnIndexForBlock:block inRanges:columnRanges];
        if (colIdx >= 0) [columns[colIdx] addObject:[block mutableCopy]];
    }

    for (NSMutableArray *column in columns) {
        [column sortUsingComparator:^NSComparisonResult(NSArray *a, NSArray *b) {

            CGFloat maxYA = 0, maxYB = 0;
            for (XLPDFLine *l in a) maxYA = MAX(maxYA, CGRectGetMaxY(l.rect));
            for (XLPDFLine *l in b) maxYB = MAX(maxYB, CGRectGetMaxY(l.rect));
            return maxYA > maxYB ? NSOrderedAscending : NSOrderedDescending;
        }];
    }

    // 跨列合并
    for (NSInteger col = 0; col < (NSInteger)columns.count - 1; col++) {

        NSMutableArray *currentCol = columns[col];
        NSMutableArray *nextCol    = columns[col + 1];
        if (currentCol.count == 0 || nextCol.count == 0) continue;

        CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
        NSMutableArray<XLPDFLine *> *tailBlock = nil;

        for (NSInteger blockIdx = (NSInteger)currentCol.count - 1; blockIdx >= 0; blockIdx--) {
            // 特别注意要倒叙,然后过滤飞主体文本block
            NSMutableArray<XLPDFLine *> *block = currentCol[blockIdx];
            if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
                [self isTailBlock:block]) {
                tailBlock = block;
                break;
            }
        }

        if (!tailBlock) continue;

        NSInteger searchCol = col + 1;
        while (searchCol < (NSInteger)columns.count) {

            NSMutableArray *searchNextCol = columns[searchCol];
            if (searchNextCol.count == 0) {
                searchCol++;
                continue;
            }

            NSArray<XLPDFLine *> *headBlock = nil;
            NSInteger headIdx = -1;
            for (NSInteger i = 0; i < (NSInteger)searchNextCol.count; i++) {
                NSArray<XLPDFLine *> *block = searchNextCol[i];
                if ([self isHeadBlock:block] &&
                    [self blockContainsParagraphEndingSymbol:block] &&
                    [self lineHeightMatches:tailBlock with:block]) {
                    headBlock = block;
                    headIdx   = i;
                    break;
                }
            }

            if (!headBlock) break;

            [self mergeBlock:headBlock intoBlock:tailBlock];
            [searchNextCol removeObjectAtIndex:headIdx];

            if (![self isTailBlock:tailBlock]) break;

            searchCol++;
        }
    }

    // 重整blockIndex + 构建结果数组
    NSMutableArray<NSArray<XLPDFLine *> *> *result = [NSMutableArray array];
    NSInteger idx = 0;
    for (NSMutableArray *column in columns) {
        for (NSArray<XLPDFLine *> *block in column) {
            for (XLPDFLine *line in block) line.blockIndex = idx;
            idx++;
            if ([self blockContainsParagraphEndingSymbol:block] || block.count > 6) {
                [result addObject:block];
            }
        }
    }

    return [result copy];
}

4.2 列检测:中心 X 聚类

跨栏合并需要先知道页面有几列,以及每列的 X 边界。引擎用了一个轻量的聚类方法:

// 收集所有行的中心X,排序后按间隙聚类
// 相邻 centerX 差值超过页宽的 10% → 认为是列间距
CGFloat gapThreshold = CGRectGetWidth(pageRect) * 0.10;

通过这个间隙阈值,可以把所有行的中心 X 分成若干簇,每簇的 [minX, maxX] 加上半行高的 padding 就是列的 X 范围。这比依赖页面宽度平均分割更准确,因为杂志栏宽不一定均等。

4.3 合并过程:倒序扫描 + 链式追踪

for (NSInteger col = 0; col < columns.count - 1; col++) {

    // 在当前列,倒序找最后一个"主体文字"的段尾 Block
    // (倒序是为了跳过可能存在的图注、小标题等非主体 Block)
    NSMutableArray *tailBlock = nil;
    CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
    for (NSInteger i = currentCol.count - 1; i >= 0; i--) {
        NSArray *block = currentCol[i];
        if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
            [self isTailBlock:block]) {
            tailBlock = block;
            break;
        }
    }

    if (!tailBlock) continue;

    // 在下一列,找第一个满足条件的段首 Block
    // 字号一致 + 无缩进 + 含句末标点(保证是正文段落,不是纯标题)
    NSArray *headBlock = nil;
    for (NSArray *block in nextCol) {
        if ([self isHeadBlock:block] &&
            [self blockContainsParagraphEndingSymbol:block] &&
            [self lineHeightMatches:tailBlock with:block]) {
            headBlock = block;
            break;
        }
    }

    // 合并:将 headBlock 的所有行追加进 tailBlock,修正 blockIndex 和 paragraphIndex
    [self mergeBlock:headBlock intoBlock:tailBlock];
    [nextCol removeObject:headBlock];

    // 如果合并后 tailBlock 仍是段尾 → 继续追踪到下下列(三栏情况)
    if ([self isTailBlock:tailBlock]) { /* 继续向右搜索 */ }
}

合并时对 paragraphIndex 的修正是一个容易出错的地方。next Block 的 paragraphIndex 从 0 开始编号,合并时需要续接 prev Block 的最大 paragraphIndex,同时修正 blockIndex 保持一致:

for (XLPDFLine *line in next) {
    line.blockIndex     = prevBlockIndex;
    line.paragraphIndex = (line.paragraphIndex - nextBaseIndex) + maxParagraphIndex;
    [prev addObject:line];
}

段落 ID 的设计

完成以上步骤后,每一行都携带了 pageIndexblockIndexparagraphIndex 三个坐标。段落 ID 由此生成:

mgid_pageIndex_blockIndex_paragraphIndex

例如:mag001_3_2_1 表示杂志 mag001,第 3 页,第 2 个文字区域,第 1 个段落。

这个 ID 有两个关键用途:

写入评论时:通过 paragraphIDFromSelection:document:mgid: 生成 ID,与评论数据一起存储到服务端。

读取评论时:通过 paragraphTextFromParagraphID:document: 反向解析 ID,定位到页面 → Block → paragraphIndex,取出对应行集合,用于高亮展示或文字复原。

反向定位的路径:

// 1. 解析 ID,得到 pageIndex / blockIndex / paragraphIndex
// 2. 取出对应页面
PDFPage *page = [document pageAtIndex:pageIndex];
// 3. 对整页重新执行分块
NSArray *pageBlocks = [self pageLinesBlocksFromPage:page document:document];
// 4. 按 blockIndex 取出对应 Block
NSArray *block = pageBlocks[blockIndex];
// 5. 按 paragraphIndex 过滤出段落行
NSArray *paragraph = [self paragraphLinesForParagraphIndex:paragraphIndex inBlock:block];

评论气泡(PDFAnnotation)的锚点应该定位在段落最后一行的位置,这样气泡显示在段尾更自然,同时把段尾行的位置信息传给服务器,服务端也能精确还原气泡坐标。


几个值得关注的工程细节

同行判断的阈值:PDF 中同一行的不同字符因字体 baseline 差异,midY 可能相差 1~3pt。引擎用行高的 50% 作为阈值,而不是固定的 1pt,避免同行字符被误判为不同行:

+ (BOOL)isSameLineByY:(CGRect)r1 rect:(CGRect)r2 {
    CGFloat threshold = MIN(CGRectGetHeight(r1), CGRectGetHeight(r2)) * 0.5;
    return fabs(CGRectGetMidY(r1) - CGRectGetMidY(r2)) < threshold;
}

列 maxX 用中位数baseMaxXForColumn: 返回的是所有行 maxX 的中位数,而不是最大值。这样可以过滤掉个别行尾有标点符号溢出导致的 maxX 偏大问题,让"末行留白"的判断更稳定。

主体行高过滤:在跨栏合并中,用 dominantLineHeightInColumn: 计算列内出现频率最高的行高(取整后做频次统计),作为主体正文的行高基准。倒序扫描段尾 Block 时,只考虑字号与主体行高接近的 Block,这样可以跳过可能夹在正文之间的小字号图注或大字号小标题。


局限性与未来方向

当前实现在以下场景有一定局限:

  • 竖排中文:过滤规则直接丢弃竖排行,不支持竖排杂志。
  • 不规则分栏:栏宽差异极大时(如 1:3 的图文混排),X 轴聚类可能误判列数。
  • 跨页段落:目前只处理单页内的跨栏,跨页的段落连续暂不支持。
  • 表格内文字:表格单元格中的文字可能因行高相近而被当作正文处理。

小结

XLPDFParagraphEngine 的核心设计思路可以归纳为:用几何信息替代语义信息,逐层收敛不确定性

层次 输入 输出 解决的问题
行提取 PDFSelection XLPDFLine 数组 去除噪声行
几何分块 XLPDFLine 数组 Block 数组 区分独立文字区域
列识别 + 分段 Block 带 paragraphIndex 的行 还原阅读顺序和段落边界
跨栏合并 Block 数组 合并后的 Block 数组 修复跨栏断段

整个流水线不依赖任何 PDF 元数据,仅凭坐标和文本表面特征运作,因此对不同来源、不同排版风格的杂志 PDF 均有较好的适应性。

DEMO地址

得物社区搜推公式融合调参框架-加乘树3.0实战

作者 得物技术
2026年3月5日 10:15

一、背景简介

近年来,搜索/推荐/广告系统在粗排(Pre-ranking)与精排(Ranking)阶段的模型训练中,呈现出一个明确的趋势:从单目标优化转向多目标建模 + 多目标融合。模型目标多、融合公式复杂,给工程维护、算法迭代效率都带来了挑战。

为了明文化直白展示公式全景、方便决策调参方向,直接配公式、线上自动算(既支持精排预估目标融合、也支持业务条件boost)。我们设计并落地了加乘树调参框架。从1.0优化至3.0,我们提供了:一个调参框架(Java版、同时引擎基建同学落地了C++版)能支持不同算法环节“公式即配即用”,一个打通AB实验的一站式产品化平台,支持一站式“辅助配置->调试->开实验->变更管控”。

带来收益:无论是粗排还是精排,“训多目标、融公式” 已成为工业界标准范式。在得物社区搜索、推荐的模型迭代实践中,我们也确实走“模型多目标训练 + 融合公式调参”范式,2025在社区推荐、社区搜索落地了几十次LR(社区推荐内外流精排、粗排,社区搜索精排)、近百次加乘树推全。

二、即配即用:算法爆发的催化剂,工程稳定的绊脚石?

在算法领域,“即配即用”的工程框架多次成为推动算法快速迭代甚至“爆发式增长”的关键基础设施。面对粗、精排“多目标建模 + 多目标融合”这一建模范式,社区算法和工程提出了如下基建目标:

即配即用提人效: 实时调整配置、线上就能自动生效数学逻辑,使算法工程师从过去几天才能完成一次调参,转变为一天内可进行多次迭代,从而将精力集中在模型和融合公式本身。

全量配置+增量配置范式: 实验只配要改的几行,降低配错风险。全量配置不动,形成天然降级能力。

DSL可解释性强: 粗、精排的融合公式配置量大,数学变换复杂,容易配错。我们提供的DSL让算法同学直接写数学公式/逻辑表达式。明文公式形成策略全景,方便算法同学决策调参方向。

编译校验与降级体系筑牢稳定性防线: 即配即用+数学公式DSL的需求,给工程稳定性带来极大挑战。我们采用“编译语法校验 + 自动用全量配置降级 + 手动切换编译/解释模式”三位一体保障稳定性。

三、可信赖底座:让复杂公式配置既灵活又可靠

全量配置+增量配置范式

传统的KV、JSON 或 YAML等配置格式在面对上百行数学公式时已显乏力:一方面配置体量大、人工修改易出错且缺乏容错机制;另一方面可读性差,难以维护和审查。

我们采用“全量配置+增量配置”的设计,天然解决了使用门槛&自动降级问题:

  • 只配增量,让使用更轻松、出错更可控: 全量配置锁定为只读,确保基线稳定;算法同学只需声明需要新增或修改的增量配置(upsert)。系统在运行时将增量动态合并到全量配置中,生成最终生效的实验配置——既简化了操作;又避免了误改全局参数的风险。
  • 增量可试,基线兜底: 增量配置有误,自动回退至基线,形成天然降级机制。

给一个社区搜索主搜精排的样例:

DSL接近数学公式/逻辑表达式明文

社区搜索、社区推荐的精排融合公式,服务了“多目标融合+业务boost调权”,语义包含:数学变换、逻辑判断、自定义UDF。当算法写下一串sin(log(max(UDF(x), y))),框架能否接住?框架必须托底,正确校验与执行,杜绝“配错即崩”。

从加乘树1.0到3.0,公式解析统一选用 ANTLR。相比手搓“逆波兰表达式”或“Flex & Bison”,它基于AST校验更可靠,且Java开发门槛低。实际加乘树的配置结构里,公式按KV配置(Key 为结果名,Value 为表达式),支持跨行引用——前序公式的输出可作为后序公式的输入,形成可串联的计算链,直至得出最终结果。

  • 公式链转DAG: 在加乘树3.0中,有相互依赖关系的多行公式,被框架解析成DAG。每个item都通过这套DAG计算融合分,1个item可能有多个融合分、每棵DAG的根结点对应1个融合分。
  • AST驱动逐行校验: 每行公式都依托编译原理,校验&解析为抽象语法树(AST)。结构化的AST可支撑后续可靠计算。
  • 加乘树3.0把DAG和AST直接翻译成代码: 框架将公式链直接翻译成可执行代码,用字节码技术加载到JVM中。每个item直接计算即可。

编译校验与降级体系筑牢稳定性防线

即配即用给算法同学迭代提效带来便利,同时给工程维稳带来挑战。尤其加乘树面临的配置是可自由组合、千变万化的数学公式时,绝对不能出现“配错即崩”的情况。我们做了如下一整套安全设计:

  • 编译原理强校验: 如何应对无限组合的公式配置?加乘树选择了编译原理强校验,用了ANTLR框架,把公式校验&解析成严谨的可访问结构(AST)。
  • DAG强校验公式链: 加乘树3.0初始化阶段自动解析公式链间的依赖关系,一边将公式链解析成DAG、一边强校验。能通过校验、最终编排成DAG的公式,才会进入实际计算;不能通过校验的危险配置(漏配公式、公式配错)都会在初始化阶段就被拦截,不会进入实际计算。
  • 自动降级范式: 加乘树设计了一套自动降级范式,方便“前置拦截错误、事中有效托底、后置发出告警”。一旦有错误的实验开流量,加乘树初始化阶段就会校验出错误,当次请求忽略AB实验配置、直接用全量配置计算,并及时发出“实验配置有误”的告警。
  • 串行重算托底: 如果有“编译原理校验”、“DAG校验”没有校验出的意外怎么办?如果框架仅仅是高峰期计算超时失败了怎么办?加乘树最后一层安全托底是“用全量配置串行重算”。无论如何保证线上效果。

四、核心攻坚:加乘树3.0升级编译执行

加乘树2.0在社区搜索落地后,“每次请求3000个item、线程并发拆的多”的情况,暴露出加乘树耗CPU、耗线程的弱点。C++版加乘树替换了计算引擎,没有采用antlr visitor解释执行数学运算的方式,而是用exprtk框架、收获了更高的性能。

受C++版加乘树的启发,我们计划替换Java版加乘树的计算引擎,降CPU消耗、降执行平响。加乘树3.0变成“直接将配置翻译成代码,字节码加载,直接计算”的编译执行形态。

极致性能:配置直译硬代码,零中间损耗 + 最优 JIT

Antlr翻译&Javassist加载,直接“公式翻译成可执行代码”: 包括多行公式的依赖关系、数学计算&UDF调用,直接拉平成硬代码。硬代码执行效率最高,没有map缓存、递归调用栈等损耗。

多行公式传递中间结果,map换POJO: 每个item维护自己的缓存map,高并发put/resize,造成明显的CPU消耗、youngGC压力。本次会初始化时决策缓存POJO,避免resize、且读写更高效。

核心Javassist管理类借鉴Dubbo写法: Dubbo的ClassGenerator写法,对内存管理考虑比较完善。本次借鉴ClassGenerator,把动态生成代码收入唯一管理单例类。

性能收益

晚高峰模块平响、CPU火焰图消耗和内存分配火焰图消耗均显著降低。

典型踩坑

字节码加载不容忍语法糖:

动态生成的字节码必须严格遵循JVM 范,平时习惯手写的Java法糖是不容忍的。例如,Float a = (float) b; 在源码中合法,但若b是Double类型,该语句涉及拆箱 + 窄化转换 + 装箱,而字节码层面需显式插入doubleValue() → (float) cast → Float.valueOf() 等指令。若直接按表面类型生成字节码,将触发VerifyError。

OOM在多处需要关注:

Javassist使用不当容易OOM:Javassist 在生成和操作字节码时(如通过 CtClass),因为其缓存机制,需要开发者主动管理资源释放。每次parse字节码的CtClass要及时释放,否则高频生成字节码容易触发OOM。这一点上,加乘树参照了Dubbo的ClassGenerator写法,创建、销毁内聚在同一个类里,即用即释放。

动态生成ClassLoader/Class/Instance要能GC:Instance能GC,ClassLoader/Class能GC吗?答案是能,只有从ClassLoader -> Class -> Instance全链路都GC Root不可达了,这一串才能GC。所以用Spring的ClassLoader这类常驻ClassLoader加载动态生成类是不行的,必须用即用即弃的自定义ClassLoader,并注意全链路的强引用问题。

我们实际验证了动态生成的类确实能被GC掉。

多重护航:防止非法Java字节码引发线上问题

ASM + Javassist双重检验: 翻译生成的代码,经Javassist生成字节码后,除Javassist .toClass()的自检验,我们还让字节码过了ASM的字节码静态校验(会运行类似JVM的类型推断验证,确保每条指令执行前后,局部变量表和操作数栈的状态是类型安全的)。

沙箱加载: 我们将加乘树管理平台封装成了一个沙箱,算法同学调试公式点击“校验”,平台会用同一套SDK模拟线上全套加载流程:“AST强校验 -> DAG强校验 -> 真实翻译代码 -> Javassist & ASM 双校验 -> 反射调用构造器创建实例”,一整套无误后才往线上推配置。

线上异步加载,任何问题自动降级: “可执行代码(执行器)初始化”读写分离,新配置上线是异步刷新,刷新错误只会造成线上流量过来找不到执行器,自动降级走全量配置(并发出告警),不影响效果。

可回退解释执行: 加乘树2.0、1.0的解释执行能力十分稳定、只是性能略差,3.0可以一键回退解释执行。

加乘树管理平台:一站式配置、调试与实验平台

面向算法同学: 做了一套一站式“辅助配置->校验->实时调试->开实验->变更管控”的使用体验,告别繁琐配置、体感更丝滑。

面向系统稳定: 加乘树管理平台把自己封装成了一个沙箱,如上一个模块所述,一切风险都拦截在沙箱爆炸。

五、稳扎稳打:从1.0到3.0的演进

加乘树1.0: 支持配公式、框架直接算公式,支持UDF,解释执行。加乘树2.0: 少量性能优化,抽象成SDK。加乘树3.0: 升级为编译执行,外观简化为只需要配公式、框架自动解析DAG。

加乘树1.0和2.0都是用的解释执行,antlr visitor遍历AST做“数学/逻辑/if判断”运算。加乘树3.0升级成了编译执行,多行公式解析DAG、每行公式用antlr解析AST时,直接翻译成Java执行代码,用字节码技术把执行代码加载进JVM直接执行。同时加乘树3.0也支持降级至解释执行。

加乘树1.0

解决:落地即配即用公式,解决手搓硬代码迭代效率低、代码腐化导致生效逻辑不清晰的问题。缺陷:费线程&CPU。

加乘树1.0于2025年1月在社区推荐外流精排落地,配法(使用外观)、降级机制是后续迭代不变的:

  • 配法:1): “全量配置+叠实验改动”的配置机制 2)配置总共分 consts(输入物料)、paramBranch(条件分支替换参数)、formulas(公式)、root(融合结果字段名)。
  • 降级机制:1): 初始化阶段就检测公式配错、漏配公式等,一旦检出就自动降级走全量配置、并发出告警 2)少量运行时才能发现的问题,串行重算、降级算全量配置。

当时是从手搓硬代码做公式融合,无DIFF迁移过来,解决了如下2个迭代痛点:

  • 迭代效率: 除调参是可配,调公式形态、调生效条件等都需要开发&上线。
  • 逻辑黑盒: boost、融合公式迭代复杂之后,生效逻辑变得黑盒,不容易分析调参方向。

加乘树1.0的实现要点

纯item维度(请求维度的公式也会每个item重复计算)。consts->paramBranch->formulas串行计算。antlr解析单行公式成AST,框架递归解析树依赖,antlr visitor解释执行。

为什么用antlr

DSL语法校验: 我们需要一种配置设计,能尽可能简洁地表征模型融合公式(支持逻辑判断/复杂数学变换/UDF)——接近Java语法&数学公式的DSL(当时有对标字节的配置外观)。我们需要准确校验DSL配置正确、并正确解析DSL配置——在antlr、手搓逆波兰表达式、flex&bison里,选了用antlr校验、解析DSL(用AST校验原理可靠,Java上手难度低)。

antlr visitor解释执行: 依靠AST解析计算是一种可靠的计算逻辑。我们需要稳定靠谱的计算引擎,因为算法同学大规模使用后、会出现大量千变万化的公式组合——依靠AST解析计算是一种可靠的计算逻辑。

类SIMD设计使性能可接受: antlr解析AST非常耗时,必须一次parse多次复用,不能在item维度重复parse。一般用antlr visitor做线上实时计算,性能是不可接受的。我们采用了一种类SIMD的代码写法,使落地性能可接受——类SIMD的设计,一次antlr visitor算一批item。最终落地的性能、没有因为antlr visitor拖过多后腿,性能比旧版硬代码融合公式还要好。

antlr语法定义文件

antlr visitor如何通过访问AST计算1行公式

加乘树2.0

解决:抽象成SDK;执行计划自动识别请求维度公式、便于序融合等逻辑写UDF。缺陷:受限于解释执行,仍然比较耗线程。

加乘树2.0于2025年9月在社区搜索落地。优化点如下:

  • 使用体验: 配置json结构简化,只需要配递归的一组公式即可(砍掉了consts、paramBranch)。if()的配法简化:旧版编译器设计的简单,将 “logic表达式”与“math表达式”分别放在2个编译器里,使用者不允许if里嵌套函数,加乘树2.0合并了编译器,if()里可以嵌套函数。支持“隐式item正排”。

  • 性能: 框架自动识别Req维度的公式,全局只计算1次。执行计划加缓存,砍掉“每次请求都重新build执行计划”,平响降低。
  • 横向扩展: Java版加乘树抽象为SDK,方便扩场景直接引用。

加乘树3.0

解决:升级为编译执行,性能大幅提升。

加乘树3.0于2026年1月在社区搜索落地。之前“核心攻坚”模块有提到,高并发&计算量大的情况下,暴露出加乘树耗CPU、耗线程的弱点(类SIMD设计虽然能让性能可接受,但毕竟antlr visitor计算方式需要升级)。

加乘树3.0替换了执行引擎。我们观察火焰图发现“按公式逻辑直接裸写的java代码”性能最高效,但是迭代效率最低。加乘树为了即配即用公式,性能却打了折扣。为了平衡“即配即用”的迭代效率问题和“性能”,我们“将配置公式直接翻译成可执行代码,用字节码技术加载到JVM中直接计算”,这让加乘树从解释执行升级为编译执行。

六、还能更好

多语言 & 模块化: 加乘树有Java版,同时有C++版,是引擎同学创新实现的另一个高性能版本。支持多种业务场景及模块(如粗排、精排),可灵活接入 Java 业务引擎或 C++ 高性能引擎。欢迎其他场景和模块接入。

稳定性 & 产品化: 重点打磨“加乘树管理平台沙箱拦截 -> 线上容错降级 -> 失败监控告警发现 -> 解释执行托底” 的有效性,定期演练降级、验证算法效果。增强“加乘树管理平台”DIFF能力,扩展展示“调试DAG”、“可DIFF动态生成的代码”,打通实时debug平台,可以“DAG展开看计算的中间结果”。

多层公式组成DAG(打磨中)

配置生成的可执行代码做DIFF(建设中)

打通模型调用自动化: 在加乘树这里打通精排模型调用,对精排模型的调用也高度抽象,一配即用、一配即可加入公式融合。

往期回顾

1.深入剖析Spark UI界面:参数与界面详解|得物技术

2.Sentinel Java客户端限流原理解析|得物技术

3.社区推荐重排技术:双阶段框架的实践与演进|得物技术

4.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

5.服务拆分之旅:测试过程全揭秘|得物技术

文 /啊俊 风林 益嘉

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

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

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

社区推荐重排技术:双阶段框架的实践与演进|得物技术

作者 得物技术
2026年2月12日 13:48

一、背景

推荐系统典型pipeline

在推荐系统多阶段Pipeline(召回→粗排→精排→重排)中,重排作为最终决策环节,承担着将精排输出的有限候选集(通常为Top 100–500个Item)转化为最优序列的关键职责。数学定义为在给定候选集C={x1,x2,,xn}C = \lbrace x_1,x_2,……,x_n \rbrace与目标列表长度LL,重排的目标是寻找一个排列πP(C,L)\pi^* \in P(C,L),使得全局收益函数最大化。

在推荐系统、搜索排序等AI领域,Pointwise 建模是精排阶段的核心方法,即对每个 Item 独立打分后排序,pointwise 建模范式面临挑战:

  • 多样性约束:精排按 item 独立打分排序 → 高分 item 往往语义/类目高度同质(如5个相似短视频连续曝光)。
  • 位置偏差:用户注意力随位置显著衰减,且不同item对位置敏感度不同。
  • 上下文建模:用户决策是序列行为,而非独立事件。

二、重排架构演进:生成式模型实践

我们的重排系统采用G-E两阶段协同框架:

  • 生成阶段(Generation):高效生成若干高质量候选排列。
  • 评估阶段(Evaluation):对候选排列进行精细化打分,选出全局最优结果。

不考虑算力和耗时的情况下,通过穷举所有排列P(C,L)P(C,L)

生成阶段主要依赖启发式规则、随机扰动 + beamSearch算法生成候选list,双阶段范式存在显著的痛点:

  • 质量-延迟-多样性的“不可能三角”:在实践中,增加生成候选list数一般可以提升最终list的质量,但边际收益递减;优化过程中,我们通过增加多目标、多样性等策略都取得了消费指标的提升,但在候选list达到百量级时,单纯增加候选集对指标的提升,同时还有:
  1. 增加beam width,系统耗时增加,DCG@K提升逐渐减少。

  2. 增加通道数,通道间重叠度逐渐增加,去重list增加逐渐减少。

  • 阶段间目标不一致:
  1. 分布偏移:启发式生成Beam Search输出的Top排列中,20%被评估模型否定,生成阶段搜索效率浪费。

  2. 梯度断层:Beam Search含argmax操作,双阶段无法端到端优化;生成模型无法感知评估反馈,优化方向偏离全局最优。

生成模型优化

生成分为启发式方法和生成式模型方法, 一般认为生成式模型方法要好于启发式方法。生成式模型逐渐成为重排主流范式,主要分为两类:自回归生成模型、非自回归生成模型。

  • 自回归生成:按位置顺序逐个生成物品,第 t 位的预测依赖前 t-1 位已生成结果。
  1. 优点:

a. 序列依赖建模强,天然捕获物品间的顺序依赖。

b. 训练简单稳定,每步使用真实前序作为输入,收敛快。

c. 生成质量高,逐步细化决策,适合长序列精细优化。

  1. 缺点:

a. 推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。

b. 局部最优风险,早期错误决策无法回溯修正,影响整体序列质量。

  • 非自回归生成:一次性预测整个推荐序列的所有位置,各位置预测相互独立。
  1. 优点:

a. 推理速度极快:生成整个序列仅需1次前向传播。

2.缺点:

b.条件独立性假设过强:各位置并行预测,难以显式建模物品间复杂依赖关系。

非自回归模型

为了对齐双阶段一致性,同时考虑线上性能,我们推进了非自回归模型的上线。模型结构如下图:

模型包括Candidates Encoder和Position Encoder,Candidates Encoder是标准的Transformer结构, 用于获取item间的交互信息;Position Encoder额外增加了Cross Attention,期望Position序列同时关注Candidate序列。

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 模型输出:一次性输出 n×L 的位置-物品得分矩阵(n 为候选 item 数,L 为目标列表长度),支持高效并行推理
p^ij=exp(xitj)i=1nexp(xitj)\hat{p}_{ij} = \frac{\exp(\mathbf{x}_i^\top \mathbf{t}_j)}{\sum_{i=1}^n \exp(\mathbf{x}_i^\top \mathbf{t}_j)}
  • 位置感知建模:引入可学习位置嵌入,显式建模“同一 item 在不同位置表现不同”的现象(如首屏效应、位置衰减)。
  • 训练目标:模型使用logloss,让正反馈label序列的生成概率最大, 同时负反馈label序列的生成概率最小:
Llog=i[pijyilog(pij^)+pij(1yi)log(1pij^)]\mathcal{L}_{\log} = -\sum_{i} \big[ p_{ij}y_i \log(\hat{p_{ij}}) + p_{ij}(1-y_i) \log(1-\hat{p_{ij}}) \big]

其中,pijp_{ij}表示位置i上是否展示物品j,yiy_{i}表示位置i上的label。

线上实验及收益:

  • 一期新增了非自回归生成通道,pvctr +0.6%,时长+0.55%。
  • 二期在所有通道排序因子中bagging非自回归模型,pvctr +1.0%,时长+1.13%。

自回归模型

由于条件独立性假设, 非自回归模型对上下文信息建模是不够的,近期我们重点推进了自回归模型的开发。

模型通过Transformer架构建模list整体收益,我们使用单向transformer模拟用户浏览行为的因果性,同时解决自回归生成的暴露偏差问题,保持训练和推理的一致性。结构如下:

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 训练目标:模型使用有序回归loss,在评估多个回合中不同长度的子列表时,能够很好地体现出序列中的增量价值。是用于判断长度为j的子列表是否已经达到i次点击或转化的损失函数。
Li,j(θj)=k=1N([yk<i]log(1pi,j(xk))+[yki]log(pi,j(xk)))L_{i,j}(\theta_j) = -\sum_{k=1}^{N} \left([y_k < i]\log(1-p_{i,j}(x_k)) + [y_k \geq i]\log(p_{i,j}(x_k))\right)

线上模型推理效率优化及实验效果:

自回归生成模型推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。因此,我们在传统自回归生成模型的基础上增加MTP(multi token prediction)结构,突破生成式重排模型推理瓶颈。其核心思想是将传统自回归的单步预测扩展为单步多token联合预测,显著减少生成迭代次数。

自回归生成模型在社区推荐已完成了推全,实验中我们新增了自回归生成模型通道,但不是完全体,仅部分位置生成调用了模型:

  • 一期调用两次模型,每次预测4个位置,pvctr +0.69%,有效vv +0.58%。
  • 二期调用两次模型,每次预测5个位置,pvctr +0.54%,有效vv +0.40%。

三、推理性能优化:端到端生成的效率保障

工程架构

为解决CPU推理模型延迟高、制约业务效果的问题,我们对DScatter模型服务进行升级,引入高性能GPU推理能力,具体方案如下:

  • GPU推理框架集成与升级:
  1. 框架升级:将现有依赖的推理框架升级为支持GPU的高性能服务框架。

  2. 硬件资源引入:引入 NVIDIA L20 等专业推理显卡,为当前的listwise评估模型及自回归生成模型提供专用算力,实现模型推理的硬件加速。

  • DScatter模型服务独立部署与容量提升:
  1. 为解决模型部署效率低与资源竞争问题,将DScatter的模型打分逻辑从现有重排服务中完全解耦,构建并部署独立的 DScatter-Model-Server 集群,从根本上消除与重排服务在CPU、内存等关键资源上的竞争。

模型优化

  • 模型格式转换与加速:

导出为 ONNX 格式,使用 TensorRT 进行量化、层融合、动态张量显存等技术加速推理。

  • Item Embedding缓存:

预计算item静态网络,线上直接查询节省计算量。

  • 自回归生成模型核心优化,KV Cache 复用:

缓存已生成token的KV和attention值,仅计算增量token相关值,避免重复计算。

  • 其他LLM推理加速技术应用落地,例如GQA

四、未来规划:迈向端到端序列生成的下一代重排架构

当前“生成-评估”双阶段范式虽在工程落地性上取得平衡,但其本质仍是局部优化:生成阶段依赖启发式规则或浅层模型生成候选,评估阶段虽能识别优质序列,却无法反向指导生成过程,导致系统能力存在理论上限。为突破这一瓶颈,我们规划构建端到端序列生成(End-to-End Sequence Generation) 架构,将重排从“候选筛选”升级为“序列创造”,直接以全局业务目标(如用户停留时长、互动深度、内容生态健康度)为优化目标。

核心架构设计:

  • 统一生成器:以 Transformer 为基础架构,搭建自回归序列建模能力,采用分层混合生成策略:
  1. 粗粒度并行生成:首层预测序列骨架(如类目分布、内容密度)等。

  2. 细粒度自回归精调:在骨架约束下,自回归生成具体 item,确保局部最优。

  • 序列级Reward Modeling:
  1. 构建多目标 reward 函数:xtr、多样性。

  2. Engagement:基于用户滑动轨迹建模序列累积收益(如滑动深度加权CTR)。

  3. Diversity:跨类目/创作者/内容形式的分布熵。

4.Fairness:冷启内容、长尾创作者曝光保障。

训练范式升级:强化学习与对比学习融合

推进自回归生成模型的架构升级与训练体系重构,引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率。

  • 搭建近线系统,生成高质量list候选,提升系统能力上限:

1.基于 DCG 的列表质量打分:

a. 对每个曝光列表L,计算其 DCG@K作为质量分数:

DCG(L)=j=1Kgain(itemj)log2(j+1)\text{DCG}(L) = \sum_{j=1}^{K} \frac{\text{gain}(item_j)}{\log_2(j + 1)}

其中 gain(item)可定义为:

若点击:+1.0

若互动(点赞/收藏):+1.5

若观看 >5s:+0.8

否则:0

2.构造偏好对:

a.对同一用户在同一上下文下的两个列表 LwL_w(win)和 LlL_l(lose)。

b.若 DCG(Lw)>DCG(Ll)+δDCG(L_w) > DCG(L_l) + \delta(δ 为 margin,如 0.1),则构成一个有效偏好对。

  • 引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率:
  1. 模型结构:

a.使用当前自回归生成模型作为策略模型。

b.固定预训练模型作为参考策略 (即 DPO 中的“旧策略”)。

2.DPO损失:

LDPO(θ)=E(x,yw,yl)D[logσ(β(logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma \left( \beta \left( \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right) \right) \right]
  • 技术价值:
  1. 突破“质量-延迟-多样性”不可能三角:通过序列级优化,在同等延迟下实现质量与多样性双提升。

  2. 为AIGC与推荐融合铺路:端到端生成器可无缝接入AIGC内容,实现“内容生成-序列编排”一体化。

参考文献:

  1. Gloeckle F, Idrissi B Y, Rozière B, et al. Better & faster large language models via multi-token prediction[J]. arXiv preprint arXiv:2404.19737, 2024.
  2. Ren Y, Yang Q, Wu Y, et al. Non-autoregressive generative models for reranking recommendation[C]//Proceedings of the 30th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2024: 5625-5634.
  3. Meng Y, Guo C, Cao Y, et al. A generative re-ranking model for list-level multi-objective optimization at taobao[C]//Proceedings of the 48th International ACM SIGIR Conference on Research and Development in Information Retrieval. 2025: 4213-4218.
  4. Zhao X, Xia L, Zhang L, et al. Deep reinforcement learning for page-wise recommendations[C]//Proceedings of the 12th ACM conference on recommender systems. 2018: 95-103.
  5. Feng Y, Hu B, Gong Y, et al. GRN: Generative Rerank Network for Context-wise Recommendation[J]. arXiv preprint arXiv:2104.00860, 2021.
  6. Pang L, Xu J, Ai Q, et al. Setrank: Learning a permutation-invariant ranking model for information retrieval[C]//Proceedings of the 43rd international ACM SIGIR conference on research and development in information retrieval. 2020: 499-508.

往期回顾

1.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

2.服务拆分之旅:测试过程全揭秘|得物技术

3.大模型网关:大模型时代的智能交通枢纽|得物技术

4.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

5.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

文 /张卓

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

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

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

❌
❌