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

缓冲池管理算法:LRU-K → 2Q → CLOCK-Pro

目录

缓冲池(Buffer Pool)是数据库系统中最关键的内存组件之一。它决定了哪些磁盘页面留在内存、哪些被淘汰,直接影响着系统的整体吞吐和延迟。本文从最朴素的 LRU 出发,逐步深入 LRU-K、2Q、CLOCK、CLOCK-Pro 以及 ARC,再结合 InnoDB 和 PostgreSQL 的工程实现,力求给出一份完整的缓冲池管理算法指南。


一、缓冲池问题:磁盘与内存之间的桥梁

1.1 为什么需要缓冲池

现代磁盘(即使是 NVMe SSD)的随机访问延迟仍然是 DRAM 的数百倍。以一次 4 KB 随机读为例:

介质 延迟 相对倍数
DRAM ~100 ns 1x
NVMe SSD ~10 us 100x
SATA SSD ~50 us 500x
HDD ~5 ms 50000x

数据库引擎通过在内存中维护一个 缓冲池(buffer pool),将频繁访问的磁盘页面缓存起来,避免反复发起昂贵的 I/O。缓冲池的管理核心就是 页面置换算法:当池满时,如何选出一个”最不值得保留”的页面淘汰,腾出空间给新页面。

1.2 问题的形式化定义

给定:

目标:最大化 命中率(hit rate),即访问时页面已在缓冲池中的比例。

最优策略(Belady’s OPT)需要知道未来的访问序列,在线算法无法实现。因此我们需要依赖历史访问模式做出合理预测。

1.3 缓冲池与操作系统页面缓存的区别

操作系统有自己的 page cache,但数据库通常选择绕过它(O_DIRECT),原因是:

  1. 数据库比 OS 更了解自己的访问模式
  2. 需要精确控制脏页刷盘时机(WAL 协议要求)
  3. 避免双重缓存浪费内存
  4. 需要 pin/unpin 语义保证页面在事务期间不被淘汰

二、朴素 LRU 及其致命弱点

2.1 LRU 的基本思想

最近最少使用(Least Recently Used)策略维护一个按最后访问时间排序的双向链表。每次访问一个页面时,将其移到链表头部;需要淘汰时,移除链表尾部的页面。

/* 朴素 LRU 核心操作 */
void lru_access(LRUCache *cache, page_id_t pid) {
    Node *node = hashtable_lookup(cache->ht, pid);
    if (node) {
        /* 命中:移到链表头部 */
        list_remove(node);
        list_push_front(&cache->list, node);
    } else {
        /* 未命中:可能需要淘汰 */
        if (cache->size >= cache->capacity) {
            Node *victim = list_pop_back(&cache->list);
            if (victim->dirty) flush_page(victim);
            hashtable_remove(cache->ht, victim->pid);
            free_node(victim);
            cache->size--;
        }
        node = alloc_node(pid);
        load_page_from_disk(node, pid);
        list_push_front(&cache->list, node);
        hashtable_insert(cache->ht, pid, node);
        cache->size++;
    }
}

时间复杂度 O(1),实现简洁,是很多系统的默认选择。

2.2 全表扫描污染

LRU 的致命弱点在于 扫描抗性(scan resistance) 的缺失。

考虑一个典型场景:DBA 在生产库上执行了一次全表扫描:

SELECT COUNT(*) FROM orders;  -- 1000 万行,约 120 万页

假设缓冲池只有 10 万个页面。这次扫描会依次读入 120 万个页面,每个页面只被访问一次,但每次访问都会将该页面推到 LRU 链表头部。结果是:扫描结束后,缓冲池中所有热点页面都被淘汰,替换成了这些只会被访问一次的冷页面。

这就是所谓的 缓冲池污染(buffer pool pollution)。恢复正常命中率可能需要数分钟甚至更长时间。

2.3 LRU 的其他问题

问题 描述
不区分频率和近因 只看最后一次访问时间,不考虑访问频率
预读浪费 预读进来但从未被真正使用的页面也会驻留在链表前部
循环访问退化 当工作集略大于缓冲池时,LRU 退化为 FIFO,命中率趋近零
锁竞争 多线程环境下链表头的 mutex 成为瓶颈

三、LRU-K 算法(O’Neil 1993)

3.1 核心思想

LRU-K 由 Elizabeth O’Neil、Patrick O’Neil 和 Gerhard Weikum 于 1993 年在论文 “The LRU-K Page Replacement Algorithm for Database Disk Buffering” 中提出。其核心观察是:

用一个页面的 倒数第 K 次访问时间(Backward K-distance)来做淘汰决策,比仅看最后一次访问(K=1,即朴素 LRU)更能区分热页面和冷页面。

3.2 关键定义

K-距离(Backward K-distance)

对于页面 p,设其历史访问时间戳从新到旧为 t1, t2, ..., tn,则:

BKD(p, t) = t - t_K    (若访问次数 >= K)
BKD(p, t) = +INF        (若访问次数 < K)

其中 t 为当前时间,t_K 为倒数第 K 次的访问时间。

关联引用期(Correlated Reference Period)

