阅读视图

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

在你的Rust类型里生成TypeScript的bindings!

你是否经常为前后端接口不一致而苦恼?改了文档改后端,改了后端改前端。为什么不直接从后端接口类型里生成前端接口呢?

当当当当!如果你在用 Rust 开发后端,用 TypeScript 开发前端,那你就有福了!今天给大家介绍一款 Rust 工具——gents。(generate ts, 优雅得像 gentleman )。

这个工具可以在你的 Rust 结构加入一点简单的宏:

#[derive(TS)]
#[ts(file_name = "person.ts", rename_all = "camelCase")]
pub struct Person {
    pub age: u16,
    pub en_name: String,
}

然后写一个 binary 或者测试函数就像这样:

#[test]
fn gents() {
    use gents::FileGroup;
    let mut group = FileGroup::new();
    // Add your root types; dependencies will be included automatically
    group.add::<Person>();
    // The second argument controls whether to generate index.ts
    group.gen_files("outdir", false);
}

运行一下你就能得到一个person.ts文件!还支持 enum 类型哦!

如果仅仅这样也太简单了!如果这个 Rust 类型使用到了别的 TS 类型,可以自动帮你搜集依赖,同时生成相应的 TypeScript 接口!这样,你就可以使用 JSON 格式在前后端通信了!是不是比 Swagger 或者 ProtoBuf 更加方便?超适合用在 monorepo 里,或者一个人包揽前后端。如果你在开发 WebAssembly 应用,那更加好了,因为你可以参考这个项目的用法!

感兴趣的朋友甚至可以研究一下这个 Rust 库的工作原理,个人感觉十分 Rustic!虽然 proc_macro 的代码真的很难读😂。如果有人对他的实现感兴趣,有机会可以再开一期!反正我看完这个代码,真的赞叹作者脑洞清奇!


我是 Rust 菜鸡,关注我,我让大家教我写代码!

可怕!我的Nodejs系统因为日志打印了Error 对象就崩溃了😱 Node.js System Crashed Because of Logging

本文为中英文双语,需要英文博客可以滑动到下面查看哦 | This is a bilingual article. Scroll down for the English version.

小伙伴们!今天我在本地调试项目的过程中,想记录一下错误信息,结果程序就"啪"地一下报出 "Maximum call stack size exceeded" 错误,然后项目直接就crash了。但是我看我用的这个开源项目,官方的代码里好多地方就是这么用的呀?我很纳闷,这是为什么呢?

Snipaste_2025-10-10_00-28-45.png

报错信息


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

报错截图

image

错误分析

晚上下班以后,晚上躺在床上,我翻来覆去睡不着,干脆打开电脑一番探究,想要知道 ,这个错误到底为何触发,实质原因是什么,以及如何解决它。让我们一起把这个小调皮鬼揪出来看看它到底在搞什么鬼吧!👻

场景复现

想象一下这个场景,你正在开心地写着代码:

app.get('/api/data', async (req, res) => {
  try {
    // 一些可能会出小差错的业务逻辑
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // 记录错误信息
    logger.debug('获取数据时出错啦~', error); // 哎呀!这一行可能会让我们的程序崩溃哦!
    res.status(500).json({ error: '内部服务器出错啦~' });
  }
});

看起来是不是很正常呢?但是当你运行这段代码的时候,突然就出现了这样的错误:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

更神奇的是,如果你把代码改成这样:

console.log(error); // 这一行却不会让程序崩溃哦,但是上prod的系统,不要这么用哦

它就能正常工作啦!这是为什么呢?🤔

小秘密大揭秘!🔍

console.log虽好,但请勿用它来记录PROD错误!

console.log 是 Node.js 原生提供的函数,它就像一个经验超级丰富的大叔,知道怎么处理各种"调皮"的对象。当 console.log 遇到包含循环引用的对象时,它会聪明地检测这些循环引用,并用 [Circular] 标记来代替实际的循环部分,这样就不会无限递归啦!

