普通视图

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

前端图形引擎架构设计:双引擎架构设计

2025年11月14日 19:50

ECS渲染引擎架构文档

写在前面

之前写过一篇ECS文章,为什么还要再写一个,本质上因为之前的文档,截止到目前来说,变化巨大,底层已经改了很多很多,所以有必要把一些内容拎出来单独去说。

由于字体文件较大,加载时间会比较久😞

另外如果有性能问题,我会及时修复,引擎改造时间太仓促,只要不是内存泄漏,暂时没去处理。

还有很多东西要做。

体验地址:baiyuze.github.io/design/#/ca…

image.png

image.png

image.png

项目概览

Duck-Core 是一个基于 ECS(Entity-Component-System)架构构建的高性能 Canvas 渲染引擎,专为复杂图形编辑场景设计。引擎的核心特色在于双渲染后端架构插件化系统设计极致的渲染性能优化

核心技术栈

  • CanvasKit-WASM - Google Skia 图形库的 WebAssembly 移植版
  • Canvas2D API - 浏览器原生渲染接口

架构核心亮点

ECS 架构模式 - 数据驱动的实体组件系统,实现逻辑与数据完全解耦

双引擎架构 - Canvas2D 与 CanvasKit 双渲染后端,运行时无缝切换

插件化设计 - 开放式扩展点,支持自定义渲染器、系统和组件

极致性能 - 颜色编码拾取、离屏渲染、渲染节流等多重优化


整体架构设计

整个引擎采用分层架构,从底层的渲染抽象到顶层的用户交互,每一层职责清晰且可独立替换。

graph TB
    subgraph "应用层"
        A[React 组件] --> B[Canvas 画布组件]
    end
    
    subgraph "引擎核心层"
        B --> C[Engine 引擎实例]
        C --> D[Core 状态管理器]
        C --> E[Camera 相机控制]
        C --> F[Entity Manager 实体管理]
    end
    
    subgraph "系统层 - System"
        C --> G[EventSystem 事件系统]
        G --> H[InputSystem 输入系统]
        G --> I[RenderSystem 渲染系统]
        G --> J[PickingSystem 拾取系统]
        G --> K[DragSystem 拖拽系统]
        G --> L[SelectionSystem 选择系统]
        G --> M[ZoomSystem 缩放系统]
        G --> M[FpsSystem FPS]
    end
    
    subgraph "渲染层 - Renderer"
        I --> N[RendererManager 渲染管理器]
        N --> O{选择渲染后端}
        O -->|Canvas2D| P[Canvas2D 渲染器组]
        O -->|CanvasKit| Q[CanvasKit 渲染器组]
        P --> R[RectRender]
        P --> S[EllipseRender]
        P --> T[TextRender]
        Q --> U[RectRender]
        Q --> V[EllipseRender]
        Q --> W[TextRender]
    end
    
    subgraph "数据层 - Component"
        X[StateStore 状态仓库]
        X --> Y[Position]
        X --> Z[Size]
        X --> AA[Color]
        X --> AB[Rotation]
        X --> AC[Selected]
    end
    
    D <--> X
    I --> X
    J --> X
    K --> X
    L --> X
    
    style C fill:#4A90E2,color:#fff
    style N fill:#E94B3C,color:#fff
    style X fill:#6ECB63,color:#fff
    style G fill:#F39C12,color:#fff

ECS 架构深度解析

什么是 ECS 架构?

ECS(Entity-Component-System)是一种源自游戏引擎的设计模式,它彻底改变了传统面向对象的继承体系,转而采用组合优于继承的理念。

三大核心概念:

  1. Entity(实体) - 仅是一个唯一 ID,不包含任何数据和逻辑
  2. Component(组件) - 纯数据结构,描述实体的属性(如位置、颜色、大小)
  3. System(系统) - 纯逻辑处理单元,操作特定组件组合的实体
graph TB
    subgraph "传统 OOP 继承方式"
        A1[GameObject]
        A1 --> A2[Rectangle]
        A1 --> A3[Circle]
        A1 --> A4[Text]
        A2 --> A5[DraggableRectangle]
        A3 --> A6[SelectableCircle]
        style A1 fill:#ff9999
    end
    
    subgraph "ECS 组合方式"
        B1[Entity 123] -.拥有.-> B2[Position]
        B1 -.拥有.-> B3[Size]
        B1 -.拥有.-> B4[Color]
        
        B5[Entity 456] -.拥有.-> B6[Position]
        B5 -.拥有.-> B7[Font]
        B5 -.拥有.-> B8[Selected]
        
        B9[RenderSystem] --> B2 & B3 & B4
        B10[DragSystem] --> B2
        B11[SelectionSystem] --> B8
        
        style B1 fill:#99ccff
        style B5 fill:#99ccff
        style B9 fill:#99ff99
        style B10 fill:#99ff99
        style B11 fill:#99ff99
    end

ECS 架构的核心优势

1. 极致的解耦性

传统 OOP 中,功能通过继承链紧密耦合。而 ECS 中,系统只依赖组件接口,实体的行为完全由组件组合决定。

// ❌ 传统方式:紧耦合的继承链
class Shape {
  render() { /* ... */ }
}
class DraggableShape extends Shape {
  drag() { /* ... */ }
}
class SelectableDraggableShape extends DraggableShape {
  select() { /* ... */ }
}

// ✅ ECS 方式:组件自由组合
const rect = createEntity()
addComponent(rect, Position, { x: 100, y: 100 })
addComponent(rect, Size, { width: 200, height: 150 })
addComponent(rect, Draggable, {})  // 可拖拽
addComponent(rect, Selected, {})   // 可选中
2. 强大的可扩展性

新增功能无需修改现有代码,只需添加新的组件和系统:

image.png

3. 天然的并行处理能力

系统之间无共享状态,可以安全地并行执行:

// 多个系统可以同时读取同一个组件
async function updateFrame() {
  await Promise.all([
    physicsSystem.update(),   // 读取 Position
    renderSystem.update(),    // 读取 Position
    collisionSystem.update(), // 读取 Position
  ])
}
System 系统架构

系统负责处理逻辑,通过查询 StateStore 获取需要的组件数据:

abstract class System {
  abstract update(stateStore: StateStore): void
}

class RenderSystem extends System {
  update(stateStore: StateStore) {
    // 查询所有拥有 Position 组件的实体
    for (const [entityId, position] of stateStore.position) {
      const size = stateStore.size.get(entityId)
      const color = stateStore.color.get(entityId)
      const type = stateStore.type.get(entityId)
      
      // 根据类型调用对应的渲染器
      this.renderMap.get(type)?.draw(entityId)
    }
  }
}

系统完整列表:

graph TB
    A[EventSystem<br/>事件总线] --> B[InputSystem<br/>输入捕获]
    A --> C[HoverSystem<br/>悬停检测]
    A --> D[ClickSystem<br/>点击处理]
    A --> E[DragSystem<br/>拖拽逻辑]
    A --> F[SelectionSystem<br/>选择管理]
    A --> G[ZoomSystem<br/>缩放控制]
    A --> H[ScrollSystem<br/>滚动平移]
    A --> I[PickingSystem<br/>图形拾取]
    A --> J[RenderSystem<br/>渲染绘制]
    A --> K[FpsSystem<br/>性能监控]
    
    style A fill:#F39C12,color:#fff
    style J fill:#E74C3C,color:#fff
    style I fill:#3498DB,color:#fff

双引擎架构设计

架构设计理念

不同的应用场景对渲染引擎有不同的需求:

  • 简单场景:需要快速启动、体积小、兼容性好
  • 复杂场景:需要高性能、丰富特效、大量图形

传统方案通常只支持单一渲染后端,难以兼顾两者。本引擎采用双引擎可切换架构,在运行时动态选择最优渲染后端。

graph TB
    A[应用启动] --> B{检测场景复杂度}
    B -->|简单场景<br/>< 100 图形| C[Canvas2D 引擎]
    B -->|复杂场景<br/>> 100 图形| D[CanvasKit 引擎]
    B -->|用户手动指定| E[用户选择]
    
    C --> F[浏览器原生 API]
    D --> G[Skia WASM 引擎]
    
    C --> H[渲染输出]
    D --> H
    
    I[运行时切换] -.->|热切换| C
    I -.->|热切换| D
    
    style C fill:#90EE90
    style D fill:#87CEEB
    style H fill:#FFD700

渲染后端对比

特性 Canvas2D CanvasKit (Skia)
启动速度 ⚡️ 即时(0ms) 🐢 需加载 WASM(~2s)
包体积 ✅ 0 KB ⚠️ ~1.5 MB
浏览器兼容性 ✅ 100% ⚠️ 需支持 WASM
渲染性能 🟡 中等 🟢 优秀
复杂路径渲染 🟡 一般 🟢 优秀
文字渲染 🟡 质量一般 🟢 亚像素级
滤镜特效 ❌ 有限 ✅ 丰富
离屏渲染 ✅ 支持 ✅ 支持
最佳场景 简单图形、快速原型 复杂设计、高性能需求

RendererManager 渲染管理器

RendererManager 是双引擎架构的核心枢纽,负责渲染器的注册、切换和调度:

class RendererManager {
  rendererName: 'Canvas2D' | 'Canvaskit' = 'Canvaskit'
  
  // 渲染器映射表
  renderer: {
    rect: typeof RectRender
    ellipse: typeof EllipseRender
    text: typeof TextRender
    img: typeof ImgRender
    polygon: typeof PolygonRender
  }
  
  // 切换渲染后端
  setRenderer(name: 'Canvas2D' | 'Canvaskit') {
    this.rendererName = name
    
    if (name === 'Canvas2D') {
      this.renderer = Canvas2DRenderers
    } else {
      this.renderer = CanvaskitRenderers
    }
  }
}

渲染器切换流程:

sequenceDiagram
    participant U as 用户操作
    participant E as Engine
    participant RM as RendererManager
    participant RS as RenderSystem
    participant R1 as Canvas2D Renderer
    participant R2 as CanvasKit Renderer
    
    U->>E: setRenderer('Canvas2D')
    E->>RM: setRenderer('Canvas2D')
    RM->>RM: 加载 Canvas2D 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 Canvas2D.RectRender
    RS->>R1: 调用 draw() 方法
    R1->>R1: 使用 ctx.fillRect()
    
    Note over U,R2: 用户再次切换引擎
    
    U->>E: setRenderer('Canvaskit')
    E->>RM: setRenderer('Canvaskit')
    RM->>RM: 加载 CanvasKit 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 CanvasKit.RectRender
    RS->>R2: 调用 draw() 方法
    R2->>R2: 使用 canvas.drawRect()

渲染器统一接口

所有渲染器实现相同的接口,保证可替换性:

abstract class BaseRenderer extends System {
  constructor(protected engine: Engine) {
    super()
  }
  
  // 统一的渲染接口
  abstract draw(entityId: string): void
  
}

自定义渲染器扩展

引擎支持用户自定义渲染器,只需实现 System 接口:

// 1. 创建自定义渲染器
class CustomStarRender extends System {
  draw(entityId: string) {
    const points = this.getComponent<Polygon>(entityId, 'polygon')
    const color = this.getComponent<Color>(entityId, 'color')
    
    // 自定义绘制逻辑
    const ctx = this.engine.ctx
    ctx.beginPath()
    points.points.forEach((p, i) => {
      i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)
    })
    ctx.closePath()
    ctx.fillStyle = color.fill
    ctx.fill()
  }
}
const customRenderMap = {
  star: CustomStarRender
}
// 2. 注册到引擎
new RendererRegistry().register({
  "custom": customRenderMap
})


字体渲染优化

CanvasKit 需要预加载字体文件,引擎实现了字体管理器:

async function loadFonts(CanvasKit: any) {
  const fontsBase = import.meta.env?.MODE === 'production' 
    ? '/design/fonts/' 
    : '/fonts/'

  const [robotoFont, notoSansFont] = await Promise.all([
    fetch(`${fontsBase}Roboto-Regular.ttf`).then(r => r.arrayBuffer()),
    fetch(`${fontsBase}NotoSansSC-VariableFont_wght_2.ttf`).then(r => r.arrayBuffer()),
  ])

  const fontMgr = CanvasKit.FontMgr.FromData(robotoFont, notoSansFont)
  return fontMgr
}

// 在 CanvasKit 初始化时调用
export async function createCanvasKit() {
  const CanvasKit = await initCanvasKit()
  const FontMgr = await loadFonts(CanvasKit)
  return { CanvasKit, FontMgr }
}

引擎工厂模式

使用工厂函数创建不同配置的引擎实例:

export function createCanvasRenderer(engine: Engine) {
  // Canvas2D 引擎创建器
  const createCanvas2D = (config: DefaultConfig) => {
    const canvas = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    canvas.style.width = config.width + 'px'
    canvas.style.height = config.height + 'px'
    canvas.width = config.width * dpr
    canvas.height = config.height * dpr
    
    const ctx = canvas.getContext('2d', {
      willReadFrequently: true,
    }) as CanvasRenderingContext2D
    ctx.scale(dpr, dpr)
    
    config.container.appendChild(canvas)
    
    return { canvasDom: canvas, canvas: ctx, ctx }
  }

  // CanvasKit 引擎创建器
  const createCanvasKitSkia = async (config: DefaultConfig) => {
    const { CanvasKit, FontMgr } = await createCanvasKit()
    const canvasDom = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    
    canvasDom.style.width = config.width + 'px'
    canvasDom.style.height = config.height + 'px'
    canvasDom.width = config.width * dpr
    canvasDom.height = config.height * dpr
    canvasDom.id = 'canvasKitCanvas'
    
    config.container.appendChild(canvasDom)
    
    const surface = CanvasKit.MakeWebGLCanvasSurface('canvasKitCanvas')
    const canvas = surface!.getCanvas()
    
    return {
      canvasDom,
      surface,
      canvas: canvas,
      FontMgr: FontMgr,
      ck: CanvasKit,
    }
  }

  return {
    createCanvas2D,
    createCanvasKitSkia,
  }
}

Engine 引擎核心

Engine 类是整个渲染系统的中枢,协调所有子系统的运行:

class Engine implements EngineContext {
  camera: Camera = new Camera()
  entityManager: Entity = new Entity()
  SystemMap: Map<string, System> = new Map()
  rendererManager: RendererManager = new RendererManager()
  
  canvas!: Canvas  // 渲染画布(类型取决于渲染后端)
  ctx!: CanvasRenderingContext2D
  ck!: CanvasKit
  
  constructor(public core: Core, rendererName?: string) {
    // 初始化渲染器
    this.rendererManager.rendererName = rendererName || 'Canvaskit'
    this.rendererManager.setRenderer(this.rendererManager.rendererName)
  }
  
  // 添加系统
  addSystem(system: System) {
    this.system.push(system)
    this.SystemMap.set(system.constructor.name, system)
  }
  
  // 获取系统
  getSystemByName<T extends System>(name: string): T | undefined {
    return this.SystemMap.get(name) as T
  }
  
  // 清空画布(适配双引擎)
  clear() {
    const canvas = this.canvas as any
    if (canvas?.clearRect) {
      // Canvas2D 清空方式
      canvas.clearRect(0, 0, this.defaultSize.width, this.defaultSize.height)
    } else {
      // CanvasKit 清空方式
      this.canvas.clear(this.ck.WHITE)
    }
  }
}

插件化系统设计

系统即插件

引擎的所有功能都以 System 形式实现,每个 System 都是独立的插件。这种设计带来极高的灵活性:

graph TB
    A[Engine 核心] --> B{System Manager}
    
    B --> C[核心系统]
    B --> D[可选系统]
    B --> E[自定义系统]
    
    C --> C1[EventSystem<br/>必需]
    C --> C2[RenderSystem<br/>必需]
    
    D --> D1[DragSystem<br/>拖拽功能]
    D --> D2[ZoomSystem<br/>缩放功能]
    D --> D3[FpsSystem<br/>性能监控]
    
    E --> E1[UndoRedoSystem<br/>撤销重做]
    E --> E2[SnappingSystem<br/>吸附对齐]
    E --> E3[AnimationSystem<br/>动画播放]
    
    style C1 fill:#e74c3c,color:#fff
    style C2 fill:#e74c3c,color:#fff
    style D1 fill:#3498db,color:#fff
    style D2 fill:#3498db,color:#fff
    style D3 fill:#3498db,color:#fff
    style E1 fill:#2ecc71,color:#fff
    style E2 fill:#2ecc71,color:#fff
    style E3 fill:#2ecc71,color:#fff

核心系统详解

1. EventSystem - 事件总线

EventSystem 是整个引擎的调度中枢,协调所有其他系统的执行:

class EventSystem extends System {
  private eventQueue: Event[] = []
  
  update(stateStore: StateStore) {
    // 执行系统更新顺序
    this.executeSystem('InputSystem')      // 1. 捕获输入
    this.executeSystem('HoverSystem')      // 2. 检测悬停
    this.executeSystem('ClickSystem')      // 3. 处理点击
    this.executeSystem('DragSystem')       // 4. 处理拖拽
    this.executeSystem('ZoomSystem')       // 5. 处理缩放
    this.executeSystem('SelectionSystem')  // 6. 更新选择
    this.executeSystem('PickingSystem')    // 7. 更新拾取缓存
    this.executeSystem('RenderSystem')     // 8. 最后渲染
  }

}
2. RenderSystem - 渲染系统

RenderSystem 负责将实体绘制到画布:

class RenderSystem extends System {
  private renderMap = new Map<string, BaseRenderer>()
  
  constructor(engine: Engine) {
    super()
    this.engine = engine
    this.initRenderMap()
  }
  
  // 初始化渲染器映射
  initRenderMap() {
    Object.entries(this.engine.rendererManager.renderer).forEach(
      ([type, RendererClass]) => {
        this.renderMap.set(type, new RendererClass(this.engine))
      }
    )
  }
  
  async update(stateStore: StateStore) {
    // 清空画布
    this.engine.clear()
    
    // 应用相机变换
    this.engine.canvas.save()
    this.engine.canvas.translate(
      this.engine.camera.translateX,
      this.engine.camera.translateY
    )
    this.engine.canvas.scale(
      this.engine.camera.zoom,
      this.engine.camera.zoom
    )
    
    // 遍历所有实体进行渲染
    for (const [entityId, pos] of stateStore.position) {
      this.engine.canvas.save()
      this.engine.canvas.translate(pos.x, pos.y)
      
      const type = stateStore.type.get(entityId)
      await this.renderMap.get(type)?.draw(entityId)
      
      this.engine.canvas.restore()
    }
    
    this.engine.canvas.restore()
  }
}

DSL 配置系统


DSL 配置系统

设计目标

DSL(Domain Specific Language)模块的目标是将图形场景序列化为 JSON 格式,实现:

  1. 场景持久化 - 保存到数据库或本地存储
  2. 场景传输 - 前后端数据交换
  3. 场景快照 - 撤销/重做功能的基础
  4. 模板复用 - 创建可复用的图形模板

配置结构

interface DSLParams {
  type: 'rect' | 'ellipse' | 'text' | 'img' | 'polygon'
  id?: string
  position: { x: number; y: number }
  size?: { width: number; height: number }
  color?: { fill: string; stroke: string }
  rotation?: { value: number }
  scale?: { value: number }
  zIndex?: { value: number }
  selected?: { isSelected: boolean }
  // 形状特定属性
  font?: { family: string; size: number; weight: string }
  radius?: { value: number }
  polygon?: { points: Point[] }
}

DSL 解析器

class DSL {
  constructor(params: DSLParams) {
    this.type = params.type
    this.id = params.id || this.generateId()
    this.position = new Position(params.position)
    this.size = params.size ? new Size(params.size) : new Size()
    this.color = params.color ? new Color(params.color) : new Color()
    // ... 初始化其他组件
  }
  
  // 转换为纯数据对象
  toJSON(): DSLParams {
    return {
      type: this.type,
      id: this.id,
      position: { x: this.position.x, y: this.position.y },
      size: { width: this.size.width, height: this.size.height },
      color: { fill: this.color.fill, stroke: this.color.stroke },
      // ...
    }
  }
}

低耦合架构实践

依赖方向

整个引擎严格遵循依赖倒置原则:

graph TB
    A[应用层<br/>React 组件] --> B[引擎接口<br/>Engine API]
    B --> C[系统层<br/>System]
    C --> D[组件层<br/>Component]
    C --> E[实体层<br/>Entity]
    
    F[渲染层<br/>Renderer] --> G[渲染接口<br/>BaseRenderer]
    C --> G
    
    style B fill:#f39c12,color:#fff
    style G fill:#f39c12,color:#fff

关键设计:

  • 上层依赖接口,不依赖具体实现
  • System 不直接依赖 Renderer,通过 RendererManager 解耦
  • Component 纯数据,零依赖

总结

Duck-Core 前端渲染引擎通过以下设计实现了高性能、高扩展性:

核心优势

  1. ECS 架构 - 数据与逻辑完全分离,组件自由组合
  2. 双引擎架构 - Canvas2D 与 CanvasKit 可热切换,兼顾兼容性与性能
  3. 插件化系统 - 所有功能以 System 形式实现,按需加载
  4. 低耦合设计 - 接口隔离、依赖倒置、事件驱动
  5. 极致性能 - 渲染节流、离屏缓存、视口裁剪、内存优化
昨天 — 2025年11月13日首页
昨天以前首页

历经4个月,基于 Tiptap 和 NestJs 打造一款 AI 驱动的智能文档协作平台 🚀🚀🚀

作者 Moment
2025年11月10日 08:56
当 AI 技术刚刚兴起时,我加入了一家 AI 初创公司。由于我们在项目中有着对高级编辑功能的需求,我第一次接触到了 Tiptap。让我印象深刻的是,Tiptap 与其他编辑器相比,最大亮点在于它的灵活

🧠 Next.js × GraphQL Yoga × GraphiQL:交互式智能之门

作者 LeonGao
2025年11月7日 09:13

image.png

“当 Next.js 提供空间容器,GraphQL Yoga 就成为了灵魂管家。”


一、GraphQL 的哲学底色:

REST: “我有一堆API,你得一个个调用。”
GraphQL: “别闹,一次请求要啥我都打包返回。”

GraphQL 的三个灵魂元素:

类型 说明 对应概念
Query 读取(想了解点啥) GET 的精神继承者
🛠️ Mutation 写入(要改点啥) POST/PUT 的理性重组
💧 Scalar 原子级数据单元 String, Int, Float, Boolean, ID

二、📦 初始化工程:Next.js + Yoga

首先你需要在项目中安装 Yoga 与 GraphQL 基础:

npm install graphql @graphql-yoga/node

Next.js(>=13)提供了 App Router,我们可以在 /app/api/graphql/route.ts/app/api/graphql/route.js 中挂载 Yoga 服务。


三、🧩 实现一个极简的 Yoga 入口

// /app/api/graphql/route.js
import { createSchema, createYoga } from 'graphql-yoga';

const typeDefs = /* GraphQL */ `
  type Query {
    hello(name: String): String
  }

  type Mutation {
    shout(message: String!): String
  }

  scalar DateTime
`;

const resolvers = {
  Query: {
    hello: (_, { name }) => `你好, ${name || '世界'} 👋`,
  },
  Mutation: {
    shout: (_, { message }) => message.toUpperCase() + '!!! 🔊',
  },
  DateTime: new Date(), // 示例 Scalar
};

const { handleRequest } = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  graphqlEndpoint: '/api/graphql',
  fetchAPI: { Request, Response },
});

export { handleRequest as GET, handleRequest as POST };

🎯 亮点解析:

  1. createYoga() 自动提供 GraphiQL(浏览器内交互界面);

  2. 你可以直接在浏览器访问:

    http://localhost:3000/api/graphql
    

    👀 然后你就会看到 GraphiQL;

  3. Yoga 内建性能优化 + Next.js Edge Runtime 兼容。


四、🚀 GraphiQL 交互:Query / Mutation 实操

打开 GraphiQL 页面后,可以尝试以下几个示例。

📤 Query 示例:

query {
  hello(name: "Neo")
}

💬 返回输出:

{
  "data": {
    "hello": "你好, Neo 👋"
  }
}

🪄 Mutation 示例:

mutation {
  shout(message: "GraphQL Yoga is awesome")
}

💬 返回:

{
  "data": {
    "shout": "GRAPHQL YOGA IS AWESOME!!! 🔊"
  }
}

🧮 Scalar 示例:

我们可以自定义一个 DateTime 标量类型(Scalar Type),用于时间序列传输。
在 Yoga 中可以使用 graphql-scalars

npm install graphql-scalars
import { DateTimeResolver, DateTimeTypeDefinition } from 'graphql-scalars';

const typeDefs = /* GraphQL */ `
  ${DateTimeTypeDefinition}

  type Query {
    now: DateTime!
  }
`;

const resolvers = {
  DateTime: DateTimeResolver,
  Query: {
    now: () => new Date(),
  },
};

🕰️ Query:

query {
  now
}

🎯 返回:"2025-11-07T08:41:32Z"


五、🧰 官方 Yoga 文档入口

The Guild 团队维护的 Yoga 是 GraphQL 服务器中的黑马,
官方文档:
🔗 the-guild.dev/graphql/yog…