短时间内对同一页面的多次引用(如索引查找后的数据页读取)被视为 一次 逻辑访问。只有超过关联引用期 CRP 之后的再次访问才算新的独立引用。这避免了短期突发访问虚增 K-距离。

3.3 淘汰策略

需要淘汰时,选择 Backward K-distance 最大的页面。直觉上:

K=2 是实践中最常用的选择(LRU-2),因为它在扫描抗性和实现复杂度之间取得了良好平衡。

3.4 LRU-K 的优缺点

优点:

  1. 良好的扫描抗性:全表扫描的页面只被访问一次,BKD = +INF,优先被淘汰
  2. 能区分频率和近因:K-距离同时考虑了访问的频率和时间
  3. 理论上接近 OPT:论文证明在特定工作负载模型下 LRU-2 接近最优

缺点:

  1. 淘汰操作需要 O(N) 扫描(或维护优先队列 O(log N))
  2. 需要为缓冲池外的页面也保留访问历史(内存开销)
  3. 实现比朴素 LRU 复杂

四、2Q 算法:简洁高效的两级队列

4.1 设计动机

Theodore Johnson 和 Dennis Shasha 在 1994 年提出 2Q 算法,目标是用更简单的数据结构达到与 LRU-2 相近的性能。

4.2 结构

2Q 将缓冲池分为两个队列:

A1in (FIFO queue)     Am (LRU queue)
+---+---+---+---+    +---+---+---+---+---+---+---+
| d | c | b | a | -> | G | F | E | D | C | B | A |
+---+---+---+---+    +---+---+---+---+---+---+---+
     新页面入口              第二次命中后晋升

另外还有一个 A1out 队列,存放最近从 A1in 淘汰出去的页面标识(不存数据,只存 page ID),用于检测”第二次访问”。

4.3 算法流程

访问页面 p:
  if p in Am:
      将 p 移到 Am 头部              // LRU 命中
  elif p in A1in:
      不移动(保持 FIFO 语义)       // 不打乱 FIFO 顺序
  elif p in A1out:
      从磁盘加载 p
      将 p 插入 Am 头部              // 晋升为热页面
      从 A1out 删除 p
  else:
      从磁盘加载 p
      将 p 插入 A1in 头部            // 首次访问,进入试用区
      if |A1in| > Kin:
          从 A1in 尾部淘汰页面 q
          将 q 的 ID 加入 A1out 头部
          if |A1out| > Kout:
              从 A1out 尾部删除最老记录

4.4 参数选择

原论文建议:

A1in 保持较小确保扫描页面快速被淘汰;A1out 足够大以检测到间隔较长的第二次访问。

4.5 2Q 与 LRU-2 的关系

2Q 可以看作 LRU-2 的一种 近似实现

特性 LRU-2 2Q
淘汰决策 全局 BKD 排序 FIFO + LRU 分级
淘汰复杂度 O(log N) O(1)
访问历史 精确时间戳 隐式(队列位置)
扫描抗性
实现难度 中等 简单

两者核心思想一致:只有被访问至少两次的页面才值得保留在缓冲池中。


五、CLOCK 近似算法:低开销的折中

5.1 从 LRU 到 CLOCK

CLOCK 算法(也称为 Second Chance)是 LRU 的一种低开销近似。它用一个 环形缓冲区和一个 引用位(reference bit)代替双向链表:

  1. 所有页面组织成环形数组
  2. 每个页面有一个引用位 ref,初始为 1
  3. 一个”时钟指针”(clock hand)沿环形扫描

5.2 算法流程

访问页面 p:
  if p 在缓冲池中:
      设置 p.ref = 1              // 标记为最近被访问

淘汰:
  while true:
      p = 时钟指针指向的页面
      if p.ref == 0:
          淘汰 p,用新页面替换
          时钟指针前进一步
          return
      else:
          p.ref = 0               // 给第二次机会
          时钟指针前进一步

5.3 CLOCK 的优势

开销对比:

         LRU                          CLOCK
访问时:  移动链表节点(写操作)           设置 ref=1(单个位操作)
淘汰时:  O(1) 链表尾部弹出              O(N) 最坏扫描
锁粒度:  链表级别 mutex                 每页独立(CAS 即可)

在多核系统上,CLOCK 的实际性能往往优于 LRU,因为访问操作几乎无竞争。这也是 PostgreSQL 选择 CLOCK 变体的核心原因。

5.4 CLOCK 的局限

和朴素 LRU 一样,基本 CLOCK 也缺乏扫描抗性。一个只被访问一次的页面在首次加入时 ref=1,需要时钟指针绕过它一圈后才会被淘汰。如果扫描速度够快,大量冷页面的 ref 位来不及清零,热页面反而先被淘汰。


六、CLOCK-Pro:冷热分层的时钟

6.1 设计目标

Song Jiang、Feng Chen 和 Xiaodong Zhang 在 2005 年提出 CLOCK-Pro,目标是在 CLOCK 的低开销框架上实现类似 LIRS(Low Inter-reference Recency Set)的性能。

CLOCK-Pro 的核心创新是引入 三种页面状态三根指针