简单来说,Node.js 的 console.log 就像一个超级厉害的武林高手,知道如何闪转腾挪,避开各种陷阱!🥋

日志库的"小烦恼"

但是我们自己封装的日志系统(比如项目中使用的 Winston)就不一样啦!为了实现各种炫酷的功能(比如格式化、过滤敏感信息等),日志库通常会使用一些第三方库来处理传入的对象。

在我们的案例中,日志系统使用了 [traverse] 库来遍历对象。这个库在大多数情况下工作得都很好,但当它遇到某些复杂的 Error 对象时,就可能会迷路啦!

Error 对象可不是普通对象那么简单哦!它们可能包含各种隐藏的属性、getter 方法,甚至在某些情况下会动态生成属性。当 [traverse] 库尝试遍历这些复杂结构时,就可能陷入无限递归的迷宫,最终导致调用栈溢出。

什么是循环引用?🌀

在深入了解这个问题之前,我们先来了解一下什么是循环引用。循环引用指的是对象之间相互引用,形成一个闭环。比如说:

const objA = { name: '小A' };
const objB = { name: '小B' };

objA.ref = objB;
objB.ref = objA; // 哎呀!形成循环引用啦!

当尝试序列化这样的对象时(比如用 JSON.stringify),就会出现问题,因为序列化过程会无限递归下去,就像两只小仓鼠在滚轮里永远跑不完一样!🐹

Error 对象虽然看起来简单,但内部结构可能非常复杂,特别是在一些框架或库中创建的 Error 对象,它们可能包含对 request、response 等对象的引用,而这些对象又可能包含对 Error 对象的引用,从而形成复杂的循环引用网络,就像一张大蜘蛛网一样!🕷️

怎样才能让我们的日志系统乖乖听话呢?✨

1. 只记录我们需要的信息

最简单直接的方法就是不要把整个 Error 对象传递给日志函数,而是只传递我们需要的具体属性:

// ❌ 不推荐的做法 - 会让日志系统"生气"
logger.debug('获取数据时出错啦~', error);

// ✅ 推荐的做法 - 让日志系统开心地工作
logger.debug('获取数据时出错啦~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. 使用专门的错误序列化函数

你可以创建一个专门用于序列化 Error 对象的函数,就像给 Error 对象穿上一件"安全外套":

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // 添加其他你需要的属性
  };
}

// 使用方式
logger.debug('获取数据时出错啦~', serializeError(error));

3. 使用成熟的错误处理库

有些库专门为处理这类问题而设计,比如 serialize-error,它们就像专业的保姆一样,会把 Error 对象照顾得好好的:

const { serializeError } = require('serialize-error');

logger.debug('获取数据时出错啦~', serializeError(error));

4. 配置日志库的防护机制

如果你使用的是 Winston,可以配置一些防护机制,给它穿上"防弹衣":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... 其他配置
});

最佳实践小贴士 🌟

  1. 永远不要直接记录原始的 Error 对象:它们可能包含复杂的循环引用结构,就像一个调皮的小恶魔。

  2. 提取关键信息:只记录我们需要的错误信息,比如 message、stack 等,就像挑选糖果一样只拿最喜欢的。

  3. 使用安全的序列化方法:确保我们的日志系统能够处理各种边界情况,做一个贴心的小棉袄。

  4. 添加防护措施:在日志处理逻辑中添加 try-catch 块,防止日志系统本身成为故障点,就像给程序戴上安全帽。

  5. 测试边界情况:在测试中模拟各种错误场景,确保日志系统在极端情况下也能正常工作,做一个负责任的好孩子。

image

Terrifying! My Node.js System Crashed Because of Logging an Error Object 😱

Fellow developers! Today, while debugging a project locally, I wanted to log some error information, but suddenly the program threw a "Maximum call stack size exceeded" error and crashed the entire project. But when I look at the open-source project I'm using, I see that the official code does this in many places. I was puzzled, why is this happening?

