土法炼钢兴趣小组的算法知识备份

【存储工程】Buffer Pool:数据库的内存管理

文章导航

分类入口
storage
标签入口
#buffer-pool#page-replacement#lru#clock#dirty-page#prefetching

目录

Buffer Pool:数据库的内存管理

数据库存储引擎的核心工作,是把磁盘上的页读进内存、在内存里修改、再写回磁盘。操作系统本身已经提供了页缓存(Page Cache)来做这件事,但几乎所有严肃的数据库系统——MySQL、PostgreSQL、Oracle、SQL Server——都选择绕过操作系统的页缓存,自己在用户态维护一块叫做缓冲池(Buffer Pool)的内存区域。

这不是重复造轮子。数据库对内存管理有一组操作系统无法满足的需求:精确的淘汰控制、脏页刷新顺序的保证、预读策略与查询计划的联动、以及页面的固定与释放语义。任何一项做不好,轻则性能下降,重则数据损坏。

本文从”为什么不用 Page Cache”这个问题出发,逐步拆解 Buffer Pool 的架构设计、页面替换算法、脏页管理、预读机制,再分别剖析 MySQL InnoDB 和 PostgreSQL 的具体实现,最后落到调优方法和监控诊断。


一、为什么数据库不用 Page Cache

操作系统的页缓存是一个通用的文件数据缓存层,对所有应用透明。数据库不用它,不是因为它”不好”,而是因为通用方案在数据库这个特定场景下会引发几个严重问题。

Double Buffering 问题

如果数据库通过标准的 read()/write() 系统调用访问数据文件,数据会在两个地方各存一份:操作系统的页缓存里一份,数据库自己的 Buffer Pool 里一份。这就是双重缓冲(Double Buffering)问题。

应用层:    Buffer Pool    [Page A] [Page B] [Page C]
                              |        |        |
                          read()/write()
                              |        |        |
内核层:    Page Cache      [Page A] [Page B] [Page C]
                              |        |        |
                          块设备 I/O
                              |        |        |
存储层:    磁盘            [Page A] [Page B] [Page C]

同一份数据在内存中占了两倍空间。对于一个配置了 128GB Buffer Pool 的数据库实例,Page Cache 可能额外占用接近 128GB 的内存,导致可用内存被大量浪费。这就是为什么数据库通常使用直接 I/O(Direct I/O),通过 O_DIRECT 标志绕过页缓存,让数据只在 Buffer Pool 里存一份。

淘汰控制的缺失

操作系统的页缓存使用一套全局的内存回收策略,由内核的 kswapd 线程和直接回收(Direct Reclaim)路径控制。数据库对此没有任何发言权:

数据库需要自己决定”淘汰哪一页”,而不是把这个决定交给一个对查询计划一无所知的内核。

预读控制的缺失

内核的预读(Readahead)算法针对顺序读做了优化,会在检测到顺序模式时自动扩大预读窗口。但数据库的读取模式远比”顺序”或”随机”复杂:

数据库需要根据查询计划主动发起预读,而不是被动依赖内核的启发式算法。

Pin/Unpin 语义

数据库在修改一个页的时候,必须保证这个页不会被淘汰出内存——因为页上可能有未提交的事务数据,或者有正在进行的 B-Tree 分裂操作。这就是固定(Pin)语义:一个页被 pin 住之后,不管内存多紧张,都不能被淘汰。

操作系统的页缓存没有提供这种语义。mlock() 可以锁定内存页,但它的粒度是进程级别的,无法精确控制到单个数据库页面。而且 mlock() 的使用有权限限制和数量限制,不适合数据库这种需要频繁 pin/unpin 大量页面的场景。

脏页刷新顺序的保证

这可能是最关键的原因。数据库需要保证脏页(Dirty Page)的刷新顺序满足预写日志(Write-Ahead Logging,WAL)的约束:在刷出一个脏数据页之前,该页对应的所有 WAL 记录必须先持久化到日志文件中。操作系统的页缓存不了解 WAL 协议,它可能在任何时候、以任何顺序把脏页刷回磁盘,这会破坏数据库的崩溃恢复保证。

这五个原因加在一起,就解释了为什么数据库要自己管理内存。接下来看 Buffer Pool 的具体架构。


二、Buffer Pool 架构

Buffer Pool 的核心职责是:管理一块固定大小的内存区域,把磁盘上的数据页按需加载进来,提供给上层的查询引擎和事务管理器使用,并在内存不够时选择合适的页淘汰出去。

整体结构

一个典型的 Buffer Pool 由以下几个组件构成:

┌──────────────────────────────────────────────────┐
│                   Buffer Pool                     │
│                                                   │
│  ┌────────────┐    ┌──────────────────────────┐  │
│  │ Page Table  │    │      Frame Array          │  │
│  │ (Hash Map)  │    │                           │  │
│  │             │    │  Frame 0: [Page 数据 16KB] │  │
│  │ (5,3) -> 0  │    │  Frame 1: [Page 数据 16KB] │  │
│  │ (5,7) -> 1  │    │  Frame 2: [Page 数据 16KB] │  │
│  │ (8,1) -> 2  │    │  Frame 3: [空闲]          │  │
│  │ ...         │    │  ...                       │  │
│  └────────────┘    └──────────────────────────┘  │
│                                                   │
│  ┌──────────────────────────────────────────┐    │
│  │          Page Descriptors                  │    │
│  │  Frame 0: page_id=(5,3) pin=2 dirty=true  │    │
│  │  Frame 1: page_id=(5,7) pin=0 dirty=false │    │
│  │  Frame 2: page_id=(8,1) pin=1 dirty=true  │    │
│  │  Frame 3: page_id=null  pin=0 dirty=false │    │
│  └──────────────────────────────────────────┘    │
│                                                   │
│  ┌──────────────┐  ┌──────────────┐              │
│  │  Free List    │  │ Replacer     │              │
│  │  [3, 4, 5...] │  │ (LRU/Clock)  │              │
│  └──────────────┘  └──────────────┘              │
└──────────────────────────────────────────────────┘

页表(Page Table)

页表是一个哈希表(Hash Table),负责从逻辑页标识(Page ID)映射到物理帧编号(Frame ID)。页标识通常由表空间编号(Tablespace ID)和页号(Page Number)组成,例如 (5, 3) 表示表空间 5 中的第 3 页。