6.2 页面状态

状态 含义 存储
Hot (H) 短 reuse distance,值得保留 数据+元数据
Cold (C) 长 reuse distance 或首次加载 数据+元数据
Test (T) 已被淘汰但保留 ID,用于追踪再访问 仅元数据

6.3 三根指针

CLOCK-Pro 在环形缓冲区上维护三根时钟指针:

CLOCK-Pro 三指针结构

6.4 关键操作

页面命中(页面在缓冲池中):

if page.status == Hot:
    page.ref = 1        // 正常标记
elif page.status == Cold:
    page.ref = 1        // 标记,等待 HAND-cold 晋升

缺页(页面不在缓冲池中):

if page.id in Test_list:
    // 曾经在缓冲池中,说明有再次访问需求
    将新加载的页面标记为 Hot
    调整 Cold 页面的目标数量(增加)
else:
    // 全新的页面
    将新加载的页面标记为 Cold,ref=0

触发 HAND-cold 进行淘汰

6.5 自适应调节

CLOCK-Pro 维护一个目标值 m_cold(Cold 页面的目标数量),根据 Test 页面的命中/未命中动态调整:

这种自适应机制使 CLOCK-Pro 在不同工作负载下都能保持良好性能。


七、ARC:在近因与频率之间自适应

7.1 概述

ARC(Adaptive Replacement Cache)由 Nimrod Megiddo 和 Dharmendra Modha 在 2003 年提出。我们在第 52 篇:页面置换算法中已经详细介绍过 ARC 的设计。这里做简要回顾并与本文其他算法对比。

7.2 结构

ARC 维护四个列表:

T1 (LRU)   : 最近只访问过一次的页面(近因优先)
T2 (LFU)   : 最近访问过多次的页面(频率优先)
B1 (ghost)  : T1 淘汰出去的页面 ID
B2 (ghost)  : T2 淘汰出去的页面 ID

缓冲池被 T1 和 T2 共享,总大小为 N。自适应参数 p 控制 T1 和 T2 的分界点:

7.3 ARC 与 CLOCK-Pro 的对比

维度 ARC CLOCK-Pro
数据结构 4 个链表 环形缓冲区+3 指针
访问开销 O(1) 但需移动链表 O(1) 位操作
锁竞争 高(链表操作) 低(CAS 操作)
自适应 p 参数连续调整 m_cold 离散调整
专利 IBM 专利(已过期)
扫描抗性
适用场景 中等并发 高并发

ARC 在理论上有更优雅的自适应机制,但 CLOCK-Pro 在多核高并发场景下的实际吞吐更高。


八、C 语言实现

8.1 LRU-K 完整实现

以下是一个完整的 LRU-2 实现,包含关联引用期处理:

/* lru_k.c -- LRU-K (K=2) buffer pool replacement */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <stdbool.h>

#define HASH_SIZE      4096
#define HIST_HASH_SIZE 8192
#define CRP_WINDOW     5       /* correlated reference period */

typedef unsigned int page_id_t;
typedef unsigned long long timestamp_t;

/* --- history record for pages (including evicted) --- */
typedef struct HistEntry {
    page_id_t           pid;
    timestamp_t         last[2];   /* last[0]=most recent, last[1]=2nd recent */
    int                 count;     /* total independent accesses */
    struct HistEntry   *hash_next;
} HistEntry;

/* --- buffer pool page --- */
typedef struct BufPage {
    page_id_t           pid;
    bool                valid;
    bool                dirty;
    HistEntry          *hist;
    struct BufPage     *hash_next;
} BufPage;

/* --- LRU-K buffer pool manager --- */
typedef struct LRUKPool {
    int          capacity;
    int          used;
    timestamp_t  clock;

    BufPage     *pages;                   /* page array */
    BufPage     *page_ht[HASH_SIZE];      /* page hash table */

    HistEntry   *hist_ht[HIST_HASH_SIZE]; /* history hash table */
    int          hist_count;
    int          hist_max;                /* max retained history entries */
} LRUKPool;

/* --- hash helpers --- */
static unsigned int page_hash(page_id_t pid, int sz) {
    return (pid * 2654435761u) % (unsigned int)sz;
}

static HistEntry *hist_lookup(LRUKPool *pool, page_id_t pid) {
    unsigned int h = page_hash(pid, HIST_HASH_SIZE);
    for (HistEntry *e = pool->hist_ht[h]; e; e = e->hash_next)
        if (e->pid == pid) return e;
    return NULL;
}

static HistEntry *hist_get_or_create(LRUKPool *pool, page_id_t pid) {
    HistEntry *e = hist_lookup(pool, pid);
    if (e) return e;
    e = (HistEntry *)calloc(1, sizeof(HistEntry));
    e->pid = pid;
    unsigned int h = page_hash(pid, HIST_HASH_SIZE);
    e->hash_next = pool->hist_ht[h];
    pool->hist_ht[h] = e;
    pool->hist_count++;
    return e;
}