Error Message


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

Error Screenshot

image

Error Analysis

After work, I couldn't resist investigating why this error was triggered, what the root cause was, and how to solve it. Let's together catch this little troublemaker and see what it's up to! 👻

Reproducing the Scenario

Imagine this scenario, you're happily coding:

app.get('/api/data', async (req, res) => {
  try {
    // Some business logic that might go wrong
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // Log the error
    logger.debug('Error fetching data~', error); // Oops! This line might crash our program!
    res.status(500).json({ error: 'Internal server error~' });
  }
});

Doesn't this look normal? But when you run this code, suddenly this error appears:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

What's even more神奇 is, if you change the code to this:

console.log(error); // This line won't crash the program, but don't use this in production systems

It works fine! Why is that? 🤔

The Big Reveal of Little Secrets! 🔍

console.log is Good, But Don't Use It to Log PROD Errors!

console.log is a native Node.js function. It's like an extremely experienced uncle who knows how to handle all kinds of "naughty" objects. When console.log encounters objects with circular references, it cleverly detects these circular references and replaces the actual circular parts with [Circular] markers, so it won't recurse infinitely!

Simply put, Node.js's console.log is like a super skilled martial arts master who knows how to dodge and avoid all kinds of traps! 🥋

The "Little Troubles" of Logging Libraries

But our custom logging systems (like Winston used in the project) are different! To implement various cool features (like formatting, filtering sensitive information, etc.), logging libraries often use third-party libraries to process incoming objects.

In our case, the logging system uses the [traverse] library to traverse objects. This library works well in most cases, but when it encounters certain complex Error objects, it might get lost!

Error objects are not as simple as ordinary objects! They may contain various hidden properties, getter methods, and in some cases, dynamically generated properties. When the [traverse] library tries to traverse these complex structures, it may fall into an infinite recursion maze, ultimately causing a stack overflow.

What Are Circular References? 🌀

Before diving deeper into this issue, let's first understand what circular references are. Circular references refer to objects that reference each other, forming a closed loop. For example:

const objA = { name: 'A' };
const objB = { name: 'B' };

objA.ref = objB;
objB.ref = objA; // Oops! Circular reference formed!

When trying to serialize such objects (like with JSON.stringify), problems arise because the serialization process will recurse infinitely, like two hamsters running forever in a wheel! 🐹

Although Error objects look simple, their internal structure can be very complex, especially Error objects created in some frameworks or libraries. They may contain references to request, response, and other objects, and these objects may in turn contain references to the Error object, forming a complex circular reference network, like a giant spider web! 🕷️

How to Make Our Logging System Behave? ✨

1. Only Log the Information We Need

The simplest and most direct method is not to pass the entire Error object to the logging function, but to pass only the specific properties we need:

// ❌ Not recommended - will make the logging system "angry"
logger.debug('Error fetching data~', error);

// ✅ Recommended - makes the logging system work happily
logger.debug('Error fetching data~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. Use a Dedicated Error Serialization Function

You can create a dedicated function for serializing Error objects, like putting a "safety coat" on the Error object:

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // Add other properties you need
  };
}

// Usage
logger.debug('Error fetching data~', serializeError(error));

3. Use Mature Error Handling Libraries

Some libraries are specifically designed to handle these kinds of issues, such as serialize-error. They're like professional nannies who will take good care of Error objects:

const { serializeError } = require('serialize-error');

logger.debug('Error fetching data~', serializeError(error));

4. Configure Protective Mechanisms for Logging Libraries

If you're using Winston, you can configure some protective mechanisms to give it "bulletproof armor":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... other configurations
});

