怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

无实习这一档是给谁的 完全没有实习经历时,简历上唯一能写的就是自己做的项目。但「图书馆座位预约」「学生成绩管理」这类选题面试官一年看几百份,而硬套企业项目(微服务、分布式事务、消息中间件)会被一问就穿——他很清楚学生不可能有那种环境。这一档收的是复刻主流产品的核心链路、一个人真能做出来、技术栈朴素但有真实难点的项目。这一档的模块比企业档多——因为企业项目里你只被分到一个模块,而个人项目是从数据结构到对外 API 全都你自己写的,能讲的面本来就宽。下面六个模块彼此独立——只做过哪几个就只写哪几个。
怎么讲才不像课设 这一页是这一档里最特殊的一个:它零依赖、只有纯语言标准库,但它恰好落在面试最爱问的知识点上(过期与淘汰策略、并发安全、缓存击穿)。含金量不在「我实现了 LRU」——那是背题;而在你能说出「我一开始用哈希表加锁,压测发现锁竞争把吞吐压住了,于是改成分段」这种自己撞出来的过程手写组件类项目最大的风险是听起来像抄教程,破解办法就是把「我的第一版是什么样、为什么不行」讲出来。
只写你当场证得出来的 这一页尤其不能编,因为面试官可以现场让你说清任何一个细节,甚至让你写一段。好消息是它也最容易证:造多少数据、开多少线程、压测跑多久,全部是我自己控制的,没有任何需要外部背书的东西不要写「有人在用」「开源被引用」——写清「我用多少线程压测、命中率和吞吐是多少」就够了。
三条自检 一、你能说出第一版是什么样、为什么不行(这是区分「自己做的」和「抄的」最有效的一问);二、能说清每个策略的取舍(为什么惰性加定期而不是只用一种);三、每个数字都能回答「这个数是怎么来的」(多少条数据、几个线程、读写比例多少)。三条都有就能写。
项目背景设定 我在做前面那个论坛项目时反复需要「把查询结果在本地存一小段时间」,一开始用一个哈希表加时间戳凑,很快发现不够用(内存会一直涨、并发下会出问题)。于是我把它抽成了一个独立的本地缓存组件纯语言标准库实现,零第三方依赖,支持过期、容量上限与淘汰、并发安全、以及同一个键的加载只执行一次。
数据和测试条件先说清楚 这个项目没有外部依赖也没有用户,所有结论都来自我自己的压测造几十万个键做容量与淘汰验证开几十到上百个线程按不同读写比例压测吞吐用固定的访问序列(含热点集中与均匀随机两种分布)对比命中率用打桩的加载函数统计它被调用了几次来验证「只加载一次」。这些条件我能当场复述,代码也能现场讲。
为什么这六块值得写 前三块正好是面试最常问的三个点,而我是自己撞上去的:不清理过期数据导致内存只涨不降、加一把大锁导致多线程读的吞吐反而不如单线程、以及热点键过期瞬间几十个线程同时去做同一件重活——这三个问题在单线程、小数据量、短时间运行的情况下全都不出现。后三块(对外 API 与可测性、统计与可诊断性、接入真实项目的踩坑)是「写一个组件」和「写一段业务代码」真正的分界线组件的使用者不是我,所以 API 好不好用、参数设错了会不会静默出问题,都得我替他想而且缓存的行为几乎全是时间相关的——时间源不可注入的话,测过期只能靠 sleep,我这一条是被自己的测试逼出来的命中率这类指标不暴露出去,使用方就无法判断自己配的容量和过期时间是否合理,只能凭感觉调最后一块是把它真接进项目才暴露的问题(空值穿透、更新后的失效时机、缓存粒度),这些在单测里永远碰不到。六块都是我自己写的,所以每一块我都能讲到根因;这也是个人项目相比「企业里被分到一个模块」的唯一优势——面比较宽,别浪费。

模块一:过期与淘汰策略

  1. 过期与淘汰策略(惰性删除配合定期清理解决内存只涨不降 + 容量上限与近似最近最少使用淘汰 + 过期时间的读写语义 + 弱引用不是万能兜底)★★★
    简历这样写 本地缓存组件(个人项目)(纯语言标准库实现,零第三方依赖):初版仅在读取时判断过期(惰性删除),压测中发现长期未被访问的过期键永远不会被清理,内存只涨不降;补上定期清理任务(后台按批扫描并限制单轮耗时,避免清理本身造成停顿),两者配合后内存在持续写入下可回落;新增容量上限与淘汰策略,用哈希表加双向链表实现访问即移到表头、超限时淘汰表尾,使内存有明确上界;明确了过期时间的语义(写入后过期与最后访问后过期两种,分别适用于「数据本身有时效」和「热点保活」,此前混用导致热点键被反复重建);对清理与淘汰的统计(当前条数、淘汰次数、过期清理次数、命中率)做了暴露,便于判断参数设置是否合理。全部结论来自自造数据的压测。
    展开完整拆解
    为什么要这么设计

    我最开始的「缓存」就是一个哈希表,值里带一个过期时间戳,读的时候判断一下过期就删掉、重新加载这个实现在我的论坛项目里跑了一段时间,看起来没问题——直到我做压测时盯着内存看。四个问题。

    一是内存只涨不降。我只在读取时判断过期。但一个键如果过期之后再也没有人读它,那它就永远不会被删除——它会一直占着内存而缓存的键往往有大量是「一次性」的(某个用户的某次查询),过期之后再也不会被访问。压测里我持续写入不同的键,内存一路涨上去、再也不下来。

    二是没有容量上限,内存没有上界。即使加了定期清理,如果写入速度快于过期速度,内存仍然会持续增长我需要的是一个「最多占用多少」的硬约束,而不是「希望它会降下来」。

    三是我加了定期清理之后,出现了停顿。我的第一版定期清理是「扫描全部键、删掉过期的」。键多的时候这一轮扫描耗时明显,而扫描期间持有锁,所有读写都被挡住——表现是每隔一段时间就卡一下。

    四是过期时间的语义我一开始没想清楚。我只有一种过期时间,但两类数据的需求完全不同「查询结果」这类数据本身有时效,应该从写入开始算而「热点配置」这类应该只要一直有人访问就不过期我用同一种语义处理,结果是热点键也被反复过期重建,缓存的意义被削弱了。

    所以四个改动:惰性删除之外补定期清理定期清理改为按批扫描并限制单轮耗时加容量上限与淘汰策略让内存有硬上界区分「写入后过期」与「最后访问后过期」两种语义

    这个模块最想说的一句话是:过期和淘汰是两件不同的事,我一开始把它们混为一谈了。过期解决的是「这份数据还准不准」,淘汰解决的是「内存够不够」——只做过期的话内存没有上界(没人访问的过期键不会被清、写得快也来不及清);只做淘汰的话数据可能已经过时但还在被使用两者必须都有,而且触发条件完全不同。

    整体链路
    数据结构 │ ├─ 哈希表:键 → 节点(值 · 写入时间 · 最后访问时间 · 过期配置) ├─ 双向链表:维护访问顺序,表头是最近访问、表尾是最久未访问 │ └─ 两者配合:哈希表负责按键定位、链表负责淘汰时找出「最久未访问的」 只有哈希表的后果:淘汰时要遍历全部键找最久未访问的 只有链表的后果:按键查找要遍历链表 过期(解决「这份数据还准不准」) │ ├─ 惰性删除:读取时判断过期,过期则删除并按未命中处理 │ 优点:零额外开销,只在访问路径上做一次判断 │ 缺点:过期之后再也没人读的键永远不会被清理 │ 而缓存里大量键是一次性的(某用户的某次查询) │ 压测中持续写入不同键,内存一路涨上去再也不下来 │ ├─ 定期清理:后台任务扫描并删除过期键 │ 第一版「扫描全部键」的后果 │ 键多时单轮耗时明显,而扫描期间持有锁 │ 所有读写被挡住 → 每隔一段时间就卡一下 │ 改为按批扫描:每轮只扫一部分、限制单轮耗时、下轮接着扫 │ └─ 两者必须配合 惰性删除保证「读到的一定不过期」 定期清理保证「没人读的过期键也会被回收」 过期时间的两种语义(我一开始只做了一种) │ ├─ 写入后过期:从写入时刻开始计时 │ 适用:数据本身有时效(查询结果 · 列表快照) │ ├─ 最后访问后过期:每次访问都重置计时 │ 适用:热点保活(配置 · 字典数据) │ 只要一直有人用就不过期,没人用了自然淘汰 │ └─ 混用的后果(我踩过) 热点键按写入后过期处理 → 被反复过期重建 缓存的意义被削弱:明明一直有人在用,却一直在重新加载 淘汰(解决「内存够不够」) │ ├─ 容量上限:条数上限(更精确的是按大小,但估算对象大小很麻烦) │ ├─ 淘汰策略:淘汰表尾(最久未访问的) │ 访问时把节点移到表头 —— 这一步让链表顺序反映访问热度 │ ├─ 超限时淘汰,可以一次淘汰多个(批量淘汰减少频繁触发) │ └─ 为什么不做更复杂的策略 按访问频次淘汰在某些访问模式下命中率更高 但它需要额外维护频次并处理「历史热点长期占位」的问题 我实测了两种分布下的命中率差异,决定先用简单策略(见取舍) 统计要暴露出来 ├─ 当前条数 · 命中次数 · 未命中次数 · 淘汰次数 · 过期清理次数 ├─ 命中率低 → 可能容量太小或过期时间太短 ├─ 淘汰次数远大于过期清理次数 → 容量是瓶颈,不是时效 └─ 不暴露统计的后果:参数只能凭感觉调
    分步拆解
    1. 先分清过期和淘汰是两件事。过期解决「数据还准不准」、淘汰解决「内存够不够」,触发条件完全不同,必须都有。
    2. 用哈希表加双向链表的组合。哈希表负责按键定位、链表负责找出最久未访问的——只有哈希表的话淘汰要遍历全部键,只有链表的话查找要遍历链表。
    3. 惰性删除是必须的:读取时判断过期。它保证「读到的一定不过期」,而且零额外开销。
    4. 但惰性删除不够,必须补定期清理。因为过期之后再也没人读的键永远不会被清理——而缓存里大量键是一次性的。
    5. 定期清理不能「扫描全部键」。键多时单轮耗时明显,而扫描期间持有锁会挡住所有读写,表现是每隔一段时间卡一下。
    6. 定期清理改为按批扫描:每轮只扫一部分、限制单轮耗时、下轮接着扫。清理本身不能成为停顿的来源。
    7. 必须有容量上限,否则内存没有硬上界。即使有定期清理,写入速度快于过期速度时内存仍会持续增长。
    8. 访问时把节点移到链表头部。这一步让链表顺序反映访问热度,淘汰表尾就是淘汰最久未访问的。
    9. 超限时可以批量淘汰多个。每次只淘汰一个会导致写入密集时频繁触发淘汰。
    10. 过期时间要区分「写入后过期」与「最后访问后过期」两种语义。混用的后果是热点键被反复过期重建——明明一直有人在用,却一直在重新加载。
    11. 容量上限用条数而不是字节数。按字节更精确,但估算对象大小很麻烦且不准;条数够用,而且要在文档里说清这个近似。
    12. 要暴露统计:条数、命中、未命中、淘汰次数、过期清理次数。不暴露的后果是参数只能凭感觉调。
    13. 命中率低要能区分原因。「淘汰次数远大于过期清理次数」说明容量是瓶颈而不是时效——这个判断靠统计才能做。
    14. 过期判断要用单调的时间源。用系统时钟的话,系统时间被调整会导致过期判断错乱。
    15. 删除与淘汰要触发回调(可选)。让使用方知道某个键被移除了、以及原因(过期还是淘汰)。
    关键决策与取舍

    惰性删除加定期清理两者都做,而不是只选一个。只做惰性删除的问题很致命:过期之后再也没人读的键永远不会被清理,而缓存里大量键是一次性的——内存只涨不降。只做定期清理的问题是清理有间隔,间隔内读到的可能是过期数据两者的分工很清楚:惰性删除保证「读到的一定不过期」,定期清理保证「没人读的也会被回收」判据是「这两种失效路径各自漏了什么」——各自都有漏的,所以必须叠加。

    定期清理按批扫描,而不是一次扫全部。一次扫全部实现最简单、清理最彻底。但键多的时候单轮耗时明显,而扫描期间持有锁会挡住所有读写——清理本身成了停顿的来源按批扫描的代价是过期键的回收有延迟(要多轮才能扫完一遍)。判据是「后台任务能不能影响前台请求」:不能,那就必须限制它单次的工作量。这一条和后端「批量任务要分批并限制单轮数量」是同一个道理。

    用近似最近最少使用而不是按访问频次淘汰。按频次淘汰在某些访问模式下命中率更高。但它需要额外维护每个键的频次计数,还要处理一个真实问题:曾经的热点在不再被访问之后,靠着历史高频次长期占着位置不被淘汰(解决它又要引入频次衰减)。我实测了两种访问分布:热点集中的分布下两者命中率差距不大,均匀随机的分布下两者都不高——既然收益不明显、复杂度明显上升,我选了简单策略,并在文档里写清它在什么访问模式下会吃亏。判据是「实测的收益差距值不值得这份复杂度」,而不是「哪个策略更先进」。

    容量上限用条数而不是字节数。字节数更精确、更贴近「内存够不够」这个真实问题。但准确估算一个对象占多少内存很麻烦、而且不同实现下差异很大条数是一个近似,但它简单可靠我的处理是用条数,但在文档里明确写出这是近似、以及使用方应该按自己缓存对象的大致大小来设置条数上限。把近似说清楚,比假装精确更好。

    踩过的坑一:只做惰性删除,压测中内存一路涨上去再也不下来。我是持续写入不同的键、盯着内存曲线才发现的。教训是:惰性策略的盲区是「不再被访问的数据」——而这类数据恰恰最需要被清理这个思路推广开来很有用:任何「用到的时候才处理」的机制,都要问一句「如果它再也不被用到呢」。

    踩过的坑二:定期清理一次扫全部,造成周期性停顿。表现是压测的耗时曲线上每隔一段时间有一个尖峰。教训是:为了解决一个问题引入的后台任务,本身可能成为新的问题源——而它的影响会以「周期性尖峰」这种很好认的形态出现,看耗时分布比看平均值容易发现。

    踩过的坑三:热点键按「写入后过期」处理,被反复重建。现象是某个一直有人访问的配置项,命中率却不高。教训是:「过期」这个词掩盖了两种不同的意图——「这份数据放久了会不准」和「这份数据没人用了就该走」,前者该从写入算,后者该从最后访问算把一个概念拆成两个之后,两类需求都能被正确表达。

    没做的部分:没做按内存实际占用的容量控制、也没做多级缓存(本地加远端)。前者需要可靠的对象大小估算,后者是另一个方向的问题(涉及一致性与失效广播)取舍依据是「这个组件的定位是一个简单可靠的本地缓存」——我更愿意把边界说清楚,而不是做一个功能多但每一处都不扎实的东西。

    数字是怎么测的

    内存回落是这个模块最直观的验证:持续写入 N 个不同的键(全部会过期),观察内存曲线报法是「只有惰性删除时内存持续增长不回落;加入定期清理后内存在若干轮清理内回落到基线附近」——曲线的形状比单个数字更能说明问题,要说明观察工具和写入速率。

    容量上限用断言型用例:设置条数上限为 N,写入远超 N 个键,断言缓存条数始终不超过 N、且被淘汰的是最久未访问的那些。

    定期清理的停顿要看耗时分布而不是平均值:报「一次扫全部时,读写耗时曲线上出现周期性尖峰;改为按批扫描后尖峰消失」。平均值可能看不出差别,尖峰要看分位数或曲线。

    命中率对比要说清访问分布:「用固定的访问序列,分别在热点集中与均匀随机两种分布下测命中率」。不说分布的命中率数字没有意义——同一个策略在不同分布下差别很大。这也是我判断「要不要用更复杂的淘汰策略」的依据。

    两种过期语义要各写一条用例:「写入后过期」的键在持续访问下断言仍会过期;「最后访问后过期」的键在持续访问下断言不过期、停止访问后断言过期。

    时间源:把系统时间往前调,断言过期判断不受影响(因为用的是单调时间源)。这条很容易漏但很能说明细致。

    统计准确性:执行已知次数的命中与未命中,断言统计值与预期完全一致。

    不要报什么:不要报「性能超过某个成熟缓存库」——我没有做过对等条件的严谨对比,说了会被追问细节。该报的是「只惰性删除时内存不回落、加定期清理后回落」「条数上限严格生效且淘汰最久未访问」「按批扫描消除了周期性尖峰」「两种分布下的命中率对比」这几件带条件的、可核对的事。

    面试追问
    Q:过期就在读的时候判断一下不就行了?为什么还要定期清理? A:因为惰性删除有一个致命盲区:过期之后再也没人读的键,永远不会被清理。而缓存里大量键本来就是一次性的(某个用户的某次查询结果),它过期之后再也不会被访问,于是一直占着内存我是在压测里持续写入不同的键、盯着内存曲线才发现的——内存一路涨上去,再也不下来。这个教训推广开来很有用:任何「用到的时候才处理」的机制,都要问一句「如果它再也不被用到呢」。所以我补了定期清理,两者分工很清楚:惰性删除保证「读到的一定不过期」,定期清理保证「没人读的过期键也会被回收」——各自都有盲区,所以必须叠加。但我加定期清理的第一版又踩了坑:我写的是「扫描全部键、删掉过期的」,键多的时候单轮耗时明显,而扫描期间持有锁,所有读写都被挡住——表现是压测的耗时曲线上每隔一段时间出现一个尖峰教训是:为了解决一个问题引入的后台任务,本身可能成为新的问题源。改成按批扫描(每轮只扫一部分、限制单轮耗时、下轮接着扫)之后尖峰就消失了,代价是过期键的回收有延迟(要多轮才能扫完一遍)另外我想强调过期和淘汰是两件不同的事,我一开始把它们混为一谈过期解决的是「这份数据还准不准」,淘汰解决的是「内存够不够」——只做过期的话内存没有硬上界(写入快于过期时仍会涨),只做淘汰的话数据可能已经过时但还在被用。所以我又加了容量上限和淘汰。
    Q:淘汰策略你为什么用最近最少使用,不用访问频次?后者命中率不是更高吗? A:在某些访问模式下确实更高,但我实测之后判断这个收益不值得那份复杂度,而且我能说清它在什么情况下会吃亏。我用固定的访问序列测了两种分布:热点集中的分布下,两者命中率差距不大(因为热点本来就会被反复访问、在链表头部待着);均匀随机的分布下两者都不高(这种分布本身就不适合缓存)。而按访问频次淘汰要付的代价是要为每个键额外维护频次计数更麻烦的是要处理一个真实问题——曾经的热点在不再被访问之后,靠着历史高频次长期占着位置不被淘汰解决它又要引入频次衰减机制,复杂度就上去了所以我选了简单策略(哈希表加双向链表:访问即移到表头、超限淘汰表尾),并在文档里写清它在什么访问模式下会吃亏。判据是「实测的收益差距值不值得这份复杂度」,而不是「哪个策略更先进」——我觉得这一点比选哪个策略更重要,因为它说明我是量过再决定的。另外有两个相关的实现决定我也想说容量上限我用的是条数而不是字节数——字节更贴近「内存够不够」这个真实问题,但准确估算一个对象占多少内存很麻烦而且不准所以我用条数这个近似,并在文档里明确写出这是近似、使用方应该按自己缓存对象的大致大小来设上限——把近似说清楚比假装精确更好。还有超限时批量淘汰多个而不是每次只淘汰一个,避免写入密集时频繁触发淘汰。

