我被技术问题所吸引。突然间,一个朋友问我,
如果你要构建一个内存缓存,你会怎么做?
它应该有良好的性能并能存储大量条目。读取操作比写入操作更频繁。我已经知道我应该怎么做,但我想听听你的方案。
我无法不接受这个挑战。
在回答这个问题和编写代码的过程中,我发现自己的思维方式发生了许多变化,这是经验带来的结果。这些变化使得软件工程变得更加容易,也是在我经验不足时从未考虑过的。
我开始写一篇长篇大论的文章,但觉得它没有完全达到预期的效果,因此这是个简化的版本。如果时间允许,我可能会在单独的文章中更详细、更简洁地探讨其中一些实践。
实践
以下是我在构建快速、简单内存缓存过程中采用的八种方法。
1. 使用现成解决方案
我们对这个问题的第一反应应当是“我们可以使用 Redis 吗?”
只要组件不是非常昂贵或复杂,或者说不是创造核心价值的部分,我们应该倾向于选择现成的方案。之前我已撰文论述过为何要建设昂贵复杂的架构(https://entropicthoughts.com/build-vs-buy.html)。
2. 成本和可靠性超过功能
如果我们发现不能使用现成的方案,我们就需要开发一种便宜且可靠的方案。通常这意味着不需要具备所有附加功能,不过这也是一个值得权衡的选择。我最喜欢的一句话是,
对于可靠型软件而言,添加新特性容易得多;相比而言,在复杂的功能型软件里增加可靠性却困难得多。
此外,人们很容易错误地认为自己需要那些实际并不存在的需求。
我并不是特别支持设计阶段。有时候确实需要,但是如果需要预先设计软件架构的话,这会增加开发成本,并拖延完成时间。另一方面,有时一点点提前分析可以帮助我们在编写任何代码之前排除掉大部分的设计方案。
在处理这个缓存时,我们可以询问有关条目持久性要求、请求率、大小、逐出条件等问题。通过这种分析,我们会发现可以只需一个线程来访问一个单一的数据结构,并且不需要主动进行逐出处理 — 这是一个巨大的胜利!简化了设计。
3. 快速实现想法至生产
上一实践的一部分原因在于轻易认为所需的功能其实并不必要。那么,如何判断哪些功能才是真正需要的呢?一般来说,最廉价且最为可靠的方法是在生产环境中验证出来。
如果我们部署最少必要的特性集,我们可以迅速了解哪些额外的特性需求最高,并且这往往是与我们预想的不同。即使相同,也会发现它们通常具有其他事先未曾考虑到的要求。
这一做法与前面所述强烈相关:使用分析来减少到最小要求,再编写最小代码以满足这些条件,并尽快将其投入生产环境以更好地了解我们试图解决的问题。
4. 简单数据结构
尽管复杂数据结构往往颇具吸引力。尤其是在存在第三方库时可以帮助我们处理这一切。然而,复杂数据结构的问题是由于了解欠缺而容易滥用,导致性能瓶颈和错误。
就该具体缓存而言,需要存储的条目数量(我们可以通过排队论从ttl和写入速率推算出这个数值)仅需19.9位信息便已足够 — 换句话说,我们可以直接利用数组来存储各项,并对键值(key)进行哈希映射以获得地址。1 我们早已确认无需逐出过期项。如果真的需要处理,那么可以使用最小堆来维护快要过期项目列表以节省成本且简化任务。这比全排序方式更为高效简便。除了独立的过程之外,我们还可以依赖高请求频率触发删除操作,从而以少许延迟换取避免并发设计工作。另一个变体是提供单独API接口以触发过期条目逐出功能,赋予调用方决定要花费多少时间在这上面的灵活性。这样一来,调用方可灵活调节该时间以进行动态延迟性能折中。
5. 早期预留资源
在为我设计的缓存做出的另一个可能引起争议的决策是其在一开始就分配完整索引数组。这看起来很浪费,但从初步计算可知,连续操作期间其实需要大多数这个空间,因而推迟分配并不会节省什么资源。
早期分配的好处是在系统启动时即可确定是否具备所需资源,这样如果资源不足可以立即崩溃,而不会等到夜里工程师睡觉时才收到 PagerDuty 的报警电话。这对于容量规划和性能预测也更加有利。
新来的读者注意啦,生产环境下易于运行是我非常看重的一件事,我会尽可能经常发表关于这个话题的文章。您应该订阅我的电子邮件列表以获取每周更新的文章摘要:https://buttondown.email/entropicthoughts
如果您不喜欢可以随时退订。
6. 设定上限
对于解决哈希冲突时定位对象所采用的基于线性探测开放寻址法,我选择了粗糙的方式,虽然这种方法简单并且通常情况下性能很好(同样只需要基本算术即可得出特定应用场景下的结论),但是在最坏情形下表现却很差:每遇到一次缓存未命中就可能遍历整个数组。
鉴于完美召回