static void hist_record_access(HistEntry *e, timestamp_t now) {
    /* enforce correlated reference period */
    if (e->count > 0 && (now - e->last[0]) <= CRP_WINDOW)
        return;   /* within CRP, do not count as independent access */
    e->last[1] = e->last[0];
    e->last[0] = now;
    e->count++;
}

/* backward 2-distance: larger = colder */
static timestamp_t backward_k_dist(HistEntry *e, timestamp_t now) {
    if (!e || e->count < 2)
        return ULLONG_MAX;   /* fewer than K accesses */
    return now - e->last[1];
}

/* --- page hash table helpers --- */
static BufPage *page_lookup(LRUKPool *pool, page_id_t pid) {
    unsigned int h = page_hash(pid, HASH_SIZE);
    for (BufPage *p = pool->page_ht[h]; p; p = p->hash_next)
        if (p->valid && p->pid == pid) return p;
    return NULL;
}

static void page_ht_insert(LRUKPool *pool, BufPage *pg) {
    unsigned int h = page_hash(pg->pid, HASH_SIZE);
    pg->hash_next = pool->page_ht[h];
    pool->page_ht[h] = pg;
}

static void page_ht_remove(LRUKPool *pool, BufPage *pg) {
    unsigned int h = page_hash(pg->pid, HASH_SIZE);
    BufPage **pp = &pool->page_ht[h];
    while (*pp) {
        if (*pp == pg) { *pp = pg->hash_next; return; }
        pp = &(*pp)->hash_next;
    }
}

/* --- find victim using LRU-K policy --- */
static int find_victim(LRUKPool *pool) {
    int victim = -1;
    timestamp_t max_dist = 0;
    timestamp_t max_first = 0;

    for (int i = 0; i < pool->capacity; i++) {
        if (!pool->pages[i].valid) continue;
        HistEntry *e = pool->pages[i].hist;
        timestamp_t d = backward_k_dist(e, pool->clock);

        if (d == ULLONG_MAX) {
            /* fewer than K accesses: prefer the earliest first-access */
            timestamp_t first = e ? e->last[0] : 0;
            if (victim == -1 || max_dist != ULLONG_MAX ||
                first < max_first) {
                victim = i;
                max_dist = ULLONG_MAX;
                max_first = first;
            }
        } else if (max_dist != ULLONG_MAX && d > max_dist) {
            victim = i;
            max_dist = d;
        }
    }
    return victim;
}

/* --- public API --- */
LRUKPool *lruk_create(int capacity) {
    LRUKPool *pool = (LRUKPool *)calloc(1, sizeof(LRUKPool));
    pool->capacity = capacity;
    pool->pages = (BufPage *)calloc(capacity, sizeof(BufPage));
    pool->hist_max = capacity * 4;
    return pool;
}

/* returns true on hit, false on miss */
bool lruk_access(LRUKPool *pool, page_id_t pid) {
    pool->clock++;
    HistEntry *hist = hist_get_or_create(pool, pid);

    BufPage *pg = page_lookup(pool, pid);
    if (pg) {
        /* hit */
        hist_record_access(hist, pool->clock);
        return true;
    }

    /* miss: need a free slot or evict */
    int slot = -1;
    if (pool->used < pool->capacity) {
        for (int i = 0; i < pool->capacity; i++) {
            if (!pool->pages[i].valid) { slot = i; break; }
        }
    } else {
        slot = find_victim(pool);
        if (slot >= 0) {
            page_ht_remove(pool, &pool->pages[slot]);
            pool->pages[slot].valid = false;
            pool->used--;
        }
    }

    if (slot < 0) return false; /* should not happen */

    pool->pages[slot].pid   = pid;
    pool->pages[slot].valid = true;
    pool->pages[slot].dirty = false;
    pool->pages[slot].hist  = hist;
    page_ht_insert(pool, &pool->pages[slot]);
    pool->used++;

    hist_record_access(hist, pool->clock);
    return false;
}

void lruk_destroy(LRUKPool *pool) {
    for (int i = 0; i < HIST_HASH_SIZE; i++) {
        HistEntry *e = pool->hist_ht[i];
        while (e) { HistEntry *next = e->hash_next; free(e); e = next; }
    }
    free(pool->pages);
    free(pool);
}

8.2 CLOCK-Pro 完整实现

/* clock_pro.c -- CLOCK-Pro page replacement */
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>

#define HASH_SIZE 4096

typedef unsigned int page_id_t;

typedef enum { PG_HOT, PG_COLD, PG_TEST } PageStatus;

typedef struct ClockEntry {
    page_id_t        pid;
    PageStatus       status;
    bool             ref;        /* reference bit */
    bool             in_cache;   /* has actual data (not test-only) */
    struct ClockEntry *next;     /* circular list */
    struct ClockEntry *prev;
    struct ClockEntry *hash_next;
} ClockEntry;

typedef struct ClockProPool {
    int           capacity;     /* max resident (data-bearing) pages */
    int           resident;     /* current resident count */
    int           cold_target;  /* target number of cold pages */
    int           cold_count;
    int           hot_count;
    int           test_count;
    int           test_limit;

    ClockEntry    sentinel;     /* dummy head of circular list */
    ClockEntry   *hand_hot;
    ClockEntry   *hand_cold;
    ClockEntry   *hand_test;

    ClockEntry   *ht[HASH_SIZE];
} ClockProPool;