Best Practice Tips 🌟

  1. Never log raw Error objects directly: They may contain complex circular reference structures, like a mischievous little devil.

  2. Extract key information: Only log the error information we need, such as message, stack, etc., like picking candy - only take your favorites.

  3. Use safe serialization methods: Ensure our logging system can handle various edge cases, be a thoughtful companion.

  4. Add protective measures: Add try-catch blocks in the logging logic to prevent the logging system itself from becoming a failure point, like giving the program a safety helmet.

  5. Test edge cases: Simulate various error scenarios in testing to ensure the logging system works properly under extreme conditions, be a responsible good child.

Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

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

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上 大家可以直接搜索下来进行使用和体验,我希望大家可以在未来可以一起解决问题!

金仓数据库KingbaseES与MyBatis-Plus整合实践:电商系统开发实战

金仓数据库KingbaseES与MyBatis-Plus整合实践:电商系统开发实战

前言:国产数据库的时代机遇

随着数字中国建设的深入推进,国产数据库在关键业务系统中扮演着越来越重要的角色。作为国产数据库的领军者,人大金仓KingbaseES在性能、安全性和稳定性方面表现出色。结合MyBatis-Plus这一强大的ORM框架,我们能够在企业级应用开发中实现高效、可靠的数据库操作。本文将通过一个电商系统的实战案例,深入探讨两者的整合之道。

1. 技术选型背后的思考

1.1 为什么选择KingbaseES?

在当前的技术环境下,数据库选型不仅仅是技术决策,更是战略决策。KingbaseES作为国产数据库的佼佼者,具有以下核心优势:

高兼容性:KingbaseES高度兼容PostgreSQL和Oracle,降低了迁移成本。在我们的电商项目中,从MySQL迁移到KingbaseES仅用了两周时间,这得益于其良好的兼容性。

卓越的性能表现:在某大型促销活动中,我们的系统需要处理每秒上万次的数据库操作。KingbaseES通过其优化的查询计划和并发控制机制,成功支撑了业务高峰。

完善的安全特性:对于电商系统而言,数据安全至关重要。KingbaseES提供了三权分立、透明加密等安全特性,为业务数据提供了坚实保障。

1.2 MyBatis-Plus的价值主张

与传统的MyBatis相比,MyBatis-Plus在以下方面展现出明显优势:

开发效率提升:根据我们的项目统计,使用MyBatis-Plus后,简单的CRUD操作代码量减少了约70%,这主要得益于其强大的通用Mapper功能。

代码可维护性:统一的代码风格和内置的最佳实践,使得团队新成员能够快速上手,降低了项目的维护成本。 在这里插入图片描述

2. 电商系统核心模块设计

2.1 数据库架构设计

在我们的电商系统中,核心表结构设计如下:

-- 商品表
CREATE TABLE tb_product (
    id BIGSERIAL PRIMARY KEY,
    product_code VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(200) NOT NULL,
    category_id BIGINT NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    stock_quantity INTEGER DEFAULT 0,
    status SMALLINT DEFAULT 1,
    description TEXT,
    specifications JSONB,
    created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 订单表
CREATE TABLE tb_order (
    id BIGSERIAL PRIMARY KEY,
    order_no VARCHAR(32) UNIQUE NOT NULL,
    user_id BIGINT NOT NULL,
    total_amount NUMERIC(10,2) NOT NULL,
    discount_amount NUMERIC(10,2) DEFAULT 0,
    pay_amount NUMERIC(10,2) NOT NULL,
    order_status SMALLINT NOT NULL,
    payment_status SMALLINT NOT NULL,
    payment_time TIMESTAMP,
    delivery_time TIMESTAMP,
    created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 订单明细表
CREATE TABLE tb_order_item (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    product_name VARCHAR(200) NOT NULL,
    unit_price NUMERIC(10,2) NOT NULL,
    quantity INTEGER NOT NULL,
    subtotal NUMERIC(10,2) NOT NULL,
    created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

2.2 核心实体类设计

@TableName(value = "tb_product")
public class Product {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    
    private String productCode;
    private String productName;
    private Long categoryId;
    private BigDecimal price;
    private Integer stockQuantity;
    private Integer status;
    private String description;
    
    @TableField(typeHandler = JsonTypeHandler.class)
    private Map<String, Object> specifications;
    
    private Date createdTime;
    private Date updatedTime;
    
    // 省略getter/setter
}

@TableName(value = "tb_order")
public class Order {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    
    private String orderNo;
    private Long userId;
    private BigDecimal totalAmount;
    private BigDecimal discountAmount;
    private BigDecimal payAmount;
    private Integer orderStatus;
    private Integer paymentStatus;
    private Date paymentTime;
    private Date deliveryTime;
    private Date createdTime;
    private Date updatedTime;
    
    // 业务方法
    public boolean canBeCanceled() {
        return this.orderStatus == 1; // 待支付状态可取消
    }
}

3. 核心业务逻辑实现

3.1 商品库存管理

在电商系统中,库存管理是最关键也是最复杂的业务之一。我们使用MyBatis-Plus结合KingbaseES实现了高效的库存管理:

@Service
public class ProductServiceImpl extends ServiceImpl<ProductDao, Product> 
    implements ProductService {
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean reduceStock(Long productId, Integer quantity) {
        // 使用悲观锁确保数据一致性
        Product product = baseMapper.selectByIdForUpdate(productId);
        if (product == null) {
            throw new BusinessException("商品不存在");
        }
        if (product.getStockQuantity() < quantity) {
            throw new BusinessException("库存不足");
        }
        
        // 更新库存
        Product updateProduct = new Product();
        updateProduct.setId(productId);
        updateProduct.setStockQuantity(product.getStockQuantity() - quantity);
        updateProduct.setUpdatedTime(new Date());
        
        return updateById(updateProduct);
    }
    
    @Override
    public IPage<ProductVo> searchProducts(Page<ProductVo> page, ProductQuery query) {
        return baseMapper.selectProductList(page, query);
    }
}

对应的Mapper接口:

public interface ProductDao extends BaseMapper<Product> {
    
    @Select("SELECT * FROM tb_product WHERE id = #{id} FOR UPDATE")
    Product selectByIdForUpdate(Long id);
    
    IPage<ProductVo> selectProductList(IPage<ProductVo> page, 
            @Param("query") ProductQuery query);
}

3.2 订单业务流程

订单处理是电商系统的核心,我们通过MyBatis-Plus实现了完整的订单生命周期管理:

@Service
public class OrderServiceImpl extends ServiceImpl<OrderDao, Order> 
    implements OrderService {
    
    @Autowired
    private ProductService productService;
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Order createOrder(OrderCreateRequest request) {
        // 1. 验证商品和库存
        List<OrderItem> orderItems = validateProducts(request.getItems());
        
        // 2. 计算订单金额
        BigDecimal totalAmount = calculateTotalAmount(orderItems);
        BigDecimal payAmount = totalAmount.subtract(request.getDiscountAmount());
        
        // 3. 创建订单
        Order order = buildOrder(request, totalAmount, payAmount);
        baseMapper.insert(order);
        
        // 4. 创建订单明细
        orderItems.forEach(item -> {
            item.setOrderId(order.getId());
            orderItemDao.insert(item);
        });
        
        // 5. 扣减库存
        reduceProductsStock(orderItems);
        
        return order;
    }
    
    @Override
    public IPage<OrderVo> queryUserOrders(Page<OrderVo> page, Long userId, 
            OrderQuery query) {
        return baseMapper.selectUserOrders(page, userId, query);
    }
}

3.3 复杂查询与分页优化

电商系统经常需要处理复杂的查询场景,我们利用MyBatis-Plus的条件构造器实现了高效的查询:

@Service
public class ProductServiceImpl implements ProductService {
    
    public List<Product> findHotProducts(int limit) {
        QueryWrapper<Product> wrapper = new QueryWrapper<>();
        wrapper.select("id", "product_name", "price", "sales_volume")
               .eq("status", 1)
               .gt("stock_quantity", 0)
               .orderByDesc("sales_volume")
               .last("LIMIT " + limit);
        
        return baseMapper.selectList(wrapper);
    }
    
    public IPage<Product> searchProductsByKeywords(Page<Product> page, 
            String keywords, Long categoryId) {
        QueryWrapper<Product> wrapper = new QueryWrapper<>();
        
        if (StringUtils.isNotBlank(keywords)) {
            wrapper.and(w -> w.like("product_name", keywords)
                             .or().like("description", keywords));
        }
        
        if (categoryId != null) {
            wrapper.eq("category_id", categoryId);
        }
        
        wrapper.eq("status", 1)
               .orderByDesc("created_time");
        
        return baseMapper.selectPage(page, wrapper);
    }
}

4. 性能优化实战

4.1 数据库连接池优化

在电商大促期间,数据库连接成为关键资源。我们通过优化Druid连接池配置来提升性能:

spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    druid:
      url: jdbc:kingbase8://localhost:54321/ecommerce
      username: app_user
      password: your_password
      initial-size: 5
      min-idle: 5
      max-active: 50
      max-wait: 60000
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000
      validation-query: SELECT 1
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false

4.2 查询性能优化

针对KingbaseES的特性,我们实施了以下优化措施:

索引策略优化:

-- 为常用查询字段创建索引
CREATE INDEX idx_product_category ON tb_product(category_id, status);
CREATE INDEX idx_product_search ON tb_product USING gin(to_tsvector('simple', product_name));
CREATE INDEX idx_order_user_time ON tb_order(user_id, created_time DESC);

查询优化实践:

@Repository
public class OrderDao extends BaseMapper<Order> {
    
    public IPage<OrderVo> selectComplexOrders(IPage<OrderVo> page, 
            @Param("query") OrderComplexQuery query) {
        return page.setRecords(baseMapper.selectComplexOrders(page, query));
    }
}

5. 实战中的经验总结

5.1 事务管理的坑与解决方案

在分布式环境下,事务管理变得复杂。我们遇到的典型问题及解决方案:

问题1:长事务导致连接池耗尽

// 错误的做法:在方法中处理大量数据
@Transactional
public void batchProcessOrders(List<Long> orderIds) {
    for (Long orderId : orderIds) {
        processSingleOrder(orderId); // 处理单个订单
    }
}

// 正确的做法:分批次处理
public void batchProcessOrders(List<Long> orderIds) {
    List<List<Long>> partitions = Lists.partition(orderIds, 100);
    for (List<Long> partition : partitions) {
        processOrderPartition(partition);
    }
}

@Transactional
void processOrderPartition(List<Long> orderIds) {
    for (Long orderId : orderIds) {
        processSingleOrder(orderId);
    }
}

5.2 并发场景下的数据一致性

在秒杀场景中,我们通过多种技术保证数据一致性:

@Service
public class SecKillService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Transactional(rollbackFor = Exception.class)
    public boolean seckillProduct(Long productId, Long userId) {
        String lockKey = "seckill:lock:" + productId;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 获取分布式锁
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                // 检查库存
                Product product = productDao.selectByIdForUpdate(productId);
                if (product.getStockQuantity() <= 0) {
                    return false;
                }
                
                // 扣减库存
                productDao.reduceStock(productId);
                
                // 创建订单
                createSeckillOrder(productId, userId);
                return true;
            }
        } finally {
            lock.unlock();
        }
        return false;
    }
}