当上层请求一个页时,Buffer Pool 先查页表:如果命中,直接返回对应帧中的数据;如果未命中,就需要从磁盘读取,并在帧数组中找到一个空闲帧(或淘汰一个旧页)来存放新数据。

页表的实现通常使用带桶锁(Bucket Lock)的哈希表,以支持高并发访问。每个桶有自己的锁,不同桶的操作可以并行进行。

帧数组(Frame Array)

帧数组是 Buffer Pool 实际存储数据的内存区域。它被划分为固定大小的槽位,每个槽位叫一个帧(Frame),大小等于一个数据库页的大小。例如,InnoDB 默认页大小为 16KB,如果 Buffer Pool 大小为 16GB,那么帧数组包含 16GB / 16KB = 1,048,576 个帧。

帧数组在数据库启动时一次性分配,运行期间不再扩缩。这避免了动态内存分配带来的碎片和延迟问题。

页描述符(Page Descriptor)

每个帧都有一个对应的页描述符,记录该帧的元数据:

struct BufferPageDescriptor {
    page_id_t   page_id;      // 当前帧中存放的页标识
    uint32_t    pin_count;    // 引用计数,>0 时不可淘汰
    bool        is_dirty;     // 是否被修改过
    lsn_t       page_lsn;    // 页面最后修改的 LSN
    lsn_t       oldest_lsn;  // 页面第一次变脏时的 LSN
    // ... 锁、统计信息等
};

几个关键字段的含义:

引用计数(Pin Count):当一个线程正在访问某个页时,它会对该页执行 pin 操作,将 pin_count 加 1;访问结束后执行 unpin 操作,将 pin_count 减 1。只有 pin_count 为 0 的页才能被淘汰。如果一个线程 pin 了一个页后忘记 unpin,这个页就会永远留在 Buffer Pool 中,逐渐导致可用帧耗尽——这是一个常见的 Buffer Pool 泄漏(Buffer Pool Leak)问题。

脏标志(Dirty Flag):当页面被修改后,is_dirty 被设为 true。脏页在被淘汰前必须先刷回磁盘,否则修改就会丢失。刷盘完成后,is_dirty 重置为 false。

日志序列号(Log Sequence Number,LSN)page_lsn 记录了该页最后一次被修改时对应的 WAL 日志位置。这个值在崩溃恢复时至关重要:恢复进程通过比较页面 LSN 和日志 LSN,判断哪些修改需要重做(Redo),哪些需要撤销(Undo)。

空闲列表(Free List)

空闲列表维护所有未被使用的帧编号。当需要加载一个新页时,Buffer Pool 先从空闲列表中取帧;如果空闲列表为空,就需要通过页面替换器淘汰一个旧页来腾出空间。

页面获取流程

当上层请求一个页时,Buffer Pool 的处理流程如下:

FetchPage(page_id):
  1. 在页表中查找 page_id
  2. 如果命中(Cache Hit):
     a. 找到对应的 frame_id
     b. pin_count++
     c. 更新替换器的访问记录
     d. 返回帧的指针
  3. 如果未命中(Cache Miss):
     a. 从空闲列表取一个 frame_id
     b. 如果空闲列表为空:
        i.  调用替换器选择一个 victim frame
        ii. 如果 victim 是脏页,先刷盘
        iii. 从页表中删除 victim 的映射
     c. 从磁盘读取 page_id 的数据到 frame
     d. 在页表中插入 (page_id -> frame_id)
     e. 初始化描述符: pin_count=1, dirty=false
     f. 返回帧的指针

这个流程中,步骤 3.b 是性能关键路径。如果频繁发生 Cache Miss 且需要淘汰脏页,每次 Miss 都会触发一次磁盘写(刷脏页)和一次磁盘读(加载新页),延迟会非常高。这就是为什么 Buffer Pool 的大小和替换策略对数据库性能至关重要。


三、页面替换策略

当 Buffer Pool 满了、又需要加载新页时,必须选择一个现有页淘汰出去。选择哪个页淘汰,就是页面替换(Page Replacement)策略要解决的问题。理想情况下,应该淘汰”未来最长时间不会被访问的页”——这是 Belady 的最优替换算法(OPT),但它需要预知未来,无法实现。所有实际算法都是在近似 OPT。

LRU(Least Recently Used)

最近最少使用(LRU)是最直观的替换策略:淘汰最近最长时间没有被访问的页。

实现方式是维护一个双向链表。每次访问一个页,就把它移到链表头部;需要淘汰时,从链表尾部取页。这样链表尾部始终是”最久没被访问”的页。

class LRUReplacer:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = OrderedDict()  # frame_id -> 访问时间

    def access(self, frame_id):
        """记录一次访问"""
        if frame_id in self.cache:
            self.cache.move_to_end(frame_id)
        else:
            self.cache[frame_id] = True

    def evict(self):
        """淘汰最久未访问的帧"""
        if not self.cache:
            return None
        frame_id, _ = self.cache.popitem(last=False)
        return frame_id

LRU 的优点是简单、对时间局部性(Temporal Locality)好的工作负载效果不错。但它有一个严重的缺陷:对扫描操作(Sequential Scan)极度脆弱

考虑这样一个场景:Buffer Pool 有 1000 个帧,其中 900 个存放着频繁访问的热数据。现在执行一次全表扫描,依次读取 5000 个页。扫描过程中,每个新页都会被放到 LRU 链表头部,把热数据逐渐挤到尾部并淘汰出去。扫描结束后,Buffer Pool 里装的全是刚扫描过、但以后不会再访问的数据,而原来的热数据全被淘汰了。这就是 LRU 污染(LRU Pollution)问题。

Clock 算法

时钟(Clock)算法是 LRU 的一种近似实现,也叫二次机会(Second Chance)算法。它用一个环形缓冲区和一个时钟指针代替 LRU 的双向链表,降低了实现的复杂度和每次访问的开销。

每个帧有一个引用位(Reference Bit)。当页被访问时,引用位设为 1。淘汰时,时钟指针顺时针扫描:

class ClockReplacer:
    def __init__(self, capacity):
        self.capacity = capacity
        self.frames = [False] * capacity  # 引用位
        self.in_pool = [False] * capacity # 是否可被淘汰
        self.hand = 0                     # 时钟指针

    def access(self, frame_id):
        """记录访问,设置引用位"""
        self.frames[frame_id] = True
        self.in_pool[frame_id] = True

    def evict(self):
        """时钟扫描,找到淘汰对象"""
        scanned = 0
        while scanned < 2 * self.capacity:
            if self.in_pool[self.hand]:
                if not self.frames[self.hand]:
                    # 引用位为 0,淘汰
                    victim = self.hand
                    self.in_pool[self.hand] = False
                    self.hand = (self.hand + 1) % self.capacity
                    return victim
                else:
                    # 引用位为 1,清零,继续
                    self.frames[self.hand] = False
            self.hand = (self.hand + 1) % self.capacity
            scanned += 1
        return None  # 所有帧都被 pin 住了

Clock 算法的优势在于每次访问只需要设置一个位,不需要像 LRU 那样移动链表节点。在高并发场景下,LRU 链表的头部会成为热点,而 Clock 的引用位设置可以无锁完成。PostgreSQL 的 Shared Buffers 就使用 Clock-Sweep 算法。

但 Clock 算法同样无法解决扫描污染问题。

LRU-K

LRU-K 算法的核心思想是:不只看一个页”最近一次”的访问时间,而是看它”最近第 K 次”的访问时间。只有被访问至少 K 次的页才有资格进入主缓冲区;访问不足 K 次的页,即使刚刚被访问过,也优先被淘汰。

实际应用中最常用的是 LRU-2(K=2)。每个页维护两个时间戳:最近一次访问时间和倒数第二次访问时间。淘汰时,比较所有页的”倒数第二次访问时间”,选择最小的那个。

LRU-K 的核心优势在于它能区分”偶尔访问一次”和”反复访问”的页面。全表扫描带进来的页只会被访问一次,倒数第二次访问时间为负无穷(或不存在),所以会被优先淘汰,不会污染热数据。

这个算法由 O’Neil、O’Neil 和 Weikum 在 1993 年的论文”The LRU-K Page Replacement Algorithm for Database Disk Buffering”中提出。

2Q(Two Queue)

2Q 算法由 Johnson 和 Shasha 在 1994 年提出,是 LRU-2 的一种简化实现。它用两个队列代替了 LRU-K 的精确时间戳维护:

淘汰策略:优先从 A1 队列尾部淘汰;如果 A1 为空,再从 Am 队列尾部淘汰。

新页 ──→ [ A1 队列 (FIFO) ] ──→ 淘汰
              │ 再次命中
              ▼
         [ Am 队列 (LRU) ] ──→ 淘汰

2Q 的优点是实现简单,且能有效抵抗扫描污染:全表扫描带进来的页只会在 A1 队列中待一会儿就被淘汰,不会影响 Am 队列中的热数据。InnoDB 的 Buffer Pool 就采用了类似 2Q 的思想,将 LRU 链表分成 young 和 old 两个子链表。

ARC(Adaptive Replacement Cache)

自适应替换缓存(Adaptive Replacement Cache)由 IBM 的 Megiddo 和 Modha 在 2003 年提出。ARC 的核心创新是:它不仅维护缓存中的页列表,还维护”最近被淘汰的页”的幽灵列表(Ghost List),并根据幽灵列表的命中情况动态调整策略。

ARC 维护四个列表:

关键机制在于自适应参数 p,它控制 T1 和 T2 的大小分配:

ARC 的优势在于它能自动适应工作负载的变化,不需要手动调参。在混合负载(OLTP + 分析查询)的场景下,ARC 通常比静态的 LRU 或 2Q 表现更好。ZFS 文件系统使用的就是 ARC 的一个变体。

但 ARC 有专利限制(IBM 持有专利至 2014 年),这限制了它在开源数据库中的使用。

替换策略对比

维度 LRU Clock LRU-K 2Q ARC
实现复杂度
每次访问开销 链表移动 设位 更新时间戳 链表移动 链表移动
并发友好度 差(链表热点) 好(无锁设位)
抗扫描污染
自适应能力
空间开销 O(n) O(n) O(n) O(n) O(2n)(幽灵列表)
实际采用 早期系统 PostgreSQL 理论研究 InnoDB(变体) ZFS

我认为在数据库场景下,纯 LRU 已经不再是一个合理的选择。任何生产级数据库都需要至少具备抗扫描污染的能力,2Q 或 Clock-Sweep 加分区是目前的工程主流。ARC 在理论上更优,但实现复杂度和历史专利问题使它在开源数据库中没有被广泛采用。


四、脏页管理与刷新策略

Buffer Pool 中的页面被修改后就变成了脏页。脏页管理是 Buffer Pool 最复杂的部分之一,因为它直接关系到数据的持久性保证和系统的 I/O 性能。

脏页追踪

每个页描述符中有一个 is_dirty 标志,标记该页是否被修改过。但光有这个标志是不够的,数据库还需要追踪脏页的修改顺序,以支持高效的检查点(Checkpoint)操作。

常见的脏页追踪方式:

脏页链表(Flush List):InnoDB 维护一个按 oldest_modification_lsn 排序的脏页链表。oldest_modification_lsn 是该页第一次变脏时的 LSN。检查点推进时,从链表尾部(最老的脏页)开始刷盘。

脏页位图(Dirty Bitmap):某些系统使用位图来追踪哪些帧是脏的。位图紧凑,扫描速度快,但无法提供顺序信息。

WAL 约束

预写日志(WAL)协议规定了一条硬约束:

在将一个脏数据页刷回磁盘之前,该页上所有修改对应的 WAL 记录必须先被刷到日志文件中。

用公式表示:

刷出数据页 P 的前提条件:
  flushed_lsn >= page_lsn(P)

其中 flushed_lsn 是 WAL 日志已经持久化到磁盘的最大 LSN,page_lsn(P) 是页面 P 上最后一次修改对应的 LSN。

如果违反这个约束——数据页先刷了,日志还没刷——那么在数据页刷盘后、日志刷盘前如果发生崩溃,恢复进程就无法知道这个页上做了什么修改,数据就会处于不一致状态。

这个约束要求 Buffer Pool 的脏页刷新逻辑和 WAL 子系统紧密配合。每次刷脏页之前,都要检查该页的 page_lsn 是否已经被 WAL 覆盖。

检查点(Checkpoint)

检查点是数据库定期执行的一个操作,目的是:

  1. 将所有(或部分)脏页刷回磁盘;
  2. 推进 WAL 日志的可回收点——检查点之前的日志可以被回收,因为对应的数据页已经持久化了。

