Random Thoughts
Recent content on Random Thoughts
马上订阅 Random Thoughts RSS 更新: https://blog.joway.io/index.xml
设计实现高性能本地内存缓存
本地内存缓存是一个在基础软件架构中非常常见的基础设施,也正因其过于常见,以至于平时很少去思考它是如何实现的。在尚未设计缓存系统前,完全没想到原来要需要考虑如此多复杂的事情。本文将由浅入深介绍如何设计一个现代的高性能内存缓存系统。
什么时候需要本地内存缓存
在大部分业务系统中,都会使用诸如 Redis、Memcached 等远程缓存,一方面可以避免自身进程内存占用过大而导致的 OOM 或 GC 问题,另一方面也可以实现多个进程共享同一份一致的缓存数据。但对于某些底层服务(例如数据库服务),远程缓存的网络延迟是不可接受的,这就势必需要引入本地内存缓存。
本地内存缓存的特点
本地内存缓存可被视作一个基于本地内存的 「KeyValue 数据库」。但相比较于传统数据库而言,它对一致性的要求十分宽松:
- 对于更新与删除的操作,需要保证强一致性
- 对于插入操作可以容忍少量丢失
- 对于读取操作可以容忍少量 Miss
与磁盘数据库的另一个不同之处在于,磁盘数据库的设计有一个前提假设是磁盘是可以随需要而不断扩容的,倘若一个磁盘数据库因磁盘占满而崩溃主要责任是在使用方。而内存缓存则没有这么宽容的假设可以建立,它必须考虑到内存是昂贵且有限的这一事实。
除此之外,由于本地内存缓存处于业务进程当中,所以其需要考虑更多业务向的问题,比如:
- 由于自身大量老生代的内存占用,是否会对所处进程产生 GC 问题。
- 当多线程场景下,如何同时解决线程安全、数据竞争、高吞吐等问题。
- 需要能够适应一些非随机的访问统计规律,例如 Zipf。
综上所述,我们可以归纳出对一个优秀的本地内存缓存系统的要求:
- 线程安全
- 高吞吐
- 高命中率
- 支持内存限制
实现路径
在实现一个完整的缓存系统前,我们需要将目标一步步拆解。
首先为了实现缓存逻辑,我们必须有一个类 Map 的 KeyValue 数据结构,同时它必须是线程安全的。为了支持内存限制,我们必须要能够驱逐一些 key,所以需要实现一个驱逐器。为了实现驱逐的同时维持高命中率,我们还需要告诉驱逐器每个 key 的访问记录,让它能够从中分析出哪些 key 可以被驱逐。综上分析,我们可以整理出一个大概的 Roadmap:
- 实现一个线程安全的 Map 数据结构:存储缓存内容
- 实现一个访问记录队列:存储访问记录
- 实现一个驱逐器:管理缓存内容
本文所有代码均使用 Golang 编写。
线程安全的 Map
简易的 Map
cache := map[string]string{}
cache["a"] = "b"
在 key 数量固定且极少的情况下,我们一般会用原生 Map 直接实现一个最简单缓存。但 Golang 原生 Map 并不是线程安全的,当多个 goroutine 同时读写该对象时,会发生冲突。