文档有这些黄金章节:

模块 功能
createYoga() 核心服务器接口
createSchema() 组装 TypeDefs + Resolvers
GraphiQL 内置交互编辑器
Plugins 扩展中间件(如 CORS、安全性、缓存)
Subscriptions 支持 WebSocket 实时推送

六、🧠 底层机制揭秘

GraphQL Yoga = “HTTP + GraphQL + Streaming + Subscriptions” 的完美合成。

具体流程如下:

  1. HTTP 层:Next.js Edge Runtime 接收 GET/POST 请求;
  2. Parsing 层:Yoga 解析 payload(query 字符串或 mutation 数据);
  3. Schema 层:类型系统匹配 + resolver 绑定;
  4. Response 层:构造 JSON Response;
  5. GraphiQL 模式:实时调试结果、查询历史保存、本地缓存变量。

🧩 伪代码底层逻辑:

handleRequest(req) {
  const query = extractGraphQL(req);
  const schema = buildSchema();
  const result = executeGraphQL({ schema, query });
  return Response.json(result);
}

七、💡 拓展方向

功能 实现思路
🔍 Subscription 实时更新 集成 WebSocket 支持 Yoga Subscriptions
🧱 Remote Schema Stitching 聚合多个微服务的 GraphQL Schema
🔐 Auth Guard 结合 NextAuth.js 注入 session 验证
⚡ Edge Runtime 优化 Yoga 支持 Edge Function 启动
🧠 Embodied AI Integration 用具身智能代理自动生成 GraphQL Query 😄

八、🎨 一个小的互动架构图(可视说明)

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Next.js × Yoga GraphQL 流程图</title>
<style>
body {
  font-family: 'Segoe UI', sans-serif;
  background: #fafafa;
  padding: 20px;
  text-align: center;
}
.node {
  display: inline-block;
  background: #dfe6e9;
  border-radius: 8px;
  padding: 10px 20px;
  margin: 10px;
  font-weight: bold;
}
.arrow {
  font-size: 24px;
  color: #636e72;
}
</style>
</head>
<body>
<h3>Next.js + GraphQL Yoga 数据流示意图</h3>
<div>
  <div class="node">Client (GraphiQL)</div>
  <div class="arrow">➡️</div>
  <div class="node">Next.js Edge API</div>
  <div class="arrow">➡️</div>
  <div class="node">Yoga Schema</div>
  <div class="arrow">➡️</div>
  <div class="node">Resolvers</div>
  <div class="arrow">➡️</div>
  <div class="node">Result JSON</div>
</div>
</body>
</html>

九、结语:

下一次当你在浏览器中打开 http://localhost:3000/api/graphql
请记得:
你看到的不是一个普通接口,
而是Web 智能交互的下一层语言接口

GraphiQL 是你的显微镜,Yoga 是你的实验室,
而 Next.js —— 是那台永不休眠的服务器灵魂。

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

作者 得物技术
2025年11月6日 15:16
在电商交易领域,管理类目作为业务责权划分、统筹、管理核心载体,随着业务复杂性的提高,其规则调整频率从最初的 1 次 / 季度到多次 / 季度,三级类目的规则复杂度也呈指数级上升。传统依赖数仓

🌐 《GraphQL in Next.js 初体验》中文笔记

作者 LeonGao
2025年11月6日 09:21

🧩 一、概览:Next.js + GraphQL 是怎么“对话”的?

Next.js 是前后端一体框架,而 GraphQL 是一种 API 查询语言。结合起来后:
🤝 Next.js 提供运行环境
🧠 GraphQL 提供数据语义接口

简单理解:

  • Next.js = 舞台和播放器(API 路径、页面渲染、Serverless 运行)
  • GraphQL = 剧本(Schema定义)、演员台词逻辑(Resolver)、导演策略(Context)

结构大致如下:

Next.js API Route ─┬─► ApolloServer / Yoga Server
                    │
                    └─► GraphQL Schema (定义数据结构)
                        └─► Resolver (具体处理逻辑)
                            └─► Context (跨请求共享上下文)

🧱 二、Schema:GraphQL 的“数据契约”

Schema 是 定义数据形状的语言模型
可以把它理解为数据库表结构 + API 文档的结合体。

示例 ⚙️

# ./graphql/schema.graphql
type User {
  id: ID!
  name: String!
  age: Int
}

type Query {
  users: [User!]!
  user(id: ID!): User
}

type Mutation {
  addUser(name: String!, age: Int): User
}

📘 要点笔记:

  • type:定义类型(用户、文章、评论等)
  • Query:读取数据的入口
  • Mutation:修改、创建、删除数据的入口

Schema 就像一份菜单,它定义了服务员能做的所有菜,但不做菜。
做菜的是 Resolver 👉


🧠 三、Resolver:GraphQL 的“大脑中枢”

Resolver 是 Schema 的执行器,它负责将查询请求映射到实际数据源
在 Next.js 中,一般写成 JS/TS 文件与 Schema 匹配。

示例 👇

// ./graphql/resolvers.js
const users = [
  { id: "1", name: "Neo", age: 29 },
  { id: "2", name: "Trinity", age: 27 },
];

export const resolvers = {
  Query: {
    users: () => users,
    user: (_, { id }) => users.find(u => u.id === id),
  },
  Mutation: {
    addUser: (_, { name, age }) => {
      const newUser = { id: String(users.length + 1), name, age };
      users.push(newUser);
      return newUser;
    },
  },
};

📘 要点笔记:

  • 每个字段对应一个函数。
  • 第一个参数 _ 通常是父级字段(这里未使用,可省略)。
  • 第二个参数 { id } 是客户端传入的变量。
  • Resolver 内不管 Schema 长啥样,它只需返回对应数据。

🌍 四、Context:连接“每个请求”的神经系统

Context 是 GraphQL 在请求周期中共享的环境对象
它的作用类似于:

  • 注入依赖(数据库实例、token 验证、用户状态)
  • 跨 Resolver 共享状态

示例:

import jwt from "jsonwebtoken";
import db from "./db.js";

export const createContext = ({ req }) => {
  const token = req.headers.authorization || "";
  let user = null;

  try {
    user = jwt.verify(token, process.env.JWT_SECRET);
  } catch (e) {
    console.log("token 无效或未提供");
  }

  return { db, user };
};

在 Resolver 中,就能这样访问:

Mutation: {
  addUser: (_, { name, age }, { db, user }) => {
    if (!user) throw new Error("未授权");
    return db.insertUser({ name, age });
  }
}

📘 要点笔记:

  • Context 在每次请求开始时创建(一次请求一次 Context)。
  • 经 Context 可安全访问外部资源且隔离状态。
  • 在 SSR 和 Serverless 模式的 Next.js 中非常实用。

⚙️ 五、在 Next.js 中整合 Apollo Server

在 Next.js 中最常见的方式是用 /api/graphql 作为后端入口。

// ./pages/api/graphql.js
import { ApolloServer } from "apollo-server-micro";
import { typeDefs } from "../../graphql/schema.js";
import { resolvers } from "../../graphql/resolvers.js";
import { createContext } from "../../graphql/context.js";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: createContext,
});

export const config = {
  api: { bodyParser: false },
};

export default server.createHandler({ path: "/api/graphql" });

💡 运行逻辑:

  1. 浏览器访问 /api/graphql
  2. Next.js 调用 ApolloServer 处理请求
  3. ApolloServer 根据 Schema 调用对应 Resolver
  4. Resolver 通过 Context 访问数据源
  5. 返回 JSON 响应

🖥️ 六、Next.js 前端调用示例

import { gql, useQuery } from "@apollo/client";

const ALL_USERS = gql`
  query {
    users {
      id
      name
      age
    }
  }
`;

export default function UsersList() {
  const { loading, error, data } = useQuery(ALL_USERS);
  if (loading) return <p>⏳ 加载中...</p>;
  if (error) return <p>❌ 出错啦: {error.message}</p>;
  return (
    <ul>
      {data.users.map(u => (
        <li key={u.id}>
          👤 {u.name}(年龄:{u.age ?? "未知"})
        </li>
      ))}
    </ul>
  );
}

在这里,前端只需声明「我想要什么」,
后端就帮你搞定「怎么算出来」。


💬 七、小结:三大组件一图流

<div style="max-width:720px;margin:auto;text-align:center;">
<svg width="100%" height="300" viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg">
  <rect x="60" y="60" width="170" height="60" rx="10" fill="#A1C4FD" stroke="#333"/>
  <text x="145" y="95" text-anchor="middle" font-size="13">Schema (定义契约)</text>

  <rect x="280" y="60" width="170" height="60" rx="10" fill="#FDD692" stroke="#333"/>
  <text x="365" y="95" text-anchor="middle" font-size="13">Resolver (处理逻辑)</text>

  <rect x="500" y="60" width="170" height="60" rx="10" fill="#C2E9FB" stroke="#333"/>
  <text x="585" y="95" text-anchor="middle" font-size="13">Context (执行环境)</text>

  <line x1="230" y1="90" x2="280" y2="90" stroke="#000" stroke-width="2" marker-end="url(#arrow)"/>
  <line x1="450" y1="90" x2="500" y2="90" stroke="#000" stroke-width="2" marker-end="url(#arrow)"/>

  <defs>
    <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
      <path d="M0,0 L0,6 L9,3 z" fill="#333"/>
    </marker>
  </defs>
</svg>
<p style="font-size:13px;color:#666;">▲ Next.js GraphQL 三件套结构关系图</p>
</div>

🚀 八、为什么 Next.js 是 GraphQL 的理想宿主?

  1. Serverless Ready:API 路由天然适合部署 Apollo 或 Yoga Server。
  2. SSR + CSR 混合渲染:可直连 GraphQL 数据,从后端直出页面。
  3. Edge Runtime:未来的 Web AGI 场景(边缘智能体)可直接调用 GraphQL 层。
  4. TypeScript 一体化支持:Schema + Resolver 均能生成类型定义,开发丝滑。

Node.js + Python 爬虫界的黄金搭档

2025年11月6日 08:41

写了一个工具叫去水印下载鸭,它的功能是解析某音、某红书等平台视频、图片,支持无水印下载。

image.png

在后端开发中,我选择了自己最为熟悉的 Node.js 技术。然而,在开发过程中,我遇到了一些棘手的问题,其中最令人头疼的就是某音的加密处理。 幸运的是,我在 GitHub 社区找到了一个开源仓库,它提供了相关的解决方案,但这个仓库是用 Python 实现的。我本想借助 AI 将其转换为 Node.js 代码,但转换后的代码运行报错。为了避免进一步的麻烦,我决定直接在 Node.js 项目中引入这个 Python 仓库,并且删减了其中我不需要的文件。

Node.js 集成 Python

在 Node.js 中使用 Python 简单又高效。我们先让 Python 程序输出结果,然后对结果稍作处理,用 /RESULT_START:结果:RESULT_END/ 这样的格式将其包裹起来。

async def fetch_one_video(url):
    """
    Fetches a single video by aweme_id.
    """
    hybird_crawler = HybridCrawler()
    data = await hybird_crawler.hybrid_parsing_single_video(url,False)
    # 只输出结果数据,使用特定标记包裹
    print(f"RESULT_START:{json.dumps(data)}:RESULT_END")

随后,在 Node.js 端,我们借助 exec 方法来运行一条 Python 命令,具体如下所示:


new Promise((resolve, reject) => {
const src = path.join(__dirname, '../script/start.py');
exec(`python ${src} ${url}`, (error, stdout, stderr) => {
    if (error) {
        console.error(`Error executing Python script: ${error}`);
        reject(null)
        return;
    }
    if (stderr) {
        reject(null)
        return;
    }
    // 使用正则表达式捕获 RESULT_START 和 RESULT_END 之间的内容
    const regex = /RESULT_START:(.*?):RESULT_END/;
    const match = stdout.match(regex);

    if (match && match[1]) {
        try {
            // 解析捕获到的 JSON 数据
            const jsonData = JSON.parse(match[1]);
            resolve(jsonData);
        } catch (parseError) {
            reject(null)
        }
    } else {
        reject(null)
    }
})
})

当调用 exec 方法时,Node.js 会调用操作系统的相关功能来创建一个新的子进程。这个子进程会独立于父进程运行。

这样,Node.js环境中使用Python就搞定了。

Docker 部署 Node.js+Python

若想在 Docker 容器中运行 Node.js 与 Python,打包时需确保容器内同时具备 Node.js 和 Python 的运行环境。

最初,Dockerfile 仅包含 Node.js 的配置,配置如下所示:

# 构建阶段
FROM node:24-alpine

WORKDIR /app

# 复制 package 文件
COPY package*.json ./

# 复制源代码
COPY . .

RUN npm i -g pnpm && pnpm i 