6. 监控与故障排查

6.1 SQL性能监控

通过配置MyBatis-Plus的SQL日志输出,结合KingbaseES的慢查询日志,我们能够及时发现性能问题:

<configuration>
    <settings>
        <setting name="logImpl" value="SLF4J" />
    </settings>
    <plugins>
        <plugin interceptor="com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor">
            <property name="sqlLog" value="true"/>
        </plugin>
    </plugins>
</configuration>

6.2 业务指标监控

我们建立了关键业务指标的监控体系:

  • 订单创建成功率
  • 库存扣减准确率
  • 平均查询响应时间
  • 数据库连接池使用率

7. 总结与展望

通过将KingbaseES与MyBatis-Plus整合应用于电商系统,我们获得了以下宝贵经验:

技术价值:

  1. KingbaseES在复杂查询和高并发场景下表现稳定
  2. MyBatis-Plus显著提升了开发效率,降低了维护成本
  3. 两者的结合为国产化替代提供了可行方案

业务价值:

  1. 系统在多次大促活动中保持稳定运行
  2. 数据处理准确率达到99.99%
  3. 平均响应时间控制在200ms以内

未来,我们将继续探索KingbaseES在分布式事务、数据分片等高级特性方面的应用,为更大规模的电商业务提供支撑。同时,随着国产数据库生态的不断完善,相信KingbaseES将在更多关键业务场景中发挥重要作用。