模块二:并发安全与锁粒度

  1. 并发安全与锁粒度(一把大锁让多线程读还不如单线程 + 分段降低竞争 + 链表调整才是真正的写操作 + 统计计数不要用重量级同步)★★★
    简历这样写 并发安全改造(纯语言标准库实现,零第三方依赖):初版用一把全局锁保护全部读写,压测发现随着线程数增加吞吐不升反降(读多写少的场景下多线程甚至不如单线程),根因是读操作也要移动访问链表、因而同样是写操作,所有线程都在争同一把锁;改为按键哈希分段,每段独立持有自己的哈希表、访问链表与锁,竞争被分散到各段,压测下吞吐随线程数上升;同时把容量上限与淘汰改为按段生效(全局精确容量需要跨段协调,会把竞争又拉回一处),并在文档中明确总容量是各段之和、单段内近似这一取舍;统计计数从加锁累加改为无锁的原子累加,避免统计本身成为竞争点;此外修正了先判断存在再写入的竞态(两个线程可能都判断为不存在)。全部结论来自自造数据与多线程压测。
    展开完整拆解
    为什么要这么设计

    做并发安全的时候我的第一反应是最稳妥的做法:所有读写方法都加同一把锁正确性没问题,压测数据让我很意外。四个问题。

    一是线程数越多吞吐反而越低。我按读多写少的比例压测(大部分请求是读缓存),预期是多线程会明显快于单线程实际结果是线程数从几个加到几十个之后吞吐不升反降,某些配置下甚至不如单线程——因为所有线程都在排队等同一把锁,而排队本身还有开销。

    二是我一开始想改成读写锁,结果发现没用。思路是「读多写少,那读的时候用共享锁就好了」。但改完之后几乎没有改善——因为我的读操作会把节点移到访问链表的头部,这是在修改链表结构,本质上是一个写操作「读缓存」在实现层面并不是只读的,这一点我完全没想到。

    三是统计计数也成了竞争点。我的命中率统计是在锁里累加的。后来我把它挪到锁外,用普通变量累加,结果统计值不准(并发下丢更新)再改成加锁累加,又把它变成了一个新的竞争点——而它只是一个计数。

    四是「先判断存在再写入」有竞态。我有一个「不存在才写入」的方法,实现是先查一下、不存在则写。两个线程可能都查到「不存在」,然后都写入——后一个覆盖了前一个。

    所以四个改动:一把全局锁改为按键哈希分段、每段独立持锁容量上限与淘汰改为按段生效统计计数改用无锁的原子累加把「先判断再写入」改成一个持锁内的原子操作

    这个模块最想说的一句话是:我以为「读缓存」是只读操作,所以以为读写锁能解决问题——而实际上因为要维护访问顺序,读也在写。这个认知错误让我在读写锁上白花了时间而想通之后思路就变了:既然读也是写、那就没办法靠区分读写来降低竞争,只能靠「把竞争分散到多个锁上」。

    整体链路
    为什么一把大锁不行 │ ├─ 所有读写方法争同一把锁 → 实际上退化为串行 │ 压测中线程数从几个加到几十个后吞吐不升反降 │ 某些配置下甚至不如单线程(排队本身还有开销) │ └─ 为什么换读写锁没用(我走过的弯路) 思路:读多写少,读用共享锁 问题:读操作要把节点移到访问链表头部 —— 这是修改链表结构 所以「读缓存」在实现层面并不是只读的 结论:既然读也是写,就没办法靠区分读写来降低竞争 分段(把竞争分散开) │ ├─ 按键的哈希值把缓存分成若干段 │ ├─ 每段独立持有:自己的哈希表 · 自己的访问链表 · 自己的锁 │ 不同段之间完全不互相阻塞 │ ├─ 段数的选择要权衡 │ 太少 → 竞争仍然集中 │ 太多 → 每段容量变小,淘汰更频繁;内存开销也增加 │ 我是按线程数量级试出来的(见取舍) │ └─ 键到段的映射要稳定:同一个键永远落在同一段 不稳定的后果:同一个键可能在多段各存一份 容量与淘汰改为按段生效 │ ├─ 每段有自己的容量上限(总上限除以段数) │ ├─ 为什么不做全局精确容量 │ 全局精确要求跨段协调(知道所有段的总条数) │ 而协调就需要一个全局的同步点 —— 竞争又被拉回一处 │ 这样分段的意义就没了 │ └─ 代价:分布不均时某段先满、开始淘汰,而其他段还有空位 也就是「总容量是各段之和、单段内近似」 这个取舍要在文档里写清,不要让使用方以为是全局精确的 统计计数(不要用重量级同步) │ ├─ 命中 · 未命中 · 淘汰 · 过期清理 的计数用原子累加 │ ├─ 放在锁里累加的后果:统计本身成了竞争点,而它只是一个计数 ├─ 用普通变量累加的后果:并发下丢更新,统计值不准 └─ 原子累加:无锁、且结果准确 —— 这是这个场景的正确选择 复合操作要在一次持锁内完成 │ ├─ 「不存在才写入」:先查再写有竞态 │ 两个线程可能都查到「不存在」,然后都写入,后者覆盖前者 │ ├─ 正确做法:查与写在同一次持锁内完成(或用标准库提供的原子方法) │ └─ 同理:「读取或加载」「递增计数」这类复合操作都不能拆成两步 这也是模块三要解决的核心问题 其他并发细节 ├─ 定期清理任务要按段加锁,不要一次锁住全部 ├─ 遍历统计(比如当前条数)可以是近似的,不必为它加全局锁 └─ 回调函数不要在持锁状态下调用 使用方的回调里可能又来调缓存 —— 会死锁
    分步拆解
    1. 先认清「读缓存也是写操作」。因为读要把节点移到访问链表头部——这是修改链表结构。不认清这一点会在读写锁上白花时间。
    2. 一把全局锁在多线程下会退化为串行。压测中线程数增加后吞吐不升反降,某些配置下甚至不如单线程(排队本身还有开销)。
    3. 用按键哈希分段来分散竞争。每段独立持有自己的哈希表、访问链表和锁,不同段之间完全不互相阻塞。
    4. 键到段的映射必须稳定。同一个键永远落在同一段——不稳定的后果是同一个键在多段各存一份。
    5. 段数要权衡:太少竞争仍集中、太多则每段容量变小导致淘汰更频繁。要按实际线程数量级实测,不要拍一个数。
    6. 容量上限与淘汰改为按段生效。全局精确容量需要跨段协调,而协调就需要一个全局同步点,竞争又被拉回一处——分段的意义就没了。
    7. 要在文档里写清「总容量是各段之和、单段内近似」。分布不均时某段先满开始淘汰而其他段还有空位,使用方必须知道这一点。
    8. 统计计数用原子累加,不要加锁、也不要用普通变量。加锁会让统计本身成为竞争点,普通变量会丢更新导致统计不准。
    9. 复合操作必须在一次持锁内完成。「不存在才写入」如果拆成先查再写,两个线程可能都查到「不存在」然后都写入。
    10. 「读取或加载」这类操作同样不能拆成两步。这是模块三要解决的核心问题。
    11. 定期清理任务要按段加锁。一次锁住全部会挡住所有段的读写。
    12. 遍历统计(当前总条数)可以是近似的。为它加全局锁不值得——而这个数字本来就是给人看的,近似完全够用。
    13. 回调函数不要在持锁状态下调用。使用方的回调里可能又来调缓存,会死锁——这是很容易埋进去的问题。
    14. 压测要覆盖不同读写比例。读多写少和读写各半的表现差别很大,只测一种得出的结论可能是错的。
    15. 压测的键分布也要覆盖两种:热点集中与均匀分散。热点集中时同一段的竞争会更明显,这是分段方案的弱点,要测出来并说清。
    关键决策与取舍

    用分段而不是读写锁,这是我走过弯路之后才想清楚的。读写锁的思路(读多写少、读用共享锁)看起来完全对症。但它失效的原因是我的读操作要移动访问链表——「读缓存」在实现层面并不是只读的想通这一点之后思路就变了:既然读也是写,就没办法靠区分读写来降低竞争,只能靠「把竞争分散到多个锁上」判据是「这个操作真的只读吗」——而判断的方法是看它有没有修改任何共享状态,包括那些为了别的目的(维护访问顺序)而做的修改。

    容量上限按段生效,接受「不是全局精确」。全局精确容量更符合使用方的直觉。但它要求跨段协调(知道所有段的总条数),而协调就需要一个全局同步点——竞争又被拉回一处,分段就白做了代价是分布不均时某段先满开始淘汰、而其他段还有空位我的处理是接受这个近似,但在文档里明确写出来——判据是「这个精确性值不值得放弃分段带来的并发收益」:不值得,因为容量上限本来就是一个防护性的约束、不需要精确到条。

    统计计数用原子累加而不是加锁。加锁的结果最准确。但统计只是一个计数,为它引入锁竞争完全不成比例——它会成为一个所有请求都要经过的竞争点而用普通变量又会丢更新、统计值不准(我试过,命中率算出来明显偏低)原子累加同时满足了「无锁」和「准确」判据是「这个操作的成本应该和它的重要性匹配」——一个统计计数不该拖累主路径。

    段数按线程数量级实测选取,而不是拍一个固定值。段数太少则竞争仍然集中;太多则每段容量变小、淘汰更频繁(同样的总容量被切得更碎),而且每段的结构本身也有内存开销我是按压测的线程数量级试了几组,看吞吐曲线在哪里趋于平坦报参数的时候我会说清这个过程,因为直接给一个数字而不说依据就是拍的。

    踩过的坑一:一把大锁让多线程比单线程还慢。这个结果非常反直觉,我第一反应是压测写错了,反复确认之后才接受。教训是:加锁保证正确性的代价可能大到让并发失去意义——而这个代价只能靠压测看出来,看代码是看不出来的。

    踩过的坑二:换读写锁几乎没有改善,白花了时间。根因是我误以为读是只读的。教训是:在优化之前先确认自己对「这段代码在做什么」的理解是对的——我如果一开始就仔细看一遍读操作都改了哪些共享状态,就不会走这个弯路。

    踩过的坑三:统计计数在两个极端之间摇摆。先是放锁外用普通变量(不准)、再是放锁里累加(成了竞争点)。教训是:遇到「要么不准要么太慢」的时候,往往是工具选错了——这个场景需要的是原子操作,而我一直在「加锁」和「不加锁」之间二选一。

    没做的部分:没做无锁的实现(用完全无锁的数据结构)。它理论上能进一步提升并发,但访问链表的维护在无锁下非常复杂、正确性极难验证——而我一个人写的东西,正确性比极限性能重要得多取舍依据是「我能不能证明它是对的」:无锁实现我没有把握,那就不做。我认为这个判断本身比做出来更能说明我知道自己的边界。

    数字是怎么测的

    吞吐随线程数的变化是这个模块最核心的数据,而且要画成曲线看:报「在读写比例为某个值时,线程数从 1 增加到 N 的吞吐曲线」。报法是「一把全局锁时曲线在几个线程后就趋于平坦甚至下降;分段后曲线继续上升」——曲线的形状比单点数字有说服力得多,而且「下降」这个反直觉的结果本身就是证据。

    必须覆盖不同读写比例:至少测「读多写少」和「读写各半」两种。只测一种得出的结论可能是错的——而读多写少恰好是缓存的典型场景,要重点报。

    键分布也要覆盖两种:热点集中与均匀分散。热点集中时同一段的竞争会更明显,这是分段方案的弱点——我把它测出来并写清,而不是只报对我有利的那组数据。

    读写锁失效要作为对照报出来:「改用读写锁后吞吐几乎没有改善」,并说明原因是读操作也在修改访问链表。报出一个失败的尝试,比只报成功的方案更能说明我理解了根因。

    段数选择要报实测过程:「试了几组段数,看吞吐曲线在哪里趋于平坦」。直接给一个数字而不说依据就是拍的。

    统计准确性用断言型用例:多线程执行已知总次数的命中与未命中,断言统计值与预期完全一致(用普通变量时这里会偏低,可以作为对照)。

    竞态用例:多线程并发调用「不存在才写入」,断言最终只有一个线程的值被写入、且返回值能让每个线程知道自己有没有写成功。

    死锁:在回调函数里再次调用缓存,断言不会死锁(因为回调是在锁外调用的)。这条容易漏但很值得写。

    不要报什么:不要报「吞吐提升 N 倍」而不说线程数与读写比例——倍数完全取决于这两个条件。该报的是「某读写比例下吞吐随线程数的两条曲线对比」「读写锁方案作为对照几乎无改善及其原因」「段数的实测选取过程」「多线程下统计值与预期完全一致」这几件带条件的、可核对的事。

    面试追问
    Q:并发安全加一把锁不就行了?为什么要分段? A:加一把锁正确性没问题,但压测结果非常反直觉:线程数从几个加到几十个之后吞吐不升反降,某些配置下甚至不如单线程。我第一反应是压测写错了,反复确认才接受——因为所有线程都在排队等同一把锁,实际上退化成了串行,而排队本身还有开销教训是:加锁保证正确性的代价可能大到让并发失去意义,而这个代价只能靠压测看出来、看代码是看不出来的。然后我走了一个弯路,我觉得这个弯路比结论更值得讲:我想「读多写少,那读的时候用共享锁就好了」,改成读写锁之后几乎没有任何改善根因是我的读操作要把节点移到访问链表的头部——这是在修改链表结构,本质上是一个写操作也就是说「读缓存」在实现层面并不是只读的,这一点我完全没想到。想通之后思路就变了:既然读也是写,就没办法靠区分读写来降低竞争,只能靠「把竞争分散到多个锁上」——所以改成按键哈希分段,每段独立持有自己的哈希表、访问链表和锁,不同段之间完全不互相阻塞,压测下吞吐就随线程数上升了。这个改动有一个必须一起接受的代价容量上限和淘汰也只能按段生效——因为全局精确容量要求跨段协调,而协调就需要一个全局同步点,竞争又被拉回一处,分段就白做了。所以我在文档里明确写了「总容量是各段之和、单段内近似」,分布不均时某段先满开始淘汰而其他段还有空位我认为把这个近似说清楚,比让使用方以为它是全局精确的更负责。
    Q:命中率统计这种小事有什么可讲的? A:它值得讲是因为我在两个极端之间来回摇摆过,而这个过程暴露了我当时的思维盲区。第一版我把命中和未命中的计数放在锁里累加——正确,但它让统计本身成了一个竞争点,而它只是一个计数:每一次读缓存都要经过它,等于给主路径又加了一道竞争。第二版我把它挪到锁外、用普通变量累加——结果并发下丢更新,命中率算出来明显偏低,而我一开始还以为是缓存效果不好、去调容量和过期时间,白折腾了一阵。第三版才用原子累加,它同时满足了「无锁」和「准确」。教训是:遇到「要么不准要么太慢」的时候,往往是工具选错了——我一直在「加锁」和「不加锁」之间二选一,而这个场景需要的是原子操作更一般的判据是:这个操作的成本应该和它的重要性匹配,一个统计计数不该拖累主路径。顺带说两个同类的、也很容易埋雷的细节一是「不存在才写入」这种复合操作不能拆成先查再写——两个线程可能都查到「不存在」然后都写入,后者覆盖前者;必须在一次持锁内完成。二是回调函数不要在持锁状态下调用——使用方的回调里可能又来调缓存,那就死锁了;我专门写了一条用例:在回调里再次调用缓存,断言不会死锁。这类问题的共同点是:它们在单线程测试下 100 次都不出现,只有并发压测或者刻意构造才会暴露。