检查点的实现方式主要有两种:

Sharp Checkpoint:在检查点时刻,停止所有写操作,把所有脏页全部刷盘。这种方式实现简单,但会造成写停顿(Write Stall),在生产环境中不可接受。MySQL 在关闭时使用 Sharp Checkpoint。

Fuzzy Checkpoint:不停止写操作,而是记录检查点开始时的 LSN,然后在后台逐步刷出脏页。在恢复时,需要从检查点 LSN 开始重放日志。InnoDB 在正常运行时使用 Fuzzy Checkpoint。

WAL 日志流:
  ... [LSN 100] [LSN 101] ... [LSN 500] [LSN 501] ...
                  ↑                        ↑
         checkpoint_lsn=100         current_lsn=501

  Buffer Pool 中脏页的 oldest_modification_lsn:
  Page A: 100  ← 最老的脏页
  Page B: 200
  Page C: 350
  Page D: 500

  检查点推进: 把 oldest_modification_lsn <= checkpoint_target 的脏页刷出
  刷出 Page A 后: checkpoint 可推进到 200
  → LSN 100 之前的日志可以回收

刷新策略

除了检查点触发的刷新,Buffer Pool 还需要在以下场景主动刷新脏页:

淘汰时刷新:当需要淘汰一个脏页来腾出帧时,必须先将其刷盘。这是最差的情况,因为它在查询的关键路径上引入了一次同步磁盘写。好的设计应该尽量避免”淘汰时才发现是脏页”的情况。

后台预刷新:一个后台线程持续扫描 Buffer Pool,主动将脏页刷回磁盘。这样在淘汰时,大部分候选页已经是干净的,可以直接丢弃。InnoDB 的 Page Cleaner 线程和 PostgreSQL 的 bgwriter 都是做这件事的。

紧急刷新:当脏页比例超过阈值,或者 WAL 日志空间即将耗尽时,数据库会进入紧急刷新模式,大量刷出脏页。这通常意味着后台刷新的速度跟不上写入速度,是一个需要关注的性能问题。

刷新的 I/O 优化

脏页刷新是 Buffer Pool 中 I/O 最密集的操作,有几种常见的优化手段:

批量刷新(Batch Flush):不是一页一页地刷,而是收集一批脏页后一起提交 I/O 请求。这可以利用操作系统的 I/O 合并(I/O Merge)能力。

按邻居合并(Neighbor Flushing):InnoDB 有一个特性叫邻居刷新(innodb_flush_neighbors):当刷一个脏页时,顺带检查它在磁盘上相邻的页是否也是脏页,如果是,一起刷出。这在 HDD 上很有效,因为可以减少随机写;但在 SSD 上应该关闭,因为 SSD 的随机写性能已经很好,邻居刷新反而增加了不必要的写放大。

-- InnoDB 邻居刷新设置
-- 0: 关闭邻居刷新(推荐 SSD)
-- 1: 刷新同一个 extent 内的连续脏页(默认值,适合 HDD)
-- 2: 刷新同一个 extent 内的所有脏页
SET GLOBAL innodb_flush_neighbors = 0;

异步 I/O(Asynchronous I/O):使用 Linux 的 AIO 接口(io_submit/io_getevents 或较新的 io_uring)提交脏页刷新请求,避免线程阻塞在 I/O 等待上。InnoDB 支持通过 innodb_use_native_aio 启用原生异步 I/O。


五、预读(Prefetching)

预读的目标是在数据被实际需要之前就把它加载到 Buffer Pool 中,避免查询在等待磁盘 I/O 时阻塞。好的预读策略可以让磁盘 I/O 和 CPU 计算重叠执行,显著提升吞吐量。但错误的预读会浪费 I/O 带宽,还会把热数据从 Buffer Pool 中挤掉。

线性预读(Sequential Prefetch)

线性预读是最基本的预读方式:当检测到对某个区域(Extent)内的页面存在顺序访问模式时,自动将后续的页面提前读入 Buffer Pool。

InnoDB 的线性预读由参数 innodb_read_ahead_threshold 控制。当一个 extent(默认 64 个连续页,共 1MB)中有超过阈值数量的页被顺序访问时,InnoDB 会异步预读下一个 extent 的所有页面。

-- 当一个 extent 中有 56 个页被顺序访问时,触发线性预读
SET GLOBAL innodb_read_ahead_threshold = 56;

阈值设得太低,会导致过度预读;设得太高,预读触发太迟,效果不明显。默认值 56 在大多数场景下是合理的。

线性预读适用于全表扫描、顺序索引扫描等顺序访问模式。对于随机访问,线性预读不会触发,也不应该触发。

随机预读(Random Prefetch)

InnoDB 曾经支持随机预读:当一个 extent 中有一定数量的页被访问(不要求顺序)时,把整个 extent 的剩余页面都读进来。但这个特性在实践中被证明弊大于利,因为在 OLTP 工作负载下,同一个 extent 中的多个页被访问可能只是巧合,并不意味着整个 extent 都是热数据。

MySQL 5.5 以后默认关闭了随机预读(innodb_random_read_ahead = OFF),建议保持关闭。

索引预读

当执行索引范围扫描时,查询引擎可以根据索引的叶子节点中记录的数据页指针,提前知道接下来需要读哪些数据页。这些数据页在磁盘上可能不连续,但它们的列表是已知的。

索引叶子节点扫描:
  Leaf 1 → 数据页 [100, 105, 108]
  Leaf 2 → 数据页 [200, 203, 207]
  Leaf 3 → 数据页 [300, 301, 305]

索引预读: 在处理 Leaf 1 的数据页时,异步提交 Leaf 2 的数据页读取请求

这种预读方式比线性预读更精确,因为它知道确切要读哪些页,不会浪费 I/O 在不需要的页上。

查询驱动预读(Query-Driven Prefetch)

更高级的预读方式是让查询优化器参与预读决策。查询优化器知道查询计划的完整结构:它知道接下来要做哪些表的扫描、需要访问哪些索引、估计要读多少数据。这些信息可以用来驱动精确的预读。

例如,一个嵌套循环连接(Nested Loop Join)查询:

SELECT o.order_id, c.name
FROM orders o JOIN customers c ON o.customer_id = c.id
WHERE o.order_date > '2025-01-01';