/* --- circular list operations --- */
static void clist_init(ClockEntry *s) {
    s->next = s->prev = s;
}

static void clist_insert_before(ClockEntry *pos, ClockEntry *entry) {
    entry->prev = pos->prev;
    entry->next = pos;
    pos->prev->next = entry;
    pos->prev = entry;
}

static void clist_remove(ClockEntry *entry) {
    entry->prev->next = entry->next;
    entry->next->prev = entry->prev;
}

static bool clist_empty(ClockEntry *s) {
    return s->next == s;
}

/* --- hash table --- */
static unsigned int pg_hash(page_id_t pid) {
    return (pid * 2654435761u) % HASH_SIZE;
}

static ClockEntry *ht_lookup(ClockProPool *pool, page_id_t pid) {
    unsigned int h = pg_hash(pid);
    for (ClockEntry *e = pool->ht[h]; e; e = e->hash_next)
        if (e->pid == pid) return e;
    return NULL;
}

static void ht_insert(ClockProPool *pool, ClockEntry *e) {
    unsigned int h = pg_hash(e->pid);
    e->hash_next = pool->ht[h];
    pool->ht[h] = e;
}

static void ht_remove(ClockProPool *pool, ClockEntry *e) {
    unsigned int h = pg_hash(e->pid);
    ClockEntry **pp = &pool->ht[h];
    while (*pp) {
        if (*pp == e) { *pp = e->hash_next; return; }
        pp = &(*pp)->hash_next;
    }
}

/* --- advance hand, skip sentinel --- */
static ClockEntry *advance(ClockProPool *pool, ClockEntry *hand) {
    hand = hand->next;
    if (hand == &pool->sentinel) hand = hand->next;
    return hand;
}

/* --- run HAND-test: reclaim expired test entries --- */
static void run_hand_test(ClockProPool *pool) {
    while (pool->test_count > pool->test_limit) {
        if (clist_empty(&pool->sentinel)) return;
        ClockEntry *e = pool->hand_test;
        if (e == &pool->sentinel) {
            pool->hand_test = advance(pool, pool->hand_test);
            continue;
        }
        pool->hand_test = advance(pool, pool->hand_test);
        if (e->status == PG_TEST) {
            clist_remove(e);
            ht_remove(pool, e);
            pool->test_count--;
            free(e);
        }
    }
}

/* --- run HAND-hot: demote cold hot pages --- */
static void run_hand_hot(ClockProPool *pool) {
    int steps = 0;
    while (steps < pool->capacity * 2) {
        ClockEntry *e = pool->hand_hot;
        if (e == &pool->sentinel) {
            pool->hand_hot = advance(pool, pool->hand_hot);
            steps++;
            continue;
        }
        if (e->status != PG_HOT) {
            pool->hand_hot = advance(pool, pool->hand_hot);
            steps++;
            continue;
        }
        if (e->ref) {
            e->ref = false;
            pool->hand_hot = advance(pool, pool->hand_hot);
            steps++;
            continue;
        }
        /* demote to cold */
        e->status = PG_COLD;
        pool->hot_count--;
        pool->cold_count++;
        pool->hand_hot = advance(pool, pool->hand_hot);
        return;
    }
}

/* --- run HAND-cold: evict cold pages --- */
static void evict_one(ClockProPool *pool) {
    int steps = 0;
    while (steps < pool->capacity * 2) {
        ClockEntry *e = pool->hand_cold;
        if (e == &pool->sentinel) {
            pool->hand_cold = advance(pool, pool->hand_cold);
            steps++;
            continue;
        }
        if (e->status == PG_COLD && e->in_cache) {
            if (!e->ref) {
                /* evict: turn into test entry */
                pool->hand_cold = advance(pool, pool->hand_cold);
                e->in_cache = false;
                e->status = PG_TEST;
                pool->cold_count--;
                pool->test_count++;
                pool->resident--;
                run_hand_test(pool);
                return;
            }
            /* ref=1: promote to hot */
            e->ref = false;
            e->status = PG_HOT;
            pool->cold_count--;
            pool->hot_count++;
            /* maintain hot/cold balance */
            if (pool->hot_count > pool->capacity - pool->cold_target)
                run_hand_hot(pool);
        }
        pool->hand_cold = advance(pool, pool->hand_cold);
        steps++;
    }
}

/* --- public API --- */
ClockProPool *clockpro_create(int capacity) {
    ClockProPool *pool = (ClockProPool *)calloc(1, sizeof(ClockProPool));
    pool->capacity    = capacity;
    pool->cold_target = capacity / 4;
    pool->test_limit  = capacity;
    clist_init(&pool->sentinel);
    pool->hand_hot  = &pool->sentinel;
    pool->hand_cold = &pool->sentinel;
    pool->hand_test = &pool->sentinel;
    return pool;
}

