在SQL数据库的运行过程中,缓冲池(buffer pool)是减少磁盘IO的核心组件。当缓冲池满时,数据库必须决定哪一页该被换出,这个决策由缓存淘汰算法完成。传统LRU看似合理,却在真实业务负载下暴露出明显短板,因此各大数据库都做了针对性改造。

一、传统LRU在SQL缓存中的失效原因
经典LRU维护一个双向链表,每次访问将页移到表头,淘汰表尾。这套逻辑在应用层缓存很好用,但数据库面临的是混合负载:既有主键点查,也有全表扫描、备份导出、统计报表。一次全表扫描会连续访问大量只使用一次的页,它们频繁被提到链表头,把真正的高频热页挤到末尾遭淘汰。
另一个问题是预读(read-ahead)。数据库常根据局部性提前读入相邻页,这些页刚进链表就被访问一次,LRU误以为它们是热数据。等真正需要旧热页时,缓冲池命中率骤降,磁盘IO飙升。下面是一段简化版错误LRU实现,它没有任何防护机制:
class NaiveLRU:
def __init__(self, capacity):
self.cap = capacity
self.cache = {}
self.order = []
def access(self, key):
if key in self.cache:
self.order.remove(key)
else:
if len(self.order) >= self.cap:
old = self.order.pop()
del self.cache[old]
self.cache[key] = 1
self.order.insert(0, key)
# 一次全表扫描调用access连续传入大量一次性key
# 会导致真正常用的key被挤出order尾部
上述代码在数据库场景下的缺陷是致命的:没有区分“偶然访问”和“持续访问”。当扫描百万级冷数据,order里全是冷key,原先热点全部丢失。这也是为什么不能直接把应用缓存的LRU搬进SQL引擎。
二、InnoDB的分代LRU改进
MySQL InnoDB采用一种叫“分区LRU”的策略。它将链表分为新生代(young)和老年代(old)两部分,默认旧区占3/8。新读入的页先放进old区头部,只有当它在old区停留超过一定时长(innodb_old_blocks_time,默认1秒)且再次被访问,才晋升到young区。这样全表扫描的页大多死在old区,不会污染young区的核心热数据。
该设计还配合刷脏解耦:淘汰的是干净页直接丢弃,脏页则先刷盘再复用,避免淘汰引发同步写抖动。以下伪代码展示分区逻辑:
// InnoDB简化淘汰逻辑
if (page_is_newly_loaded(p)) {
// 放入old区头
list_prepend(old_list, p);
} else if (in_old_list(p) && time_since_load(p) > old_blocks_time) {
// 满足停留时间且再访问,晋升
list_move_to_young(p);
} else {
// 已在young区,移到young头
list_move_to_head(young_list, p);
}
// 淘汰时从old区尾取
victim = list_tail(old_list);
分代LRU的优点是改动小、对扫描类负载鲁棒,缺点是参数(old区比例、停留时间)需按业务调优,且仍可能把短期突发热页误判为冷页。在混合读写尤其读多写少场景,它显著提升了命中率。
三、基于频率与容量的LRFU与TinyLFU思路
除了分代,另一种方向是同时考虑“最近”和“最频”。LRFU给每个页算一个衰减价值,访问越近、次数越多分值越高。TinyLFU则用极小的计数 sketch 记录频率,结合窗口LRU接纳新页,只让高频项进入主缓存。对SQL而言,这意味着长尾低频的报表页很难挤掉交易热页。
下面用Python模拟一个最简TinyLFU准入判断:
class TinyLFU:
def __init__(self, window=100, main=900):
self.win = {}
self.main = {}
self.freq = {}
def _inc(self, k):
self.freq[k] = self.freq.get(k, 0) + 1
def get(self, k):
if k in self.main:
self._inc(k)
return self.main[k]
if k in self.win:
self._inc(k)
# 频率够高才晋升
if self.freq[k] >= 2:
self.main[k] = self.win.pop(k)
return self.win.get(k)
return None
def put(self, k, v):
self.win[k] = v
self._inc(k)
if len(self.win) > 100:
# 简单淘汰window最旧
old = next(iter(self.win))
del self.win[old]
这类算法在应对“扫描加突发”的复合负载时比纯LRU更稳,但实现复杂、需额外内存存频率。SQL数据库若引入,通常只在特定缓存(如计划缓存、二级缓存)中使用,缓冲池仍以分代LRU为主。
四、如何选择与调优
实际生产中,如果你用的是MySQL或PostgreSQL,优先理解并调好分代LRU相关参数,例如innodb_old_blocks_pct与innodb_old_blocks_time。对分析型副本可拉长old_blocks_time避免报表污染;对交易库可缩小old区。若自研缓存层,评估TinyLFU类方案能降低长尾影响。
总之,SQL缓存淘汰不是简单“最近最少”,而是要在扫描抵抗、频率识别、刷盘开销间权衡。弄清负载特征,再选改进策略,比盲目扩内存更有效。
SQL_cacheLRU_algorithmbuffer_pool修改时间:2026-08-11 02:15:31