查询优化器知道外层循环会扫描 orders 表的某个范围,内层循环会逐个查找 customers 表的主键。在处理当前批次的 orders 行时,可以提前把下一批次对应的 customers 页面预读进来。

这种预读在学术文献中有充分的讨论,但在实际数据库产品中的实现程度参差不齐。一些数据库通过 Batch Key Access(BKA)连接方式间接实现了这种效果:先收集一批连接键,排序后批量查找,使得内层表的访问从随机变成接近顺序。

预读的风险

预读不是越多越好。过度预读会带来几个问题:

InnoDB 提供了两个状态变量来监控预读的效果:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_ahead%';
+---------------------------------------+--------+
| Variable_name                         | Value  |
+---------------------------------------+--------+
| Innodb_buffer_pool_read_ahead         | 153280 |
| Innodb_buffer_pool_read_ahead_evicted | 2048   |
+---------------------------------------+--------+

Innodb_buffer_pool_read_ahead_evicted 表示预读进来但还没被使用就被淘汰的页数。如果这个值占 Innodb_buffer_pool_read_ahead 的比例很高(比如超过 20%),说明预读过于激进,可以考虑提高 innodb_read_ahead_threshold


六、MySQL InnoDB Buffer Pool

InnoDB 是 MySQL 的默认存储引擎,它的 Buffer Pool 实现是数据库领域中最成熟、最经过实战检验的实现之一。

Buffer Pool 实例

从 MySQL 5.6 开始,InnoDB 支持将 Buffer Pool 分成多个实例(Instance)。每个实例有自己的 LRU 链表、Free 链表、Flush 链表和互斥锁。页面按 page_id 哈希分配到不同的实例,不同实例的操作可以并行进行,减少了锁争用。

-- 查看 Buffer Pool 实例数
SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';

-- 设置 Buffer Pool 实例数(需要重启)
-- 推荐值:当 buffer_pool_size >= 1GB 时,设为 8 或 16
-- buffer_pool_size < 1GB 时,强制为 1
SET GLOBAL innodb_buffer_pool_instances = 8;

每个实例的大小 = innodb_buffer_pool_size / innodb_buffer_pool_instances。实例数不宜过多,因为每个实例都有自己的管理开销。经验上,每个实例至少 1GB 是合理的。

Chunk 机制

从 MySQL 5.7.5 开始,InnoDB 引入了 chunk 机制,允许在线调整 Buffer Pool 大小(Online Buffer Pool Resizing)。Buffer Pool 被划分为多个 chunk,每个 chunk 是一块连续的内存区域,默认大小 128MB(由 innodb_buffer_pool_chunk_size 控制)。

Buffer Pool Instance 0:
  Chunk 0: 128MB  [Frame 0 ... Frame 8191]
  Chunk 1: 128MB  [Frame 8192 ... Frame 16383]
  ...

Buffer Pool Instance 1:
  Chunk 0: 128MB
  Chunk 1: 128MB
  ...

扩大 Buffer Pool 时,InnoDB 分配新的 chunk 并将其加入实例;缩小时,释放多余的 chunk。这个过程不需要重启数据库,但缩小操作可能需要先淘汰大量页面,会有短暂的性能波动。

innodb_buffer_pool_size 的有效值必须是 innodb_buffer_pool_chunk_size * innodb_buffer_pool_instances 的整数倍。如果设置的值不满足这个条件,InnoDB 会自动向上调整。

Old/Young 子链表

InnoDB 的 LRU 链表被分成两个子链表:young 子链表和 old 子链表。这就是前面提到的 2Q 思想的实现。

LRU 链表:
  ┌────── young 区域(约 5/8)──────┐┌────── old 区域(约 3/8)──────┐
  │ 热数据,频繁访问的页             ││ 新加载的页,一次性扫描数据     │
  │ ←── head                        ││                    tail ──→   │
  └──────────────────────────────────┘└──────────────────────────────┘
                                      ↑
                                  midpoint

新读入的页不会直接放到 LRU 链表头部,而是放到 old 区域的头部(即 midpoint 位置)。这样,全表扫描带进来的页只会在 old 区域中待着,不会把 young 区域的热数据挤掉。

一个页要从 old 区域提升到 young 区域,需要满足两个条件:

  1. 页在 old 区域中停留的时间超过 innodb_old_blocks_time(默认 1000 毫秒);
  2. 页被再次访问。

这个时间窗口的设计非常巧妙:全表扫描时,每个页被读入后很快就被下一个页替换了,停留时间远不到 1 秒,所以不会被提升到 young 区域;而真正的热数据,会在较长时间跨度内被反复访问,自然会进入 young 区域。

-- 查看 old 区域占比(默认 37%,即约 3/8)
SHOW VARIABLES LIKE 'innodb_old_blocks_pct';

-- 查看 old 区域停留时间阈值(毫秒)
SHOW VARIABLES LIKE 'innodb_old_blocks_time';

自适应刷新(Adaptive Flushing)

InnoDB 的自适应刷新机制(innodb_adaptive_flushing)会根据当前的脏页比例和 Redo Log 生成速度动态调整刷新速率。

核心逻辑是:

  1. 计算当前脏页比例;
  2. 计算 Redo Log 的消耗速度;
  3. 估算如果不加速刷新,Redo Log 多久会被写满;
  4. 根据以上信息,计算每秒需要刷出多少页。

InnoDB 中相关的参数:

-- 自适应刷新开关(默认 ON)
SHOW VARIABLES LIKE 'innodb_adaptive_flushing';

-- 脏页比例低水位(低于此值不触发自适应刷新,默认 10%)
SHOW VARIABLES LIKE 'innodb_adaptive_flushing_lwm';

-- 最大脏页比例(超过此值会激进刷新,默认 90%)
SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct';

-- 最大脏页比例低水位(开始预刷新的阈值,默认 10%)
SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct_lwm';

-- Page Cleaner 线程数(默认 4,建议与 buffer_pool_instances 一致)
SHOW VARIABLES LIKE 'innodb_page_cleaners';

-- I/O 容量限制
SHOW VARIABLES LIKE 'innodb_io_capacity';          -- 正常刷新速率上限(默认 200 IOPS)
SHOW VARIABLES LIKE 'innodb_io_capacity_max';      -- 紧急刷新速率上限(默认 2000 IOPS)