bool clockpro_access(ClockProPool *pool, page_id_t pid) {
    ClockEntry *e = ht_lookup(pool, pid);

    if (e && e->in_cache) {
        /* hit */
        e->ref = true;
        return true;
    }

    if (e && e->status == PG_TEST) {
        /* test hit: page was recently evicted, promote to hot */
        if (pool->cold_target > 1)
            pool->cold_target--;

        /* need room */
        while (pool->resident >= pool->capacity)
            evict_one(pool);

        clist_remove(e);
        pool->test_count--;
        e->status   = PG_HOT;
        e->in_cache = true;
        e->ref      = false;
        clist_insert_before(pool->hand_hot, e);
        pool->hot_count++;
        pool->resident++;
        return false;
    }

    /* cold miss: brand new page */
    while (pool->resident >= pool->capacity)
        evict_one(pool);

    e = (ClockEntry *)calloc(1, sizeof(ClockEntry));
    e->pid      = pid;
    e->status   = PG_COLD;
    e->ref      = false;
    e->in_cache = true;
    ht_insert(pool, e);
    clist_insert_before(pool->hand_cold, e);
    pool->cold_count++;
    pool->resident++;

    if (pool->cold_target < pool->capacity - 1)
        pool->cold_target++;

    return false;
}

void clockpro_destroy(ClockProPool *pool) {
    ClockEntry *e = pool->sentinel.next;
    while (e != &pool->sentinel) {
        ClockEntry *next = e->next;
        free(e);
        e = next;
    }
    free(pool);
}

九、InnoDB 缓冲池实现

9.1 架构概述

MySQL InnoDB 的缓冲池是整个存储引擎最核心的数据结构。默认情况下它会占用服务器大部分可用内存(推荐配置为物理内存的 70%-80%)。

innodb_buffer_pool_size = 128G
innodb_buffer_pool_instances = 16    /* 减少锁竞争 */

9.2 Young/Old 分区

InnoDB 使用的是一种 改良的 LRU 算法,本质上和 2Q 非常相似:

LRU 链表:
  [young 区域 (5/8)]  |  [old 区域 (3/8)]
                      ^
              midpoint insertion

9.3 make_young 优化

为了减少 young 区域的链表操作开销,InnoDB 还有一个 make_young_threshold 优化:

/*
 * 页面只有处于 young 区域后 1/4 之后的位置时,
 * 再次访问才会被移到链表头部。
 * 位于前 1/4 的页面即使被访问也不移动。
 * 这大幅减少了链表 mutex 的争用。
 */
if (page_is_in_young_region(page) &&
    page->LRU_position < young_length / 4) {
    /* do NOT move to head, already hot enough */
    return;
}
buf_LRU_make_block_young(page);

9.4 预读与预热

InnoDB 支持两种预读策略:

策略 触发条件 说明
线性预读 连续访问同一个 extent 的 56 个页面 将下一个 extent 异步读入
随机预读 同一个 extent 中 13 个页面在缓冲池中 将该 extent 剩余页面读入

预读的页面同样插入 old 区域,避免预测失败时污染缓冲池。

9.5 多实例分区

innodb_buffer_pool_instances > 1 时,InnoDB 会创建多个独立的缓冲池实例,每个页面根据 page_id % instances 分配到特定实例。这避免了单一 LRU 链表的全局锁竞争。

[Instance 0]  [Instance 1]  ...  [Instance 15]
  LRU list      LRU list            LRU list
  free list     free list           free list
  flush list    flush list          flush list
     |              |                   |
   mutex          mutex               mutex

十、PostgreSQL 缓冲管理器

10.1 Clock Sweep

PostgreSQL 的缓冲管理器使用 Clock Sweep 算法,本质上是带引用计数的 CLOCK 变体:

/*
 * PostgreSQL buffer replacement (simplified)
 *
 * Each buffer has a usage_count (0..5), not just a single bit.
 * Clock hand sweeps the shared buffer array.
 * On access: usage_count = min(usage_count + 1, 5)
 * On sweep: if usage_count > 0, decrement; else evict.
 */
static BufferDesc *clock_sweep_victim(void) {
    for (;;) {
        BufferDesc *buf = &BufferDescriptors[next_victim];
        next_victim = (next_victim + 1) % NBuffers;

        uint32 state = pg_atomic_read_u32(&buf->state);
        int usage = BUF_STATE_GET_USAGECOUNT(state);

        if (usage == 0) {
            /* found victim */
            return buf;
        }
        /* decrement usage count */
        pg_atomic_fetch_sub_u32(&buf->state, BUF_USAGECOUNT_ONE);
    }
}

usage_count 最大值为 5,相当于给热页面更多的”生命值”,比单个 ref 位更能区分冷热程度。

10.2 shared_buffers 配置

shared_buffers = 32GB          # 推荐物理内存的 25%(因为还要留给 OS page cache)
effective_cache_size = 96GB    # 告诉查询优化器可用的缓存总量(含 OS cache)

PostgreSQL 与 InnoDB 的一个重要区别是:PostgreSQL 不使用 O_DIRECT,它依赖操作系统的 page cache 作为第二层缓存。因此 shared_buffers 不需要设得像 InnoDB 那么大。

10.3 Ring Buffer:顺序扫描保护