模块三:同一键的加载只执行一次

  1. 同一键的加载只执行一次(热点失效瞬间的重复加载合并 + 加载必须在锁外执行 + 失败与超时后的占位清理 + 递归加载的检测)★★★
    简历这样写 加载合并机制(纯语言标准库实现,零第三方依赖):热点键失效的瞬间,压测中出现数十个线程同时未命中并各自执行同一个加载函数(用打桩的加载函数计数确认),改为同一个键的加载只执行一次、其余线程等待并复用结果;实现上把占位登记放在锁内、加载函数本身在锁外执行——初版在持锁状态下调用加载函数,导致一次较慢的加载把整段的读写全部阻塞;补上失败与超时后的占位清理(初版加载抛异常后占位残留,后续请求全部拿到同一个失败结果、且再也不会重新加载),以及等待方的超时上限(避免加载方长时间不返回时等待线程被无限阻塞);对加载函数内部又访问同一个键的递归情况做了检测并直接报错,而不是死锁。加载函数的调用次数可通过打桩精确统计。
    展开完整拆解
    为什么要这么设计

    这个模块解决的是一个我在论坛项目里真实遇到、但当时不知道该怎么描述的现象:某个热点查询的缓存一过期,数据库那边的压力就会突然抖一下抽成组件之后我用打桩的加载函数计数,才把它量化出来。四个问题。

    一是热点键失效瞬间,几十个线程各自执行了同一次加载。我的实现是「未命中就调用加载函数、把结果写回缓存」。单线程下完全正确;但几十个线程同时未命中时,它们各自都走了这条路——同一个昂贵的加载被执行了几十遍我是用一个会计数的加载函数打桩才确认的:一次热点失效,加载函数被调用了几十次。

    二是我把加载函数放在锁里执行,结果更糟。我的第一个修法是「持锁期间完成未命中判断和加载」,这样确实只有一个线程会加载但加载函数是使用方提供的、可能是一次数据库查询——一次较慢的加载把整段的读写全部阻塞了压测里的表现是耗时曲线上出现很宽的平台期,比重复加载更难接受。

    三是加载失败之后,后续请求全都拿到同一个失败结果、而且再也不会重新加载。我用一个占位来表示「这个键正在加载」。但加载抛异常时我没有清理占位——于是那个键上永远挂着一个失败的占位,后面所有请求都在复用这个失败结果这个 bug 比原问题严重得多,因为它是永久性的。

    四是加载函数里又访问了同一个键,直接死锁。这是我自己在测试时不小心写出来的:加载函数内部又调了一次「读取或加载」同一个键它在等自己完成,永远等不到。

    所以四个改动:同一个键的加载只执行一次、其余线程等待并复用结果占位登记在锁内、加载函数在锁外执行失败与超时后清理占位检测递归加载并直接报错。

    这个模块最想说的一句话是:解决「重复加载」的关键不是「加锁让别人等」,而是「让别人等的同时不要挡住无关的操作」。我的第一个修法(把加载放锁里)确实消除了重复加载,但它把一个局部问题(同一个键的重复加载)变成了一个全局问题(整段被阻塞)正确的做法是把「登记我要加载这个键」和「实际执行加载」分开:前者很快、可以持锁;后者可能很慢、必须在锁外。

    整体链路
    正常路径 │ ├─ 读取 → 命中且未过期 → 直接返回 │ └─ 未命中或已过期 → 进入加载流程(下面) 加载流程(关键是把「登记」和「执行」分开) │ ├─ 第一步(持锁,很快) │ 检查这个键上有没有「正在加载」的占位 │ 没有 → 登记一个占位,标记自己是加载方 → 释放锁 │ 有 → 记下这个占位,标记自己是等待方 → 释放锁 │ ├─ 第二步(锁外) │ 加载方:执行加载函数 → 写回缓存 → 唤醒等待方 → 移除占位 │ 等待方:在占位上等待结果(带超时) │ └─ 为什么加载函数必须在锁外执行(我踩过) 加载函数是使用方提供的,可能是一次数据库查询 在持锁状态下执行 → 一次较慢的加载把整段读写全部阻塞 压测表现:耗时曲线上出现很宽的平台期,比重复加载更难接受 失败与超时(这一块我漏了,后果比原问题严重) │ ├─ 加载函数抛异常 │ 必须清理占位,并把异常传递给全部等待方 │ 不清理的后果 │ 那个键上永远挂着一个失败的占位 │ 后续所有请求都复用这个失败结果,而且再也不会重新加载 │ 这个 bug 是永久性的,比偶尔重复加载严重得多 │ ├─ 加载方超时或线程被中断 │ 同样要清理占位,让后来者有机会重新加载 │ ├─ 等待方也要有超时上限 │ 不设的后果:加载方长时间不返回时,等待线程被无限阻塞 │ 超时后等待方可以选择自己去加载,或者返回失败(我选返回失败) │ └─ 清理占位要保证只被清理一次(避免误清后来者登记的新占位) 做法是清理时校验占位是不是自己登记的那一个 递归加载的检测 ├─ 加载函数内部又访问同一个键 → 它在等自己完成,永远等不到 ├─ 我是自己在测试时不小心写出来的 ├─ 做法:记录当前线程正在加载哪些键,发现递归直接报错 └─ 直接报错比死锁好得多 —— 死锁没有任何线索,报错能指出问题在哪 其他相关设计 │ ├─ 「读取或加载」必须是一个方法,不要让使用方自己判断再加载 │ 让使用方自己写「先读、没有就加载再写回」的后果 │ 每个调用点都要重复实现一遍,而且都会漏掉合并 │ ├─ 加载成功后写回缓存要用加载方拿到的那个键的过期配置 │ └─ 可以提供「返回旧值并后台刷新」的选项(见取舍) 我没有默认开启,因为它会返回过期数据,语义上要使用方明确接受
    分步拆解
    1. 「读取或加载」必须做成组件提供的一个方法。让使用方自己写「先读、没有就加载再写回」的后果是每个调用点都要重复实现一遍,而且都会漏掉合并。
    2. 把「登记我要加载」和「实际执行加载」分成两步。前者很快、可以持锁;后者可能很慢、必须在锁外。
    3. 登记占位要在持锁状态下做。否则两个线程可能都登记成功,合并就失效了。
    4. 加载函数绝对不能在持锁状态下执行。它是使用方提供的、可能是一次数据库查询——一次较慢的加载会把整段读写全部阻塞,表现是耗时曲线上很宽的平台期。
    5. 加载方完成后要写回缓存、唤醒全部等待方、移除占位。三件事的顺序要保证等待方能拿到结果。
    6. 加载抛异常时必须清理占位并把异常传给全部等待方。不清理的后果是那个键上永远挂着一个失败的占位,后续所有请求都复用这个失败结果、而且再也不会重新加载——这个 bug 是永久性的。
    7. 加载方超时或被中断时同样要清理占位。要用「无论如何都会执行」的收尾方式,不能只在正常路径上清理。
    8. 清理占位时要校验它是不是自己登记的那一个。否则可能误清后来者登记的新占位,导致合并失效甚至更混乱。
    9. 等待方必须有超时上限。不设的后果是加载方长时间不返回时,等待线程被无限阻塞。
    10. 等待超时后的行为要明确。可以选择自己去加载或返回失败——我选返回失败,因为「自己去加载」在加载方本身很慢时会让情况更糟。
    11. 要检测递归加载:加载函数内部又访问同一个键。它在等自己完成、永远等不到——做法是记录当前线程正在加载哪些键,发现递归直接报错。
    12. 报错比死锁好得多。死锁没有任何线索,而报错能直接指出问题在哪一个键上。
    13. 合并只针对「同一个键」。不同键的加载应该完全并行,不要因为实现方便而让它们互相等待。
    14. 加载函数的调用次数要能被统计。这是验证合并是否生效的唯一可靠方式——用打桩的加载函数计数。
    15. 可以提供「返回旧值并后台刷新」的选项,但不要默认开启。因为它会返回过期数据,语义上要使用方明确接受。
    关键决策与取舍

    把加载函数放在锁外执行,这是这个模块最关键的一个决定,而我第一版做错了。放在锁内实现最简单(一个大的同步块就搞定),也确实消除了重复加载但它把一个局部问题(同一个键的重复加载)变成了一个全局问题(整段被阻塞)——加载函数是使用方提供的,它可能是一次很慢的数据库查询,而我把它放进了自己的临界区判据是「这段代码的耗时是不是由我控制」:不由我控制(是使用方的回调),那就绝对不能放在锁里这一条我后来推广成一个习惯:任何在临界区里调用外部代码的地方都要停下来想一下。

    等待超时之后返回失败,而不是「自己去加载」。自己去加载看起来更积极(不让用户拿到失败)。但等待超时通常意味着加载方本身很慢(比如数据库压力大),这时候再放一个线程去加载只会让情况更糟——而且它会退化成「超时之后大家都自己加载」,也就是回到了最初的重复加载问题判据是「这个补救措施会不会加重根本原因」:会,那就不该做。

    加载失败不缓存失败结果,让后来者可以重新加载。缓存失败结果(短时间内直接返回失败)能防止「持续失败时反复冲击下游」。但它和我踩的那个坑只有一线之隔——我的坑就是失败占位没清理,导致永久返回失败所以我的选择是不缓存失败、但把「防止反复冲击」的责任交给使用方的加载函数自己(它可以在内部做熔断)。判据是「哪一种错误更难被发现」:永久返回失败极难被发现(它不报错、只是一直不对),而反复加载至少是可观测的。

    「返回旧值并后台刷新」做成可选而不是默认。它能让热点键失效时完全没有等待(先返回过期值、后台去刷新)。但它返回的是过期数据——对某些数据可以接受,对另一些完全不行,而组件无法替使用方做这个判断所以默认关闭、需要时显式开启判据是「这个行为改变了数据的语义吗」:改变了,那就必须由使用方明确接受。

    踩过的坑一:加载函数放在锁里,一次慢加载阻塞整段。压测里的表现是耗时曲线上出现很宽的平台期。教训是:解决「重复加载」的关键不是「加锁让别人等」,而是「让别人等的同时不要挡住无关的操作」——正确的做法是把「登记我要加载这个键」(很快,可以持锁)和「实际执行加载」(可能很慢,必须锁外)分开。

    踩过的坑二:加载抛异常后占位没清理,那个键永久返回失败。这个 bug 比原问题严重得多,因为它是永久性的,而且不报错、只是那个键一直不对教训是:任何「登记一个中间状态」的地方,都必须保证在所有退出路径上(成功、异常、超时、中断)都会被清理——而正常路径上的清理是最容易写的,异常路径最容易漏。

    踩过的坑三:加载函数里又访问同一个键,直接死锁。这是我自己测试时不小心写出来的。教训是:如果一种误用会导致死锁,那就主动检测它并报错——死锁不给你任何线索(进程就停在那),而一条报错能直接指出问题在哪个键上。这个成本很低但对使用方(包括未来的我自己)帮助很大。

    没做的部分:没做「提前刷新」(在键即将过期时主动后台更新,让它永不失效)。它能进一步消除失效瞬间的等待,但需要额外的后台任务去跟踪「哪些键即将过期且是热点」,而判断「是不是热点」又需要访问频次统计取舍依据是「加载合并已经把重复加载降到一次,剩下的收益是那一次的等待时间」——收益变小而复杂度明显上升,所以我停在这里,但能说清下一步是什么。

    数字是怎么测的

    加载函数的调用次数是这个模块唯一真正重要的数字,而且它极容易精确统计:用一个会计数的加载函数打桩,让 N 个线程在同一个键失效的瞬间并发请求,断言加载函数只被调用 1 次(改造前是 N 次)报法是「N 个线程并发请求同一个失效键,加载函数调用次数从 N 降到 1」——这个数字是断言级的、没有任何解释空间。

    不同键要并行,也要断言:N 个线程请求 N 个不同的键,断言它们的加载是并行的(总耗时接近单次加载耗时,而不是 N 倍)。这条防的是「为了实现方便把所有加载都串行化」。

    锁外执行要通过耗时分布验证:让加载函数故意睡一段时间,断言期间其他键的读写不被阻塞并报「加载在锁内时耗时曲线出现宽平台期、移到锁外后消失」作为对照

    失败清理是踩坑的直接回归:让加载函数抛异常,断言全部等待方都收到异常、且占位被清理——随后再次请求时加载函数会被重新调用最后半句是关键,它验证的正是「不会永久失败」。

    超时清理:让加载方长时间不返回,断言等待方在超时后返回失败而不是无限阻塞断言加载方超时后占位被清理。

    占位校验:构造「加载方超时后新的加载方登记了占位」的场景,断言旧加载方的清理不会误删新占位。

    递归检测:写一个在内部访问同一个键的加载函数,断言抛出明确的错误而不是死锁(可以给测试设超时来确认不是卡住)。

    不要报什么:不要报「下游压力降低 N 倍」——我没有真实下游,那个倍数就是并发线程数、没有额外信息。该报的是「N 个线程并发请求同一失效键时加载函数调用次数从 N 降到 1」「不同键的加载并行、总耗时接近单次」「加载在锁内与锁外的耗时分布对照」「加载失败后占位被清理且下次会重新加载」这几件可核对的事。

    面试追问
    Q:缓存未命中就去加载,这有什么问题? A:单线程下完全没问题,问题出在「热点键失效的那一瞬间」。我的实现是「未命中就调用加载函数、把结果写回缓存」,但几十个线程同时未命中时,它们各自都走了这条路——同一个昂贵的加载被执行了几十遍我是用一个会计数的加载函数打桩才把它量化出来的:一次热点失效,加载函数被调用了几十次。(这个现象我在自己的论坛项目里其实遇到过——某个热点查询的缓存一过期,数据库那边的压力就抖一下,但当时我不知道该怎么描述它。)解法是「同一个键的加载只执行一次,其余线程等待并复用结果」。但我第一版的修法是错的,而且这个错误比原问题更严重:我把「未命中判断 + 加载」整个放进了持锁的代码块里。这样确实只有一个线程会加载,但加载函数是使用方提供的、可能是一次很慢的数据库查询——我把它放进了自己的临界区,结果一次慢加载把整段的读写全部阻塞了。压测里的表现是耗时曲线上出现很宽的平台期,比重复加载更难接受正确的做法是把两件事分开:「登记我要加载这个键」很快、可以持锁;「实际执行加载」可能很慢、必须在锁外。我总结的判据是:这段代码的耗时是不是由我控制?不由我控制(是使用方的回调),那就绝对不能放在锁里。这一条后来变成了我的一个习惯——任何在临界区里调用外部代码的地方都要停下来想一下。另外有一条我专门写了用例的:不同键的加载必须并行,不能为了实现方便把所有加载都串行化。
    Q:加载失败了怎么办?等待的那些线程呢? A:这一块我漏了,而漏掉之后产生的 bug 比原问题严重得多——因为它是永久性的。我用一个占位表示「这个键正在加载」,但加载函数抛异常时我没有清理占位——于是那个键上永远挂着一个失败的占位,后续所有请求都在复用这个失败结果,而且再也不会重新加载它不报错、只是那个键一直不对,极难被发现。教训是:任何「登记一个中间状态」的地方,都必须保证在所有退出路径上(成功、异常、超时、线程被中断)都会被清理——而正常路径上的清理最容易写,异常路径最容易漏。修好之后的行为是:加载抛异常时清理占位、并把异常传递给全部等待方;随后再次请求时加载函数会被重新调用(我把「下次会重新加载」也写成了断言,因为它才是这个修复的关键)。还有两个相关的设计一是等待方必须有超时上限,否则加载方长时间不返回时等待线程被无限阻塞;超时之后我选择返回失败,而不是「自己去加载」——因为等待超时通常意味着加载方本身很慢(比如下游压力大),这时候再放一个线程去加载只会让情况更糟,而且会退化成「超时之后大家都自己加载」,等于回到最初的重复加载问题判据是这个补救措施会不会加重根本原因。二是我没有缓存失败结果。缓存失败能防止持续失败时反复冲击下游,但它和我踩的那个坑只有一线之隔——永久返回失败极难被发现,而反复加载至少是可观测的,所以我选择不缓存失败、把「防止反复冲击」交给使用方的加载函数自己去做。另外清理占位时要校验它是不是自己登记的那一个,否则可能误删后来者的新占位。