# 暴露端口
EXPOSE 3000

# 启动应用
CMD [ "npm", "start" ]

为了在Docker镜像中引入Python,我在Dockerfile中加入了对Python3的支持,以确保容器内同时具备Node.js和Python的运行环境。以下是Dockerfile的修改内容:

FROM node:24-alpine

RUN apt-get update && \
    apt-get install -y --no-install-recommends python3 python3-pip && \
    rm -rf /var/lib/apt/lists/*
 
 #省略...

然而,在构建镜像的过程中,我发现下载python3python3-pip等包的速度非常慢,并且在安装过程中还涉及到编译,这使得整个构建过程变得异常耗时。

经过一番思考,我决定调整策略:先基于Python镜像构建,再在其上添加Node.js环境。于是,我对Dockerfile进行了如下调整:

FROM python:3.11-slim-bookworm

RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g' \
        /etc/apt/sources.list.d/debian.sources && \
    sed -i 's|http://security.debian.org|https://mirrors.aliyun.com|g' \
        /etc/apt/sources.list.d/debian.sources

RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
    && rm -rf /var/lib/apt/lists/* \
    && npm config set registry https://registry.npmmirror.com/
 
#省略...

这样完成解决Docker部署问题。

最后

如果你也遇到了需要在项目中同时使用 Node.js 和 Python 的情况,不妨参考本文的操作方法,或许能为你提供一些思路和帮助。

去水印下载鸭仅限于学习,请勿用于其他用途,否则后果自负,且软件没有进行任何店铺售卖,谨防受骗!

Cursor 2.0 支持模型并发,我用国产 RWKV 模型实现了一模一样的效果 🤩🤩🤩

作者 Moment
2025年11月5日 19:21

最近 Cursor 发布 2.0 版本,其中一个比较亮点的功能就是它可以同时指挥 8 个 Agent 执行任务,最后选择你觉得最好的那个答案。

而在底层模型层面,来自中国本土团队的 RWKV 项目也带来了更具突破性的成果:RWKV7-G0a3 13.3B ——当前全球最强的开源纯 RNN 大语言模型。

这一版本以 RWKV6-world-v2.1 14B 为基础,继续训练了 2 万亿 tokens(并融合了 35B 来自 DeepSeek v3.1 的高质量语料),在保持完全 RNN 架构、无注意力机制(No Attention)、无微调、无刷榜的前提下,取得了与主流 Transformer 模型相媲美甚至更优的表现。

20251104141030

在多项权威基准测试中(包括 MMLU、MMLU-Pro、GSM8K、MATH500、CEval 等),RWKV7-G0a3 在语言理解、逻辑推理与数学推演等任务上均实现显著提升。其中,MMLU-Pro 测评显示模型在多学科综合知识上的掌握更加扎实;GSM8K 与 MATH500 结果表明,其在中高难度数学与逻辑问题上的推理能力已达到同规模模型的领先水平。与此同时,RWKV7-G0a3 继续保持了 RWKV 系列一贯的高推理效率与低显存占用优势,展现出纯 RNN 架构在大模型时代下的强大潜力。

Uncheatable Eval 使用最新的论文、新闻、代码与小说等实时数据进行评测,通过“压缩率”(即 Compression is Intelligence)指标,衡量模型在真实语料下的语言建模能力与泛化水平。

20251104141244

MMLU 系列用于测评语言模型在多学科知识与认知推理方面的能力,其中 MMLU Pro 为进阶版本,包含更复杂的问题设计与更严苛的评测标准。

20251104141616

欲获取更多详细信息,请访问该模型的 官方公众号文章 阅读。

这意味着:

在以 Transformer (deep learning architecture) 架构主导的大模型时代,RWKV 所代表的“纯 RNN ”路线再度崛起:以更低的计算与显存成本、更自然的时序记忆机制,走出一条与主流 LLM 截然不同的进化路径。

RWKV 命名规则中,G0a3 标识了训练数据在版本与质量上的升级(例如:质量层级为 G# > G#a2 > G#,数据规模层级为 G1 > G0),即便参数量相同,G0a3 系列在泛化能力上也具备潜在优势。综合来看,RWKV7-G0a3 13.3B 的发布,不仅刷新了 RNN 模型性能的新高度,也象征着 RWKV 系列在“摆脱 Transformer 架构垄断”路径上迈出了一步。

模型下载

下载 RWKV7-G0a3 13.3B 模型(.pth 格式):

下载 .gguf 格式: modelscope.cn/models/shou…

下载 Ollama 格式: ollama.com/mollysama

如何使用 RWKV 模型(本地部署)

可以使用 RWKV RunnerAi00rwkv pip 等推理工具在本地部署 RWKV 模型。

RWKV 模型同时兼容主流推理框架,如 llama.cppOllama

目前最快的 RWKV 推理工具是 Albatross

由于 RWKV7-G0a3 13.3B 属于新模型,建议优先使用 RWKV Runner 以确保结果稳定与准确。

更多关于部署与推理的使用教程,可参考 RWKV 官网 - 模型部署和推理教程

前端如何实现模型并发的效果

首先,我们要知道模型并发的效果,那我们要知道连接了发起了一个请求之后,它是怎么回复的:

20251105181748

我们现在对的网络请求已经进行了截取,这是其中的一些数据,我们将一下核心的数据写到 json 文件里面让他能够更好的展示:

20251105181910

首先我们知道这是一个流式返回,但是一次流式返回了包含了的内容非常多,这里就是我们并发的关键了,这里的 index 代表并发的下标,而 delta.content 是具体的内容,这样我们知道了 SSE 实现并发的原理了,实际上就是调用 SSE,后端在一次 SSE 的返回中返回同一个问题的不同的结果并通过下标来区分。

我们已经把 SSE 返回机制摸清楚了。下面就轻松地走一遍“边生成边展示”的整个流程:从流式到达、到何时更新、再到怎么把半成品 HTML 安全地渲染出来,最后配上 UI 的滚动与分批加载。读完你就能一眼看懂这套实时渲染是怎么跑起来的。

先说结论:这件事其实就五步,顺次串起来就好了——流式接收、增量累积与触发、HTML 提取与补全、UI 局部更新、以及 iframe 的分批渲染。下面逐段拆开讲。

一、流式数据接收(ai.ts:195–251)

// 使用 ReadableStream 读取流式数据
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let partial = ""; // 处理不完整的行

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  const chunk = decoder.decode(value, { stream: true });
  partial += chunk;

  // 逐行解析 SSE 格式:data: {...}
  const lines = partial.split("\n");
  partial = lines.pop() || ""; // 保留不完整的行

  for (const line of lines) {
    if (!line.startsWith("data: ")) continue;
    const json: StreamChunk = JSON.parse(data);
    // 处理每个 chunk...
  }
}

这里的代码的核心要点如下:

  • 逐行处理:SSE 数据按行到达,split('\n') 拆开,半截 JSON 用 partial 暂存。
  • 字符安全:TextDecoder(..., { stream: true }) 负责拼接多字节字符,避免中文或 emoji 被截断。
  • 过滤噪声:只解析 data: 开头的有效行,忽略心跳、空行、注释。
  • 流式收尾:遇到 [DONE] 仅结束对应流 index,其余继续处理。

二、增量累积与智能触发(ai.ts:224–244)

// 为每个 index 累积内容
contentBuffers[index] += delta;

// 判断是否应该触发渲染
const lastLength = lastRenderedLength.get(index) || 0;
const shouldRender = this.shouldTriggerRender(
  contentBuffers[index],
  lastLength
);

if (shouldRender && onProgress) {
  const htmlCode = this.extractHTMLCode(contentBuffers[index]);
  onProgress(index, contentBuffers[index], htmlCode);
  lastRenderedLength.set(index, contentBuffers[index].length);
}

这里的代码的核心要点如下:

  • 多流并行:每个 index 各自独立累积,互不干扰。
  • 智能触发:通过与 lastRenderedLength 比较控制频率,避免“来一点就刷”。
  • 精准更新:只触发对应 index 的渲染,避免全局重排。
  • 兜底刷新:流结束后进行最终更新,确保结果完整。

三、触发策略(ai.ts:62–101)

private static shouldTriggerRender(
  newContent: string,
  oldLength: number,
): boolean {
  // 1. 首次渲染:内容超过 20 字符
  if (oldLength === 0 && newLength > 20) {
    return true;
  }

  // 2. 关键闭合标签出现(语义区块完成)
  const keyClosingTags = [
    '</header>', '</section>', '</main>',
    '</article>', '</footer>', '</nav>',
    '</aside>', '</div>', '</body>', '</html>'
  ];

  const addedContent = newContent.substring(oldLength);
  for (const tag of keyClosingTags) {
    if (addedContent.includes(tag)) {
      return true; // 区块完成,立即渲染
    }
  }

  // 3. 内容增长超过 200 字符(防止长时间不更新)
  if (newLength - oldLength > 200) {
    return true;
  }

  return false;
}

这里的代码的核心要点如下:

  • 首帧提速:内容首次超过 20 字符立即渲染,减少“首屏空白”。
  • 语义闭合优先:检测新增片段中的关键闭合标签(如 </section></div>),保证块级内容完整展示。
  • 超长兜底:即使未闭合,增量超 200 字符也强制刷新。
  • 性能友好:仅比较“新增部分”,无需重复扫描旧文本;参数可根据模型节奏与设备性能调节。

四、HTML 提取与自动补全(ai.ts:19–59, 104–145)

private static extractHTMLCode(content: string): string {
  // 方式1: 完整的 ```html 代码块
  const codeBlockMatch = content.match(/```html\s*([\s\S]*?)```/);
  if (codeBlockMatch) return codeBlockMatch[1].trim();

  // 方式2: 未完成的代码块(流式渲染)
  const incompleteMatch = content.match(/```html\s*([\s\S]*?)$/);
  if (incompleteMatch) {
    return this.autoCompleteHTML(incompleteMatch[1].trim());
  }

  // 方式3: 直接以 <!DOCTYPE 或 <html 开头
  if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
    return this.autoCompleteHTML(trimmed);
  }

  return '';
}

private static autoCompleteHTML(html: string): string {
  // 移除最后不完整的标签(如 "<div cla")
  if (lastOpenBracket > lastCloseBracket) {
    result = html.substring(0, lastOpenBracket);
  }

  // 自动闭合 script、body、html 标签
  // 确保浏览器可以渲染未完成的 HTML
  return result;
}

这里的代码的核心要点如下:

  • 多格式兼容:完整块、未闭合块、裸 HTML 均可识别。
  • 容错补齐:遇到半截标签(如 <div cla)自动裁剪,再补上 </script></body></html> 等关键闭合。
  • 最小修正:仅做“可渲染”层面的修复,保持生成内容原貌。
  • 安全回退:提不出 HTML 时返回空字符串,避免将解释性文字误渲染。

五、UI 实时更新(ChatPage.tsx:240–253)

await AIService.generateMultipleResponses(
  userPrompt,
  totalCount,
  (index, content, htmlCode) => {
    // 实时更新对应 index 的结果
    setResults((prev) =>
      prev.map((result, i) =>
        i === index
          ? {
              ...result,
              content, // 原始 Markdown 内容
              htmlCode, // 提取的 HTML 代码
              isLoading: false,
            }
          : result
      )
    );
  }
);

这里的代码的核心要点如下:

  • 局部更新:仅更新目标项,prev.map 保证不可变数据结构,减少重渲染。
  • 双轨推进:content 用于 Markdown 文本,htmlCode 用于预览展示。
  • 快速反馈:首批数据到达即撤骨架屏,让用户感知“正在生成”。
  • 状态持久:结果存入 sessionStorage,刷新或返回依旧保留上下文。

六、iframe 分批渲染优化(ChatPage.tsx:109–175)

// 找出已准备好但还未渲染的索引
const readyIndexes = results
  .filter(({ result }) => !result.isLoading && result.htmlCode)
  .map(({ index }) => index)
  .sort((a, b) => a - b);

// 第一次渲染:一次性全部加载(用户体验优先)
if (!hasRenderedOnce.current) {
  setIframeRenderQueue(new Set(readyIndexes));
  hasRenderedOnce.current = true;
  return;
}

// 后续渲染:分批加载(每批 8 个,间隔 300ms)
// 避免一次性创建太多 iframe 导致卡顿
const processBatch = () => {
  const toAdd = stillNotInQueue.slice(0, batchSize); // 8 个
  toAdd.forEach((index) => newQueue.add(index));

  if (stillNotInQueue.length > batchSize) {
    setTimeout(processBatch, 300); // 继续下一批
  }
};

这里的代码的核心要点如下:

  • 首批全放:初次渲染不延迟,保证响应速度。
  • 后续分批:按批次(默认 8 个/300ms)渐进挂载,防止主线程卡顿。
  • 动态调度:每轮重新计算“未入队项”,保证不遗漏。
  • 轻量 DOM:仅渲染必要 iframe,滚动与交互更顺滑;参数可按性能灵活调整。

小结

通过语义闭合与字数阈值控制更新频率让画面稳定流畅,HTML 半成品自动补齐避免黑屏,iframe 分批挂载减轻主线程压力并配合 requestAnimationFrame 提升滚动顺滑度,状态由 sessionStorage 兜底并以日志辅助调参;整体逻辑是流式接收边累积、攒到关键点就渲染一帧、UI 精准更新该动的那格,调顺节奏即可实现实时渲染的又快又稳。

效果展示

前面我们说了这么多代码相关的,接下来我们可以把我们的项目运行起来看一下最终运行的效果:

20251105190158

为了让 UI 的效果显示得更好,建议使用 33%缩放的屏幕效果。

20251105190242

在输入框输入我们要问的问题,点击发送,你会看到这样的效果:

20251105190307

这会你能实时看到 24 个页面实时渲染的效果:

20251105190430

这样我们就借助 RWKV7-G0a3 13.3B 模型实现了一个跟 Cursor2.0 版本一模一样的效果了。

总结

RWKV7-G0a3 13.3B 是由中国团队推出的最新一代纯 RNN 大语言模型,在无 Attention 架构下实现了与主流 Transformer 模型相媲美的性能,并在多项基准测试中表现优异。它以更低显存占用和高推理效率展示了 RNN 架构的强大潜力。而前端并发实现中,通过 SSE 流式返回不同 index 的内容,实现了同时生成多个模型响应的并行效果。结合智能触发渲染与分批 iframe 更新,最终达成了类似 Cursor 2.0 的多 Agent 实时对比体验。

前端仓库地址

后端仓库地址

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

作者 得物技术
2025年10月23日 16:45
循环依赖暴露了代码结构的设计缺陷。理论上应通过分层和抽象来避免,但在复杂的业务交互中仍难以杜绝。虽然Spring利用三级缓存等机制默默解决了这一问题,使程序得以运行,但这绝不应是懈怠设计的借口。我们更

第一个成功在APP store 上架的APP

作者 Pluto538
2025年10月12日 23:18

XunDoc开发之旅:当AI医生遇上家庭健康管家

当我在生活中目睹家人为管理复杂的健康数据、用药提醒而手忙脚乱时,一个想法冒了出来:我能否打造一个App,像一位贴心的家庭健康管家,把全家人的健康都管起来?它不仅要能记录数据,还要够聪明,能解答健康疑惑,能主动提醒。这就是 XunDoc App。

1. 搭建家庭的健康数据中枢

起初,我转向AI助手寻求架构指导。我的构想很明确:一个以家庭为单位,能管理成员信息、记录多种健康指标(血压、血糖等)的系统。AI很快给出了基于SwiftUI和MVVM模式的代码框架,并建议用UserDefaults来存储数据。

但对于一个完整的应用而言,我马上遇到了第一个问题:数据如何在不同视图间高效、准确地共享? 一开始我简单地使用@State,但随着功能增多,数据流变得一团糟,经常出现视图数据不同步的情况。

接着在Claude解决不了的时候我去询问Deepseek,它一针见血地指出:“你的数据管理太分散了,应该使用EnvironmentObject配合单例模式,建立一个统一的数据源。” 这个建议成了项目的转折点。我创建了FamilyShareManagerHealthDataManager这两个核心管家。当我把家庭成员的增删改查、健康数据的录入与读取都交给它们统一调度后,整个应用的数据就像被接通了任督二脉,立刻流畅稳定了起来。

2. 请来AI医生:集成Moonshot API

基础框架搭好,接下来就是实现核心的“智能”部分了。我想让用户能通过文字和图片,向AI咨询健康问题。我再次找到AI助手,描述了皮肤分析、报告解读等四种咨询场景,它很快帮我写出了调用Moonshot多模态API的代码。

然而,每件事都不能事事如意的。文字咨询很顺利,但一到图片上传就频繁失败。AI给出的代码在处理稍大一点的图片时就会崩溃,日志里满是编码错误。我一度怀疑是网络问题,但反复排查后,我询问Deepseek,他告诉我:“多模态API对图片的Base64编码和大小有严格限制,你需要在前端进行压缩和校验。”

我把他给我的建议给到了Claude。claude帮我编写了一个“图片预处理”函数,自动将图片压缩到4MB以内并确保编码格式正确。当这个“关卡”被设立后,之前桀骜不驯的图片上传功能终于变得温顺听话。看着App里拍张照就能得到专业的皮肤分析建议,那种将前沿AI技术握在手中的感觉,实在令人兴奋。

3. 打造永不遗忘的智能提醒系统

健康管理,贵在坚持,难在记忆。我决心打造一个强大的医疗提醒模块。我的想法是:它不能是普通的闹钟,而要像一位专业的护士,能区分用药、复查、预约等不同类型,并能灵活设置重复。

AI助手根据我的描述,生成了利用UserNotifications框架的初始代码。但很快,我发现了一个新问题:对于“每周一次”的重复提醒,当用户点击“完成”后,系统并不会自动创建下一周的通知。这完全违背了“提醒”的初衷。

“这需要你自己实现一个智能调度的逻辑,在用户完成一个提醒时,计算出下一次触发的时间,并重新提交一个本地通知。” 这是deepseek告诉我的,我把这个需求告诉给了Claude。于是,在MedicalNotificationManager中, claude加入了一个“重新调度”的函数。当您标记一个每周的用药提醒为“已完成”时,App会悄无声息地为您安排好下一周的同一时刻的提醒。这个功能的实现,让XunDoc从一个被动的记录工具,真正蜕变为一个主动的健康守护者。

4. 临门一脚:App Store上架“渡劫”指南

当XunDoc终于在模拟器和我的测试机上稳定运行后,我感觉胜利在望。但很快我就意识到,从“本地能跑”到“商店能下”,中间隔着一道巨大的鸿沟——苹果的审核。证书、描述文件、权限声明、截图尺寸……这些繁琐的流程让我一头雾水。

这次,我直接找到了DeepSeek:“我的App开发完了,现在需要上传到App Store,请给我一个最详细、针对新手的小白教程。”

DeepSeek给出的回复堪称保姆级,它把整个过程拆解成了“配置App ID和证书”、“在App Store Connect中创建应用”、“在Xcode中进行归档打包”三大步。我就像拿着攻略打游戏,一步步跟着操作:

  • 创建App ID:在苹果开发者后台,我按照说明创建了唯一的App ID com.[我的ID].XunDoc
  • 搞定证书:最让我头疼的证书环节,DeepSeek指导我分别创建了“Development”和“Distribution”证书,并耐心解释了二者的区别。
  • 设置权限:因为App需要用到相机(拍照诊断)、相册(上传图片)和通知(医疗提醒),我根据指南,在Info.plist文件中一一添加了对应的权限描述,确保审核员能清楚知道我们为什么需要这些权限。

一切准备就绪,我在Xcode中点击了“Product” -> “Archive”。看着进度条缓缓填满,我的心也提到了嗓子眼。打包成功!随后通过“Distribute App”流程,我将我这两天的汗水上传到了App Store Connect。当然不是一次就通过上传的。

image.png

5. 从“能用”到“好用”:三次UI大迭代的觉醒

应用上架最初的兴奋感过去后,我陆续收到了一些早期用户的反馈:“功能很多,但不知道从哪里开始用”、“界面有点拥挤,找东西费劲”。这让我意识到,我的产品在工程师思维里是“功能完备”,但在用户眼里可能却是“复杂难用”。

我决定重新设计UI。第一站,我找到了国产的Mastergo。我将XunDoc的核心界面截图喂给它,并提示:“请为这款家庭健康管理应用生成几套更现代、更友好的UI设计方案。”

Mastergo给出的方案让我大开眼界。它弱化了我之前强调的“卡片”边界,采用了更大的留白和更清晰的视觉层级。它建议将底部的标签栏导航做得更精致,并引入了一个全局的“+”浮动按钮,用于快速记录健康数据。这是我第一套迭代方案的灵感来源:从“功能堆砌”转向“简洁现代”

image.png 然而,Mastergo的方案虽然美观,但有些交互逻辑不太符合iOS的规范。于是,第二站,我请来了Stitch。我将完整的产品介绍、所有功能模块的说明,以及第一版的设计图都给了它,并下达指令:“请基于这些材料,完全重现XunDoc的完整UI,但要遵循iOS Human Interface Guidelines,并确保信息架构清晰,新用户能快速上手。”等到他设计好了后 我将我的设计图UI截图给Claude,让他尽可能的帮我生成。

image.png (以上是我的Stitch构建出来的页面) Claude展现出了惊人的理解力。它不仅仅是在画界面,而是在重构产品的信息架构。它建议将“AI咨询”的四种模式(皮肤、症状、报告、用药)从并列排列,改为一个主导航入口,进去后再通过图标和简短说明让用户选择。同时,它将“首页”重新定义为真正的“健康概览”,只显示最关键的数据和今日提醒,其他所有功能都规整地收纳入标签栏。这形成了我的第二套迭代方案从“简洁现代”深化为“结构清晰”

image.png

拿着Claude的输出,我结合Mastergo和Stitch的视觉灵感,再让Cluade一步一步的微调。我意识到,颜色不仅是美观,更是传达情绪和功能的重要工具。我将原本统一的蓝色系,根据功能模块进行了区分:健康数据用沉稳的蓝色,AI咨询用代表智慧的紫色,医疗提醒用醒目的橙色。图标也设计得更加线性轻量,减少了视觉负担。(其实这是Deepseek给我的建议)这就是最终的第三套迭代方案在清晰的结构上,注入温暖与亲和力

image.png 这次从Stitch到Claude的UI重塑之旅,让我深刻意识到,一个成功的产品不仅仅是代码的堆砌。它是一次与用户的对话,而设计,就是这门对话的语言。通过让不同的AI助手在我的引导下“协同创作”,我成功地让XunDoc从一個工程师的作品,蜕变成一个真正为用户着想的产品。

现在这款app已经成功上架到了我的App store上 大家可以直接搜索下来进行使用和体验,我希望大家可以在未来可以一起解决问题!

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

作者 得物技术
2025年9月18日 14:09

一、问题背景

业务同学在 OneDBA 平台进行一次正常 DDL 变更完成后(变更内容跟此次问题无关),发现一些 SQL 开始出现慢查,同时变更后的表比变更前的表存储空间膨胀了几乎 100%。经过分析和流程复现完整还原了整个事件,发现了 MySQL 在平衡 B+tree 页分裂方面遇到单行记录太大时的一些缺陷,整理分享。

为了能更好的说明问题背后的机制,会进行一些关键的“MySQL原理”和“当前DDL变更流程”方面的知识铺垫,熟悉的同学可以跳过。

本次 DDL 变更后带来了如下问题:

  • 变更后,表存储空间膨胀了几乎 100%;
  • 变更后,表统计信息出现了严重偏差;
  • 变更后,部分有排序的 SQL 出现了慢查。

现在来看,表空间膨胀跟统计信息出错是同一个问题导致,而统计信息出错间接导致了部分SQL出现了慢查,下面带着这些问题开始一步步分析找根因。

二、索引结构

B+tree

InnoDB 表是索引组织表,也就是所谓的索引即数据,数据即索引。索引分为聚集索引和二级索引,所有行数据都存储在聚集索引,二级索引存储的是字段值和主键,但不管哪种索引,其结构都是 B+tree 结构。

一棵 B+tree 分为根页、非叶子节点和叶子节点,一个简单的示意图(from Jeremy Cole)如下:

由于 InnoDB B+tree 结构高扇区特性,所以每个索引高度基本在 3-5 层之间,层级(Level)从叶子节点的 0 开始编号,沿树向上递增。每层的页面节点之间使用双向链表,前一个指针和后一个指针按key升序排列。

最小存储单位是页,每个页有一个编号,页内的记录使用单向链表,按 key 升序排列。每个数据页中有两个虚拟的行记录,用来限定记录的边界;其中最小值(Infimum)表示小于页面上任何 key 的值,并且始终是单向链表记录列表中的第一个记录;最大值(Supremum)表示大于页面上任何 key 的值,并且始终是单向链表记录列表中的最后一条记录。这两个值在页创建时被建立,并且在任何情况下不会被删除。

非叶子节点页包含子页的最小 key 和子页号,称为“节点指针”。

现在我们知道了我们插入的数据最终根据主键顺序存储在叶子节点(页)里面,可以满足点查和范围查询的需求。

页(page)

默认一个页 16K 大小,且 InnoDB 规定一个页最少能够存储两行数据,这里需要注意规定一个页最少能够存储两行数据是指在空间分配上,并不是说一个页必须要存两行,也可以存一行。

怎么实现一个页必须要能够存储两行记录呢? 当一条记录 <8k 时会存储在当前页内,反之 >8k 时必须溢出存储,当前页只存储溢出页面的地址,需 20 个字节(行格式:Dynamic),这样就能保证一个页肯定能最少存储的下两条记录。

溢出页

当一个记录 >8k 时会循环查找可以溢出存储的字段,text类字段会优先溢出,没有就开始挑选 varchar 类字段,总之这是 InnoDB 内部行为,目前无法干预。

建表时无论是使用 text 类型,还是 varchar 类型,当大小 <8k 时都是存储在当前页,也就是在 B+tree 结构中,只有 >8k 时才会进行溢出存储。

页面分裂

随着表数据的变化,对记录的新增、更新、删除;那么如何在 B+tree 中高效管理动态数据也是一项核心挑战。

MySQL InnoDB 引擎通过页面分裂和页面合并两大关键机制来动态调整存储结构,不仅能确保数据的逻辑完整性和逻辑顺序正确,还能保证数据库的整体性能。这些机制发生于 InnoDB 的 B+tree 索引结构内部,其具体操作是:

  • 页面分裂:当已满的索引页无法容纳新记录时,创建新页并重新分配记录。
  • 页面合并:当页内记录因删除/更新低于阈值时,与相邻页合并以优化空间。

深入理解上述机制至关重要,因为页面的分裂与合并将直接影响存储效率、I/O模式、加锁行为及整体性能。其中页面的分裂一般分为两种:

  • 中间点(mid point)分裂:将原始页面中50%数据移动到新申请页面,这是最普通的分裂方法。
  • 插入点(insert point)分裂:判断本次插入是否递增 or 递减,如果判定为顺序插入,就在当前插入点进行分裂,这里情况细分较多,大部分情况是直接插入到新申请页面,也可能会涉及到已存在记录移动到新页面,有有些特殊情况下还会直接插入老的页面(老页面的记录被移动到新页面)。

表空间管理

InnoDB的B+tree是通过多层结构映射在磁盘上的,从它的逻辑存储结构来看,所有数据都被有逻辑地存放在一个空间中,这个空间就叫做表空间(tablespace)。表空间由段(segment)、区(extent)、页(page)组成,搞这么多手段的唯一目的就是为了降低IO的随机性,保证存储物理上尽可能是顺序的。

三、当前DDL变更机制

在整个数据库平台(OneDBA)构建过程中,MySQL 结构变更模块是核心基础能力,也是研发同学在日常业务迭代过程中使用频率较高的功能之一。

主要围绕对表加字段、加索引、改属性等操作,为了减少这些操作对线上数据库或业务的影响,早期便为 MySQL 结构变更开发了一套基于容器运行的无锁变更程序,核心采用的是全量数据复制+增量 binlog 回放来进行变更,也是业界通用做法(内部代号:dw-osc,基于 GitHub 开源的 ghost 工具二次开发),主要解决的核心问题:

  • 实现无锁化的结构变更,变更过程中不会阻挡业务对表的读写操作。
  • 实现变更不会导致较大主从数据延迟,避免业务从库读取不到数据导致业务故障。
  • 实现同时支持大规模任务变更,使用容器实现使用完即销毁,无变更任务时不占用资源。

变更工具工作原理简单描述 (重要)

重点:

简单理解工具进行 DDL 变更过程中为了保证数据一致性,对于全量数据的复制与 binlog 回放是并行交叉处理,这种机制它有一个特点就是【第三步】会导致新插入的记录可能会先写入到表中(主键 ID 大的记录先写入到了表),然后【第二步】中复制数据后写入到表中(主键 ID 小的记录后写入表)。

这里顺便说一下当前得物结构变更整体架构:由于变更工具的工作原理需消费大量 binlog 日志保证数据一致性,会导致在变更过程中会有大量的带宽占用问题,为了消除带宽占用问题,开发了 Proxy 代理程序,在此基础之上支持了多云商、多区域本地化变更。

目前整体架构图如下:

四、变更后,表为什么膨胀?

原因说明

上面几个关键点铺垫完了,回到第一个问题,这里先直接说明根本原因,后面会阐述一下排查过程(有同学感兴趣所以分享一下,整个过程还是耗费不少时间)。

在『结构变更机制』介绍中,我们发现这种变更机制它有一个特点,就是【第三步】会导致新插入的记录可能会先写入到表中(主键 ID 大的记录先写入到了表),然后【第二步】中复制数据后写入到表中(主键 ID 小的记录)。这种写入特性叠加单行记录过大的时候(业务表单行记录大小 5k 左右),会碰到 MySQL 页分裂的一个瑕疵(暂且称之为瑕疵,或许是一个 Bug),导致了一个页只存储了 1 条记录(16k 的页只存储了 5k,浪费 2/3 空间),放大了存储问题。

流程复现

下面直接复现一下这种现象下导致异常页分裂的过程:

CREATE TABLE `sbtest` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `pad` varchar(12000),
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

然后插入两行 5k 大小的大主键记录(模拟变更时 binlog 回放先插入数据):

insert into sbtest values (10000, repeat('a',5120));
insert into sbtest values (10001, repeat('a',5120));

这里写了一个小工具打印记录对应的 page 号和 heap 号。

# ./peng
[pk:10000] page: 3 -> heap: 2
[pk:10001] page: 3 -> heap: 3

可以看到两条记录都存在 3 号页,此时表只有这一个页。

继续开始顺序插入数据(模拟变更时 copy 全量数据过程),插入 rec-1:

insert into sbtest values (1, repeat('a',5120));
# ./peng
[pk:1] page: 3 -> heap: 4
[pk:10000] page: 3 -> heap: 2
[pk:10001] page: 3 -> heap: 3

插入 rec-2:

insert into sbtest values (2, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 5 -> heap: 3

可以看到开始分裂了,page 3 被提升为根节点了,同时分裂出两个叶子节点,各自存了两条数据。此时已经形成了一棵 2 层高的树,还是用图表示吧,比较直观,如下:

插入 rec-3:

insert into sbtest values (3, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 5 -> heap: 3

示意图如下:

插入 rec-4:

insert into sbtest values (4, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 6 -> heap: 2

这里开始分裂一个新页 page 6,开始出现比较复杂的情况,同时也为后面分裂导致一个页只有 1 条数据埋下伏笔:

这里可以看到把 10001 这条记录从 page 5 上面迁移到了新建的 page 6 上面(老的 page 5 中会删除 10001 这条记录,并放入到删除链表中),而把当前插入的 rec-4 插入到了原来的 page 5 上面,这个处理逻辑在代码中是一个特殊处理,向右分裂时,当插入点页面前面有大于等于两条记录时,会设置分裂记录为 10001,所以把它迁移到了 page 6,同时会把当前插入记录插入到原 page 5。具体可以看 btr_page_get_split_rec_to_right 函数。

/* 这里返回true表示将行记录向右分裂:即分配的新page的hint_page_no为原page+1 */
ibool btr_page_get_split_rec_to_right(
/*============================*/
        btr_cur_t*        cursor,
        rec_t**           split_rec)
{
  page_t*        page;
  rec_t*        insert_point;
  
  // 获取当前游标页和insert_point
  page = btr_cur_get_page(cursor);
  insert_point = btr_cur_get_rec(cursor);
  
  /* 使用启发式方法:如果新的插入操作紧跟在同一页面上的前一个插入操作之后,
     我们假设这里存在一个顺序插入的模式。 */
  
  // PAGE_LAST_INSERT代表上次插入位置,insert_point代表小于等于待插入目标记录的最大记录位置
  // 如果PAGE_LAST_INSERT=insert_point意味着本次待插入的记录是紧接着上次已插入的记录,
  // 这是一种顺序插入模式,一旦判定是顺序插入,必然反回true,向右分裂
  if (page_header_get_ptr(page, PAGE_LAST_INSERT) == insert_point) {
    // 1. 获取当前insert_point的page内的下一条记录,并判断是否是supremum记录
    // 2. 如果不是,继续判断当前insert_point的下下条记录是否是supremum记录
    // 也就是说,会向后看两条记录,这两条记录有一条为supremum记录,
    // split_rec都会被设置为NULL,向右分裂
    rec_t*        next_rec;
    next_rec = page_rec_get_next(insert_point);
    
    if (page_rec_is_supremum(next_rec)) {
    split_at_new:
      /* split_rec为NULL表示从新插入的记录开始分裂,插入到新页 */
      *split_rec = nullptr;
    } else {
      rec_t* next_next_rec = page_rec_get_next(next_rec);
      if (page_rec_is_supremum(next_next_rec)) {
        goto split_at_new;
      }
      
      /* 如果不是supremum记录,则设置拆分记录为下下条记录 */


      /* 这样做的目的是,如果从插入点开始向上有 >= 2 条用户记录,
         我们在该页上保留 1 条记录,因为这样后面的顺序插入就可以使用
         自适应哈希索引,因为它们只需查看此页面上的记录即可对正确的
         搜索位置进行必要的检查 */
      
      *split_rec = next_next_rec;
    }
    
    return true;
  }
  
  return false;
}