innodb_io_capacityinnodb_io_capacity_max 的设置应该匹配底层存储设备的实际能力。对于 HDD,200-400 IOPS 是合理的;对于 SATA SSD,可以设到 2000-5000;对于 NVMe SSD,可以设到 10000-20000 或更高。设得太低,脏页刷不出去,Redo Log 会被写满导致写停顿;设得太高,刷新 I/O 会抢占正常查询的 I/O 带宽。

用 SHOW ENGINE INNODB STATUS 监控

SHOW ENGINE INNODB STATUSBUFFER POOL AND MEMORY 区域包含了 Buffer Pool 的关键指标:

SHOW ENGINE INNODB STATUS\G

输出中的关键段落(以下输出经删减,仅保留 Buffer Pool 相关部分):

----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137363456
Dictionary memory allocated 365080
Buffer pool size   8192
Free buffers       1024
Database pages     7128
Old database pages 2611
Modified db pages  320
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 12450, not young 78230
0.00 youngs/s, 0.00 non-youngs/s
Pages read 45230, created 1200, written 8900
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 998 / 1000, young-making rate 5 / 1000 not 15 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 7128, unzip_LRU len: 0
I/O sum[56]:cur[0], unzip sum[0]:cur[0]

几个核心指标的解读:

也可以通过 INFORMATION_SCHEMA 获取结构化数据:

SELECT
    POOL_ID,
    POOL_SIZE,
    FREE_BUFFERS,
    DATABASE_PAGES,
    OLD_DATABASE_PAGES,
    MODIFIED_DATABASE_PAGES,
    PAGES_MADE_YOUNG,
    PAGES_NOT_MADE_YOUNG,
    HIT_RATE
FROM INFORMATION_SCHEMA.INNODB_BUFFER_POOL_STATS;

七、PostgreSQL Shared Buffers

PostgreSQL 的内存管理架构与 InnoDB 有本质的不同。PostgreSQL 是多进程架构(每个连接一个后端进程),所有进程通过共享内存(Shared Memory)访问同一块缓冲区——共享缓冲区(Shared Buffers)。

shared_buffers 参数

shared_buffers 是 PostgreSQL 中 Buffer Pool 的等价物,控制用于缓存数据页的共享内存大小。默认值是 128MB,在生产环境中通常需要调大。

# postgresql.conf
shared_buffers = 8GB    # 推荐值:物理内存的 25%

PostgreSQL 社区的通用建议是将 shared_buffers 设为物理内存的 25%。但这个建议的背景是:PostgreSQL 还依赖操作系统的 Page Cache 来做第二层缓存。PostgreSQL 使用 Buffered I/O(不是 Direct I/O),数据会在 Shared Buffers 和 Page Cache 中各存一份。

这确实是一个 Double Buffering 的问题,但 PostgreSQL 社区认为这个代价是可接受的,理由是:

  1. Page Cache 可以缓存比 shared_buffers 大得多的数据集;
  2. Page Cache 的 LRU 策略虽然不如数据库自己的策略精确,但在 shared_buffers 未命中时提供了一个”兜底”层;
  3. 使用 Direct I/O 会失去操作系统的预读优化和 I/O 调度。

这是一个工程取舍的问题。不同的系统做了不同的选择,不存在绝对的对错。

Clock-Sweep 替换算法

PostgreSQL 使用 Clock-Sweep 算法管理 Shared Buffers 中的页面替换。每个缓冲区描述符(Buffer Descriptor)有一个使用计数(Usage Count),而不是简单的引用位。

当一个页面被访问时,usage_count 加 1,最大值为 5。时钟指针扫描时,如果 usage_count 大于 0,就减 1 并跳过;如果 usage_count 为 0,就选择该页淘汰。

// PostgreSQL 源码 src/backend/storage/buffer/freelist.c 中的逻辑(经简化)
// 基于 PostgreSQL 16
for (;;) {
    buf = GetBufferDescriptor(ClockSweepTick());

    if (buf->usage_count > 0) {
        buf->usage_count--;
        continue;
    }

    if (buf->refcount == 0) {
        // 找到淘汰目标
        return buf;
    }
}

usage_count 的上限为 5,这意味着一个被频繁访问的页最多需要被时钟指针扫过 5 次才会被淘汰。这提供了一定程度的”频率”保护——访问越频繁的页,usage_count 越高,越不容易被淘汰。

但与 InnoDB 的 old/young 分区相比,Clock-Sweep 对大规模顺序扫描的抵抗能力较弱。PostgreSQL 通过另一种机制来解决这个问题。

Ring Buffer:大扫描的隔离机制

当 PostgreSQL 检测到以下操作时,会分配一个小的环形缓冲区(Ring Buffer),将该操作的 I/O 限制在这个环形缓冲区内,避免污染主 Shared Buffers:

操作类型 Ring Buffer 大小 触发条件
批量读(Bulk Read) 256KB(32 页) 全表顺序扫描大于 shared_buffers / 4 的表
批量写(Bulk Write) 16MB(2048 页) COPYCREATE TABLE AS SELECTALTER TABLE
VACUUM 256KB(32 页) VACUUM 操作

Ring Buffer 的工作方式是:操作只能使用环形缓冲区内的帧,写满后从头开始覆盖。这样,无论扫描的表有多大,最多只占用 Ring Buffer 大小的共享内存,不会影响 Shared Buffers 中其他热数据。

这个设计在效果上类似于 InnoDB 的 old 子链表,但实现更加直接。

bgwriter 后台写进程

bgwriter(Background Writer)是 PostgreSQL 的后台脏页刷新进程,负责定期扫描 Shared Buffers,将脏页刷回磁盘,以确保总有足够的干净页供后端进程使用。

核心参数:

# postgresql.conf
bgwriter_delay = 200ms              # 扫描间隔
bgwriter_lru_maxpages = 100         # 每次扫描最多刷出的页数
bgwriter_lru_multiplier = 2.0       # 根据最近的需求预估需要多少干净页
bgwriter_flush_after = 512kB        # 累积写入量达到此值后强制 fsync

bgwriter_lru_multiplier 的工作方式是:bgwriter 统计两次扫描之间后端进程使用了多少个干净页(记为 N),然后在下一次扫描时尝试刷出 N * bgwriter_lru_multiplier 个脏页。这样 bgwriter 可以自动跟上写入速度的变化。

如果 bgwriter 来不及刷出足够的干净页,后端进程在需要帧时就不得不自己刷脏页——这会导致查询延迟增加。可以通过 pg_stat_bgwriter 视图监控:

SELECT
    buffers_clean,          -- bgwriter 刷出的页数
    maxwritten_clean,       -- bgwriter 因达到 lru_maxpages 限制而停止的次数
    buffers_backend,        -- 后端进程自己刷出的页数(应该尽量为 0)
    buffers_alloc           -- 分配的新页数
FROM pg_stat_bgwriter;

如果 buffers_backend 持续增长,说明 bgwriter 跟不上节奏,需要调大 bgwriter_lru_maxpages 或减小 bgwriter_delay

checkpointer 检查点进程

PostgreSQL 9.2 开始将检查点逻辑从 bgwriter 中分离出来,由独立的 checkpointer 进程负责。checkpointer 在以下条件下触发检查点:

  1. WAL 日志量达到 max_wal_size(默认 1GB);
  2. 距离上次检查点的时间达到 checkpoint_timeout(默认 5 分钟);
  3. 手动执行 CHECKPOINT 命令。
# postgresql.conf
checkpoint_timeout = 15min          # 检查点最大间隔
max_wal_size = 4GB                  # 触发检查点的 WAL 累积量
checkpoint_completion_target = 0.9  # 检查点刷新在下次检查点前完成的比例

checkpoint_completion_target 控制检查点的刷新”舒适度”。设为 0.9 意味着检查点会尝试在下次检查点触发前 90% 的时间内完成所有脏页的刷新,平滑 I/O 负载,避免集中刷新造成的 I/O 尖峰。


八、Buffer Pool 大小调优

Buffer Pool 的大小是数据库性能调优中最关键的参数之一。设得太小,缓存命中率低,磁盘 I/O 成为瓶颈;设得太大,操作系统和其他进程的可用内存不足,可能触发 swap 甚至 OOM。

初始估算方法

一个基本的出发点是根据物理内存和数据库角色来分配:

场景 Buffer Pool 建议大小
专用数据库服务器(InnoDB) 物理内存的 70%-80%
专用数据库服务器(PostgreSQL) 物理内存的 25%(依赖 Page Cache)
混合部署(数据库 + 应用) 物理内存的 40%-50%
容器化部署 容器内存限制的 60%-70%

InnoDB 的建议值比 PostgreSQL 高很多,是因为 InnoDB 使用 Direct I/O,Buffer Pool 是唯一的数据缓存层;而 PostgreSQL 使用 Buffered I/O,Page Cache 提供了额外的缓存。

但这些只是起点。实际的最优大小取决于工作集(Working Set)的大小和访问模式。

工作集分析

工作集是指在一段时间内被实际访问到的数据页集合。如果 Buffer Pool 能装下整个工作集,命中率就会接近 100%;如果装不下,命中率取决于替换算法对冷热数据的区分能力。

InnoDB 提供了一种间接估算工作集大小的方式——通过观察不同 Buffer Pool 大小下的命中率变化:

-- 查看当前命中率
SELECT
    (1 - (
        (SELECT VARIABLE_VALUE FROM performance_schema.global_status
         WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads')
        /
        (SELECT VARIABLE_VALUE FROM performance_schema.global_status
         WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests')
    )) * 100 AS hit_rate_pct;

如果命中率已经是 99.9%,增大 Buffer Pool 不会有明显收益;如果命中率只有 95%,那 5% 的未命中意味着每 20 次读请求就有一次要走磁盘,增大 Buffer Pool 可能会显著改善性能。

命中率与性能的非线性关系

命中率和性能之间的关系不是线性的。假设 Buffer Pool 命中延迟为 0.1ms,磁盘读取延迟为 5ms:

命中率    平均延迟            相对性能
99.9%    0.1*0.999 + 5*0.001 = 0.105ms    1.00x
99.0%    0.1*0.99  + 5*0.01  = 0.149ms    0.70x
95.0%    0.1*0.95  + 5*0.05  = 0.345ms    0.30x
90.0%    0.1*0.90  + 5*0.10  = 0.590ms    0.18x

从 99.9% 下降到 99.0% ,平均延迟增加了 42%;从 99.0% 下降到 95.0%,延迟又翻了一倍多。这说明在高命中率区间,每一个百分点的提升都很有价值。

内存压力的平衡

增大 Buffer Pool 不是没有代价的。数据库之外的内存需求包括:

一个安全的做法是:

buffer_pool_size <= 物理内存 - OS 预留(2GB) - 连接内存(连接数 * 单连接开销) - 其他进程(1GB)

在容器化环境中,还要注意 cgroup 的内存限制。如果 Buffer Pool 设得太大,加上其他内存分配超过了 cgroup 限制,容器会被 OOM Killer 杀掉。

动态调整

InnoDB 支持在线调整 Buffer Pool 大小:

-- 在线扩大 Buffer Pool
SET GLOBAL innodb_buffer_pool_size = 16 * 1024 * 1024 * 1024;  -- 16GB

-- 查看调整进度
SHOW STATUS LIKE 'Innodb_buffer_pool_resize_status';

调整过程中,InnoDB 会:

  1. 扩大时:分配新的 chunk,将其加入实例;
  2. 缩小时:标记多余的 chunk,等待其中的页被淘汰后释放。

缩小操作可能需要较长时间,因为需要等待 chunk 中所有页都被淘汰或迁移。在此期间,Buffer Pool 的行为是正常的,但可能会有更频繁的页面淘汰。

PostgreSQL 调整 shared_buffers 需要重启,这是它相比 InnoDB 的一个劣势。不过 PostgreSQL 16 引入了一些动态调整的初步支持。


九、Buffer Pool 监控与诊断

Buffer Pool 的状态直接反映数据库的 I/O 健康状况。建立持续的监控是性能调优的基础。

核心监控指标

以下是需要持续关注的 Buffer Pool 指标:

指标 含义 健康范围
命中率 缓存命中次数 / 总读请求次数 >= 99%
脏页比例 脏页数 / 总页数 < 75%(InnoDB)
空闲页数 Free List 中的帧数 > 0
淘汰速率 每秒淘汰的页数 平稳,无突增
后端刷盘数(PG) 后端进程自己刷出的脏页数 尽量为 0
预读浪费率 预读后未被访问就淘汰的比例 < 20%
等待 I/O 数 Pending Reads/Writes 尽量为 0

InnoDB 监控脚本

以下脚本收集 InnoDB Buffer Pool 的关键指标,适合接入监控系统:

-- InnoDB Buffer Pool 健康检查
SELECT
    'buffer_pool_size_gb' AS metric,
    ROUND(@@innodb_buffer_pool_size / 1024 / 1024 / 1024, 2) AS value
UNION ALL
SELECT
    'hit_rate_pct',
    ROUND((1 - s1.VARIABLE_VALUE / s2.VARIABLE_VALUE) * 100, 2)
FROM
    performance_schema.global_status s1,
    performance_schema.global_status s2
WHERE
    s1.VARIABLE_NAME = 'Innodb_buffer_pool_reads'
    AND s2.VARIABLE_NAME = 'Innodb_buffer_pool_read_requests'
UNION ALL
SELECT
    'dirty_pages',
    VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty'
UNION ALL
SELECT
    'free_pages',
    VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_free'
UNION ALL
SELECT
    'total_pages',
    VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_total'
UNION ALL
SELECT
    'pending_reads',
    VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads';

PostgreSQL 监控查询

-- Shared Buffers 使用情况(需要 pg_buffercache 扩展)
CREATE EXTENSION IF NOT EXISTS pg_buffercache;

-- 各数据库的缓冲区占用
SELECT
    d.datname AS database,
    COUNT(*) AS buffers,
    COUNT(*) FILTER (WHERE b.isdirty) AS dirty_buffers,
    pg_size_pretty(COUNT(*) * 8192::bigint) AS size
FROM pg_buffercache b
JOIN pg_database d ON d.oid = b.reldatabase
GROUP BY d.datname
ORDER BY buffers DESC;
-- 各表的缓冲区占用(定位哪些表占了最多缓存)
SELECT
    c.relname AS table_name,
    COUNT(*) AS buffers,
    COUNT(*) FILTER (WHERE b.isdirty) AS dirty_buffers,
    ROUND(100.0 * COUNT(*) / (SELECT setting::int
        FROM pg_settings WHERE name = 'shared_buffers'), 1) AS pct_of_total
FROM pg_buffercache b
JOIN pg_class c ON c.relfilenode = b.relfilenode
WHERE b.reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database())
GROUP BY c.relname
ORDER BY buffers DESC
LIMIT 20;
-- bgwriter 与 checkpointer 统计
SELECT
    buffers_checkpoint,     -- checkpointer 刷出的页数
    buffers_clean,          -- bgwriter 刷出的页数
    maxwritten_clean,       -- bgwriter 达到限制而停止的次数
    buffers_backend,        -- 后端进程自己刷出的页数
    buffers_backend_fsync,  -- 后端进程自己执行 fsync 的次数(应该为 0)
    buffers_alloc,          -- 分配的缓冲区总数
    stats_reset             -- 统计信息上次重置的时间
FROM pg_stat_bgwriter;

命中率下降的排查流程

当发现 Buffer Pool 命中率下降时,按以下流程排查:

命中率下降
  │
  ├── 是否有大量全表扫描?
  │     └── 是 → 检查查询计划,添加索引或优化查询
  │           └── InnoDB: 检查 Pages not made young 是否异常增加
  │           └── PG: 检查 Ring Buffer 是否生效
  │
  ├── 工作集是否增大?
  │     └── 是 → 考虑增大 Buffer Pool
  │           └── 先确认内存是否充足
  │
  ├── 是否有连接数暴增?
  │     └── 是 → 连接内存挤占了 Buffer Pool 的有效空间
  │           └── 检查连接池配置
  │
  └── 是否有大事务或长事务?
        └── 是 → 长事务可能导致大量页面被 pin 住无法淘汰
              └── 检查事务状态,终止异常事务

脏页比例过高的排查

脏页比例过高
  │
  ├── 写入速率是否突增?
  │     └── 是 → 确认是否是批量导入或大更新操作
  │           └── 如果是临时性的,等操作完成即可
  │           └── 如果是持续性的,调大 innodb_io_capacity
  │
  ├── I/O 带宽是否饱和?
  │     └── 是 → 存储设备成为瓶颈
  │           └── 检查 iostat,确认设备利用率
  │           └── 考虑升级存储或分散 I/O
  │
  └── Page Cleaner / bgwriter 是否正常工作?
        └── InnoDB: 检查 innodb_page_cleaners 数量
        └── PG: 检查 buffers_backend 是否持续增长

持续监控建议

生产环境中,建议将以下指标接入时序数据库(如 Prometheus)进行持续监控和告警:

# Prometheus 告警规则示例(以 InnoDB 为例)
groups:
  - name: buffer_pool
    rules:
      - alert: BufferPoolHitRateLow
        expr: |
          (1 - rate(mysql_global_status_innodb_buffer_pool_reads[5m])
               / rate(mysql_global_status_innodb_buffer_pool_read_requests[5m])
          ) < 0.99
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Buffer Pool 命中率低于 99%"

      - alert: BufferPoolDirtyPagesHigh
        expr: |
          mysql_global_status_innodb_buffer_pool_pages_dirty
          / mysql_global_status_innodb_buffer_pool_pages_total
          > 0.75
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "脏页比例超过 75%"

      - alert: BufferPoolFreePagesCritical
        expr: mysql_global_status_innodb_buffer_pool_pages_free < 100
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Buffer Pool 空闲页不足 100"

对于 PostgreSQL,可以通过 pg_stat_bgwriter 指标设置类似的告警,重点关注 buffers_backend 的增长速率——如果后端进程频繁自己刷脏页,说明 bgwriter 配置不足或 I/O 带宽不够。


参考资料

论文

官方文档

源码

书籍


上一篇: B-Tree 与 B+Tree:页式存储引擎的工程实现

下一篇: WAL 与崩溃恢复:ARIES 协议

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-07-15 · algorithms

页面置换算法:LRU 的谎言与 ARC 的真相

教科书说 LRU 是最好的页面置换算法。然后你在数据库上跑了一次全表扫描,热数据全被冲走了。本文从 Bélády 的最优算法出发,逐层揭示 LRU 的理论缺陷,到 CLOCK 的工程妥协,再到 ARC 的自适应智慧。

2026-04-22 · db / storage

数据库内核实验索引

汇总本站数据库内核与存储引擎实验文章,重点覆盖从零实现 LSM-Tree 及其工程权衡。


By .