模块四:对外 API 与可测性

  1. 对外 API 与可测性(时间源可注入才能测过期 + 构建器式配置与参数校验 + 默认值必须安全 + 回调绝不在持锁时调用)★★★
    简历这样写 组件对外 API 与可测性设计(Java + JUnit):缓存的行为几乎全与时间相关,初版直接读系统时间,导致过期相关的测试只能靠线程休眠(用例慢且偶发失败);改为把时间源抽成接口注入,测试中用可手动推进的实现,使过期、续期、定时清理等场景不依赖真实等待即可断言,相关用例耗时从秒级降到毫秒级且结果稳定;对外配置改为构建器式并在构建时校验参数合法性与互斥组合(如容量非正、过期时间为负、未设置加载器却调用加载方法),把原先运行期才暴露的错误提前到初始化时快速失败默认值按安全方向选取(默认有容量上限而非无界,避免使用方漏配导致内存持续增长);淘汰与移除回调不在持锁期间调用,避免使用方回调里的耗时操作或反过来访问缓存造成阻塞与自锁,该问题通过在回调中故意反向调用缓存复现。
    展开完整拆解
    为什么要这么设计

    前三块讲的都是缓存内部怎么实现。这一块是我写组件和写业务代码最大的区别:使用者不是我。业务代码写错了我自己会碰到;而组件的 API 设计错了,是别人(或者三个月后的我)以一种莫名其妙的方式踩坑。四个问题。

    一是过期根本没法测。缓存的行为几乎全是时间相关的——过期、续期、定时清理,全都要「等一段时间之后」才能验证我最初直接读系统时间,所以测过期只能让线程睡一会儿后果有两个:一是用例慢(睡一秒就是一秒),二是它会偶发失败——机器一卡,睡的时间和实际经过的时间就对不上了更糟的是我想测「一小时后过期」,那就完全测不了。

    二是参数配错了要等到运行时才炸。我一开始是构造函数收一堆参数。容量传了 0、过期时间传了负数,构造的时候一声不响,等到真正 put 或者取的时候才出现奇怪的行为更隐蔽的是「没设置加载器却调用了自动加载的方法」——这在运行到那一行之前完全看不出来。

    三是默认值选错了方向。我最初的默认容量是「无上限」,理由是「不设限制最不容易出错」但这个方向恰恰是危险的——使用方漏配容量,缓存就一直涨,直到内存出问题而如果默认是有上限的,漏配的后果只是命中率低一些,是可以被察觉、也不会致命的。

    四是回调在持锁时调用,直接把使用方坑死。我提供了淘汰和移除的回调。而我是在拿着锁的时候调它的——使用方如果在回调里做了耗时操作(比如写日志到磁盘),就把锁一直占着;如果他在回调里反过来访问缓存,就直接自锁死住了我是自己在回调里故意反向调用缓存时撞出来的。

    所以四个改动:时间源抽成接口注入构建器式配置并在构建时校验参数与互斥组合默认值按安全方向选取回调移出锁外调用

    这个模块最想说的一句话是:组件的 API 是给别人用的,所以「用错的时候会发生什么」比「用对的时候有多方便」更重要。我最初的每一个设计都是站在实现者的角度做的——直接读系统时间最简单、构造函数收参数最直接、默认无上限最不容易报错、在锁里调回调最省事而这四个「最简单」,对使用者来说分别是:测不了、错了不知道、漏配就出事、以及会莫名死锁。

    整体链路
    时间源(这是可测性的根) │ ├─ 抽一个接口,只有一个方法:返回当前时刻 │ 生产实现:读系统时间(用单调递增的那个,不要用挂钟时间) │ 测试实现:内部保存一个值,提供「推进 N 毫秒」的方法 │ ├─ 缓存内部所有涉及时间的地方,全部只经过这个接口 │ 任何一处漏掉、直接读系统时间,测试就又变得不可靠 │ └─ 直接读系统时间的后果(我踩过) 测过期只能让线程睡一会儿 用例慢:睡一秒就是一秒 偶发失败:机器一卡,睡的时间和实际经过的时间对不上 想测「一小时后过期」,完全测不了 为什么必须用单调递增的时间而不是挂钟时间 ├─ 挂钟时间会被系统对时或用户手动调整 ├─ 时间被往回调的话,已经写入的过期时刻会变成「很久以后」 │ 表现是这些条目永远不过期 └─ 我改设备时间复现过这个问题 构建器式配置 │ ├─ 链式设置:容量 · 过期时间 · 加载器 · 淘汰回调 · 是否开启统计 │ ├─ 在构建的那一刻做全部校验 │ 单项合法性:容量必须为正 · 过期时间不能为负 │ 互斥与依赖:没设加载器就不能用自动加载的方法 │ 有歧义的组合直接拒绝,而不是猜使用方想干什么 │ └─ 不校验的后果 容量传 0、过期时间传负数,构造时一声不响 等到真正读写时才出现奇怪的行为 「没设加载器却调用自动加载」在运行到那一行之前完全看不出来 默认值要选安全的方向 │ ├─ 默认有容量上限,而不是无界 │ 我最初选无界,理由是「不设限制最不容易出错」—— 方向错了 │ 漏配容量 → 缓存一直涨 → 内存出问题 │ 而默认有上限的话,漏配的后果只是命中率低一些,可察觉且不致命 │ ├─ 默认不开启统计(有开销),但要让它很容易被打开 │ └─ 判据:漏配这个参数的后果,是「效果差一点」还是「出故障」 回调的契约(这一条最容易坑到使用方) │ ├─ 绝不在持锁期间调用回调 │ 在锁里调的后果 │ 使用方在回调里做耗时操作(写日志到磁盘)→ 锁被长期占用 │ 使用方在回调里反过来访问缓存 → 自锁,直接死住 │ 我是自己在回调里故意反向调用缓存时撞出来的 │ ├─ 做法:锁内先把要通知的内容收集起来,出锁后再逐个调用 │ ├─ 回调抛异常不能影响缓存本身 │ 捕获并记录,不要让使用方的 bug 破坏缓存状态 │ └─ 文档里明确写清:回调可能在任意线程执行、不保证顺序 API 表面要小 ├─ 只暴露必要的方法,内部结构一律不外泄 │ 返回内部集合的引用 = 使用方能绕过锁改动它 ├─ 需要遍历时返回快照,并说明它是某一时刻的视图 └─ 方法命名要能体现副作用(会不会触发加载、会不会刷新访问时间)
    分步拆解
    1. 时间源必须抽成接口注入,这是整个可测性的根。直接读系统时间的后果是测过期只能靠线程休眠——用例慢、而且机器一卡就偶发失败。
    2. 测试实现要能手动推进时间。这样「一小时后过期」也能在毫秒内断言——而靠 sleep 是根本测不了的。
    3. 缓存内部涉及时间的地方必须全部经过这个接口。任何一处漏掉、直接读系统时间,测试就又变得不可靠——而且这种漏掉很难发现。
    4. 生产实现要用单调递增的时间,不要用挂钟时间。挂钟会被对时或手动调整,时间往回调之后,已写入的过期时刻会变成「很久以后」,表现是这些条目永远不过期——我改设备时间复现过。
    5. 配置改成构建器式,在构建那一刻做全部校验。而不是把参数堆在构造函数里。
    6. 单项合法性要校验:容量必须为正、过期时间不能为负。不校验的后果是构造时一声不响、运行时出现奇怪行为。
    7. 互斥与依赖关系也要校验。比如没设加载器就不能用自动加载的方法——这类错误在运行到那一行之前完全看不出来。
    8. 有歧义的组合直接拒绝,不要猜使用方想干什么。猜错的代价是他以为配好了、实际行为完全不同。
    9. 默认值要按「漏配的后果」来选,而不是按「哪个不容易报错」来选。我最初默认无界容量,方向就错了。
    10. 默认有容量上限。漏配容量的后果从「内存持续增长直到出故障」变成「命中率低一些」——后者可察觉且不致命。
    11. 默认不开启统计(它有开销),但要让它很容易打开。取舍要留给使用方,但默认值要保守。
    12. 回调绝不能在持锁期间调用。使用方在回调里做耗时操作会长期占锁,反过来访问缓存会直接自锁死住。
    13. 做法是锁内收集要通知的内容、出锁后再逐个调用。这一步实现起来不难,但想不到的话就是一个必然会被踩的坑。
    14. 回调抛异常不能影响缓存本身。捕获并记录——不要让使用方的 bug 破坏缓存状态。
    15. 文档里要写清回调可能在任意线程执行、不保证顺序。不写的话使用方会默认它是同步有序的。
    16. API 表面要小,内部结构一律不外泄。返回内部集合的引用等于使用方能绕过锁改动它。
    17. 需要遍历时返回快照,并说明它是某一时刻的视图。让使用方知道它不是实时的。
    18. 方法命名要能体现副作用。会不会触发加载、会不会刷新访问时间——这些从名字上看不出来就一定会被误用。
    关键决策与取舍

    时间源抽成接口注入,代价是多一层间接调用。直接读系统时间最简单、也没有任何性能损耗。但缓存的行为几乎全是时间相关的——如果时间不可控,那么过期、续期、定时清理这三块的正确性我全都验证不了,只能靠 sleep 猜而 sleep 的用例慢、还会偶发失败(机器一卡,睡的时间和实际经过的时间就对不上),这种「有时候红有时候绿」的测试比没有测试更糟——因为它会让人开始忽略失败判据是「这个东西不可控的话,我有多少行为是验证不了的」:几乎全部,那它就必须可注入这一条我认为是整个组件里最值得讲的设计,因为它不是为了功能,纯粹是为了让正确性可以被证明。

    生产实现选单调递增的时间而不是挂钟时间。挂钟时间更直观(能对应到真实日期)。但它会被系统对时或用户手动调整——时间被往回调之后,已经写入的过期时刻就变成了「很久以后」,这些条目会永远不过期代价是拿不到「这条数据是几点写入的」这种可读信息(需要的话另存一个字段)。判据是「这个值被用来做什么」:用来算时间差,那就必须用单调的;用来展示才用挂钟的。

    构建器式配置而不是多参数构造函数,代价是多写一个类。构造函数最直接。但参数一多就说不清谁是谁(尤其是几个同类型的参数并排),而且没有地方做「组合是否合法」的校验构建器的真正价值不是链式调用好看,而是它有一个明确的「构建时刻」可以做完整校验——把原本运行期才暴露的错误提前到初始化时快速失败判据是「这个错误在什么时候被发现」:越早越好,而初始化时失败是最容易定位的。

    默认容量有上限而不是无界,这是我调整过方向的一个决定。我最初选无界,理由是「不设限制最不容易出错」——现在看这个理由是错的,它混淆了「不报错」和「安全」无界的缓存在使用方漏配容量时会一直涨,直到内存出问题;而默认有上限的话,漏配的后果只是命中率低一些判据是「漏配这个参数的后果,是效果差一点还是出故障」:出故障的那个方向绝不能做默认值。代价是使用方如果真的想要无界,得显式配置——而这正是我想要的:危险的选择应该需要显式表达。

    回调移到锁外调用,代价是通知的时机会稍晚一点、而且要多一个收集的步骤。在锁里调最省事(拿到淘汰的条目顺手就通知了)。但这等于把使用方的代码放进了我的临界区——他在回调里写一次磁盘日志,我的锁就被占那么久;他在回调里反过来访问缓存,就直接自锁死住判据是「临界区里有没有我控制不了的代码」:有,那就必须把它移出去这一条和我在其他地方遵守的「锁内只做纯内存操作」是同一个原则。

    踩过的坑一:过期相关的测试靠 sleep,用例又慢又偶发失败。我一开始不觉得这是问题,直到用例多了之后跑一遍要等很久、而且时不时红一个教训是:一个组件如果它的核心行为依赖外部条件(时间、随机数、当前用户),那这些条件必须可注入——否则它的正确性只能靠运气验证。

    踩过的坑二:我在淘汰回调里故意反向调用了一次缓存,程序直接死住了。当时愣了一下才反应过来是自锁。教训是:只要回调是使用方提供的,就必须假设它会做任何事——包括反过来调我所以回调只能在锁外调用,而且要捕获它抛出的异常。

    踩过的坑三:容量传 0 的时候构造成功了,运行起来行为很怪。我排查了一会儿才发现是自己传错了参数。教训是:能在初始化时发现的错误,绝不要留到运行时——初始化失败的定位成本几乎为零,而运行时的怪异行为可能要查很久。

    没做的部分:没做配置的运行时动态调整(比如运行中改容量)。它需要处理「调小容量时立刻淘汰多少条」「调整过程中的并发读写」这些问题,而我判断这个需求在本地缓存的场景里不强——取舍依据是「这个能力带来的复杂度和它的使用频率是否匹配」。不过我能说清如果要做,缩容那一步需要在同一把锁里完成、且要限制单次淘汰的数量避免长时间占锁。

    数字是怎么测的

    可测性的收益要用测试本身的数字来报,这是最有说服力的:「过期相关用例改为注入时间源后,耗时从秒级降到毫秒级,且连续跑多轮不再出现偶发失败」。「不再偶发失败」比耗时下降更重要,要明确写出来。

    并说明一个靠 sleep 做不到的能力:「注入时间源后,「一小时后过期」这类场景也能在毫秒内断言」——这一条直接说明了为什么必须注入,而不只是为了快。

    时间源覆盖完整性用断言型用例:只推进注入的时间源、不做任何真实等待,断言过期、续期、定时清理三类行为都按预期发生如果有任何一处漏用了系统时间,这个用例就会失败——这是一条很好的回归。

    挂钟时间的坑是踩坑的直接回归:把注入的时间源往回调断言已写入条目的过期判断不受影响(用单调时间就不会受影响)。我最初是改设备时间复现的,改成注入之后这个场景可以直接写成用例。

    参数校验要报清覆盖了哪些组合:容量非正、过期时间为负、未设加载器却调用自动加载方法——断言这些在构建时就抛出、且异常信息能指出是哪个参数「异常信息能指出哪个参数」这一条要报,因为它决定了排查成本。

    默认值的安全性可以报一个对比:「不配置容量时,默认上限为 N;持续写入 M 条后内存占用稳定在某个水平,而无界实现下持续增长」。这个对比说明了默认值方向的意义。

    回调不持锁是断言型用例:在淘汰回调里反过来访问缓存断言不发生死锁、且回调能正常完成在回调里故意休眠,断言其他线程的读写不被阻塞后一条是「锁内有不可控代码」这个问题的直接度量。

    回调异常隔离:回调中抛出异常,断言缓存本身状态正常、后续读写不受影响、且异常被记录。

    API 封闭性:断言对外方法不返回内部集合的可修改引用(遍历接口返回的是快照)。

    不要报什么:不要报「API 更易用」这种无法度量的说法。该报的是「过期用例耗时从秒级降到毫秒级且不再偶发失败」「只推进注入时间源即可断言三类时间行为」「时间往回调后过期判断不受影响」「N 类非法参数组合在构建时快速失败」「回调中反向访问缓存不死锁、回调休眠不阻塞其他线程」这几件可核对的事。

    面试追问
    Q:缓存里读一下系统时间就行,为什么要把时间抽成接口注入? A:因为缓存的行为几乎全是时间相关的——过期、续期、定时清理,全都要「等一段时间之后」才能验证。时间不可控的话,这些行为的正确性我根本没法证明。我一开始就是直接读系统时间,所以测过期只能让线程睡一会儿,两个后果一是用例慢,睡一秒就是一秒,用例多了跑一遍要等很久二是它会偶发失败——机器一卡,睡的时间和实际经过的时间就对不上了而「有时候红有时候绿」的测试比没有测试更糟,因为它会让人开始忽略失败更关键的是有些场景根本测不了:我想验证「一小时后过期」,靠 sleep 是不可能的。改成注入之后,测试实现内部保存一个值、提供「推进 N 毫秒」的方法,一小时后的行为也能在毫秒内断言判据是「这个东西不可控的话,我有多少行为是验证不了的」:几乎全部,那它就必须可注入这里有个容易漏的点:缓存内部涉及时间的地方必须全部经过这个接口,任何一处直接读了系统时间,测试就又变得不可靠——而且这种漏掉很难发现。我的办法是写一个只推进注入时间源、不做任何真实等待的用例,如果有一处漏用,这个用例就会失败另外生产实现要用单调递增的时间而不是挂钟时间挂钟会被系统对时或用户手动调整,时间往回调之后,已经写入的过期时刻就变成了「很久以后」,这些条目会永远不过期——我是改设备时间复现的判据是「这个值被用来做什么」:用来算时间差就必须用单调的,用来展示才用挂钟的。
    Q:淘汰回调直接在淘汰的地方调用不就行了,有什么讲究? A:不行,而且这是我踩得最狠的一个坑:我在淘汰回调里故意反向调用了一次缓存,程序直接死住了——自锁。原因是我在拿着锁的时候调回调,等于把使用方的代码放进了我的临界区后果有两种他在回调里做耗时操作(比如写一条日志到磁盘),我的锁就被占那么久,其他线程全在等他在回调里反过来访问缓存,就直接死锁判据是「临界区里有没有我控制不了的代码」:有,那就必须移出去——这和我在缓存里坚持的「锁内只做纯内存操作」是同一个原则做法是锁内先把要通知的内容收集起来,出锁之后再逐个调用,代价是通知时机稍晚一点、多一个收集步骤,但换来的是使用方在回调里做什么都不会影响缓存还有两条相关的契约回调抛异常不能影响缓存本身(捕获并记录,不要让使用方的 bug 破坏我的状态);文档里要写清回调可能在任意线程执行、不保证顺序——不写的话使用方会默认它是同步有序的。说回更大的一层:这一整块我最想表达的是「组件的使用者不是我」。我最初的每个设计都是站在实现者角度做的——直接读系统时间最简单、构造函数收参数最直接、默认无上限最不容易报错、在锁里调回调最省事而这四个「最简单」对使用者来说分别是:测不了、错了不知道、漏配就出事、以及会莫名死锁默认值那一条我尤其想说:我最初默认容量无界,理由是「不设限制最不容易出错」——这个理由混淆了「不报错」和「安全」无界缓存在使用方漏配时会一直涨到内存出问题;而默认有上限的话,漏配的后果只是命中率低一些,可察觉且不致命判据是「漏配这个参数的后果,是效果差一点还是出故障」——出故障的那个方向绝不能做默认值,危险的选择应该需要显式表达。