插入 rec-5:

insert into sbtest values (5, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:5] page: 7 -> heap: 3
[pk:10000] page: 7 -> heap: 2
[pk:10001] page: 6 -> heap: 2

开始分裂一个新页 page 7,新的组织结构方式如下图:

此时是一个正常的插入点右分裂机制,把老的 page 5 中的记录 10000 都移动到了 page 7,并且新插入的 rec-5 也写入到了 page 7 中。到此时看上去一切正常,接下来再插入记录在当前这种结构下就会产生异常。

插入 rec-6:

insert into sbtest values (5, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:5] page: 7 -> heap: 3
[pk:6] page: 8 -> heap: 3
[pk:10000] page: 8 -> heap: 2
[pk:10001] page: 6 -> heap: 2

此时也是一个正常的插入点右分裂机制,把老的 page 7 中的记录 10000 都移动到了 page 8,并且新插入的 rec-6 也写入到了 page 8 中,但是我们可以发现 page 7 中只有一条孤零零的 rec-5 了,一个页只存储了一条记录。

按照代码中正常的插入点右分裂机制,继续插入 rec-7 会导致 rec-6 成为一个单页、插入 rec-8 又会导致 rec-7 成为一个单页,一直这样循环下去。

目前来看就是在插入 rec-4,触发了一个内部优化策略(具体优化没太去研究),进行了一些特殊的记录迁移和插入动作,当然跟记录过大也有很大关系。