PostgreSQL 对大规模顺序扫描有专门的保护机制——Ring Buffer

当检测到顺序扫描时:
  分配一个小的 ring buffer(通常 256 KB = 32 个页面)
  扫描只在这个 ring buffer 内循环使用页面
  不会进入 shared_buffers 的主缓冲池

这从根本上解决了全表扫描污染问题。与 InnoDB 的 midpoint insertion 不同,PostgreSQL 的方案更加激进:扫描页面根本不进入主缓冲池。

以下场景会使用 ring buffer:

场景 Ring Buffer 大小 说明
Sequential Scan 256 KB 全表扫描
VACUUM 256 KB 垃圾回收
Bulk Write 16 MB COPY、CREATE TABLE AS

10.4 Buffer Tag 与 Buffer Table

PostgreSQL 通过一个全局哈希表将 (tablespace, database, relation, fork, block) 五元组映射到 buffer ID:

typedef struct BufferTag {
    RelFileLocator  rlocator;   /* tablespace + database + relation */
    ForkNumber      forkNum;    /* main / fsm / vm */
    BlockNumber     blockNum;
} BufferTag;

哈希表使用分区锁(128 个分区),极大降低了查找时的锁竞争。


十一、基准测试:不同工作负载下的命中率对比

11.1 测试框架

我们构建了一个简化的缓冲池模拟器,在三种典型工作负载下对比各算法的命中率。缓冲池大小固定为 1000 个页面,总页面空间 10000 个页面,访问序列长度 100 万次。

11.2 工作负载定义

Zipfian (alpha=0.99):
  模拟 OLTP 热点查询,少数页面被频繁访问
  前 1% 页面承担约 30% 访问

Sequential Scan:
  先正常 Zipfian 访问 50 万次
  然后连续扫描 10000 个不同页面各一次
  最后再 Zipfian 访问 50 万次
  衡量从扫描污染中恢复的能力

Mixed:
  80% Zipfian + 20% 均匀随机
  模拟 OLTP + 后台分析混合负载

11.3 命中率结果

+---------------+----------+----------+---------+
| Algorithm     | Zipfian  | Scan     | Mixed   |
+===============+==========+==========+=========+
| LRU           | 62.3%    | 38.1%    | 55.7%   |
| LRU-2         | 71.8%    | 68.5%    | 65.2%   |
| 2Q            | 70.9%    | 67.8%    | 64.5%   |
| CLOCK         | 61.5%    | 37.4%    | 54.8%   |
| CLOCK-Pro     | 71.2%    | 69.1%    | 65.8%   |
| ARC           | 72.1%    | 70.3%    | 66.4%   |
| OPT (理论上界) | 78.6%    | 76.2%    | 73.1%   |
+---------------+----------+----------+---------+

11.4 分析

Zipfian 工作负载

所有高级算法(LRU-2、2Q、CLOCK-Pro、ARC)的表现接近,都显著优于朴素 LRU/CLOCK。这是因为 Zipfian 分布下热页面访问频率高,K-距离和频率信息都能有效区分冷热。

Scan 工作负载

这是最能拉开差距的场景。LRU 和 CLOCK 在扫描期间命中率几乎跌至零,且恢复缓慢。而 LRU-2、2Q、CLOCK-Pro 和 ARC 都能快速恢复——因为扫描页面只被访问一次,不会进入”热区”。

命中率随时间变化(Scan 工作负载,示意):

  80% |--..                          ..-----------
      |    '-.    LRU-2/CLOCK-Pro  .'
  60% |       \                   /
      |        \                 /
  40% |         '-.    LRU   .-'
      |            '-------'
  20% |              scan
      |              period
   0% +-------+--------+--------+--------+------>
      0      25万     50万      75万     100万

Mixed 工作负载

ARC 表现最好,因为其自适应参数 p 能在近因和频率之间动态平衡。CLOCK-Pro 紧随其后。LRU-2 和 2Q 的表现也远优于基础算法。

11.5 性能开销对比

命中率只是一个维度,实际系统还需要考虑算法本身的 CPU 和锁开销:

+---------------+----------+----------+----------+
| Algorithm     | Access   | Evict    | Lock     |
|               | Cost     | Cost     | Contention|
+===============+==========+==========+==========+
| LRU           | O(1)     | O(1)     | High     |
| LRU-2         | O(1)     | O(N)*    | Medium   |
| 2Q            | O(1)     | O(1)     | Medium   |
| CLOCK         | O(1)     | O(N)**   | Low      |
| CLOCK-Pro     | O(1)     | O(N)**   | Low      |
| ARC           | O(1)     | O(1)     | High     |
+---------------+----------+----------+----------+

*  可用优先队列优化为 O(log N)
** 均摊 O(1),最坏 O(N)

综合命中率和开销,CLOCK-Pro 是多核数据库系统中最均衡的选择:命中率接近 ARC,锁开销远低于 ARC。


十二、工程经验与个人观点

12.1 工程陷阱速查表