国产数据库的发展不是选择题,而是必答题。作为技术人员,我们应该积极拥抱变化,在技术自主可控的道路上不断探索和实践,为构建安全可靠的数字基础设施贡献自己的力量。

Wireshark常用过滤规则

以下是一些实用的Wireshark数据包过滤规则,按协议和场景分类,适用于分析网络流量。

1. TLS/SSL协议过滤

  • 所有TLS流量
tls
  • 所有TLS握手数据包
tls.handshake
  • Client Hello (类型1)
tls.handshake.type == 1
  • Server Hello (类型2)
tls.handshake.type == 2
  • 特定SNI匹配
tls.handshake.extensions_server_name == "example.com"
tls.handshake.extensions_server_name contains "google"
  • TLS版本过滤
tls.record.version == 0x0303  # TLS 1.2
tls.record.version == 0x0304  # TLS 1.3
  • 加密套件
tls.handshake.ciphersuite

2. HTTP/HTTPS过滤

  • 所有HTTP流量
http
  • HTTP请求
http.request
  • HTTP响应
http.response
  • 特定HTTP方法
http.request.method == "GET"
http.request.method == "POST"
  • 特定URI路径
http.request.uri contains "/api"
http contains "login"
  • HTTP状态码
http.response.code == 200
http.response.code == 404
  • User-Agent过滤
http.user_agent contains "Chrome"
http contains "Mozilla"
  • HTTPS流量(需解密或查看Client Hello)
ssl.handshake.extensions_server_name contains "api.example.com"

3. DNS协议过滤

  • 所有DNS查询
dns
  • DNS查询包
dns.flags.response == 0
  • DNS响应包
dns.flags.response == 1
  • 特定域名查询
dns.qry.name contains "google.com"
dns.qry.name == "www.example.com"
  • DNS类型过滤
dns.qry.type == 1  # A记录
dns.qry.type == 28 # AAAA记录

4. IP和网络层过滤

  • 特定IP地址
ip.src == 192.168.1.100
ip.dst == 8.8.8.8
  • IP地址范围
ip.addr == 192.168.1.0/24
ip.src >= 192.168.1.1 && ip.src <= 192.168.1.255
  • 特定端口
tcp.port == 80 || tcp.port == 443
udp.port == 53  # DNS
  • 特定协议
ip.proto == 6  # TCP
ip.proto == 17 # UDP
ip.proto == 1  # ICMP
  • MAC地址
eth.src == aa:bb:cc:dd:ee:ff

5. TCP协议过滤

  • TCP流量
tcp
  • TCP SYN包(连接建立)
tcp.flags.syn == 1 && tcp.flags.ack == 0
  • TCP SYN-ACK包
tcp.flags.syn == 1 && tcp.flags.ack == 1
  • TCP FIN包(连接关闭)
tcp.flags.fin == 1
  • TCP重传包
tcp.analysis.retransmission
  • TCP窗口大小
tcp.window_size > 65535
  • TCP序列号过滤
