ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

手写KV缓存:从LRU淘汰到并发控制的完整实践

手写KV缓存:从LRU淘汰到并发控制的完整实践 KV缓存这四个字看着简单真正动手写一遍你会在并发竞争、内存边界和淘汰策略三个方向上陆续翻车。前阵子我们订单查询接口高峰期把MySQL连接池直接打满慢查询一屏一屏往外冒我第一反应是先接Redis把热点挡掉。结果上线以后数据库压力确实下来了但接口P99的降幅远没有预期那么大——原因不复杂请求虽然不再穿透数据库但每次多了一次网络往返、一次序列化和反序列化在业务逻辑本身已经非常轻的接口里这笔开销反而变成了大头。也就是从那天起我开始认真规划在进程内实现一个KV缓存后来干脆把它做成了一个带TTL过期、带LRU淘汰、带并发控制的小组件。这篇文章就是我这件事的完整记录包含骨架设计、淘汰策略选型、并发踩坑、完整代码和性能观察适合想搞懂缓存底层逻辑的同学也适合正在纠结“要不要自己写缓存”的后端工程师。1. 为什么放着Caffeine不用要自己造一个KV缓存1.1 数据库被打满那天我决定不先接Redis先说当时的场景。服务是一个订单查询接口典型的读多写少热点集中在少部分高频用户上。高峰期QPS一上来MySQL连接池立刻被打满接口P99从正常的50ms飙到800ms数据库端慢查询日志刷得飞快。我当时的第一反应和大多数人一样上Redis把订单详情序列化以后丢进去。第一版确实解决了数据库的压力但我们上线后重新看了接口耗时分布发现P99还是有300多毫秒这不对劲。进一步拆分以后才看清楚Redis部署在另一组机器上跨可用区RTT就0.3ms到0.5ms再加上JSON序列化和反序列化每次读缓存整体耗时在1ms上下。如果接口自身业务逻辑只需要2ms到3ms那这笔远程缓存开销占比超过三成接口延迟自然压不下来。这个案例让我意识到一个问题远程缓存擅长的是容量和共享但在极端的单次热路径上本地内存才是真正零成本的那一层。于是我开始在进程内自己造一个KV缓存把最热的那批数据直接塞进堆内存读一次只有几十纳秒到几百纳秒路径短、开销几乎可以忽略。1.2 本地缓存和Redis的定位不冲突很多后端同学有个误区觉得“有了Redis就不需要本地缓存”其实这两者是不同层级的组件定位完全不冲突。我后来的实现方案是这样的本地KV缓存作为一级缓存Redis作为二级缓存MySQL在最底层。请求到达时先查本地本地没有再看Redis再没有才回源数据库。能放一级缓存的数据有几个特点读频率极高、数据更新不频繁、即使短暂读到旧值业务也能接受、单机内存装得下。在我们订单查询的场景里热点订单大概几千个每个订单序列化后几KB本地放几万个条目毫无压力。而那些需要跨实例强一致的数据比如库存、支付状态绝对不放进本地缓存只走Redis防止用户在多台机器上读到不一致的结果。1.3 动手之前必须想清楚的四件事自己实现一个KV缓存之前我建议先回答四个问题否则后面很容易白干。第一是数据量级。单机是几千条还是几百万条直接决定了内存上限设计和淘汰策略选择。几千条用FIFO都能凑合几百万条就必须认真考虑LRU和字节级内存控制。第二是读写比例。读多写少的场景才是缓存的主场。如果写入频率和读取频率差不多缓存的价值会大打折扣而且你还会被缓存和数据库的一致性问题折磨。第三是数据一致性容忍度。自己写本地缓存意味着没有中心节点协调各个实例之间天然不一致。业务方愿不愿意接受“更新后最多几秒钟内还是旧值”必须提前确认。第四是团队有没有人维护。自研组件不是写完就完了后续的容量规划、问题排查、性能调优都要有人负责。如果团队很小、没人愿意长期维护那老老实实用Caffeine才是对项目负责。想清楚这四件事再决定要不要亲自动手。2. 先把缓存骨架搭对哈希表、TTL过期与内存边界2.1 哈希表KV缓存的天然底座KV缓存的本质就是一个字典映射给定key能在尽量短的时间内找到value。哈希表是天然的底座结构平均时间复杂度O(1)读写都非常快而且实现逻辑足够简单不容易引入隐藏bug。有同学会问为什么不用跳表或者B树因为KV缓存的核心操作就是单点读写没有范围查询需求。跳表和B树的优势在于有序性和区间扫描KV缓存用不上反而会因为更复杂的结构和更高的维护成本拖累性能。哈希表的哈希冲突、扩容机制这些基础能力JDK的HashMap已经实现得很成熟自研时直接复用就好。不过在并发场景下直接裸用HashMap是不行的这点我会在第四部分重点讲。骨架阶段先把基础数据结构定下来一个哈希表负责O(1)定位一个链表或者其它顺序结构负责处理淘汰顺序。2.2 TTL过期惰性删除兜底主动扫描收尾缓存数据不能永久有效否则数据库里的数据更新了缓存里永远是旧值。TTL过期是最常见的解决方案实现上有两种互补的思路。惰性删除是最简单的get的时候检查当前时间是否超过过期时间点如果超过了就删除并返回null。这个方案的优点是实现成本低没有额外的线程扫描读取不频繁的冷数据即使过期了也不会带来额外开销。但它有一个致命缺点如果一条数据过期后永远没有人来get它就永远不会被删除会一直在内存里占坑。大量这类冷数据堆积起来内存会被慢慢蚕食。主动扫描就是来补这个漏洞的。实现上可以每5分钟遍历一次所有条目把过期数据清理掉数据量大的时候可以像Redis那样做随机采样每次抽一批出来检查。我自己实现的做法是惰性删除为主、主动扫描兜底每轮扫描只需要把过期条目从哈希表和链表里摘掉开销不大但避免了内存无限被花呗式透支。2.3 内存上限比“存多少条”更重要的是“存多大”设计缓存容量时第一反应往往是限制“最多存多少条”。maxEntries的实现代价最低超了就直接触发淘汰。但只限制条数会漏掉一个更危险的东西大value。我实际遇到过这个问题。某次业务往value里塞了一个包含图片base64的字段单条缓存对象有2MB左右。当时缓存有10万条满打满算直接把老年代占掉一大部分Full GC频率肉眼可见地升高接口延迟也跟着抖动。那次排障以后我在缓存里加了字节级的容量估算写入的时候粗略计算key字节数、value字节数再加上固定对象头开销累加到全局计数器超过maxBytes以后就触发淘汰直到内存占用降到安全水位。容量估算不需要极其精确大概够用就行。Java里String对象有对象头、char数组有自己的内存占用粗略按“字节数加64到128字节固定开销”估算已经能防住绝大多数的内存失控。缓存组件最重要的原则也在这里缓存永远不能拖垮主流程宁可miss也不能OOM。3. LRU、LFU、FIFO淘汰策略的选型比我想象的更讲究3.1 FIFO为什么会坑人谁都会想缓存满了以后随便踢一个出去不就行了FIFO就是这么干的队列先进先出最先放进来的最先被淘汰。实现确实简单一个环形队列或者链表就能搞定。但实际跑起来以后你会发现FIFO的命中率低得可怜。问题在于访问模式。互联网服务的流量往往是高度倾斜的少数热点key承载了大多数请求而FIFO只认“放进来早晚”不认“被访问多频繁”。一批生命周期很短的key比如带时间戳的拉流请求刚被放进来没多久就把之前的热点数据挤出去了。热点数据被淘汰后下一次请求就不得不穿透到数据库重新加载然后又可能被新的短命key挤掉命中率雪崩。我在早期版本里真的试过FIFO压测时发现命中率只有50%出头换LRU后直接提升到85%以上。淘汰策略不是缓存里可以随便糊弄的部分它直接决定了缓存对业务的加速效果。3.2 LRU的双向链表加哈希表怎么做到O(1)LRU的全称是Least Recently Used最近最少使用。核心思想是在容量不够时优先淘汰“最久没有访问过”的那个key。这个直觉规则在大多数业务场景下都非常有效。数据结构上的经典实现是哈希表加双向链表。哈希表负责O(1)的key定位双向链表负责维护访问顺序。具体规则是这样的每当我们get或者put一个key就把对应的链表节点移动到链表头部新写入的节点也放在头部当容量满了就把链表尾部的节点淘汰掉因为尾部意味着“最久没有被访问过”。这里要特别解释一下为什么用双向链表而不是单向链表。删除任意节点的时候需要拿到它的前驱节点把前驱的next指针指向当前节点的后继。如果是单向链表要找到前驱必须从头遍历时间复杂度退化成O(n)。双向链表每个节点自己保存prev指针删除操作只需要O(1)时间整体才能保持高性能。用生活化的类比来说这就像食堂打饭时队伍里的窗口规则最近打过饭的人排到最前面很久没出现的人自然会被挤到队伍末尾一旦食堂容量不够末尾的人就先被请出去。3.3 LFU和W-TinyLFU强淘汰策略是否值得LRU虽然好用但也有自己的短板。一个key可能曾经特别热但最近一段时间已经没人访问了它依然会因为“历史地位”赖在链表头部附近很久都轮不到被淘汰。反过来一个未来即将大热的key现在才刚刚开始有访问却可能因为先被放进来、在链表相对靠后的位置在冷启动阶段就被过早挤出去。LFULeast Frequently Used就是针对这个问题出现的按访问频率来淘汰频率最低的先淘汰。听起来比LRU更聪明但实现起来麻烦很多。首先每个key要维护一个计数器其次老热点key的频率值会被越堆越高形成永远淘汰不掉的僵尸数据必须引入频率衰减机制衰减周期怎么定、衰减幅度多大都会直接影响命中率。Caffeine用的W-TinyLFU把这件事做到了产品级它用频率过滤器加窗口缓存的组合既保留LFU的频率敏锐度又能适应突发流量。但这种复杂策略真的不需要每个人自己实现。对于绝大多数自研KV缓存来说业务访问基本是二八分布LRU已经能拿到很好的命中率。命中率敏感到极致老老实实用Caffeine别自己造LFU。3.4 LinkedHashMap实现LRU时最容易踩的坑Java里其实有个偷懒的LRU实现方式继承LinkedHashMap。但这里藏着一个特别容易踩的坑我在一段时间里也差点翻车。LinkedHashMap默认是按插入顺序来维护遍历顺序的只有构造函数里的accessOrder参数设置为true时它才会在访问节点时把这个节点移动到链表尾部。很多人直接继承LinkedHashMap重写removeEldestEntry方法以为这就实现了LRU结果整个腾挪逻辑根本没被触发因为accessOrder默认为false访问顺序压根不参与节点移动。正确的写法是这样的LinkedHashMapString, String cache new LinkedHashMap(16, 0.75f, true) { Override protected boolean removeEldestEntry(Map.EntryString, String eldest) { return size() 100; } };第三个参数true就是开启访问顺序排序的关键。这个方法只适合单线程场景并发环境需要外面再包锁否则同样的并发覆盖问题还是躲不掉。4. 并发是缓存实现里最隐蔽的坑4.1 默认HashMap并发写入为什么直接翻车缓存一定是被多线程共享的所以并发安全是绕不开的话题。如果直接在并发环境里裸用HashMapJDK7时代有一个著名的坑扩容时在高并发put下链表可能形成环下次get的时候CPU直接打满。JDK8改成尾插法以后死循环问题缓解了很多但数据覆盖、size计数不准确这些并发问题依然存在。就算换成ConcurrentHashMap也只能保证单个节点操作的线程安全。KV缓存里还有一条维护访问顺序的链表get操作不仅要读哈希表还要把节点挪到链表头部这是一个跨结构的复合操作ConcurrentHashMap管不了这条链表的并发修改。缓存组件必须自己在更高一层做并发控制。4.2 锁的粒度从全局锁到分段锁最简单正确的方案是给整个缓存加一把全局锁所有读写都串行化。这种做法实现成本最低几行代码就能保证安全但并发能力自然受限。我们压测时全局锁版本在8线程、读多写少的混合负载下每秒大概能撑120万次操作其实已经能覆盖很多中小项目的需求了。如果并发更高就要考虑分段锁。思路很直接把key空间通过哈希函数切分成N个段每个段内部有自己独立的哈希表、独立的链表和独立的一把锁。写入和读取都按key的哈希路由到对应段不同段之间的线程完全不会互相阻塞。我把自研版本的缓存分成了16个段实测性能能到380万次操作每秒左右提升非常明显。代价是段与段之间不共享容量每个段的淘汰是局部最优已经不是严格的全局限额LRU了。在工程上这个权衡通常是可以接受的因为热点key本身就是倾斜的局部淘汰带来的命中率损失远小于并发性能提升带来的收益。4.3 LRU的“读也是写”让读写锁变得尴尬设计并发方案时直觉上会觉得缓存是典型的“读多写少”应该用读写锁提升并发度。这个直觉在普通哈希表上是成立的但在带LRU的缓存上却会踩坑LRU的get操作本质上也是写操作因为它要把节点移动到链表头部这直接就修改了链表的指针结构。如果get只加读锁并发的get请求可以同时进入但它们会同时改写链表指针数据竞争照样发生链表结构很快就会被撕裂。如果get也加写锁那读写锁就完全退化成互斥锁不仅没有并发收益还增加了锁的实现复杂度。所以最终我的线上版本没有用读写锁而是选择了互斥锁加分段的组合。虽然看起来不够花哨但正确性和性能都完全可控。5. 完整可运行的LRU缓存实现与压测结果5.1 第一个版本全局锁加双向链表先把最基础的版本完整贴出来这个版本是可运行的核心逻辑集中在get和put两个方法里。我本地跑过多次逻辑上没毛病适合作为理解LRU缓存的入门代码。import java.util.HashMap; import java.util.Map; public class LRUCacheK, V { private final int capacity; private final MapK, NodeK, V map new HashMap(); private final NodeK, V head new Node(null, null); private final NodeK, V tail new Node(null, null); private int size; public LRUCache(int capacity) { this.capacity capacity; head.next tail; tail.prev head; } public synchronized V get(K key) { NodeK, V node map.get(key); if (node null) { return null; } moveToHead(node); return node.value; } public synchronized void put(K key, V value) { NodeK, V node map.get(key); if (node ! null) { node.value value; moveToHead(node); return; } NodeK, V newNode new Node(key, value); map.put(key, newNode); addToHead(newNode); size; if (size capacity) { NodeK, V tailNode removeTail(); map.remove(tailNode.key); size--; } } private void moveToHead(NodeK, V node) { removeNode(node); addToHead(node); } private void addToHead(NodeK, V node) { node.prev head; node.next head.next; head.next.prev node; head.next node; } private void removeNode(NodeK, V node) { node.prev.next node.next; node.next.prev node.prev; } private NodeK, V removeTail() { NodeK, V node tail.prev; removeNode(node); return node; } private static class NodeK, V { K key; V value; NodeK, V prev; NodeK, V next; Node(K key, V value) { this.key key; this.value value; } } }代码里head和tail是两个哨兵节点它们不存储真实数据只用来标记链表的边界。这样在移动头尾节点的时候不需要额外判断空指针链表操作会清爽很多。5.2 关键操作源码级拆解来逐个拆解核心方法。get方法里流程是先用map.get(key)在哈希表里定位节点。如果节点不存在直接返回null不会触发任何链表操作。如果存在调用moveToHead把节点挪到链表头部同时返回value。这里moveToHead动作很重要它意味着这个key的“最近访问时间”被刷新了。put方法分两种情况。第一种key已经存在那么直接覆盖value再把节点移动到头部size不变。第二种key不存在创建新节点插入到哈希表同时加到链表头部size加一。此时如果size超过capacity就从尾部摘一个节点出来从哈希表里也删掉。链表指针操作看起来简单但顺序错了很容易指针丢失。moveToHead内部是先removeNode再addToHead。removeNode只做两件事把prev节点指向next把next节点指回prev这个节点就从链表中摘出来了。addToHead把新节点插入到head和原来的第一个真实节点之间先设置node的prev和next再修改前后两个节点的指向。如果先把head.next指向新节点再设置新节点的prev旧的首个节点的prev指针可能还没被正确更新链表就断了。所以指针操作的先后顺序是链表类代码最需要小心的地方。5.3 分段锁升级版全局锁版本正确性没问题但在高并发下的扩展性有限。分段锁版本的改造思路很直接维护一个LRUCache数组每个元素是一个独立的LRU缓存实例有自己的锁。根据key的哈希值路由到对应段段的内部逻辑完全复用全局锁版本。public class ShardedLRUCacheK, V { private final LRUCacheK, V[] shards; private final int shardCount; SuppressWarnings(unchecked) public ShardedLRUCache(int shardCount, int perShardCapacity) { this.shardCount shardCount; this.shards new LRUCache[shardCount]; for (int i 0; i shardCount; i) { shards[i] new LRUCache(perShardCapacity); } } private LRUCacheK, V shardFor(K key) { int index key.hashCode() (shardCount - 1); return shards[index]; } public V get(K key) { return shardFor(key).get(key); } public void put(K key, V value) { shardFor(key).put(key, value); } }这里注意一个细节shardCount必须是2的幂因为hashCode与(shardCount - 1)做按位与运算才能保证结果均匀分布在0到shardCount-1之间。如果shardCount不是2的幂路由结果会有偏斜某些段会被打满部分段却一直闲着。改造量其实很小主要逻辑完全复用但并发能力提升非常显著。从压测数据看16分段比全局锁版本快了三倍左右。5.4 粗测性能差异压测条件简单说明一下JDK118线程并发读写读占95%、写占5%key是随机字符串value是固定大小对象预热后再取稳定值。全局锁版本大约每秒120万次操作16分段版本大约每秒380万次操作。这个数据只代表我本机的相对趋势大家照搬到不同CPU、不同JDK版本、不同value大小下的绝对值会差别很大。但趋势是明确的分段锁在缓存这种天然可分片的场景里能带来非常可观的并发提升。另外想多提醒一句如果value是很大的对象或者读写时频繁创建新对象性能瓶颈往往在内存分配和GC上锁竞争反而不是主矛盾。别一上来就搞多重的优化先用全局锁版本跑通业务再根据压测数据判断需不需要升级到分段。6. 自研KV缓存的天花板什么时候该换Caffeine和Redis6.1 单机内存与进程重启是天然天花板自己写KV缓存有几个绕不过的上限。第一个上限是单机内存进程内缓存只能使用所在JVM的堆内存不管你怎么优化容量始终受制于一台机器的物理资源。第二个上限是进程重启发布新版本意味着进程重启内存里所有的缓存条目瞬间清零。服务端最常见的发布方式是滚动发布一个实例一个实例重启。每次有一个实例重启完它的本地缓存都是空的如果负载均衡马上把流量打进来这个空缓存实例上的所有请求都会穿透到后端。如果后端数据库刚好在承载着高流量这一轮穿透就可能把数据库打垮。要缓解这个问题要么在本地缓存之上再叠加一个Redis二级缓存要么在实例启动时做热点数据预热提前把高频key加载进去。6.2 多实例一致性问题不会消失本地缓存的另一个问题是多实例之间的数据不一致。同一个key在负载均衡下可能分散请求到不同的机器上每台机器各自缓存了一份数据。某台机器上某个key的value被更新了其它机器的缓存里还是旧值直到它们的TTL过期才会刷新。缩短TTL不是银弹。TTL越短缓存效果越差回源率越高TTL越长旧数据存活时间越久业务风险越大。对于强烈依赖数据一致性的场景比如余额、库存、订单状态根本不该放进本地KV缓存交给Redis这类中心化组件借助版本号、发布订阅机制来做失效通知才是靠谱的方案。6.3 我的选型红线什么时候不自己造自研KV缓存做的次数多了我给自己总结了一条非常清晰的选型红线可以直接给大家参考维度手写LRU缓存CaffeineRedis访问延迟微秒级微秒级亚毫秒到毫秒级容量受单机堆内存限制受单机堆内存限制可横向扩展容量大数据一致性多实例各自缓存天然不一致多实例各自缓存天然不一致中心化存储可做一致性协调功能丰富度自己写多少有多少统计、异步加载、多种淘汰策略持久化、分布式锁、过期策略丰富维护成本全量自扛成熟稳定社区活跃需要独立运维一套服务如果业务还在验证阶段或者团队没有专门的人力维护一个缓存组件直接用Caffeine是更稳妥的选择。它内部把W-TinyLFU、异步加载、统计监控这些能力都做好了性能还极其优秀完全没必要重复造轮子。真正的自研KV缓存更适合这三类场景一是热点数据的本地加速比如用户维度的轻量配置二是监控系统、内部工具这类规模可控、可接受重启清空的数据三是以学习为目的想彻底理解缓存内部机制。最后分享一个我自己相当受用的心得完整实现一个KV缓存以后再去看任何缓存中间件的文档或者源码视角会完全不一样——你看到的不再是一堆API而是它背后的哈希表、链表、锁和淘汰策略。另一个切身建议是自研缓存上线后一定要把命中率指标监控起来。如果命中率长期低于60%到70%问题大概率出在容量配置太小、淘汰策略不合适或者key设计太粗粒度这时候先别急着加机器先调参收益往往更明显。
返回列表