模块五:统计与可诊断性

  1. 统计与可诊断性(命中率不暴露就只能凭感觉调参 + 未命中要分清是过期还是被淘汰 + 计数用低开销累加而不是同步自增 + 长期为零的指标要当异常看)★★
    简历这样写 缓存统计与可诊断性(Java + 并发计数):组件初版不对外提供任何运行数据,使用方无法判断自己配置的容量与过期时间是否合理,只能凭感觉调整;补充统计能力:命中数、未命中数、加载耗时、淘汰数、过期数,其中未命中按「从未写入 / 已过期 / 被淘汰」分类计数——这三种未命中对应完全不同的处理(补数据 / 调过期时间 / 调容量),不区分则统计无法指导调参;计数改用低竞争的累加方式而非共享变量同步自增,实测在多线程压测下开启统计对读吞吐的影响控制在很小的比例内(原用同步自增时统计本身成为新的竞争点);统计默认关闭但可一行开启,并提供快照式读取避免读统计时阻塞正常读写;另将「长期为零的指标」列为排查线索(如淘汰数始终为零说明容量远大于实际需要、加载次数为零说明配置未生效)。
    展开完整拆解
    为什么要这么设计

    这一块是我把缓存接进真实项目之后才发现缺失的。组件跑起来了、功能都对,但我完全不知道它在实际使用中表现如何——而更要紧的是,我不知道自己配的参数是不是合理的。四个问题。

    一是不暴露命中率,参数就只能凭感觉调。我给缓存配了容量和过期时间,但这两个数字是我拍的容量配大了浪费内存、配小了缓存不断被淘汰等于没起作用;过期时间配长了数据陈旧、配短了缓存频繁失效而我手里没有任何数据能判断当前配置在哪一边——只能改一个值、然后感觉一下「好像快了点」。

    二是未命中的原因不分类,统计出来也没法用。我最初只记了命中数和未命中数。但未命中率高这件事完全不能指导我做任何决定——因为它可能是三种完全不同的原因这个键从来没被写入过(那是数据本身的问题)、写过但已经过期(那该调过期时间)、写过但被淘汰了(那该调容量)三种原因对应三种完全不同的处理,混在一个数字里等于没有信息。

    三是统计本身成了新的竞争点。我最开始用一个共享变量做同步自增来计数。结果在多线程压测下发现开启统计之后读的吞吐明显下降——因为所有线程都在争抢同一个计数变量这个问题很讽刺:我加统计是为了了解性能,结果统计本身把性能拖下来了。

    四是我漏掉了「长期为零的指标」这条线索。我一开始只关注数值大小。后来有一次排查问题时才意识到,某个指标一直是零往往说明有东西没生效——比如加载次数一直为零,说明我配的自动加载根本没被走到;淘汰数一直为零,说明容量远大于实际需要(这是浪费,不是好事)。

    所以四个改动:对外提供命中、未命中、加载耗时、淘汰、过期这几组数据未命中按三种原因分类计数计数改用低竞争的累加方式把长期为零的指标当成排查线索而不是「一切正常」

    这个模块最想说的一句话是:一个组件如果不暴露自己的运行数据,那么使用方对它的所有配置都只能是猜的。我给缓存配容量和过期时间的时候,这两个数字完全是拍出来的——而拍出来的参数没有任何办法验证对不对命中率、淘汰数、过期数这几个指标存在的意义,不是为了好看,而是为了让「我该把容量调大还是调小」这个问题有一个基于事实的答案。

    整体链路
    要统计哪些数据 │ ├─ 命中数 · 未命中数(最基础,但单独看几乎没用,见下) ├─ 加载次数 · 加载总耗时 · 加载失败次数 ├─ 淘汰数(因容量满被踢出) ├─ 过期数(因超时被清理) └─ 当前条目数 · 当前占用估算 未命中必须分类,否则统计无法指导调参 │ ├─ 从未写入过 → 数据本身的问题,该看是不是查询模式有问题 ├─ 写过但已过期 → 过期时间可能配短了 ├─ 写过但被淘汰 → 容量可能配小了 │ └─ 不分类的后果(我踩过) 只知道「未命中率高」,但它完全不能指导任何决定 三种原因对应三种完全不同的处理,混在一个数字里等于没有信息 实现分类需要一点代价 ├─ 要能知道「这个键曾经在缓存里」 ├─ 做法:淘汰和过期时把键记进一个有上限的最近记录里 │ 记全部历史键是不可接受的(那等于没有淘汰) └─ 所以这个分类是近似的 —— 要在文档里说清它是采样性质的 计数的开销(统计不能反过来拖慢缓存) │ ├─ 用共享变量同步自增的后果(我踩过) │ 多线程压测下开启统计后读吞吐明显下降 │ 所有线程都在争抢同一个计数变量 │ 很讽刺:加统计是为了了解性能,结果统计把性能拖下来了 │ ├─ 改成低竞争的累加方式(分散到多个单元、读取时汇总) │ ├─ 统计默认关闭,但要能一行打开 │ 判据:有开销的能力,默认关闭;但开启的成本要足够低 │ └─ 读统计时返回快照,不要在读统计的过程中持锁 否则监控每分钟拉一次数据就等于每分钟阻塞一次业务 怎么用这些数据调参(这才是统计的目的) │ ├─ 淘汰数高而过期数低 → 容量偏小 ├─ 过期数高而淘汰数低 → 过期时间偏短 ├─ 命中率低但淘汰与过期都低 → 查询模式本身分散,缓存可能不适用 ├─ 加载平均耗时很高 → 缓存价值大,值得把容量调大 └─ 加载失败次数上升 → 后端数据源有问题,不是缓存的问题 长期为零的指标要当异常看,不要当正常 ├─ 加载次数一直为零 → 配的自动加载根本没被走到 ├─ 淘汰数一直为零 → 容量远大于实际需要,是浪费 ├─ 命中数一直为零 → 每次的键都不同,缓存完全没起作用 └─ 我一开始只关注数值大小,漏掉了这条线索 暴露方式 ├─ 提供一个方法返回统计快照(一次性拿全,避免多次调用不一致) ├─ 统计对象是不可变的,拿到之后不会再变 └─ 不要在组件里直接接监控系统 —— 把数据给出去,接什么由使用方决定
    分步拆解
    1. 先想清楚统计的目的:让使用方能判断自己配的参数合不合理。不是为了好看——而是为了让「容量该调大还是调小」这个问题有一个基于事实的答案。
    2. 基础指标:命中数、未命中数、加载次数与总耗时、加载失败数、淘汰数、过期数、当前条目数。
    3. 未命中必须按三种原因分类。从未写入过、写过但已过期、写过但被淘汰——三种对应三种完全不同的处理。
    4. 不分类的后果是统计出来也用不上。只知道「未命中率高」完全不能指导任何决定,因为处理方式取决于原因。
    5. 实现分类需要知道「这个键曾经在缓存里」。做法是淘汰和过期时把键记进一个有上限的最近记录里——记全部历史键是不可接受的(那等于没有淘汰)。
    6. 所以这个分类是近似的,文档里要说清它是采样性质的。不说清的话使用方会当成精确数字来推断。
    7. 计数不能用共享变量同步自增。多线程压测下开启统计后读吞吐明显下降——所有线程都在争抢同一个计数变量
    8. 改成低竞争的累加方式:分散到多个单元、读取时汇总。写入路径上不再有单点争抢。
    9. 统计默认关闭,但要能一行打开。判据是「有开销的能力默认关闭,但开启的成本要足够低」——否则等于没提供。
    10. 读统计要返回快照,不能在读的过程中持锁。否则监控每分钟拉一次数据就等于每分钟阻塞一次业务。
    11. 统计快照要一次性拿全、且是不可变的。分多次调用拿到的数据彼此不一致,算出来的命中率是错的。
    12. 调参规则要写在文档里,而不是让使用方自己悟。淘汰数高而过期数低就是容量偏小,过期数高而淘汰数低就是过期时间偏短。
    13. 命中率低但淘汰与过期都低,说明查询模式本身分散。这种情况缓存可能根本不适用——这也是一个该给出的结论。
    14. 加载平均耗时很高,说明缓存价值大,值得把容量调大。这个指标决定了「缓存这件事值不值得」。
    15. 加载失败次数上升说明后端数据源有问题,不是缓存的问题。把这两件事分开,避免误判。
    16. 长期为零的指标要当异常看,不要当「一切正常」。我一开始只关注数值大小,漏掉了这条线索。
    17. 加载次数一直为零,说明配的自动加载根本没被走到。这是配置没生效,而不是没有加载需求。
    18. 淘汰数一直为零,说明容量远大于实际需要。这是浪费而不是好事——很容易被误读成健康。
    19. 命中数一直为零,说明每次的键都不同、缓存完全没起作用。这时候该改的是调用方而不是缓存参数。
    20. 不要在组件里直接接监控系统。把数据给出去,接什么由使用方决定——组件不该绑定他的技术栈。
    关键决策与取舍

    把未命中按原因分类,代价是要额外记录「这个键曾经在缓存里」。只记总的命中与未命中最省事、开销也最小。但未命中率这个数字单独看完全不能指导决定——它可能是键从来没写入过(数据本身的问题)、可能是写过但过期了(该调过期时间)、也可能是写过但被淘汰了(该调容量)三种原因对应三种完全不同的处理,混在一个数字里等于没有信息判据是「这个指标能不能唯一地推出一个行动」:不能,那它就该被拆开代价是需要维护一个有上限的最近淘汰记录(记全部历史键是不可接受的,那等于没有淘汰),所以分类是近似的——这一点我在文档里明确写了它是采样性质的因为不说清的话使用方会当成精确数字来推断。

    计数改用低竞争的累加方式,代价是读取统计时要做一次汇总。共享变量同步自增最简单、数值也是精确的。但多线程压测下开启统计之后读的吞吐明显下降——所有线程都在争抢同一个计数变量这件事很讽刺:我加统计是为了了解性能,结果统计本身成了新的竞争点改成分散到多个单元、读取时汇总之后,写入路径上不再有单点争抢,代价是读统计要多做一次求和判据是「这个开销落在哪条路径上」:写入路径是高频的、读统计是低频的,那就该把开销从高频路径挪到低频路径这和缓存本身「把代价挪到不常走的路上」是同一个思路。

    统计默认关闭而不是默认开启。默认开启的话使用方什么都不用做就有数据。但它确实有开销(哪怕已经优化过),而缓存这类组件被引入的场合往往就是为了性能——默认给它加一份开销不合适判据是「有开销的能力默认关闭,但开启的成本要足够低」:所以我把开启做成一行配置,否则默认关闭就等于没提供这个能力代价是使用方可能一直不知道有这个东西——缓解办法是在文档最前面就提到它。

    读统计返回一次性的不可变快照,而不是提供一组分别读取的方法。分别读取的接口更灵活(只要一个数就只拿一个)。但分多次调用拿到的数据彼此不一致——用不一致的命中数和未命中数算出来的命中率是错的而且如果读取过程中持锁,监控每分钟拉一次数据就等于每分钟阻塞一次业务判据是「这些数字之间有没有相互推算的关系」:有(命中率要用两个数相除),那它们就必须在同一时刻取出。

    把「长期为零的指标」当成排查线索,这是我后来才加上的一条判断规则。我一开始只关注数值大小,觉得零就是「没发生」、没什么可看的后来排查一个问题时才意识到,某个指标一直是零往往说明有东西没生效加载次数一直为零,说明我配的自动加载根本没被走到(配置没生效,而不是没有加载需求);淘汰数一直为零,说明容量远大于实际需要,这是浪费而不是好事——但它很容易被误读成健康;命中数一直为零,说明每次的键都不同、缓存完全没起作用,这时候该改的是调用方而不是缓存参数判据是「这个指标为零,是因为一切正常还是因为这条路径没被走到」。

    不在组件里直接接监控系统,只把数据交出去。直接上报最省事(使用方什么都不用做)。但那会让组件绑定一套具体的监控技术栈,而使用方的项目可能用的是另一套判据是「这个决定该由谁做」:数据往哪里去是使用方的事,组件的职责边界到「把数据准确地暴露出来」为止。

    踩过的坑一:参数完全是拍出来的,而我没有任何办法验证。容量配大了浪费内存、配小了缓存不断被淘汰等于没起作用;过期时间配长了数据陈旧、配短了缓存频繁失效。而我手里没有数据,只能改一个值然后感觉一下「好像快了点」教训是:一个组件如果不暴露自己的运行数据,那么使用方对它的所有配置都只能是猜的——而猜出来的参数没法验证对不对。

    踩过的坑二:统计本身把性能拖下来了。我用共享变量同步自增,压测时发现开启统计后读吞吐明显下降教训是:任何加在高频路径上的观测手段,都要先确认它自己的开销——观测不该改变被观测对象的表现这也是我把统计做成默认关闭的原因之一。

    踩过的坑三:我曾经把「淘汰数为零」当成配置得当的证据。实际上它说明容量远大于需要、内存被白占了。教训是:指标要成对地看——淘汰数低只有在「容量也不大」的前提下才是好事,单看一个数很容易得出相反的结论。

    没做的部分:没做按键前缀分组的统计(比如分别看不同业务前缀的命中率)。它需要在每次读写时解析键并归类,这个开销落在高频路径上,而且分组规则因使用方而异取舍依据是「这个能力的开销落在高频路径上、且规则无法通用」——我的替代方案是建议使用方按业务建多个缓存实例,每个实例的统计天然是分开的,这样开销为零、分组也完全由他决定。

    数字是怎么测的

    统计开销必须报,因为它是这一块最容易被质疑的地方:「多线程压测下,开启统计后读吞吐的下降幅度控制在很小的比例内而改造前用共享变量同步自增时下降明显」。两个数一起报,才说明改造的必要性。

    压测条件要写清:线程数、键的数量与分布(是集中在少数热点键还是分散)、读写比例、持续时长。键的分布尤其要说——它直接决定命中率,不说明分布的命中率数字是没有意义的。

    分类计数用断言型用例:造三种场景各自触发一次未命中——取一个从未写入的键、取一个已过期的键、取一个已被淘汰的键断言三个分类计数各增加一次、且总未命中数等于三者之和最后这条等式关系要断言,它能发现分类漏计。

    分类的近似性也要报,不能含糊:「最近淘汰记录的上限为 N,超出这个范围的键会被归为『从未写入』」——明确说出它的失效边界,比声称精确要好。

    快照一致性:在持续读写的同时反复取统计快照,断言每个快照内部命中数与未命中数之和等于该时刻的总请求数(自洽),并断言取快照期间业务读写的耗时没有出现尖峰(不持锁)。

    淘汰与过期计数的准确性:容量设为 N、写入 N+M 条,断言淘汰数等于 M;写入若干条后推进注入的时间源越过过期时间,断言过期数等于这些条目数这里直接复用了时间源可注入的能力,不需要真实等待。

    加载耗时统计:让加载器固定耗时若干毫秒,触发 N 次加载,断言加载次数为 N、总耗时落在预期区间;让加载器抛异常,断言加载失败数增加而加载次数的口径与文档一致(成功与失败是否都计入加载次数,这一点必须在文档里定义清楚,否则使用方算不对平均耗时)。

    调参规则可以报一个实测对照:「固定键分布,容量从 N 调到 2N 后,淘汰数明显下降、命中率上升、内存占用上升」——三个数一起变,说明这套指标真的能指导调参,而不只是摆着看。

    默认关闭:断言未显式开启统计时,统计相关的计数逻辑不在读写路径上执行(不是「计了但不给看」)。

    不要报什么:不要报一个孤立的「命中率 N%」——不说明键分布的命中率可以随意做高。该报的是「压测条件与键分布」「开启统计前后的吞吐影响,以及改造前同步自增时的影响」「三类未命中分类各自可断言且总数自洽」「分类的近似边界」「容量翻倍后淘汰数、命中率、内存三者的联动变化」这几件带条件的事。

    面试追问
    Q:缓存记一个命中率就够了吧,为什么要分那么多指标? A:命中率单独看几乎没用,因为它推不出任何行动。我最初就只记了命中数和未命中数,然后发现「未命中率高」这个结论完全不能指导我做任何决定——因为它可能是三种完全不同的原因这个键从来没被写入过(那是数据本身或查询模式的问题)、写过但已经过期(那该调过期时间)、写过但被淘汰了(那该调容量)三种原因对应三种完全不同的处理,混在一个数字里等于没有信息判据是「这个指标能不能唯一地推出一个行动」:不能,那它就该被拆开。所以现在未命中按这三类分别计数,另外还记加载次数与总耗时、加载失败数、淘汰数、过期数有了这些,调参才有依据淘汰数高而过期数低就是容量偏小;过期数高而淘汰数低就是过期时间偏短;命中率低但淘汰和过期都低,说明查询模式本身分散、缓存可能根本不适用;加载平均耗时很高说明缓存价值大、值得把容量调大;加载失败数上升是后端数据源的问题,不是缓存的问题实现分类是有代价的:要能知道「这个键曾经在缓存里」,我的做法是淘汰和过期时把键记进一个有上限的最近记录里——记全部历史键是不可接受的,那等于没有淘汰所以这个分类是近似的,我在文档里明确写了它是采样性质的、以及超出记录范围的键会被归到「从未写入」——不说清的话使用方会当成精确数字来推断。还有一条我后来才加的判断规则:长期为零的指标要当异常看,不要当「一切正常」。我一开始只关注数值大小,觉得零就是没发生。后来才意识到:加载次数一直为零说明我配的自动加载根本没被走到(是配置没生效);淘汰数一直为零说明容量远大于实际需要,这是浪费而不是好事——我曾经把它当成配置得当的证据;命中数一直为零说明每次的键都不同、缓存完全没起作用,这时候该改的是调用方而不是缓存参数教训是指标要成对地看——淘汰数低只有在「容量也不大」的前提下才是好事。
    Q:加统计会不会影响缓存本身的性能? A:会,而且我踩过——我最开始用一个共享变量做同步自增来计数,结果多线程压测下发现开启统计之后读的吞吐明显下降,因为所有线程都在争抢同一个计数变量。这件事很讽刺:我加统计是为了了解性能,结果统计本身成了新的竞争点改法是把计数分散到多个单元、读取时再汇总,这样写入路径上不再有单点争抢,代价是读统计要多做一次求和判据是「这个开销落在哪条路径上」:写入是高频的、读统计是低频的(监控可能每分钟才拉一次),那就该把开销从高频路径挪到低频路径——这和缓存本身「把代价挪到不常走的路上」是同一个思路除此之外还有三个设计是为了控制影响一是统计默认关闭,但开启只需要一行配置:判据是「有开销的能力默认关闭,但开启的成本要足够低」——否则默认关闭就等于没提供这个能力;而且我断言了未开启时统计逻辑根本不在读写路径上执行,不是「计了但不给看」。二是读统计返回一次性的不可变快照,而且不持锁如果读取过程中持锁,监控每分钟拉一次数据就等于每分钟阻塞一次业务而做成一次性快照是因为分多次调用拿到的数据彼此不一致——用不一致的命中数和未命中数算出来的命中率是错的判据是「这些数字之间有没有相互推算的关系」:有,那它们就必须在同一时刻取出。三是不在组件里直接接监控系统,只把数据交出去——直接上报最省事,但那会让组件绑定一套具体的监控技术栈,而使用方的项目可能用的是另一套数据往哪里去是使用方的事,组件的职责到「把数据准确暴露出来」为止我报这块的数字时会把条件写清:压测的线程数、键的数量与分布、读写比例、持续时长——尤其是键的分布必须说,它直接决定命中率,不说明分布的命中率数字是可以随意做高的。

