我最开始的「缓存」就是一个哈希表,值里带一个过期时间戳,读的时候判断一下过期就删掉、重新加载。这个实现在我的论坛项目里跑了一段时间,看起来没问题——直到我做压测时盯着内存看。四个问题。
一是内存只涨不降。我只在读取时判断过期。但一个键如果过期之后再也没有人读它,那它就永远不会被删除——它会一直占着内存。而缓存的键往往有大量是「一次性」的(某个用户的某次查询),过期之后再也不会被访问。压测里我持续写入不同的键,内存一路涨上去、再也不下来。
二是没有容量上限,内存没有上界。即使加了定期清理,如果写入速度快于过期速度,内存仍然会持续增长。我需要的是一个「最多占用多少」的硬约束,而不是「希望它会降下来」。
三是我加了定期清理之后,出现了停顿。我的第一版定期清理是「扫描全部键、删掉过期的」。键多的时候这一轮扫描耗时明显,而扫描期间持有锁,所有读写都被挡住——表现是每隔一段时间就卡一下。
四是过期时间的语义我一开始没想清楚。我只有一种过期时间,但两类数据的需求完全不同:「查询结果」这类数据本身有时效,应该从写入开始算;而「热点配置」这类应该只要一直有人访问就不过期。我用同一种语义处理,结果是热点键也被反复过期重建,缓存的意义被削弱了。
所以四个改动:惰性删除之外补定期清理、定期清理改为按批扫描并限制单轮耗时、加容量上限与淘汰策略让内存有硬上界、区分「写入后过期」与「最后访问后过期」两种语义。
这个模块最想说的一句话是:过期和淘汰是两件不同的事,我一开始把它们混为一谈了。过期解决的是「这份数据还准不准」,淘汰解决的是「内存够不够」——只做过期的话内存没有上界(没人访问的过期键不会被清、写得快也来不及清);只做淘汰的话数据可能已经过时但还在被使用。两者必须都有,而且触发条件完全不同。
惰性删除加定期清理两者都做,而不是只选一个。只做惰性删除的问题很致命:过期之后再也没人读的键永远不会被清理,而缓存里大量键是一次性的——内存只涨不降。只做定期清理的问题是清理有间隔,间隔内读到的可能是过期数据。两者的分工很清楚:惰性删除保证「读到的一定不过期」,定期清理保证「没人读的也会被回收」。判据是「这两种失效路径各自漏了什么」——各自都有漏的,所以必须叠加。
定期清理按批扫描,而不是一次扫全部。一次扫全部实现最简单、清理最彻底。但键多的时候单轮耗时明显,而扫描期间持有锁会挡住所有读写——清理本身成了停顿的来源。按批扫描的代价是过期键的回收有延迟(要多轮才能扫完一遍)。判据是「后台任务能不能影响前台请求」:不能,那就必须限制它单次的工作量。这一条和后端「批量任务要分批并限制单轮数量」是同一个道理。
用近似最近最少使用而不是按访问频次淘汰。按频次淘汰在某些访问模式下命中率更高。但它需要额外维护每个键的频次计数,还要处理一个真实问题:曾经的热点在不再被访问之后,靠着历史高频次长期占着位置不被淘汰(解决它又要引入频次衰减)。我实测了两种访问分布:热点集中的分布下两者命中率差距不大,均匀随机的分布下两者都不高——既然收益不明显、复杂度明显上升,我选了简单策略,并在文档里写清它在什么访问模式下会吃亏。判据是「实测的收益差距值不值得这份复杂度」,而不是「哪个策略更先进」。
容量上限用条数而不是字节数。字节数更精确、更贴近「内存够不够」这个真实问题。但准确估算一个对象占多少内存很麻烦、而且不同实现下差异很大;条数是一个近似,但它简单可靠。我的处理是用条数,但在文档里明确写出这是近似、以及使用方应该按自己缓存对象的大致大小来设置条数上限。把近似说清楚,比假装精确更好。
踩过的坑一:只做惰性删除,压测中内存一路涨上去再也不下来。我是持续写入不同的键、盯着内存曲线才发现的。教训是:惰性策略的盲区是「不再被访问的数据」——而这类数据恰恰最需要被清理。这个思路推广开来很有用:任何「用到的时候才处理」的机制,都要问一句「如果它再也不被用到呢」。
踩过的坑二:定期清理一次扫全部,造成周期性停顿。表现是压测的耗时曲线上每隔一段时间有一个尖峰。教训是:为了解决一个问题引入的后台任务,本身可能成为新的问题源——而它的影响会以「周期性尖峰」这种很好认的形态出现,看耗时分布比看平均值容易发现。
踩过的坑三:热点键按「写入后过期」处理,被反复重建。现象是某个一直有人访问的配置项,命中率却不高。教训是:「过期」这个词掩盖了两种不同的意图——「这份数据放久了会不准」和「这份数据没人用了就该走」,前者该从写入算,后者该从最后访问算。把一个概念拆成两个之后,两类需求都能被正确表达。
没做的部分:没做按内存实际占用的容量控制、也没做多级缓存(本地加远端)。前者需要可靠的对象大小估算,后者是另一个方向的问题(涉及一致性与失效广播)。取舍依据是「这个组件的定位是一个简单可靠的本地缓存」——我更愿意把边界说清楚,而不是做一个功能多但每一处都不扎实的东西。
内存回落是这个模块最直观的验证:持续写入 N 个不同的键(全部会过期),观察内存曲线。报法是「只有惰性删除时内存持续增长不回落;加入定期清理后内存在若干轮清理内回落到基线附近」——曲线的形状比单个数字更能说明问题,要说明观察工具和写入速率。
容量上限用断言型用例:设置条数上限为 N,写入远超 N 个键,断言缓存条数始终不超过 N、且被淘汰的是最久未访问的那些。
定期清理的停顿要看耗时分布而不是平均值:报「一次扫全部时,读写耗时曲线上出现周期性尖峰;改为按批扫描后尖峰消失」。平均值可能看不出差别,尖峰要看分位数或曲线。
命中率对比要说清访问分布:「用固定的访问序列,分别在热点集中与均匀随机两种分布下测命中率」。不说分布的命中率数字没有意义——同一个策略在不同分布下差别很大。这也是我判断「要不要用更复杂的淘汰策略」的依据。
两种过期语义要各写一条用例:「写入后过期」的键在持续访问下断言仍会过期;「最后访问后过期」的键在持续访问下断言不过期、停止访问后断言过期。
时间源:把系统时间往前调,断言过期判断不受影响(因为用的是单调时间源)。这条很容易漏但很能说明细致。
统计准确性:执行已知次数的命中与未命中,断言统计值与预期完全一致。
不要报什么:不要报「性能超过某个成熟缓存库」——我没有做过对等条件的严谨对比,说了会被追问细节。该报的是「只惰性删除时内存不回落、加定期清理后回落」「条数上限严格生效且淘汰最久未访问」「按批扫描消除了周期性尖峰」「两种分布下的命中率对比」这几件带条件的、可核对的事。
做并发安全的时候我的第一反应是最稳妥的做法:所有读写方法都加同一把锁。正确性没问题,压测数据让我很意外。四个问题。
一是线程数越多吞吐反而越低。我按读多写少的比例压测(大部分请求是读缓存),预期是多线程会明显快于单线程。实际结果是线程数从几个加到几十个之后吞吐不升反降,某些配置下甚至不如单线程——因为所有线程都在排队等同一把锁,而排队本身还有开销。
二是我一开始想改成读写锁,结果发现没用。思路是「读多写少,那读的时候用共享锁就好了」。但改完之后几乎没有改善——因为我的读操作会把节点移到访问链表的头部,这是在修改链表结构,本质上是一个写操作。「读缓存」在实现层面并不是只读的,这一点我完全没想到。
三是统计计数也成了竞争点。我的命中率统计是在锁里累加的。后来我把它挪到锁外,用普通变量累加,结果统计值不准(并发下丢更新);再改成加锁累加,又把它变成了一个新的竞争点——而它只是一个计数。
四是「先判断存在再写入」有竞态。我有一个「不存在才写入」的方法,实现是先查一下、不存在则写。两个线程可能都查到「不存在」,然后都写入——后一个覆盖了前一个。
所以四个改动:一把全局锁改为按键哈希分段、每段独立持锁、容量上限与淘汰改为按段生效、统计计数改用无锁的原子累加、把「先判断再写入」改成一个持锁内的原子操作。
这个模块最想说的一句话是:我以为「读缓存」是只读操作,所以以为读写锁能解决问题——而实际上因为要维护访问顺序,读也在写。这个认知错误让我在读写锁上白花了时间;而想通之后思路就变了:既然读也是写、那就没办法靠区分读写来降低竞争,只能靠「把竞争分散到多个锁上」。
用分段而不是读写锁,这是我走过弯路之后才想清楚的。读写锁的思路(读多写少、读用共享锁)看起来完全对症。但它失效的原因是我的读操作要移动访问链表——「读缓存」在实现层面并不是只读的。想通这一点之后思路就变了:既然读也是写,就没办法靠区分读写来降低竞争,只能靠「把竞争分散到多个锁上」。判据是「这个操作真的只读吗」——而判断的方法是看它有没有修改任何共享状态,包括那些为了别的目的(维护访问顺序)而做的修改。
容量上限按段生效,接受「不是全局精确」。全局精确容量更符合使用方的直觉。但它要求跨段协调(知道所有段的总条数),而协调就需要一个全局同步点——竞争又被拉回一处,分段就白做了。代价是分布不均时某段先满开始淘汰、而其他段还有空位。我的处理是接受这个近似,但在文档里明确写出来——判据是「这个精确性值不值得放弃分段带来的并发收益」:不值得,因为容量上限本来就是一个防护性的约束、不需要精确到条。
统计计数用原子累加而不是加锁。加锁的结果最准确。但统计只是一个计数,为它引入锁竞争完全不成比例——它会成为一个所有请求都要经过的竞争点。而用普通变量又会丢更新、统计值不准(我试过,命中率算出来明显偏低)。原子累加同时满足了「无锁」和「准确」。判据是「这个操作的成本应该和它的重要性匹配」——一个统计计数不该拖累主路径。
段数按线程数量级实测选取,而不是拍一个固定值。段数太少则竞争仍然集中;太多则每段容量变小、淘汰更频繁(同样的总容量被切得更碎),而且每段的结构本身也有内存开销。我是按压测的线程数量级试了几组,看吞吐曲线在哪里趋于平坦。报参数的时候我会说清这个过程,因为直接给一个数字而不说依据就是拍的。
踩过的坑一:一把大锁让多线程比单线程还慢。这个结果非常反直觉,我第一反应是压测写错了,反复确认之后才接受。教训是:加锁保证正确性的代价可能大到让并发失去意义——而这个代价只能靠压测看出来,看代码是看不出来的。
踩过的坑二:换读写锁几乎没有改善,白花了时间。根因是我误以为读是只读的。教训是:在优化之前先确认自己对「这段代码在做什么」的理解是对的——我如果一开始就仔细看一遍读操作都改了哪些共享状态,就不会走这个弯路。
踩过的坑三:统计计数在两个极端之间摇摆。先是放锁外用普通变量(不准)、再是放锁里累加(成了竞争点)。教训是:遇到「要么不准要么太慢」的时候,往往是工具选错了——这个场景需要的是原子操作,而我一直在「加锁」和「不加锁」之间二选一。
没做的部分:没做无锁的实现(用完全无锁的数据结构)。它理论上能进一步提升并发,但访问链表的维护在无锁下非常复杂、正确性极难验证——而我一个人写的东西,正确性比极限性能重要得多。取舍依据是「我能不能证明它是对的」:无锁实现我没有把握,那就不做。我认为这个判断本身比做出来更能说明我知道自己的边界。
吞吐随线程数的变化是这个模块最核心的数据,而且要画成曲线看:报「在读写比例为某个值时,线程数从 1 增加到 N 的吞吐曲线」。报法是「一把全局锁时曲线在几个线程后就趋于平坦甚至下降;分段后曲线继续上升」——曲线的形状比单点数字有说服力得多,而且「下降」这个反直觉的结果本身就是证据。
必须覆盖不同读写比例:至少测「读多写少」和「读写各半」两种。只测一种得出的结论可能是错的——而读多写少恰好是缓存的典型场景,要重点报。
键分布也要覆盖两种:热点集中与均匀分散。热点集中时同一段的竞争会更明显,这是分段方案的弱点——我把它测出来并写清,而不是只报对我有利的那组数据。
读写锁失效要作为对照报出来:「改用读写锁后吞吐几乎没有改善」,并说明原因是读操作也在修改访问链表。报出一个失败的尝试,比只报成功的方案更能说明我理解了根因。
段数选择要报实测过程:「试了几组段数,看吞吐曲线在哪里趋于平坦」。直接给一个数字而不说依据就是拍的。
统计准确性用断言型用例:多线程执行已知总次数的命中与未命中,断言统计值与预期完全一致(用普通变量时这里会偏低,可以作为对照)。
竞态用例:多线程并发调用「不存在才写入」,断言最终只有一个线程的值被写入、且返回值能让每个线程知道自己有没有写成功。
死锁:在回调函数里再次调用缓存,断言不会死锁(因为回调是在锁外调用的)。这条容易漏但很值得写。
不要报什么:不要报「吞吐提升 N 倍」而不说线程数与读写比例——倍数完全取决于这两个条件。该报的是「某读写比例下吞吐随线程数的两条曲线对比」「读写锁方案作为对照几乎无改善及其原因」「段数的实测选取过程」「多线程下统计值与预期完全一致」这几件带条件的、可核对的事。
这个模块解决的是一个我在论坛项目里真实遇到、但当时不知道该怎么描述的现象:某个热点查询的缓存一过期,数据库那边的压力就会突然抖一下。抽成组件之后我用打桩的加载函数计数,才把它量化出来。四个问题。
一是热点键失效瞬间,几十个线程各自执行了同一次加载。我的实现是「未命中就调用加载函数、把结果写回缓存」。单线程下完全正确;但几十个线程同时未命中时,它们各自都走了这条路——同一个昂贵的加载被执行了几十遍。我是用一个会计数的加载函数打桩才确认的:一次热点失效,加载函数被调用了几十次。
二是我把加载函数放在锁里执行,结果更糟。我的第一个修法是「持锁期间完成未命中判断和加载」,这样确实只有一个线程会加载。但加载函数是使用方提供的、可能是一次数据库查询——一次较慢的加载把整段的读写全部阻塞了。压测里的表现是耗时曲线上出现很宽的平台期,比重复加载更难接受。
三是加载失败之后,后续请求全都拿到同一个失败结果、而且再也不会重新加载。我用一个占位来表示「这个键正在加载」。但加载抛异常时我没有清理占位——于是那个键上永远挂着一个失败的占位,后面所有请求都在复用这个失败结果。这个 bug 比原问题严重得多,因为它是永久性的。
四是加载函数里又访问了同一个键,直接死锁。这是我自己在测试时不小心写出来的:加载函数内部又调了一次「读取或加载」同一个键。它在等自己完成,永远等不到。
所以四个改动:同一个键的加载只执行一次、其余线程等待并复用结果、占位登记在锁内、加载函数在锁外执行、失败与超时后清理占位、检测递归加载并直接报错。
这个模块最想说的一句话是:解决「重复加载」的关键不是「加锁让别人等」,而是「让别人等的同时不要挡住无关的操作」。我的第一个修法(把加载放锁里)确实消除了重复加载,但它把一个局部问题(同一个键的重复加载)变成了一个全局问题(整段被阻塞)。正确的做法是把「登记我要加载这个键」和「实际执行加载」分开:前者很快、可以持锁;后者可能很慢、必须在锁外。
把加载函数放在锁外执行,这是这个模块最关键的一个决定,而我第一版做错了。放在锁内实现最简单(一个大的同步块就搞定),也确实消除了重复加载。但它把一个局部问题(同一个键的重复加载)变成了一个全局问题(整段被阻塞)——加载函数是使用方提供的,它可能是一次很慢的数据库查询,而我把它放进了自己的临界区。判据是「这段代码的耗时是不是由我控制」:不由我控制(是使用方的回调),那就绝对不能放在锁里。这一条我后来推广成一个习惯:任何在临界区里调用外部代码的地方都要停下来想一下。
等待超时之后返回失败,而不是「自己去加载」。自己去加载看起来更积极(不让用户拿到失败)。但等待超时通常意味着加载方本身很慢(比如数据库压力大),这时候再放一个线程去加载只会让情况更糟——而且它会退化成「超时之后大家都自己加载」,也就是回到了最初的重复加载问题。判据是「这个补救措施会不会加重根本原因」:会,那就不该做。
加载失败不缓存失败结果,让后来者可以重新加载。缓存失败结果(短时间内直接返回失败)能防止「持续失败时反复冲击下游」。但它和我踩的那个坑只有一线之隔——我的坑就是失败占位没清理,导致永久返回失败。所以我的选择是不缓存失败、但把「防止反复冲击」的责任交给使用方的加载函数自己(它可以在内部做熔断)。判据是「哪一种错误更难被发现」:永久返回失败极难被发现(它不报错、只是一直不对),而反复加载至少是可观测的。
「返回旧值并后台刷新」做成可选而不是默认。它能让热点键失效时完全没有等待(先返回过期值、后台去刷新)。但它返回的是过期数据——对某些数据可以接受,对另一些完全不行,而组件无法替使用方做这个判断。所以默认关闭、需要时显式开启。判据是「这个行为改变了数据的语义吗」:改变了,那就必须由使用方明确接受。
踩过的坑一:加载函数放在锁里,一次慢加载阻塞整段。压测里的表现是耗时曲线上出现很宽的平台期。教训是:解决「重复加载」的关键不是「加锁让别人等」,而是「让别人等的同时不要挡住无关的操作」——正确的做法是把「登记我要加载这个键」(很快,可以持锁)和「实际执行加载」(可能很慢,必须锁外)分开。
踩过的坑二:加载抛异常后占位没清理,那个键永久返回失败。这个 bug 比原问题严重得多,因为它是永久性的,而且不报错、只是那个键一直不对。教训是:任何「登记一个中间状态」的地方,都必须保证在所有退出路径上(成功、异常、超时、中断)都会被清理——而正常路径上的清理是最容易写的,异常路径最容易漏。
踩过的坑三:加载函数里又访问同一个键,直接死锁。这是我自己测试时不小心写出来的。教训是:如果一种误用会导致死锁,那就主动检测它并报错——死锁不给你任何线索(进程就停在那),而一条报错能直接指出问题在哪个键上。这个成本很低但对使用方(包括未来的我自己)帮助很大。
没做的部分:没做「提前刷新」(在键即将过期时主动后台更新,让它永不失效)。它能进一步消除失效瞬间的等待,但需要额外的后台任务去跟踪「哪些键即将过期且是热点」,而判断「是不是热点」又需要访问频次统计。取舍依据是「加载合并已经把重复加载降到一次,剩下的收益是那一次的等待时间」——收益变小而复杂度明显上升,所以我停在这里,但能说清下一步是什么。
加载函数的调用次数是这个模块唯一真正重要的数字,而且它极容易精确统计:用一个会计数的加载函数打桩,让 N 个线程在同一个键失效的瞬间并发请求,断言加载函数只被调用 1 次(改造前是 N 次)。报法是「N 个线程并发请求同一个失效键,加载函数调用次数从 N 降到 1」——这个数字是断言级的、没有任何解释空间。
不同键要并行,也要断言:N 个线程请求 N 个不同的键,断言它们的加载是并行的(总耗时接近单次加载耗时,而不是 N 倍)。这条防的是「为了实现方便把所有加载都串行化」。
锁外执行要通过耗时分布验证:让加载函数故意睡一段时间,断言期间其他键的读写不被阻塞;并报「加载在锁内时耗时曲线出现宽平台期、移到锁外后消失」作为对照。
失败清理是踩坑的直接回归:让加载函数抛异常,断言全部等待方都收到异常、且占位被清理——随后再次请求时加载函数会被重新调用。最后半句是关键,它验证的正是「不会永久失败」。
超时清理:让加载方长时间不返回,断言等待方在超时后返回失败而不是无限阻塞;断言加载方超时后占位被清理。
占位校验:构造「加载方超时后新的加载方登记了占位」的场景,断言旧加载方的清理不会误删新占位。
递归检测:写一个在内部访问同一个键的加载函数,断言抛出明确的错误而不是死锁(可以给测试设超时来确认不是卡住)。
不要报什么:不要报「下游压力降低 N 倍」——我没有真实下游,那个倍数就是并发线程数、没有额外信息。该报的是「N 个线程并发请求同一失效键时加载函数调用次数从 N 降到 1」「不同键的加载并行、总耗时接近单次」「加载在锁内与锁外的耗时分布对照」「加载失败后占位被清理且下次会重新加载」这几件可核对的事。
前三块讲的都是缓存内部怎么实现。这一块是我写组件和写业务代码最大的区别:使用者不是我。业务代码写错了我自己会碰到;而组件的 API 设计错了,是别人(或者三个月后的我)以一种莫名其妙的方式踩坑。四个问题。
一是过期根本没法测。缓存的行为几乎全是时间相关的——过期、续期、定时清理,全都要「等一段时间之后」才能验证。我最初直接读系统时间,所以测过期只能让线程睡一会儿。后果有两个:一是用例慢(睡一秒就是一秒),二是它会偶发失败——机器一卡,睡的时间和实际经过的时间就对不上了。更糟的是我想测「一小时后过期」,那就完全测不了。
二是参数配错了要等到运行时才炸。我一开始是构造函数收一堆参数。容量传了 0、过期时间传了负数,构造的时候一声不响,等到真正 put 或者取的时候才出现奇怪的行为。更隐蔽的是「没设置加载器却调用了自动加载的方法」——这在运行到那一行之前完全看不出来。
三是默认值选错了方向。我最初的默认容量是「无上限」,理由是「不设限制最不容易出错」。但这个方向恰恰是危险的——使用方漏配容量,缓存就一直涨,直到内存出问题。而如果默认是有上限的,漏配的后果只是命中率低一些,是可以被察觉、也不会致命的。
四是回调在持锁时调用,直接把使用方坑死。我提供了淘汰和移除的回调。而我是在拿着锁的时候调它的——使用方如果在回调里做了耗时操作(比如写日志到磁盘),就把锁一直占着;如果他在回调里反过来访问缓存,就直接自锁死住了。我是自己在回调里故意反向调用缓存时撞出来的。
所以四个改动:时间源抽成接口注入、构建器式配置并在构建时校验参数与互斥组合、默认值按安全方向选取、回调移出锁外调用。
这个模块最想说的一句话是:组件的 API 是给别人用的,所以「用错的时候会发生什么」比「用对的时候有多方便」更重要。我最初的每一个设计都是站在实现者的角度做的——直接读系统时间最简单、构造函数收参数最直接、默认无上限最不容易报错、在锁里调回调最省事。而这四个「最简单」,对使用者来说分别是:测不了、错了不知道、漏配就出事、以及会莫名死锁。
时间源抽成接口注入,代价是多一层间接调用。直接读系统时间最简单、也没有任何性能损耗。但缓存的行为几乎全是时间相关的——如果时间不可控,那么过期、续期、定时清理这三块的正确性我全都验证不了,只能靠 sleep 猜。而 sleep 的用例慢、还会偶发失败(机器一卡,睡的时间和实际经过的时间就对不上),这种「有时候红有时候绿」的测试比没有测试更糟——因为它会让人开始忽略失败。判据是「这个东西不可控的话,我有多少行为是验证不了的」:几乎全部,那它就必须可注入。这一条我认为是整个组件里最值得讲的设计,因为它不是为了功能,纯粹是为了让正确性可以被证明。
生产实现选单调递增的时间而不是挂钟时间。挂钟时间更直观(能对应到真实日期)。但它会被系统对时或用户手动调整——时间被往回调之后,已经写入的过期时刻就变成了「很久以后」,这些条目会永远不过期。代价是拿不到「这条数据是几点写入的」这种可读信息(需要的话另存一个字段)。判据是「这个值被用来做什么」:用来算时间差,那就必须用单调的;用来展示才用挂钟的。
构建器式配置而不是多参数构造函数,代价是多写一个类。构造函数最直接。但参数一多就说不清谁是谁(尤其是几个同类型的参数并排),而且没有地方做「组合是否合法」的校验。构建器的真正价值不是链式调用好看,而是它有一个明确的「构建时刻」可以做完整校验——把原本运行期才暴露的错误提前到初始化时快速失败。判据是「这个错误在什么时候被发现」:越早越好,而初始化时失败是最容易定位的。
默认容量有上限而不是无界,这是我调整过方向的一个决定。我最初选无界,理由是「不设限制最不容易出错」——现在看这个理由是错的,它混淆了「不报错」和「安全」。无界的缓存在使用方漏配容量时会一直涨,直到内存出问题;而默认有上限的话,漏配的后果只是命中率低一些。判据是「漏配这个参数的后果,是效果差一点还是出故障」:出故障的那个方向绝不能做默认值。代价是使用方如果真的想要无界,得显式配置——而这正是我想要的:危险的选择应该需要显式表达。
回调移到锁外调用,代价是通知的时机会稍晚一点、而且要多一个收集的步骤。在锁里调最省事(拿到淘汰的条目顺手就通知了)。但这等于把使用方的代码放进了我的临界区——他在回调里写一次磁盘日志,我的锁就被占那么久;他在回调里反过来访问缓存,就直接自锁死住。判据是「临界区里有没有我控制不了的代码」:有,那就必须把它移出去。这一条和我在其他地方遵守的「锁内只做纯内存操作」是同一个原则。
踩过的坑一:过期相关的测试靠 sleep,用例又慢又偶发失败。我一开始不觉得这是问题,直到用例多了之后跑一遍要等很久、而且时不时红一个。教训是:一个组件如果它的核心行为依赖外部条件(时间、随机数、当前用户),那这些条件必须可注入——否则它的正确性只能靠运气验证。
踩过的坑二:我在淘汰回调里故意反向调用了一次缓存,程序直接死住了。当时愣了一下才反应过来是自锁。教训是:只要回调是使用方提供的,就必须假设它会做任何事——包括反过来调我。所以回调只能在锁外调用,而且要捕获它抛出的异常。
踩过的坑三:容量传 0 的时候构造成功了,运行起来行为很怪。我排查了一会儿才发现是自己传错了参数。教训是:能在初始化时发现的错误,绝不要留到运行时——初始化失败的定位成本几乎为零,而运行时的怪异行为可能要查很久。
没做的部分:没做配置的运行时动态调整(比如运行中改容量)。它需要处理「调小容量时立刻淘汰多少条」「调整过程中的并发读写」这些问题,而我判断这个需求在本地缓存的场景里不强——取舍依据是「这个能力带来的复杂度和它的使用频率是否匹配」。不过我能说清如果要做,缩容那一步需要在同一把锁里完成、且要限制单次淘汰的数量避免长时间占锁。
可测性的收益要用测试本身的数字来报,这是最有说服力的:「过期相关用例改为注入时间源后,耗时从秒级降到毫秒级,且连续跑多轮不再出现偶发失败」。「不再偶发失败」比耗时下降更重要,要明确写出来。
并说明一个靠 sleep 做不到的能力:「注入时间源后,「一小时后过期」这类场景也能在毫秒内断言」——这一条直接说明了为什么必须注入,而不只是为了快。
时间源覆盖完整性用断言型用例:只推进注入的时间源、不做任何真实等待,断言过期、续期、定时清理三类行为都按预期发生。如果有任何一处漏用了系统时间,这个用例就会失败——这是一条很好的回归。
挂钟时间的坑是踩坑的直接回归:把注入的时间源往回调,断言已写入条目的过期判断不受影响(用单调时间就不会受影响)。我最初是改设备时间复现的,改成注入之后这个场景可以直接写成用例。
参数校验要报清覆盖了哪些组合:容量非正、过期时间为负、未设加载器却调用自动加载方法——断言这些在构建时就抛出、且异常信息能指出是哪个参数。「异常信息能指出哪个参数」这一条要报,因为它决定了排查成本。
默认值的安全性可以报一个对比:「不配置容量时,默认上限为 N;持续写入 M 条后内存占用稳定在某个水平,而无界实现下持续增长」。这个对比说明了默认值方向的意义。
回调不持锁是断言型用例:在淘汰回调里反过来访问缓存,断言不发生死锁、且回调能正常完成;在回调里故意休眠,断言其他线程的读写不被阻塞。后一条是「锁内有不可控代码」这个问题的直接度量。
回调异常隔离:回调中抛出异常,断言缓存本身状态正常、后续读写不受影响、且异常被记录。
API 封闭性:断言对外方法不返回内部集合的可修改引用(遍历接口返回的是快照)。
不要报什么:不要报「API 更易用」这种无法度量的说法。该报的是「过期用例耗时从秒级降到毫秒级且不再偶发失败」「只推进注入时间源即可断言三类时间行为」「时间往回调后过期判断不受影响」「N 类非法参数组合在构建时快速失败」「回调中反向访问缓存不死锁、回调休眠不阻塞其他线程」这几件可核对的事。
这一块是我把缓存接进真实项目之后才发现缺失的。组件跑起来了、功能都对,但我完全不知道它在实际使用中表现如何——而更要紧的是,我不知道自己配的参数是不是合理的。四个问题。
一是不暴露命中率,参数就只能凭感觉调。我给缓存配了容量和过期时间,但这两个数字是我拍的。容量配大了浪费内存、配小了缓存不断被淘汰等于没起作用;过期时间配长了数据陈旧、配短了缓存频繁失效。而我手里没有任何数据能判断当前配置在哪一边——只能改一个值、然后感觉一下「好像快了点」。
二是未命中的原因不分类,统计出来也没法用。我最初只记了命中数和未命中数。但未命中率高这件事完全不能指导我做任何决定——因为它可能是三种完全不同的原因:这个键从来没被写入过(那是数据本身的问题)、写过但已经过期(那该调过期时间)、写过但被淘汰了(那该调容量)。三种原因对应三种完全不同的处理,混在一个数字里等于没有信息。
三是统计本身成了新的竞争点。我最开始用一个共享变量做同步自增来计数。结果在多线程压测下发现开启统计之后读的吞吐明显下降——因为所有线程都在争抢同一个计数变量。这个问题很讽刺:我加统计是为了了解性能,结果统计本身把性能拖下来了。
四是我漏掉了「长期为零的指标」这条线索。我一开始只关注数值大小。后来有一次排查问题时才意识到,某个指标一直是零往往说明有东西没生效——比如加载次数一直为零,说明我配的自动加载根本没被走到;淘汰数一直为零,说明容量远大于实际需要(这是浪费,不是好事)。
所以四个改动:对外提供命中、未命中、加载耗时、淘汰、过期这几组数据、未命中按三种原因分类计数、计数改用低竞争的累加方式、把长期为零的指标当成排查线索而不是「一切正常」。
这个模块最想说的一句话是:一个组件如果不暴露自己的运行数据,那么使用方对它的所有配置都只能是猜的。我给缓存配容量和过期时间的时候,这两个数字完全是拍出来的——而拍出来的参数没有任何办法验证对不对。命中率、淘汰数、过期数这几个指标存在的意义,不是为了好看,而是为了让「我该把容量调大还是调小」这个问题有一个基于事实的答案。
把未命中按原因分类,代价是要额外记录「这个键曾经在缓存里」。只记总的命中与未命中最省事、开销也最小。但未命中率这个数字单独看完全不能指导决定——它可能是键从来没写入过(数据本身的问题)、可能是写过但过期了(该调过期时间)、也可能是写过但被淘汰了(该调容量)。三种原因对应三种完全不同的处理,混在一个数字里等于没有信息。判据是「这个指标能不能唯一地推出一个行动」:不能,那它就该被拆开。代价是需要维护一个有上限的最近淘汰记录(记全部历史键是不可接受的,那等于没有淘汰),所以分类是近似的——这一点我在文档里明确写了它是采样性质的,因为不说清的话使用方会当成精确数字来推断。
计数改用低竞争的累加方式,代价是读取统计时要做一次汇总。共享变量同步自增最简单、数值也是精确的。但多线程压测下开启统计之后读的吞吐明显下降——所有线程都在争抢同一个计数变量。这件事很讽刺:我加统计是为了了解性能,结果统计本身成了新的竞争点。改成分散到多个单元、读取时汇总之后,写入路径上不再有单点争抢,代价是读统计要多做一次求和。判据是「这个开销落在哪条路径上」:写入路径是高频的、读统计是低频的,那就该把开销从高频路径挪到低频路径。这和缓存本身「把代价挪到不常走的路上」是同一个思路。
统计默认关闭而不是默认开启。默认开启的话使用方什么都不用做就有数据。但它确实有开销(哪怕已经优化过),而缓存这类组件被引入的场合往往就是为了性能——默认给它加一份开销不合适。判据是「有开销的能力默认关闭,但开启的成本要足够低」:所以我把开启做成一行配置,否则默认关闭就等于没提供这个能力。代价是使用方可能一直不知道有这个东西——缓解办法是在文档最前面就提到它。
读统计返回一次性的不可变快照,而不是提供一组分别读取的方法。分别读取的接口更灵活(只要一个数就只拿一个)。但分多次调用拿到的数据彼此不一致——用不一致的命中数和未命中数算出来的命中率是错的。而且如果读取过程中持锁,监控每分钟拉一次数据就等于每分钟阻塞一次业务。判据是「这些数字之间有没有相互推算的关系」:有(命中率要用两个数相除),那它们就必须在同一时刻取出。
把「长期为零的指标」当成排查线索,这是我后来才加上的一条判断规则。我一开始只关注数值大小,觉得零就是「没发生」、没什么可看的。后来排查一个问题时才意识到,某个指标一直是零往往说明有东西没生效:加载次数一直为零,说明我配的自动加载根本没被走到(配置没生效,而不是没有加载需求);淘汰数一直为零,说明容量远大于实际需要,这是浪费而不是好事——但它很容易被误读成健康;命中数一直为零,说明每次的键都不同、缓存完全没起作用,这时候该改的是调用方而不是缓存参数。判据是「这个指标为零,是因为一切正常还是因为这条路径没被走到」。
不在组件里直接接监控系统,只把数据交出去。直接上报最省事(使用方什么都不用做)。但那会让组件绑定一套具体的监控技术栈,而使用方的项目可能用的是另一套。判据是「这个决定该由谁做」:数据往哪里去是使用方的事,组件的职责边界到「把数据准确地暴露出来」为止。
踩过的坑一:参数完全是拍出来的,而我没有任何办法验证。容量配大了浪费内存、配小了缓存不断被淘汰等于没起作用;过期时间配长了数据陈旧、配短了缓存频繁失效。而我手里没有数据,只能改一个值然后感觉一下「好像快了点」。教训是:一个组件如果不暴露自己的运行数据,那么使用方对它的所有配置都只能是猜的——而猜出来的参数没法验证对不对。
踩过的坑二:统计本身把性能拖下来了。我用共享变量同步自增,压测时发现开启统计后读吞吐明显下降。教训是:任何加在高频路径上的观测手段,都要先确认它自己的开销——观测不该改变被观测对象的表现。这也是我把统计做成默认关闭的原因之一。
踩过的坑三:我曾经把「淘汰数为零」当成配置得当的证据。实际上它说明容量远大于需要、内存被白占了。教训是:指标要成对地看——淘汰数低只有在「容量也不大」的前提下才是好事,单看一个数很容易得出相反的结论。
没做的部分:没做按键前缀分组的统计(比如分别看不同业务前缀的命中率)。它需要在每次读写时解析键并归类,这个开销落在高频路径上,而且分组规则因使用方而异。取舍依据是「这个能力的开销落在高频路径上、且规则无法通用」——我的替代方案是建议使用方按业务建多个缓存实例,每个实例的统计天然是分开的,这样开销为零、分组也完全由他决定。
统计开销必须报,因为它是这一块最容易被质疑的地方:「多线程压测下,开启统计后读吞吐的下降幅度控制在很小的比例内;而改造前用共享变量同步自增时下降明显」。两个数一起报,才说明改造的必要性。
压测条件要写清:线程数、键的数量与分布(是集中在少数热点键还是分散)、读写比例、持续时长。键的分布尤其要说——它直接决定命中率,不说明分布的命中率数字是没有意义的。
分类计数用断言型用例:造三种场景各自触发一次未命中——取一个从未写入的键、取一个已过期的键、取一个已被淘汰的键,断言三个分类计数各增加一次、且总未命中数等于三者之和。最后这条等式关系要断言,它能发现分类漏计。
分类的近似性也要报,不能含糊:「最近淘汰记录的上限为 N,超出这个范围的键会被归为『从未写入』」——明确说出它的失效边界,比声称精确要好。
快照一致性:在持续读写的同时反复取统计快照,断言每个快照内部命中数与未命中数之和等于该时刻的总请求数(自洽),并断言取快照期间业务读写的耗时没有出现尖峰(不持锁)。
淘汰与过期计数的准确性:容量设为 N、写入 N+M 条,断言淘汰数等于 M;写入若干条后推进注入的时间源越过过期时间,断言过期数等于这些条目数。这里直接复用了时间源可注入的能力,不需要真实等待。
加载耗时统计:让加载器固定耗时若干毫秒,触发 N 次加载,断言加载次数为 N、总耗时落在预期区间;让加载器抛异常,断言加载失败数增加而加载次数的口径与文档一致(成功与失败是否都计入加载次数,这一点必须在文档里定义清楚,否则使用方算不对平均耗时)。
调参规则可以报一个实测对照:「固定键分布,容量从 N 调到 2N 后,淘汰数明显下降、命中率上升、内存占用上升」——三个数一起变,说明这套指标真的能指导调参,而不只是摆着看。
默认关闭:断言未显式开启统计时,统计相关的计数逻辑不在读写路径上执行(不是「计了但不给看」)。
不要报什么:不要报一个孤立的「命中率 N%」——不说明键分布的命中率可以随意做高。该报的是「压测条件与键分布」「开启统计前后的吞吐影响,以及改造前同步自增时的影响」「三类未命中分类各自可断言且总数自洽」「分类的近似边界」「容量翻倍后淘汰数、命中率、内存三者的联动变化」这几件带条件的事。
前五块都是把组件本身做好。这一块全是把它真接进项目之后才暴露的问题——而且这些问题在单测里永远碰不到,因为单测里没有数据库、没有事务、也没有别人写的调用代码。四个问题。
一是空结果不缓存,等于给了一条绕过缓存的通道。我的加载逻辑是「缓存没有就查库,查到就写进缓存」。但查不到的时候我什么都不写——所以同一个不存在的键,每次请求都要落到数据库。我是用脚本反复请求一个不存在的键时发现的:缓存命中率一直是零,而数据库的查询次数和请求次数完全一样。这意味着只要有人反复查不存在的数据,缓存就等于不存在。
二是更新数据后去更新缓存,并发下会不一致。我最初的做法很自然:改完库之后把新值写进缓存。但两个请求同时改同一条数据时,它们写库的顺序和写缓存的顺序可能是反的——结果缓存里留下的是先提交那个的值,和库里不一致,而且这个不一致会一直留到过期。我起两个线程并发改同一个键复现了它。
三是删缓存的时机放错了。我改成「更新后删缓存」之后还是出了问题:我把删除放在事务提交之前——而在提交之前,别的线程读到的还是旧数据,它会把旧值重新填回缓存。删除动作发生了,但删掉的位置马上被旧数据占回去了。
四是缓存粒度和对象可变性两个坑一起来。粒度上我一开始缓存的是整个聚合对象(用户的全部信息),结果改一个昵称就要让整块失效——而其他字段其实没变。可变性上更隐蔽:我直接把缓存里的对象引用返回给调用方,而调用方拿到之后改了其中一个字段——这个修改直接作用在缓存里的那份数据上,后面所有人读到的都是被改过的。这个问题我排查了很久,因为它表现为「数据莫名其妙变了」,看不出和缓存有关系。
所以四个改动:缓存空结果并给它单独的较短过期时间、更新后删缓存而不是改缓存、删除放在事务提交之后、缓存粒度按用途拆开、对外返回不可变视图或副本。
这个模块最想说的一句话是:一个组件自身的正确性和它「接进项目之后是否正确」是两件事。前五块的问题我都能在单测里造出来;而这一块的四个问题,全都需要真实的数据库、真实的事务边界、以及别人写的调用代码才会暴露。我认为这是「写过组件」和「用过自己写的组件」之间的差别——后者才能发现这些。
缓存空结果,代价是会占用一部分容量去存「没有」。不缓存空最省空间、也不会有「数据已创建但缓存里还是空」的问题。但它等于给了一条绕过缓存的通道——同一个不存在的键每次都落到数据库,只要有人反复查不存在的数据,缓存就等于不存在。判据是「有没有一种输入能让缓存完全失效」:有,那这条路径就必须被堵上。而空结果的过期时间要单独设、且明显短于正常数据——理由是数据可能马上就被创建出来,用一样的过期时间会导致新建的数据要等很久才可见。这个「同一个缓存里两类数据用不同过期时间」的设计,是我在这块想清楚的一个点:过期时间该由「这份数据多久可能变」决定,而不是全局配一个。
更新后删缓存而不是改缓存,代价是下次读要重新加载。改缓存看起来更高效(省一次加载)、也更直觉。但并发下两个请求写库的顺序和写缓存的顺序可能是反的——缓存里留下的是先提交那个的值,而且这个不一致会一直留到过期。删除则不存在这个问题:它是幂等的,顺序颠倒也不会产生错误结果。判据是「写入顺序无法保证时,就不要写」。代价是删完之后第一次读会慢一点,而这个代价换来的是「不会长期不一致」——我认为这个交换是明显划算的,因为一次慢是可见的、而长期不一致是不可见的。
删除放在事务提交之后,而不是业务方法里顺手删。顺手删最简单。但在提交之前,别的线程读到的还是旧数据——它会把旧值重新填回缓存,于是删除动作发生了,位置却马上被旧数据占回去。这个坑很隐蔽,因为代码看起来完全正确(确实删了)。判据是「这个动作依赖的状态在什么时候才对外可见」:数据要到提交后才对外可见,那清理动作也必须在那之后。代价是要接一层事务同步回调、而且提交后删除可能失败——所以要有兜底:记录下来后续重试,或者依赖过期时间自然收敛。不能假设删除一定成功。
缓存粒度按用途拆开,代价是一次更新要删多个键。缓存整个聚合对象最省事(一个键搞定)。但改一个昵称就要让整块失效,而其他字段其实没变。拆开之后改动只影响相关的那一份,代价是要维护一份「哪些键需要一起删」的清单——漏删一个就留下一处不一致,所以这个清单要写在代码里而不是记在脑子里。但也不能拆太细:一个页面要读十几个键的话,收益就被调用次数吃掉了。判据是「这些字段是不是一起变、一起被读」:一起的就放一份,不一起的就分开。
对外返回不可变视图或副本,而不是缓存里的对象引用。返回引用零开销。但调用方拿到之后改一个字段,这个修改直接作用在缓存里的那份数据上,后面所有人读到的都是被改过的。判据是「我交出去的东西,别人能不能改到我的状态」:能,那就必须切断。副本有拷贝开销、不可变视图更省但要求类型配合——我的选择是能用不可变视图的就用,不行才拷贝。写入方向也有同样的问题:使用方写进来的对象他自己可能还留着引用,后续修改同样会影响缓存里的内容——这一点我在文档里写明了。
键里带版本号,用换键代替写兼容逻辑。写兼容逻辑能保留旧缓存。但缓存本来就是可以重建的数据——为可重建的数据写兼容逻辑,投入产出比很差,而且兼容逻辑本身容易出错。判据是「这份数据丢了能不能重建」:能,那就直接换键让旧的自然过期。代价是变更后有一段时间命中率低(要重新填充)——这个代价可预期、也可接受。
踩过的坑一:空结果不缓存,导致缓存对某一类请求完全无效。我是用脚本反复请求一个不存在的键时发现的——命中率一直是零、数据库查询次数和请求次数完全一样。教训是:设计缓存时不能只想「命中的路径」,要问「有没有一种输入能让它永远不命中」——这个问题问出来,空结果这个漏洞就自己暴露了。
踩过的坑二:删缓存放在了事务提交之前,删了等于没删。代码看起来完全正确。教训是:清理动作的时机要对齐「数据对外可见的时刻」,而不是对齐「我写完代码的位置」——这类错误在单线程测试里完全不会暴露。
踩过的坑三:调用方改了我返回的缓存对象,导致数据莫名其妙变了。我排查了很久,因为现象完全看不出和缓存有关系——数据没人改却变了。教训是:只要交出去的是可变对象的引用,就等于把内部状态的写权限一起交出去了——而这种错误的表现和它的原因之间距离很远,排查成本极高。
没做的部分:没做「更新后延迟再删一次」这类进一步降低不一致窗口的手段。它能覆盖「提交后删除与另一个线程的回填之间的极短窗口」,但需要引入延迟任务、而且延迟多久本身是个没法算准的经验值。取舍依据是「当前的过期时间已经把不一致的持续时间限定在可接受范围内,而引入延迟任务会带来新的可靠性问题(任务丢了怎么办)」——我选择了用较短的过期时间兜底,并且能说清这个窗口的存在和它的量级。
空结果这一条最适合用数据库查询次数来报,比命中率直观:「用脚本反复请求同一个不存在的键 N 次,改造前数据库查询次数等于 N,改造后为 1」。这两个数是可以直接从数据库侧统计核对的。
空结果的过期时间要报它的效果边界:「空结果过期时间设为 X(明显短于正常数据的 Y);数据创建后最迟 X 之后可见,若创建路径主动删除空标记则立即可见」。把「最迟多久可见」说清楚,这是一个使用方必须知道的约束。
并发更新的不一致是断言型用例:起两个线程并发更新同一个键、各自写入不同的值,循环多轮后断言缓存中的值与数据库最终值一致。改造前(更新缓存)这个用例会失败,改造后(删除缓存)通过——要说明「改造前会失败」,否则这个用例看不出价值。
事务提交时机是踩坑的直接回归:在事务提交前后各设一个观察点,让另一个线程在事务提交前读取该键,断言提交后缓存中不存在旧值。这条用例专门针对「删除被旧数据回填」这个场景,把删除挪回提交前它就会失败。
缓存粒度可以报一个失效范围的对比:「更新一个字段时,改造前失效的缓存条目数为 N(整个聚合对象),改造后为 1」,并报「一次更新需要删除的键数量」以及是否有遗漏(用例遍历清单断言全部被删)。
可变对象是断言型用例:取出缓存值后尝试修改它,断言修改不生效(不可变视图会拒绝)或不影响缓存内的数据(副本);再次读取,断言拿到的仍是原始值。后一条是这个坑的直接度量。
写入方向也要断言:写入一个对象后由外部修改它,断言缓存内的数据不受影响(如果做了写入时拷贝);如果没做,就在文档里明确写出这个约束——说清约束和实现保护都是可接受的,含糊不清才不行。
加载失败不缓存:让加载器抛异常,断言缓存中未写入任何值、下一次请求会重新尝试加载(而不是拿到一个被缓存的失败)。
结构变更换键:改变缓存对象结构并升版本号后,断言旧键不再被读取、新键正常填充、且不出现反序列化异常。
不一致窗口要诚实报出来:「提交后删除与另一线程回填之间存在一个极短窗口,该窗口内可能读到旧值;不一致的最长持续时间由过期时间上界限定」。主动说出这个窗口的存在和量级,比声称「保证一致」可信得多——而后者在我这个实现里是不成立的。
不要报什么:不要说「保证缓存与数据库强一致」——我的方案做不到,只能限定不一致的持续时间。该报的是「反复请求不存在的键时数据库查询次数从 N 降到 1」「并发更新用例在改造前失败改造后通过」「事务提交前删除会被回填的回归用例」「单字段更新的失效条目数从 N 降到 1」「取出后修改不影响缓存内数据」以及「不一致窗口的存在与上界」这几件可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 本地缓存组件(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据