排查过程

有同学对这个问题排查过程比较感兴趣,所以这里也整理分享一下,简化了一些无用信息,仅供参考。

表总行数在 400 百万,正常情况下的大小在 33G 左右,变更之后的大小在 67G 左右。

  • 首先根据备份恢复了一个数据库现场出来。
  • 统计了业务表行大小,发现行基本偏大,在 4-7k 之间(一个页只存了2行,浪费1/3空间)。
  • 分析了变更前后的表数据页,以及每个页存储多少行数据。
    • 发现变更之前数据页大概 200 百万,变更之后 400 百万,解释了存储翻倍。
    • 发现变更之前存储 1 行的页基本没有,变更之后存储 1 行的页接近 400 百万。

基于现在这些信息我们知道了存储翻倍的根本原因,就是之前一个页存储 2 条记录,现在一个页只存储了 1 条记录,新的问题来了,为什么变更后会存储 1 条记录,继续寻找答案。

  • 我们首先在备份恢复的实例上面进行了一次静态变更,就是变更期间没有新的 DML 操作,没有复现。但说明了一个问题,异常跟增量有关,此时大概知道跟变更过程中的 binlog 回放特性有关【上面说的回放会导致主键 ID 大的记录先写入表中】。
  • 写了个工具把 400 百万数据每条记录分布在哪个页里面,以及页里面的记录对应的 heap 是什么都记录到数据库表中分析,慢长等待跑数据。

  • 数据分析完后通过分析发现存储一条数据的页对应的记录的 heap 值基本都是 3,正常应该是 2,意味着这些页并不是一开始就存一条数据,而是产生了页分裂导致的。
  • 开始继续再看页分裂相关的资料和代码,列出页分裂的各种情况,结合上面的信息构建了一个复现环境。插入数据页分裂核心函数。
    • btr_cur_optimistic_insert:乐观插入数据,当前页直接存储
    • btr_cur_pessimistic_insert:悲观插入数据,开始分裂页
    • btr_root_raise_and_insert:单独处理根节点的分裂
    • btr_page_split_and_insert:分裂普通页,所有流程都在这个函数
    • btr_page_get_split_rec_to_right:判断是否是向右分裂
    • btr_page_get_split_rec_to_left:判断是否是向左分裂