tcp.seq == 123456
  • TCP会话跟踪
tcp.stream == 0  # 特定TCP流

6. 应用层协议过滤

  • SMTP邮件
smtp
  • FTP
ftp
ftp-data
  • SSH
ssh
  • RDP(远程桌面)
rdp
  • SMB(文件共享)
smb || smb2
  • NTP时间同步
ntp
  • SNMP
snmp

7. 高级过滤和组合

  • 组合条件
ip.src == 192.168.1.100 && tcp.dstport == 80
  • 排除条件
!(ip.src == 192.168.1.1)
  • 逻辑运算
(ip.dst == 8.8.8.8 || ip.dst == 1.1.1.1) && udp.dstport == 53
  • 字符串匹配
frame contains "password"
http contains "secret"
  • 数据包大小
frame.len > 1500

8. 性能和异常检测

  • 大数据包
frame.len > 1400
  • 异常端口
tcp.port > 1024 && tcp.port < 65535
  • 广播/多播
eth.dst[0] & 1  # 以1结尾的MAC地址
  • ARP流量
arp
  • ICMP(ping等)
icmp
  • 丢包检测
tcp.analysis.lost_segment
tcp.analysis.duplicate_ack

9. 流和会话过滤

  • 特定TCP/UDP流
tcp.stream == 1
udp.stream == 0
  • 跟随特定流
    • 右键数据包 > Follow > TCP/UDP/TLS Stream
  • 统计特定流的数据量
statistics.io.graph  # 图形化流量分析

10. 捕获过滤器(Capture Filter)

这些在抓包前使用,减少无关数据:

  • 仅捕获特定主机
host 192.168.1.100
  • 特定端口
port 80 or port 443
  • 特定协议
tcp port 80
udp port 53
  • 网络
net 192.168.1.0/24
  • 排除本地流量
not host 192.168.0.0/16

11. 使用建议

  • 显示过滤器:用于分析已捕获数据包,语法灵活。
  • 捕获过滤器:用于实时抓包,减少数据量。
  • 验证过滤器:过滤栏背景绿色表示语法正确,红色表示错误。
  • 保存过滤器:右键过滤栏 > "Manage Filter Expressions"。
  • 组合使用:用括号和逻辑运算符(如 &&||!)组合条件。

TypeScript的新类型(五):tuple元组

定义

元组(tuple)是⼀种特殊的数组类型,可以存储固定数量的元素,并且每个元素的类型是已知的且可以不同。元组⽤于精确描述⼀组值的类型,?表示可选元素。

  • 注意: 在ts中tuple 不是关键词,只是⼀种 特殊的数组 形式
  • 可以存储固定数量元素,且元素类型已定义并且可以不同
  • 给元组赋值时元素的个数元素类型都要符合定义时候的声明(除了 ? 可选元素和 ...元素类型[] 任意数量元素情况)
// 第⼀个元素必须是 string 类型,第⼆个元素必须是 number 类型。
let arr1: [string,number]
arr1 = ['hello',123]
// 不可以赋值,arr1声明时是两个元素,赋值的是三个
arr1 = ['hello',123,false]

?可选元素的定义

  • ?加在元素后面,表示该属性为可选元素
// 第⼀个元素必须是 number 类型,第⼆个元素是可选的,如果存在,必须是 boolean 类型。
let arr2: [number,boolean?]
arr2 = [100,false]
arr2 = [200]

...元素类型[],任意数量的元素的定义

  • ...string[]允许元组有任意数量的元素,在...后元素的数据类型
// 第⼀个元素必须是 number 类型,后⾯的元素可以是任意数量的 string 类型
let arr3: [number,...string[]]
arr3 = [100,'hello','world']
arr3 = [100]

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

一、问题背景

业务同学在 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页的生死博弈|得物技术

一、前 言

本文核心介绍,为何业界会有这样的说法?—— “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语言实践|得物技术

文 / 太空

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

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

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

❌