模块六:接入真实项目的踩坑

  1. 接入真实项目的踩坑(空结果不缓存导致同一个不存在的键反复查库 + 数据更新后该删还是该改 + 缓存粒度选大了改一个字段全失效 + 序列化与可变对象被外部改掉)★★★
    简历这样写 缓存接入与一致性处理(Java + Spring Boot + MySQL):把自研缓存接入项目后暴露出单测覆盖不到的问题并逐项处理:查询结果为空时不写缓存,导致同一个不存在的键每次都落到数据库,改为缓存空结果并单独设置较短过期时间,用脚本反复请求不存在的键复现并验证数据库查询次数下降;数据更新后原为更新缓存值,在并发更新下出现缓存值与库不一致(两个请求的写入顺序与提交顺序不一致),改为更新后删除缓存、下次读取时重新加载,并把删除放在事务提交之后执行(提交前删除会被其他线程读到旧数据重新填回);缓存粒度从整个聚合对象拆为按用途分开,避免改一个字段导致整块缓存失效;对外返回不可变视图或副本,修复了调用方拿到缓存对象后直接修改、污染其他调用方读取结果的问题。
    展开完整拆解
    为什么要这么设计

    前五块都是把组件本身做好。这一块全是把它真接进项目之后才暴露的问题——而且这些问题在单测里永远碰不到,因为单测里没有数据库、没有事务、也没有别人写的调用代码。四个问题。

    一是空结果不缓存,等于给了一条绕过缓存的通道。我的加载逻辑是「缓存没有就查库,查到就写进缓存」。但查不到的时候我什么都不写——所以同一个不存在的键,每次请求都要落到数据库我是用脚本反复请求一个不存在的键时发现的:缓存命中率一直是零,而数据库的查询次数和请求次数完全一样这意味着只要有人反复查不存在的数据,缓存就等于不存在。

    二是更新数据后去更新缓存,并发下会不一致。我最初的做法很自然:改完库之后把新值写进缓存。但两个请求同时改同一条数据时,它们写库的顺序和写缓存的顺序可能是反的——结果缓存里留下的是先提交那个的值,和库里不一致,而且这个不一致会一直留到过期我起两个线程并发改同一个键复现了它。

    三是删缓存的时机放错了。我改成「更新后删缓存」之后还是出了问题:我把删除放在事务提交之前——而在提交之前,别的线程读到的还是旧数据,它会把旧值重新填回缓存删除动作发生了,但删掉的位置马上被旧数据占回去了。

    四是缓存粒度和对象可变性两个坑一起来。粒度上我一开始缓存的是整个聚合对象(用户的全部信息),结果改一个昵称就要让整块失效——而其他字段其实没变可变性上更隐蔽:我直接把缓存里的对象引用返回给调用方,而调用方拿到之后改了其中一个字段——这个修改直接作用在缓存里的那份数据上,后面所有人读到的都是被改过的这个问题我排查了很久,因为它表现为「数据莫名其妙变了」,看不出和缓存有关系。

    所以四个改动:缓存空结果并给它单独的较短过期时间更新后删缓存而不是改缓存删除放在事务提交之后缓存粒度按用途拆开、对外返回不可变视图或副本

    这个模块最想说的一句话是:一个组件自身的正确性和它「接进项目之后是否正确」是两件事。前五块的问题我都能在单测里造出来;而这一块的四个问题,全都需要真实的数据库、真实的事务边界、以及别人写的调用代码才会暴露我认为这是「写过组件」和「用过自己写的组件」之间的差别——后者才能发现这些。

    整体链路
    空结果的处理(防止反复查库) │ ├─ 问题:查不到时什么都不写,同一个不存在的键每次都落到数据库 │ 我是用脚本反复请求一个不存在的键发现的 │ 缓存命中率一直是零 │ 数据库查询次数和请求次数完全一样 │ 只要有人反复查不存在的数据,缓存就等于不存在 │ ├─ 做法:把「空」也缓存起来,但用一个明确的标记表示空 │ 不能用 null 表示,否则和「缓存里没有这个键」分不开 │ ├─ 空结果单独设一个较短的过期时间 │ 理由:数据可能马上就被创建出来了 │ 用和正常数据一样的过期时间 → 新建的数据要等很久才可见 │ └─ 数据被创建时要主动删掉这个空标记(如果创建路径可控) 数据更新后:删缓存,不要改缓存 │ ├─ 改缓存的后果(我起两个线程并发改同一个键复现的) │ 两个请求同时改同一条数据 │ 它们写库的顺序和写缓存的顺序可能是反的 │ 缓存里留下的是先提交那个的值,和库里不一致 │ 而且这个不一致会一直留到过期 │ ├─ 删缓存则不存在这个问题:下次读时重新从库加载 │ 代价:删完之后第一次读会慢一点(要重新加载) │ 这个代价换来的是「不会长期不一致」 │ └─ 判断依据:写入顺序无法保证时,就不要写;删除是幂等的 删除的时机(这一步我放错过) │ ├─ 放在事务提交之前的后果 │ 提交之前别的线程读到的还是旧数据 │ 它会把旧值重新填回缓存 │ 删除动作发生了,但位置马上被旧数据占回去 │ ├─ 正确做法:在事务提交之后再删 │ Spring 里可以挂在事务同步回调的提交后阶段 │ └─ 提交后删除失败怎么办 要有兜底:记录下来后续重试,或者依赖过期时间自然收敛 不能假设删除一定成功 缓存粒度 │ ├─ 缓存整个聚合对象的后果 │ 改一个昵称就要让整块失效,而其他字段其实没变 │ ├─ 按用途拆开:列表页要的摘要 · 详情页要的完整信息,各自缓存 │ 好处:改动只影响相关的那一份 │ 代价:一次更新可能要删多个键,要有清单,不能漏 │ └─ 拆太细也不好:一个页面要读十几个键,收益被调用次数吃掉 判据是「这些字段是不是一起变、一起被读」 可变对象(最隐蔽的一个坑) │ ├─ 直接返回缓存里的对象引用的后果 │ 调用方拿到之后改了其中一个字段 │ 这个修改直接作用在缓存里的那份数据上 │ 后面所有人读到的都是被改过的 │ 我排查了很久 —— 表现是「数据莫名其妙变了」,看不出和缓存有关 │ ├─ 做法:返回不可变视图,或者返回一个副本 │ 副本有拷贝开销,不可变视图更省但要求类型配合 │ └─ 写入时也要考虑:使用方写进来的对象他自己还留着引用吗 其他接入期的问题 ├─ 缓存键的命名要带业务前缀与版本,避免不同用途撞键 ├─ 结构变更后旧缓存反序列化失败 → 键里带版本号,变更即换键 ├─ 加载失败不要写入缓存,否则失败被缓存住 └─ 不要在事务里做耗时的缓存操作,事务应该尽快结束
    分步拆解
    1. 空结果必须缓存,否则同一个不存在的键每次都落到数据库。我用脚本反复请求一个不存在的键复现——命中率一直是零、数据库查询次数和请求次数完全一样。
    2. 空要用一个明确的标记表示,不能用 null。否则「缓存了空」和「缓存里没有这个键」分不开,逻辑上就无法区分。
    3. 空结果要单独设一个较短的过期时间。因为数据可能马上就被创建出来了——用和正常数据一样的过期时间,新建的数据要等很久才可见。
    4. 如果创建路径可控,数据被创建时要主动删掉这个空标记。比只靠短过期时间更及时。
    5. 数据更新后要删缓存,不要改缓存。并发下两个请求写库的顺序和写缓存的顺序可能是反的,缓存里留下先提交那个的值——我起两个线程复现过。
    6. 删缓存的代价是下次读要重新加载,慢一点。但它换来的是不会长期不一致——而改缓存造成的不一致会一直留到过期。
    7. 判断依据是「写入顺序无法保证时就不要写」。删除是幂等的,顺序颠倒也不会产生错误结果。
    8. 删除必须放在事务提交之后。放在提交之前的后果是别的线程读到旧数据、把旧值重新填回缓存——删除发生了但位置马上被占回去。
    9. Spring 里可以挂在事务同步回调的提交后阶段。而不是在业务方法里顺手删。
    10. 提交后删除失败要有兜底。记录下来后续重试,或者依赖过期时间自然收敛——不能假设删除一定成功。
    11. 缓存粒度不要用整个聚合对象。改一个昵称就要让整块失效,而其他字段其实没变。
    12. 按用途拆开:列表页的摘要与详情页的完整信息各自缓存。好处是改动只影响相关的那一份。
    13. 拆开的代价是一次更新可能要删多个键,必须有清单。漏删一个就留下一处不一致——这个清单要写在代码里而不是记在脑子里。
    14. 也不要拆太细。一个页面要读十几个键的话,收益被调用次数吃掉了。判据是「这些字段是不是一起变、一起被读」。
    15. 绝不能直接返回缓存里的对象引用。调用方改一个字段就直接作用在缓存里的那份数据上,后面所有人读到的都是被改过的。
    16. 返回不可变视图或副本。副本有拷贝开销、不可变视图更省但要求类型配合——两者都比返回可变引用安全。
    17. 写入时也要考虑使用方是否还持有该对象的引用。他后续修改同样会影响缓存里的内容。
    18. 缓存键要带业务前缀与版本号。避免不同用途撞键,而版本号让结构变更时可以直接换键。
    19. 结构变更后旧缓存会反序列化失败。键里带版本、变更即换键——比写兼容逻辑简单得多。
    20. 加载失败不要写入缓存。否则失败被缓存住,后续一段时间内都拿不到正确数据。
    21. 不要在事务里做耗时的缓存操作。事务应该尽快结束,而缓存操作可能涉及网络或序列化。
    关键决策与取舍

    缓存空结果,代价是会占用一部分容量去存「没有」。不缓存空最省空间、也不会有「数据已创建但缓存里还是空」的问题。但它等于给了一条绕过缓存的通道——同一个不存在的键每次都落到数据库,只要有人反复查不存在的数据,缓存就等于不存在判据是「有没有一种输入能让缓存完全失效」:有,那这条路径就必须被堵上而空结果的过期时间要单独设、且明显短于正常数据——理由是数据可能马上就被创建出来,用一样的过期时间会导致新建的数据要等很久才可见这个「同一个缓存里两类数据用不同过期时间」的设计,是我在这块想清楚的一个点:过期时间该由「这份数据多久可能变」决定,而不是全局配一个。

    更新后删缓存而不是改缓存,代价是下次读要重新加载。改缓存看起来更高效(省一次加载)、也更直觉。但并发下两个请求写库的顺序和写缓存的顺序可能是反的——缓存里留下的是先提交那个的值,而且这个不一致会一直留到过期删除则不存在这个问题:它是幂等的,顺序颠倒也不会产生错误结果判据是「写入顺序无法保证时,就不要写」代价是删完之后第一次读会慢一点,而这个代价换来的是「不会长期不一致」——我认为这个交换是明显划算的,因为一次慢是可见的、而长期不一致是不可见的。

    删除放在事务提交之后,而不是业务方法里顺手删。顺手删最简单。但在提交之前,别的线程读到的还是旧数据——它会把旧值重新填回缓存,于是删除动作发生了,位置却马上被旧数据占回去这个坑很隐蔽,因为代码看起来完全正确(确实删了)判据是「这个动作依赖的状态在什么时候才对外可见」:数据要到提交后才对外可见,那清理动作也必须在那之后代价是要接一层事务同步回调、而且提交后删除可能失败——所以要有兜底:记录下来后续重试,或者依赖过期时间自然收敛。不能假设删除一定成功。

    缓存粒度按用途拆开,代价是一次更新要删多个键。缓存整个聚合对象最省事(一个键搞定)。但改一个昵称就要让整块失效,而其他字段其实没变拆开之后改动只影响相关的那一份,代价是要维护一份「哪些键需要一起删」的清单——漏删一个就留下一处不一致,所以这个清单要写在代码里而不是记在脑子里但也不能拆太细:一个页面要读十几个键的话,收益就被调用次数吃掉了判据是「这些字段是不是一起变、一起被读」:一起的就放一份,不一起的就分开。

    对外返回不可变视图或副本,而不是缓存里的对象引用。返回引用零开销。但调用方拿到之后改一个字段,这个修改直接作用在缓存里的那份数据上,后面所有人读到的都是被改过的判据是「我交出去的东西,别人能不能改到我的状态」:能,那就必须切断副本有拷贝开销、不可变视图更省但要求类型配合——我的选择是能用不可变视图的就用,不行才拷贝写入方向也有同样的问题:使用方写进来的对象他自己可能还留着引用,后续修改同样会影响缓存里的内容——这一点我在文档里写明了。

    键里带版本号,用换键代替写兼容逻辑。写兼容逻辑能保留旧缓存。但缓存本来就是可以重建的数据——为可重建的数据写兼容逻辑,投入产出比很差,而且兼容逻辑本身容易出错判据是「这份数据丢了能不能重建」:能,那就直接换键让旧的自然过期代价是变更后有一段时间命中率低(要重新填充)——这个代价可预期、也可接受。

    踩过的坑一:空结果不缓存,导致缓存对某一类请求完全无效。我是用脚本反复请求一个不存在的键时发现的——命中率一直是零、数据库查询次数和请求次数完全一样教训是:设计缓存时不能只想「命中的路径」,要问「有没有一种输入能让它永远不命中」——这个问题问出来,空结果这个漏洞就自己暴露了。

    踩过的坑二:删缓存放在了事务提交之前,删了等于没删。代码看起来完全正确。教训是:清理动作的时机要对齐「数据对外可见的时刻」,而不是对齐「我写完代码的位置」——这类错误在单线程测试里完全不会暴露。

    踩过的坑三:调用方改了我返回的缓存对象,导致数据莫名其妙变了。我排查了很久,因为现象完全看不出和缓存有关系——数据没人改却变了教训是:只要交出去的是可变对象的引用,就等于把内部状态的写权限一起交出去了——而这种错误的表现和它的原因之间距离很远,排查成本极高。

    没做的部分:没做「更新后延迟再删一次」这类进一步降低不一致窗口的手段。它能覆盖「提交后删除与另一个线程的回填之间的极短窗口」,但需要引入延迟任务、而且延迟多久本身是个没法算准的经验值取舍依据是「当前的过期时间已经把不一致的持续时间限定在可接受范围内,而引入延迟任务会带来新的可靠性问题(任务丢了怎么办)」——我选择了用较短的过期时间兜底,并且能说清这个窗口的存在和它的量级。

    数字是怎么测的

    空结果这一条最适合用数据库查询次数来报,比命中率直观:「用脚本反复请求同一个不存在的键 N 次,改造前数据库查询次数等于 N,改造后为 1」。这两个数是可以直接从数据库侧统计核对的。

    空结果的过期时间要报它的效果边界:「空结果过期时间设为 X(明显短于正常数据的 Y);数据创建后最迟 X 之后可见,若创建路径主动删除空标记则立即可见」。把「最迟多久可见」说清楚,这是一个使用方必须知道的约束。

    并发更新的不一致是断言型用例:起两个线程并发更新同一个键、各自写入不同的值,循环多轮后断言缓存中的值与数据库最终值一致改造前(更新缓存)这个用例会失败,改造后(删除缓存)通过——要说明「改造前会失败」,否则这个用例看不出价值。

    事务提交时机是踩坑的直接回归:在事务提交前后各设一个观察点,让另一个线程在事务提交前读取该键断言提交后缓存中不存在旧值这条用例专门针对「删除被旧数据回填」这个场景,把删除挪回提交前它就会失败。

    缓存粒度可以报一个失效范围的对比:「更新一个字段时,改造前失效的缓存条目数为 N(整个聚合对象),改造后为 1」,并报「一次更新需要删除的键数量」以及是否有遗漏(用例遍历清单断言全部被删)。

    可变对象是断言型用例:取出缓存值后尝试修改它断言修改不生效(不可变视图会拒绝)或不影响缓存内的数据(副本)再次读取,断言拿到的仍是原始值后一条是这个坑的直接度量。

    写入方向也要断言:写入一个对象后由外部修改它断言缓存内的数据不受影响(如果做了写入时拷贝);如果没做,就在文档里明确写出这个约束——说清约束和实现保护都是可接受的,含糊不清才不行。

    加载失败不缓存:让加载器抛异常,断言缓存中未写入任何值、下一次请求会重新尝试加载(而不是拿到一个被缓存的失败)。

    结构变更换键:改变缓存对象结构并升版本号后,断言旧键不再被读取、新键正常填充、且不出现反序列化异常。

    不一致窗口要诚实报出来:「提交后删除与另一线程回填之间存在一个极短窗口,该窗口内可能读到旧值;不一致的最长持续时间由过期时间上界限定」。主动说出这个窗口的存在和量级,比声称「保证一致」可信得多——而后者在我这个实现里是不成立的。

    不要报什么:不要说「保证缓存与数据库强一致」——我的方案做不到,只能限定不一致的持续时间。该报的是「反复请求不存在的键时数据库查询次数从 N 降到 1」「并发更新用例在改造前失败改造后通过」「事务提交前删除会被回填的回归用例」「单字段更新的失效条目数从 N 降到 1」「取出后修改不影响缓存内数据」以及「不一致窗口的存在与上界」这几件可核对的事。

    面试追问
    Q:数据更新之后,缓存该更新还是该删除? A:删除,而且删除的时机比「删还是改」这个选择更容易出错——这两个坑我都踩了。先说为什么不改:我最初的做法很自然,改完库之后把新值写进缓存。但并发下两个请求同时改同一条数据时,它们写库的顺序和写缓存的顺序可能是反的——缓存里留下的是先提交那个的值,和库里不一致,而且这个不一致会一直留到过期。我起两个线程并发改同一个键复现了它。删除不存在这个问题:它是幂等的,顺序颠倒也不会产生错误结果判据是「写入顺序无法保证时,就不要写」代价是删完之后第一次读要重新加载、慢一点——但这个交换是划算的,因为一次慢是可见的,而长期不一致是不可见的。然后是我踩得更狠的那个:删除的时机。我改成删除之后还是出了问题,因为我把删除放在了事务提交之前——而在提交之前,别的线程读到的还是旧数据,它会把旧值重新填回缓存删除动作确实发生了,但位置马上被旧数据占回去了这个坑很隐蔽,因为代码看起来完全正确,而且在单线程测试里根本不会暴露正确做法是在事务提交之后再删(Spring 里挂在事务同步回调的提交后阶段),判据是「这个动作依赖的状态在什么时候才对外可见」——数据要到提交后才可见,那清理也必须在那之后还有一个必须处理的:提交后删除可能失败,所以要有兜底——记录下来后续重试,或者依赖过期时间自然收敛,不能假设删除一定成功最后我会主动说清一件事:这套方案不是强一致的。提交后删除与另一个线程回填之间存在一个极短窗口,窗口内可能读到旧值;我能保证的是不一致的最长持续时间由过期时间限定住我考虑过「延迟再删一次」来缩小这个窗口,但它需要引入延迟任务、延迟多久本身是个算不准的经验值、而且任务丢了又是新的可靠性问题——所以我选择用较短的过期时间兜底,并且能说清这个窗口的存在和量级。
    Q:你把自己写的缓存接进项目之后,遇到过什么单测发现不了的问题? A:三个,而且都需要真实的数据库、真实的事务边界、或者别人写的调用代码才会暴露。第一个是空结果不缓存。我的加载逻辑是「缓存没有就查库,查到就写进缓存」,但查不到的时候我什么都不写——所以同一个不存在的键,每次请求都要落到数据库我是用脚本反复请求一个不存在的键时发现的:命中率一直是零,数据库查询次数和请求次数完全一样教训是:设计缓存时不能只想命中的路径,要问「有没有一种输入能让它永远不命中」——这个问题问出来,漏洞就自己暴露了。改法是把空也缓存起来,用一个明确的标记表示(不能用 null,否则和「缓存里没有这个键」分不开)并且给空结果单独设一个明显更短的过期时间——因为数据可能马上就被创建出来,用一样的过期时间会导致新建的数据要等很久才可见;创建路径可控的话还要主动删掉这个空标记。第二个是缓存粒度。我一开始缓存的是整个聚合对象(用户的全部信息),结果改一个昵称就要让整块失效,而其他字段其实没变。改成按用途拆开(列表页的摘要、详情页的完整信息各自缓存),代价是一次更新可能要删多个键、必须有清单,漏删一个就留下一处不一致——所以清单要写在代码里而不是记在脑子里但也不能拆太细,一个页面要读十几个键的话收益就被调用次数吃掉了,判据是「这些字段是不是一起变、一起被读」第三个是我排查最久的:调用方改掉了我返回的缓存对象。我直接把缓存里的对象引用返回出去,调用方拿到之后改了其中一个字段——这个修改直接作用在缓存里的那份数据上,后面所有人读到的都是被改过的难查是因为现象和原因距离很远:表现是「数据没人改却莫名变了」,完全看不出和缓存有关系教训是:只要交出去的是可变对象的引用,就等于把内部状态的写权限一起交出去了。改法是返回不可变视图,不行才返回副本(视图更省但要求类型配合);写入方向也有同样问题——使用方写进来的对象他自己可能还留着引用,这一点我在文档里写明了这一整块我最想说的是:组件自身的正确性和「接进项目之后是否正确」是两件事。前五块的问题我都能在单测里造出来,而这三个不行——我觉得这是「写过组件」和「用过自己写的组件」之间的差别。

没有匹配的内容,换个关键词试试。

项目拆解 · 本地缓存组件(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据