heap

heap 是页里面的一个概念,用来标记记录在页里面的相对位置,页里面的第一条用户记录一般是 2,而 0 和 1 默认分配给了最大最小虚拟记录,在页面创建的时候就初始化好了,最大最小记录上面有简单介绍。

解析 ibd 文件

更快的方式还是应该分析物理 ibd 文件,能够解析出页的具体数据,以及被分裂删除的数据,分裂就是把一个页里面的部分记录移动到新的页,然后删除老的记录,但不会真正删除,而是移动到页里面的一个删除链表,后面可以复用。

五、变更后,统计信息为什么差异巨大?

表统计信息主要涉及索引基数统计(也就是唯一值的数量),主键索引的基数统计也就是表行数,在优化器进行成本估算时有些 SQL 条件会使用索引基数进行抉择索引选择(大部分情况是 index dive 方式估算扫描行数)。

InnoDB 统计信息收集算法简单理解就是采样叶子节点 N 个页(默认 20 个页),扫描统计每个页的唯一值数量,N 个页的唯一值数量累加,然后除以N得到单个页平均唯一值数量,再乘以表的总页面数量就估算出了索引总的唯一值数量。

但是当一个页只有 1 条数据的时候统计信息会产生严重偏差(上面已经分析出了表膨胀的原因就是一个页只存储了 1 条记录),主要是代码里面有个优化逻辑,对单个页的唯一值进行了减 1 操作,具体描述如下注释。本来一个页面就只有 1 条记录,再进行减 1 操作就变成 0 了,根据上面的公式得到的索引总唯一值就偏差非常大了。

static bool dict_stats_analyze_index_for_n_prefix(
    ...
    // 记录页唯一key数量
    uint64_t n_diff_on_leaf_page;
    
    // 开始进行dive,获取n_diff_on_leaf_page的值
    dict_stats_analyze_index_below_cur(pcur.get_btr_cur(), n_prefix,
                                       &n_diff_on_leaf_page, &n_external_pages);
    
    /* 为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。
    一次是某个页面的最后一个值,一次是另一个页面的第一个值。请考虑以下示例:
    Leaf level:
    page: (2,2,2,2,3,3)
    ... 许多页面类似于 (3,3,3,3,3,3)...
    page: (3,3,3,3,5,5)
    ... 许多页面类似于 (5,5,5,5,5,5)...
    page: (5,5,5,5,8,8)
    page: (8,8,8,8,9,9)
    我们的算法会(正确地)估计平均每页有 2 条不同的记录。
    由于有 4 页 non-boring 记录,它会(错误地)将不同记录的数量估计为 8 条
    */ 
    if (n_diff_on_leaf_page > 0) {
      n_diff_on_leaf_page--;
    }
    
    // 更新数据,在所有分析的页面上发现的不同键值数量的累计总和
    n_diff_data->n_diff_all_analyzed_pages += n_diff_on_leaf_page;
)

可以看到PRIMARY主键异常情况下统计数据只有 20 万,表有 400 百万数据。正常情况下主键统计数据有 200 百万,也与表实际行数差异较大,同样是因为单个页面行数太少(正常情况大部分也只有2条数据),再进行减1操作后,导致统计也不准确。

MySQL> select table_name,index_name,stat_value,sample_size from mysql.innodb_index_stats where database_name like 'sbtest' and TABLE_NAME like 'table_1' and stat_name='n_diff_pfx01';
+-------------------+--------------------------------------------+------------+-------------+
| table_name        | index_name                                 | stat_value | sample_size |
+-------------------+--------------------------------------------+------------+-------------+
| table_1           | PRIMARY                                    |     206508 |          20 |
+-------------------+--------------------------------------------+------------+-------------+
11 rows in set (0.00 sec)

优化

为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。

这里应该是可以优化的,对于主键来说是不是可以判断只有一个字段时不需要进行减1操作,会导致表行数统计非常不准确,毕竟相邻页不会数据重叠。

最低限度也需要判断单个页只有一条数据时不需要减1操作。

六、统计信息与慢SQL之间的关联关系?

当前 MySQL 对大部分 SQL 在评估扫描行数时都不再依赖统计信息数据,而是通过一种 index dive 采样算法实时获取大概需要扫描的数据,这种方式的缺点就是成本略高,所以也提供有参数来控制某些 SQL 是走 index dive 还是直接使用统计数据。

另外在SQL带有 order by field limit 时会触发MySQL内部的一个关于 prefer_ordering_index 的 ORDER BY 优化,在该优化中,会比较使用有序索引和无序索引的代价,谁低用谁。

当时业务有问题的慢 SQL 就是被这个优化干扰了。

# where条件
user_id = ? and biz = ? and is_del = ? and status in (?) ORDER BY modify_time limit 5


# 表索引
idx_modify_time(`modify_time`)
idx_user_biz_del(`user_id`,`biz`, `is_del`)

正常走 idx_user_biz_del 索引为过滤性最好,但需要对 modify_time 字段进行排序。

这个优化机制就是想尝试走 idx_modify_time 索引,走有序索引想避免排序,然后套了一个公式来预估如果走 idx_modify_time 有序索引大概需要扫描多少行?公式非常简单直接:表总行数 / 最优索引的扫描行数 * limit。

  • 表总行数:也就是统计信息里面主键的 n_rows
  • 最优索引的扫描行数:也就是走 idx_user_biz_del 索引需要扫描的行数
  • limit:也就是 SQL 语句里面的 limit 值

使用有序索引预估的行数对比最优索引的扫描行数来决定使用谁,在这种改变索引的策略下,如果表的总行数估计较低(就是上面主键的统计值),会导致更倾向于选择有序索引。

但一个最重要的因素被 MySQL 忽略了,就是实际业务数据分布并不是按它给的这种公式来,往往需要扫描很多数据才能满足 limit 值,造成慢 SQL。

七、如何临时解决该问题?

发现问题后,可控的情况下选择在低峰期对表执行原生 alter table xxx engine=innodb 语句, MySQL 内部重新整理了表空间数据,相关问题恢复正常。但这个原生 DDL 语句,虽然变更不会产生锁表,但该语句无法限速,同时也会导致主从数据较大延迟。

为什么原生 DDL 语句可以解决该问题?看两者在流程上的对比区别。

alter table xxx engine=innodb变更流程 当前工具结构变更流程
1. 建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。
  1. 拷贝全量数据:将目标表中的全量数据同步至临时表。
  2. 增量DML临时存储在一个缓冲区内。
  3. 全量数据复制完成后,开始应用增量DML日志。
  4. 切换新旧表:重命名原表作为备份,再用临时表替换原表。
  5. 变更完成 | 1. 创建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。
  6. 拷贝全量数据:将目标表中的全量数据同步至临时表。
  7. 解析Binlog并同步增量数据: 将目标表中的增量数据同步至临时表。
  8. 切换新旧表:重命名原表作为备份,再用临时表替换原表。
  9. 变更完成 |

可以看出结构变更唯一不同的就是增量 DML 语句是等全量数据复制完成后才开始应用,所以能修复表空间,没有导致表膨胀。

八、如何长期解决该问题?

关于业务侧的改造这里不做过多说明,我们看看从变更流程上面是否可以避免这个问题。

既然在变更过程中复制全量数据和 binlog 增量数据回放存在交叉并行执行的可能,那么如果我们先执行全量数据复制,然后再进行增量 binlog 回放是不是就可以绕过这个页分裂问题(就变成了跟 MySQL 原生 DDL 一样的流程)。

变更工具实际改动如下图:

这样就不存在最大记录先插入到表中的问题,丢弃的记录后续全量复制也同样会把记录复制到临时表中。并且这个优化还能解决需要大量回放 binlog 问题,细节可以看看 gh-ost 的 PR-1378。

九、总结

本文先介绍了一些关于 InnoDB 索引机制和页溢出、页分裂方面的知识;介绍了业界通用的 DDL 变更工具流程原理。

随后详细分析了变更后表空间膨胀问题根因,主要是当前变更流程机制叠加单行记录过大的时候(业务表单行记录大小 5k 左右),会碰到 MySQL 页分裂的一个瑕疵,导致了一个页只存储了 1 条记录(16k 的页只存储了 5k,浪费 2/3 空间),导致存储空间膨胀问题。

最后分析了统计信息出错的原因和统计信息出错与慢 SQL 之间的关联关系,以及解决方案。

全文完,感谢阅读。

往期回顾

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

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

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

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

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

文 / 东青

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

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

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

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

作者 得物技术
2025年9月16日 14:17

一、前 言

本文核心介绍,为何业界会有这样的说法?—— “MySQL单表存储数据量最好别超过千万级别”

当然这里是有前提条件的,也是我们最常使用到的:

  • InnoDB存储引擎;
  • 使用的是默认索引数据结构——B+树;
  • 正常普通表数据(列数量控制在几个到一二十个,普通字段类型及长度)。

接下来咱们就探究一下原因,逐步揭开答案。

二、MySQL是如何存储数据的?

核心结构:B+树 + 16KB数据页

这里如下,建一张普通表user:

CREATE TABLE `user` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(100NOT NULL DEFAULT '' COMMENT '名字',
  `age` int(11NOT NULL DEFAULT '0' COMMENT '年龄',
  PRIMARY KEY (`id`),
  KEY `idx_age` (`age`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

数据页(Page)

介绍

InnoDB存储的最小单位,固定为16KB 。每页存储表数据(行记录)、索引、元信息等。数据加载到内存时以页为单位,减少磁盘I/O次数。

页的结构

假设我们有这么一张user数据表。其中id是唯一主键。这看起来的一行行数据,为了方便,我们后面就叫它们record吧。这张表看起来就跟个excel表格一样。excel的数据在硬盘上是一个xx.excel的文件。而上面user表数据,在硬盘上其实也是类似,放在了user.ibd文件下。含义是user表的innodb data文件,又叫表空间。虽然在数据表里,它们看起来是挨在一起的。但实际上在user.ibd里他们被分成很多小份的数据页,每份大小16k。类似于下面这样。

ibd文件内部有大量的页,我们把视角聚焦一下,放到页上面。整个页16k,不大,但record这么多,一页肯定放不下,所以会分开放到很多页里。并且这16k,也不可能全用来放record对吧。因为record们被分成好多份,放到好多页里了,为了唯一标识具体是哪一页,那就需要引入页号(其实是一个表空间的地址偏移量)。同时为了把这些数据页给关联起来,于是引入了前后指针,用于指向前后的页。这些都被加到了页头里。页是需要读写的,16k说小也不小,写一半电源线被拔了也是有可能发生的,所以为了保证数据页的正确性,还引入了校验码。这个被加到了页尾。那剩下的空间,才是用来放我们的record的。而record如果行数特别多的话,进入到页内时挨个遍历,效率也不太行,所以为这些数据生成了一个页目录,具体实现细节不重要。只需要知道,它可以通过二分查找的方式将查找效率从O(n) 变成O(lgn)

 

从页到索引—B+树索引

如果想查一条record,我们可以把表空间里每一页都捞出来(全表扫描),再把里面的record捞出来挨个判断是不是我们要找的。行数量小的时候,这么操作也没啥问题。行数量大了,性能就慢了,于是为了加速搜索,我们可以在每个数据页里选出主键id最小的record,而且只需要它们的主键id和所在页的页号。组成新的record,放入到一个新生成的一个数据页中,这个新数据页跟之前的页结构没啥区别,而且大小还是16k。但为了跟之前的数据页进行区分。数据页里加入了页层级(page level) 的信息,从0开始往上算。于是页与页之间就有了上下层级的概念,就像下面这样。

突然页跟页之间看起来就像是一棵倒过来的树了。也就是我们常说的B+ 树索引。最下面那一层,page level 为0,也就是所谓的叶子结点,其余都叫非叶子结点。上面展示的是两层的树,如果数据变多了,我们还可以再通过类似的方法,再往上构建一层。就成了三层的树。

  • 聚簇索引:数据按主键组织成一棵B+树。叶子节点存储完整行数据 ,非叶子节点存储主键值+指向子页的指针(类似目录)。
  • 二级索引:叶子节点存储主键值,查询时需回表(根据主键回聚簇索引查数据)。
  • 行格式:如COMPACT格式,行数据包含事务ID、回滚指针、列值等信息。行大小影响单页存储的行数

存入数据如下

比如表数据已存在id为1-10的数据存储,简单比方如下:

然后需要插入id=11的数据:

  • 加载1号数据页入内存,分析判定;
  • id=11的数据大于id=10,那么锁定页号5,判定5号页是否还可以存下数据11;
  • 可以存下,将id=11的数据写入到5号页中。

关键原理总结

所有数据通过B+树有序组织,数据存储在数据页上,页与页之间以双向链表连接,非叶子节点提供快速定位路径,叶子节点存储实际的数据。 

三、MySQL是如何查询到数据的?

上面我们已经介绍了MySQL中使用页存储数据,以及B+树索引数据的结构,那现在我们就可以通过这样一棵B+树加速查询。

**举个例子:select ***

from table where id = 5

比方说我们想要查找行数据5。会先从顶层页的record们入手。record里包含了主键id和页号(页地址)

如下图所示,左边2号页最小id是1,向右3号页最小id是4,然后4号页最小是7,最后5号页最小是10。

那id=5的数据如果存在,5大于4小于7,那必定在3号页里面。于是顺着的record的页地址就到了3号数据页里,于是加载3号数据页到内存。在数据页里找到id=5的数据行,完成查询。

另外需要注意的是,上面的页的页号并不是连续的,它们在磁盘里也不一定是挨在一起的。这个过程中查询了2个页(1号跟3号),如果这三个页都在磁盘中(没有被提前加载到内存中),那么最多需要经历两次磁盘IO查询,它们才能被加载到内存中。(如果考虑1号如果是root常驻内存,那么需要磁盘IO一次即可定位到)。

查询步骤总结

以聚簇索引搜索为例(假设id是主键):

  • 从根页开始搜索 :

加载根页(常驻内存)到Buffer Pool,根据指针找到下一层节点。

  • 逐层定位叶子节点 :

在非叶子节点页(存储主键+指针)中二分查找 ,定位id=5所在范围的子页(如页A)。

重复此过程,直到叶子节点页。

  • 叶子节点二分查找 :

在叶子页内通过主键二分查找定位到行记录,返回完整数据。

I/O次数分析 :

  • 树高为3时:根页 + 中间页 + 叶子页 = 3次磁盘I/O (若页不在内存中)。
  • B+树矮胖特性 :3层即可支撑千万级数据(接下来分析),是高效查询的基础。

四、2000万这个上限值如何算出来的?

在我们清楚了MySQL是如何存储及查询数据后,那么2000万这个数值又是如何得来的呢?超过2000万比如存储一亿数据会如何?

B+树承载的记录数量

从上面的结构里可以看出B+树的最末级叶子结点里放了record数据。而非叶子结点里则放了用来加速查询的索引数据。也就是说同样一个16k的页,非叶子节点里每一条数据都指向一个新的页,而新的页有两种可能。

  • 如果是末级叶子节点的话,那么里面放的就是一行行record数据。
  • 如果是非叶子节点,那么就会循环继续指向新的数据页。

假设

  • 非叶子节点内指向其他内存页的指针数量为x(非叶子节点指针扇出值)
  • 叶子节点内能容纳的record数量为y(叶子节点单页行数)
  • B+树的层数为z(树高)

那这棵B+树放的行数据总量等于 (x ^ (z-1)) * y

核心公式:单表最大行数 = 非叶节点扇出指针数 ^ (树高-1) × 单页行数

非叶子节点指针扇出值—x 怎么算?

我们回去看数据页的结构。

非叶子节点里主要放索引查询相关的数据,放的是主键和指向页号。

  • 主键假设是bigint(8Byte),而页号在源码里叫FIL_PAGE_OFFSET(4Byte),那么非叶子节点里的一条数据是12Byte左右。
  • 整个数据页16k, 页头页尾那部分数据全加起来大概128Byte,加上页目录毛估占1k吧。那剩下的15k除以12Byte,等于1280,也就是可以指向x=1280页。

我们常说的二叉树指的是一个结点可以发散出两个新的结点。m叉树一个节点能指向m个新的结点。这个指向新节点的操作就叫扇出(fanout) 。而上面的B+树,它能指向1280个新的节点,恐怖如斯,可以说扇出非常高了。

单页行数—y的计算

叶子节点和非叶子节点的数据结构是一样的,所以也假设剩下15kb可以发挥。

叶子节点里放的是真正的行数据。假设一条行数据1kb,所以一页里能放y=15行

行总数计算

回到 (x ^ (z-1)) * y 这个公式。

已知x=1280,y=15。

假设B+树是两层,那z=2。则是(1280 ^ (2-1)) * 15 ≈ 2w

假设B+树是三层,那z=3。则是 (1280 ^ (3-1)) * 15 ≈ 2.5kw

这个2.5kw,就是我们常说的单表建议最大行数2kw的由来。 毕竟再加一层,数据就大得有点离谱了。三层数据页对应最多三次磁盘IO,也比较合理。

  • 临界点 :当行数突破约2000万时,树高可能从3层变为4层:
  • 树高=4时:最大行数 ≈ 1280^3 × 15 结果已超过百亿(远大于2000万)
  • 性能断崖 :树高从3→4,查询I/O次数从3次增至4次 (多一次磁盘寻址),尤其在回表查询、高并发、深分页时性能骤降。

行数超一亿就慢了吗?

上面假设单行数据用了1kb,所以一个数据页能放个15行数据。

如果我单行数据用不了这么多,比如只用了250byte。那么单个数据页能放60行数据。

那同样是三层B+树,单表支持的行数就是 (1280 ^ (3-1)) * 60 ≈ 1个亿。

你看我一个亿的数据,其实也就三层B+树,在这个B+树里要查到某行数据,最多也是三次磁盘IO。所以并不慢。

B树承载的记录数量

我们都知道,现在MySQL的索引都是B+树,而有一种树,跟B+树很像,叫B树,也叫B-树

它跟B+树最大的区别在于,B+树只在末级叶子结点处放数据表行数据,而B树则会在叶子和非叶子结点上都放。

于是,B树的结构就类似这样:

B树将行数据都存在非叶子节点上,假设每个数据页还是16kb,掐头去尾每页剩15kb,并且一条数据表行数据还是占1kb,就算不考虑各种页指针的情况下,也只能放个15条数据。数据页扇出明显变少了

计算可承载的总行数的公式也变成了一个等比数列

15 + 15^2 +15^3 + ... + 15^z

其中z还是层数的意思。

为了能放2kw左右的数据,需要z>=6。也就是树需要有6层,查一次要访问6个页。假设这6个页并不连续,为了查询其中一条数据,最坏情况需要进行6次磁盘IO

而B+树同样情况下放2kw数据左右,查一次最多是3次磁盘IO

磁盘IO越多则越慢,这两者在性能上差距略大。

为此,B+树比B树更适合成为MySQL的索引

五、总结:生死博弈的核心

B+树叶子和非叶子结点的数据页都是16k,且数据结构一致,区别在于叶子节点放的是真实的行数据,而非叶子结点放的是主键和下一个页的地址。

B+树一般有两到三层,由于其高扇出,三层就能支持2kw以上的数据,且一次查询最多1~3次磁盘IO,性能也还行。

存储同样量级的数据,B树比B+树层级更高,因此磁盘IO也更多,所以B+树更适合成为MySQL索引。

索引结构不会影响单表最大行数,2kw也只是推荐值,超过了这个值可能会导致B+树层级更高,影响查询性能。

单表最大值还受主键大小和磁盘大小限制。

16KB页与B+树的平衡 :页大小限制了单页行数和指针数,B+树通过多阶平衡确保低树高。

2000万不是绝对 :若行小于1KB(如只存ID),上限可到5000万+;若行较大(如含大字段),可能500万就性能下降。

优化建议:

  • 控制单行大小(避免TEXT/BLOB直接入表)。
  • 分库分表:单表接近千万级时提前规划。
  • 冷热分离:历史数据归档。

本质:通过页大小和B+树结构,MySQL在磁盘I/O和内存效率之间取得平衡。超出平衡点时,性能从“平缓下降”变为“断崖下跌”。

六、拓展问题

为啥设计单页大小16k?

MySQL索引采用的是B+树数据结构,每个叶子节点(叶子块)存储一个索引条目的信息。而MySQL使用的是页式存储(Paged storage)技术,将磁盘上的数据划分为一个个固定大小的页面,每个页面包含若干个索引条目。

为了提高索引查询效率和降低磁盘I/O的频率,MySQL设置了16KB的单页大小。这是因为在MySQL中:

  • 内存大小限制:MySQL的索引需要放在内存中进行查询,如果页面过大,将导致索引无法完全加载到内存中,从而影响查询效率。
  • 磁盘I/O限制: 当需要查询一个索引时,MySQL需要把相关的页面加载到内存中进行处理,如果页面过大,将增加磁盘I/O的开销,降低查询效率。
  • 索引效率限制:在B+树数据结构中,每个叶子节点存储着一个索引条目,因此如果每个页面能够存放更多索引条目,就可以减少B+树结构的深度,从而提高索引查询效率。

综上所述,MySQL索引单页大小设置为16KB可以兼顾内存大小、磁盘I/O和索引查询效率等多方面因素,是一种比较优化的方案。需要注意的是,对于某些特殊的应用场景,可能需要根据实际情况对单页大小进行调整。

字符串怎么做索引?

在MySQL中,可以通过B+树索引结构对字符串类型的列进行排序。具体来说,当使用B+树索引进行排序时,MySQL会根据字符串的字典序(Lexicographic Order)进行排序。

字典序是指将字符串中的每个字符依次比较,直到找到不同的字符为止。如果两个字符串在相同的位置上具有不同的字符,则以这两个字符的ASCII码值比较大小,并按照升序或降序排列。例如,字符串"abc"和"def"比较大小时,先比较'a'和'd'的ASCII码,因为'd'的ASCII码大于'a',所以"def"大于"abc"。

需要注意的是,如果对长字符串进行排序,可能会影响索引查询的性能,因此可以考虑使用前缀索引或全文索引来优化。同时,在实际开发中,还需要注意选择适当的字符集和排序规则,以确保排序结果正确和稳定。

中文字符串怎么做索引?

中文字符串排序在MySQL中可以使用多种方式,最常见的有以下两种:

  • 按拼音排序:对于中文字符串,可以按照拼音进行排序。可以使用拼音排序插件,如pinyin或zhuyin插件,来实现中文字符串按照拼音进行排序。这些插件会将中文字符串转换为拼音或注音后,再进行排序。

例如,先安装pinyin插件:

INSTALL PLUGIN pinyin SONAME 'ha_pinyin.so';

然后创建对应的索引并按拼音排序:

CREATE INDEX idx_name_pinyin ON mytable(name) USING BTREE WITH PARSER pinyin;
SELECT * FROM mytable ORDER BY name COLLATE pinyin;
  • 按Unicode码点排序:可以使用UTF-8字符集,并选择utf8mb4_unicode_ci排序规则,在使用此排序规则时,MySQL会按照Unicode码点进行排序,适合于较为通用的中文字符串排序需求。

例如:

CREATE INDEX idx_name_unicode ON mytable(name) USING BTREE;
SELECT * FROM mytable ORDER BY name COLLATE utf8mb4_unicode_ci;

需要注意的是,不同的排序方式可能会对性能产生影响,因此需要根据具体需求选择合适的排序方式,并进行必要的测试和验证。同时,在进行中文字符串排序时,还需要考虑到中文字符的复杂性,例如同音字、繁简体等问题,以确保排序结果正确和稳定。

索引字段的长度有限制吗?

在MySQL中,索引的长度通常是由三个因素决定的:数据类型、字符集和存储引擎。不同的数据类型、字符集和存储引擎所支持的最大索引长度也有所不同。

一般情况下,索引的长度不应该超过存储引擎所支持的最大索引长度。在InnoDB存储引擎中,单个索引所能包含的最大字节数为767个字节(前缀索引除外)。如果索引的长度超过了最大长度,则会导致创建索引失败。因此,在设计表结构时,需要根据索引列的数据类型和字符集等因素,合理设置索引长度,以充分利用索引的优势。

对于字符串类型的索引,还需要注意以下几点:

  • 对于UTF-8字符集,每个字符占用1-4个字节,因此索引长度需要根据实际情况进行计算。例如,一个VARCHAR(255)类型的列在utf8mb4字符集下的最大长度为255*4=1020个字节。
  • 可以使用前缀索引来减少索引的大小,提高索引查询效率。在创建前缀索引时需要指定前缀长度。例如,可以在创建索引时使用name(10)来指定name列的前10个字符作为索引。
  • 在使用全文索引对字符串进行搜索时,MySQL会将文本内容分割成单个词汇后建立倒排索引。在建立索引时需要考虑到中英文分词的问题,以确保全文索引的准确性和查询效率。

综上所述,索引的长度需要根据数据类型、字符集和存储引擎等多个因素进行综合考虑,并合理设置索引长度,以提高索引查询效率和利用率。

往期回顾

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

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

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

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

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

文 / 太空

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

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

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

❌
❌