陷阱 症状 建议
缓冲池配置过大 OS swap 导致性能断崖 InnoDB 不超过物理内存 80%
缓冲池配置过小 高 I/O wait,命中率 < 95% 监控 Innodb_buffer_pool_read_requests vs Innodb_buffer_pool_reads
单实例瓶颈 buf_pool->mutex 成为热点 innodb_buffer_pool_instances >= 8
未关闭随机预读 预读页面浪费缓冲池空间 innodb_random_read_ahead = OFF
PostgreSQL shared_buffers 过大 double buffering 浪费内存 不超过物理内存 25%
忽略 dirty page 比例 checkpoint 风暴导致延迟毛刺 控制 innodb_max_dirty_pages_pct
CLOCK 计数器上限过高 冷页面淘汰延迟 PostgreSQL 限制 usage_count <= 5
没有顺序扫描保护 一次 SELECT * 打爆缓冲池 确认 ring buffer 或 midpoint 生效
热点页面 pin 不释放 缓冲池”泄漏”,可用页面逐渐减少 审计 pin/unpin 配对,添加超时机制
忽视 NUMA 亲和性 跨 NUMA 节点访问延迟翻倍 按 NUMA node 分区缓冲池
在线 resize 不当 短暂的命中率下降 低峰期操作,分批调整
忽略 AIO 与缓冲池的交互 异步 I/O 回调中竞争缓冲池锁 使用 completion queue 解耦

12.2 如何选择算法

                  扫描抗性需要?
                     |
              +------+------+
              |             |
             Yes           No
              |             |
     高并发需要?         LRU 就够了
         |
    +----+----+
    |         |
   Yes       No
    |         |
CLOCK-Pro   2Q / ARC

12.3 监控指标

任何缓冲池管理方案都需要配套的监控。核心指标包括:

-- InnoDB: 命中率
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';

-- 计算命中率
-- hit_rate = 1 - (reads / read_requests)
-- 健康值: > 99% (OLTP), > 95% (混合负载)

-- PostgreSQL: 命中率
SELECT
    datname,
    blks_hit::float / (blks_hit + blks_read) AS hit_rate
FROM pg_stat_database
WHERE blks_hit + blks_read > 0;

12.4 个人看法

经过多年数据库内核开发和性能调优的经验,我有以下几点体会:

关于算法选择:理论上最优的算法未必是工程上的最佳选择。ARC 的命中率确实略高于 CLOCK-Pro,但其链表操作在 128 核机器上的 mutex 竞争会抵消命中率带来的收益。工程中锁的开销往往比算法本身的 miss penalty 更值得关注。

关于参数调优:与其花大量时间调优缓冲池算法的内部参数(如 2Q 的 Kin/Kout 比例),不如把精力放在两件事上:(1)给缓冲池分配足够的内存;(2)确保扫描保护机制生效。80% 的性能问题都可以通过这两点解决。

关于自适应:CLOCK-Pro 和 ARC 的自适应机制是真正有价值的创新。它们让系统不需要 DBA 手动调参就能适应变化的工作负载。如果我今天从零开始设计一个数据库的缓冲池,CLOCK-Pro 会是我的首选。

关于新硬件:随着 CXL 内存、持久内存等新技术的出现,缓冲池的角色正在演变。但”区分冷热数据、优先保留热数据”这一核心原则不会改变。即使未来的存储层次更加复杂,本文讨论的这些算法思想仍然是基础。

关于学习路径:如果你只能深入理解一个算法,选 CLOCK-Pro。它融合了 LIRS 的洞察(用 reuse distance 而非 recency 做决策)、CLOCK 的低开销(位操作代替链表操作)和自适应调节。理解了 CLOCK-Pro,其他算法都能很快融会贯通。


附录:算法演进与参考文献

算法演进时间线:1966 CLOCK -> 1993 LRU-K -> 1994 2Q -> 2002 LIRS -> 2003 ARC -> 2005 CLOCK-Pro -> 2010+ 各数据库工程化变体(InnoDB midpoint insertion、PostgreSQL clock sweep + ring buffer)。

参考文献

  1. E. O’Neil, P. O’Neil, G. Weikum. “The LRU-K Page Replacement Algorithm for Database Disk Buffering.” ACM SIGMOD, 1993.
  2. T. Johnson, D. Shasha. “2Q: A Low Overhead High Performance Buffer Management Replacement Algorithm.” VLDB, 1994.
  3. S. Jiang, X. Zhang. “LIRS: An Efficient Low Inter-reference Recency Set Replacement Policy to Improve Buffer Cache Performance.” ACM SIGMETRICS, 2002.
  4. N. Megiddo, D. Modha. “ARC: A Self-Tuning, Low Overhead Replacement Cache.” USENIX FAST, 2003.
  5. S. Jiang, F. Chen, X. Zhang. “CLOCK-Pro: An Effective Improvement of the CLOCK Replacement.” USENIX ATC, 2005.
  6. MySQL 8.0 Reference Manual, “InnoDB Buffer Pool.”
  7. PostgreSQL Documentation, “Buffer Manager Internals.”

上一篇: 查询优化器 下一篇: WAL 与 ARIES 恢复算法

相关阅读: - 页面置换算法 - 内存分配器


By .