观察、决策、行动三个面板,旁边的标题说明布隆过滤器回答“也许”。

布隆过滤器能廉价地说一句“也许”

布隆过滤器能廉价地排除某个元素属于它所表示的集合,而肯定答案只能解释为“可能存在”。这种不对称依赖更新正确、哈希规则保持一致。对于已经插入的元素,普通只增不减算法没有假阴性;但过时或损坏的过滤器仍可能把数据库里真实存在的键判断成不存在,不能把算法性质无限扩大成应用保证。 位图保存的是候选线索,不是记录 先准备一张包含 m 个零位的位图,并为每个键计算 k 个哈希位置。插入时将这些位置全部置一;查询时,只要任意所需位置为零,就返回否定,否则返回“也许”。不同键可能共用位置,所以全部位置都是一,并不能证明查询的键真的被插入过。 再次插入同一个键,不会改变已经置好的位。过滤器没有保存原始键和值,也不能枚举原集合。如果误判会影响正确性,精确集合或数据库仍然是权威答案;不能仅凭“也许存在”就授予权限、扣款,或者丢弃必须处理的工作。少一次查询的收益不能替代业务判断。 按预计元素规模分配空间 在哈希近似均匀且相互独立的常用模型下,插入 n 个不同元素后的假阳性概率近似为 p = (1 − exp(−kn/m))^k。固定 m 和 n 时,k 接近 (m/n) ln(2) 可以使这个近似值最小。哈希次数并不是越多越好:除了增加计算,它也会更快地把位图填满。 选择合适的哈希数量后,百分之一的误判目标大约需要每个元素九点六位,尚未计算实现开销。一千万个元素因此需要大约一千二百万字节的位图,并不是几百万个位就够用。讨论空间时必须区分位、字节、位图本体以及整个进程的内存,避免把不同口径混在一起。 假阳性率针对的是确定不存在的元素查询,不是所有肯定答案中错误答案所占的比例;后者还取决于调用方有多大概率查询真实存在的元素。应使用已知非成员测量,并将有限样本的观察值与概率模型区分开来,不能看到一次误判较少就宣称达到了严格概率保证。 让表示的集合跟上权威数据 如果准备根据否定答案跳过数据库查询,过滤器就必须包含所有相关数据库插入。先发布新行、稍后才更新过滤器,会产生可见性缺口。重建过程也需要定义快照和增量更新如何交接;过滤器的一次查询成功,并不能证明应用层这些步骤已经原子完成。 写入方和读取方还必须保持位数、哈希构造、种子或域前缀以及字节编码一致。改变这些输入就会改变位置。普通布隆过滤器不能通过清除某个元素对应的位来安全删除,因为其他元素可能依赖同一位;计数型或其他支持删除的结构,使用的是不同契约,不能把接口混为一谈。 坏掉的样子 向固定大小的位图插入过多元素,会提高假阳性率,逐渐失去减少昂贵查询的收益。正确插入的元素仍不会被误判为不存在,但查询越来越难得到否定答案。扩大固定过滤器需要重建或采用相应增长设计,不能只修改取模用的位数,否则已有位的位置含义就变了。 真实产品不一定像下面的固定大小模型。Redis 的 BF.RESERVE 在允许扩展时可以增加子过滤器,禁止扩展时则在达到容量后拒绝插入。子过滤器会增加内存和查询工作;应检查实际配置与实现,不能假定产品会像没有容量检查的插入循环一样静默变得饱和。 更新丢失、编码不一致、快照落后和错误删除,都可能破坏应用期望的否定答案安全性。除了观察假阳性,还要记录插入失败与过滤器新鲜度。过滤器是一项优化,它的维护仍属于数据生命周期的一部分,不是创建一次之后就可以忽略的永久索引。 快速验证 保存为 bloom_check.py,用 Python 3.10 以上版本执行 python3 bloom_check.py。实验已在 Python 3.14.4 上运行,仅用标准库。七个带域前缀的 SHA-256 摘要、明确的 UTF-8 编码和固定键序列保证测试可重现,不需要外部服务、网络访问或文件写入。 ...

十二月 15, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明缓存新鲜度遵循时钟。

缓存的新鲜度要对着时钟判断

响应经过另一个缓存,不代表自动获得一份全新的新鲜度额度。缓存必须计入交付路径上已经积累的年龄,再增加自己的本地停留时间。否则,每一层看起来都只缓存六十秒,串在一起却可能把旧内容直接复用远超源站预期的时间。判断新鲜度需要明确时间来源,不能只把配置中的秒数当成最终交付保证。 先算清年龄 Age 估计从响应生成或成功验证以来经过的秒数,其中包括缓存停留与网络传输。收到的 Age 是计算当前年龄的输入,不是在本地存放期间保持不变的计数器。Date 提供响应生成时间;把这些信息联系起来时,时钟同步和实际测得的请求、响应时刻都可能影响结果。只截取一组响应头,不能看到它随后在缓存里又停留了多久。 RFC 9111 给出两条年龄估计路径:接收时刻减去 Date,负数归零,得到表观年龄;收到的 Age 加响应延迟,得到校正年龄值。保守算法取两者较大值,再加本地停留时间。例如请求时刻为一百秒,接收为一百零二秒,Date 为九十五秒,收到的 Age 为十秒,当前时刻为一百零七秒,表观年龄是七秒,校正值是十二秒,当前年龄最终为十七秒。只读原始 Age 就漏算了七秒。 新鲜度寿命也有优先顺序。共享缓存存在 s-maxage 时先用它,否则 max-age 优先于 Expires 减 Date;私有缓存忽略 s-maxage。没有显式过期信息时,在允许的条件下可能使用启发式寿命,但并不存在统一固定时长。只有适用的新鲜度寿命严格大于当前年龄,响应才是新鲜的;恰好相等时已经到达陈旧边界,而不是再多送一次的许可。 坏掉的样子 新鲜不代表适合任意请求。存储许可、网址与 Vary 匹配、验证要求仍然有效。即使寿命设得很长,no-cache 也要求复用前验证。ETag 是验证器,不是免于询问源站的许可。成功重验证可以更新保存的元数据而不重新传正文,但仅仅从另一个缓存收到响应,不能把年龄清零,否则每经过一层就会错误地延长直接复用期限。 提供陈旧响应需要另外的许可,不能把源站故障当成自动授权。must-revalidate 禁止过期后未经成功验证就复用,即使网络断开也不例外。stale-while-revalidate 可以允许在有界的陈旧窗口内一边返回旧内容、一边验证,但仍受适用限制约束,也不会把旧内容重新变成新鲜内容。RFC 9111 已废弃 Warning 字段,因此没有旧式 Warning: 110 不能作为新鲜度证据。 ...

十二月 3, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明缓存集中失效会造成重复工作。

缓存集中失效会放大回源工作

缓存雪崩或击穿所造成的一个关键风险,是并发未命中对同一依赖发起大量重复工作。同时到期只是触发条件之一:容量淘汰、刚部署的空缓存、缓存服务不可用,都可能造成类似突发。即使只有一把热门键,也足以压垮后端。判断风险时应数清楚:一份结果尚未计算完的这段时间内,到底有多少次重建真正抵达数据源。 在真实边界内合并工作 Go 的 singleflight Group 对同一键,在这个 Group 内最多保持一次在途函数执行;重复调用者共享结果,也共享错误。不同 Group 之间不会协调,即使它们位于同一个进程;不同服务实例通常也各有自己的 Group。因此它既不是分布式锁,也不是保存已完成结果的缓存。把 Group 放在每个请求内部重新创建,无法实现跨请求合并。 实际使用时,应让请求共享同一个 Group,并使键准确表示等价工作。不同租户、权限、币种或响应表示如果错误地使用同一个键,可能拿到不属于自己的结果。对于缓存回填,在共享函数内部应再次检查缓存,确实仍缺失才访问数据源,并在函数返回前完成成功结果的缓存写入。这些存储操作都不是 singleflight 自动提供的能力。 错开时间不能改变新鲜度承诺 随机抖动可以打散不同条目或独立副本的到期时间,却不能阻止访问同一过期热键的人一起未命中。如果最大 TTL 是业务规定的新鲜度上限,在有界范围内随机缩短寿命,不会偷偷越过这个上限;任意增加正向抖动则改变了原有承诺。不能脱离请求量、计算成本和允许陈旧程度,宣称几个百分点或几秒钟就普遍足够。 提前刷新和允许使用旧响应,有机会避免用户等待,但必须有明确业务语义。暂时展示上一版公开目录可能可接受,已经撤销的权限或当前余额则未必可以。并发合并只减少重复计算,不负责判断陈旧内容能否使用;如果缓存完全是空的,系统仍需要取得一份数据源结果,不能把后台刷新当作无条件的可用性保证。 坏掉的样子 共享加载函数一旦卡住,可能同时拖住很多等待者。要为数据源调用设置执行期限,也让每个调用者能限制自己的等待时间。DoChan 返回的是结果通道,不是取消机制:某个等待者离开,不会自动停止底层执行。Forget 允许同一键重新启动一次执行,但并不取消原有执行,用它解除等待可能重新制造两次并行加载。 错误只在本次执行仍处于在途状态时共享。执行结束后立刻重试,会向故障依赖发起一轮又一轮新请求。应根据依赖的实际约束设置重试与准入策略,对可以缓存的“不存在”和瞬时失败分别处理。另一个常见误判是把进程内的数字推广到全局:一百个各有 Group 的实例,仍可能同时产生一百次重建,本地只执行一次不等于源站只收到一次。 快速验证 需要 Go 1.25 或更新版本;本次使用 Go 1.27.0 和 x/sync v0.22.0。在下面创建的独立模块目录中,将 Go 代码放入 main.go,再运行 go run -race main.go。只有依赖安装需要外网。程序从“已经发生冷缓存未命中”的边界开始,用有计数器的加载函数观察行为,没有连接真实数据库、缓存服务,也没有实现 TTL 定时器。 ...

十一月 22, 2027 · 2 分钟 · XB Field Notes
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

LRU 缓存淘汰最久未用的条目

LRU 按最近使用顺序选择淘汰对象,而不是按历史热度、存放年龄或业务重要程度选择。有界缓存需要腾出空间时,精确的最近最少使用策略会移除最后一次有效访问发生得最早的条目。它利用的是时间局部性,既不保证命中有价值的数据,也不保证留下来的数据仍然新鲜。 沿访问顺序找出淘汰对象 假设缓存只能存三个条目,并按从旧到新的方向列出。依次加载 A、B、C 后,顺序为 A、B、C;再次读取 A,顺序变成 B、C、A;此时加载 D,就淘汰 B,留下 C、A、D。普通字典即使保留插入顺序,也不会因为某个条目被读取就自动调整它的位置。 实现必须明确哪些操作算作一次访问。常见精确方案把查找表与双向链表结合起来,以较低成本完成查找、移到最近端和从最旧端移除。维护顺序需要额外元数据,也让缓存命中变成状态更新;并发实现还必须协调这些更新,不能把读取缓存理解成永远没有写入开销。 精确顺序与采样近似不是同一契约 Redis 使用近似 LRU:抽取候选并维护较好的淘汰候选池,而不是为每个键保存完整的精确访问顺序。采样数量可以配置,更多采样用额外计算换取更接近精确 LRU 的结果。它不承诺下面小模型的逐个淘汰顺序,也不能由此推导所有淘汰操作都有固定耗时。 候选资格同样重要。Redis 的 allkeys-lru 可以淘汰任意键,volatile-lru 则只考虑带过期信息的键。设置了有效期,并不保证键在截止时间之前不会被淘汰;如果没有符合条件的带过期时间的键,volatile 策略在内存压力处理上会表现得像 noeviction。 容量约束不是新鲜度约束 限制三个条目,不等于限制三千字节。不同键、值和维护结构占用不同空间,按数量限制的缓存仍可能持有很大的对象。Python 的 lru_cache 会持有参数与返回值的引用,直到条目被淘汰或缓存被清空。选择容量单位时,应对应真正需要控制的资源,而不是只看条目数是否稳定。 最近访问也不能说明源数据有没有变化。频繁读取一个过时答案,反而可能让它一直留在缓存中。有效期、显式失效和版本化键分别处理新鲜度问题;淘汰规则既不是一致性协议,也不是备份策略,不能因为缓存命中就跳过业务对数据有效性的判断。 坏掉的样子 一次扫描大量只用一遍的条目,可能挤掉本来有用的工作集。容量为三时循环访问四个不同键,即使这些键都在被反复请求,也可能每次都未命中。LRU 不会因为过去访问次数很多就永久保留一个键。LFU、插入前的准入策略或调整容量可能适合某些负载,但不能仅凭“读多”就自动选出正确方案。 应在有代表性的时间段一起观察命中、未命中、淘汰、保留字节数以及未命中的代价。很高的命中率也可能掩盖少量特别昂贵的回源;单看淘汰数量,不知道被移除的数据是否有价值。修改策略前,先还原实际访问轨迹,再解释缓存为什么做出了这些选择。 缓存命中还必须允许安全复用。带副作用的函数,或每次调用都必须创建独立可变对象的函数,不适合直接做记忆化。Python 的 lru_cache 能保持多线程下内部结构一致,但同时发生的未命中仍可能让被包装函数执行多次。线程安全不等于抑制同一项工作被重复执行。 快速验证 将代码保存为 lru_check.py,用 Python 3.10 以上版本执行 python3 lru_check.py。实验已在 Python 3.14.4 上运行,只使用标准库:OrderedDict 展示精确访问顺序,真正的 functools 装饰器另行验证记忆化行为。不启动 Redis,不发送网络请求,也不写文件。 ...

十一月 12, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是一致性哈希限制重新映射的英文标题。

一致性哈希减少重新映射

一致性哈希限制成员变化时的数据放置变化,但它本身不搬运数据、不让副本达成一致,也不把单个热键的请求自动分散。经典顺时针哈希环把键交给哈希位置之后遇到的第一个节点点位;如果位置恰好相等,就归该点,超过最大点位则绕回最小点位。理解这个查找方向,是判断迁移范围的前提。 先把归属区间画对 一个点拥有的是前驱之后到自身为止的区间,即 (前驱, 当前点]。假设 A 位于 20,B 位于 50,C 位于 80,那么 B 负责大于 20 且不大于 50 的位置;超过 80 的键绕回 A。这不是从 B 到其后继 C 的弧段,左右端点是否包含也不是可以随意改变的细节。 在 35 加入 D,它从顺时针后继 B 手中取得大于 20 到 35 的位置;移除 B,则将 B 原来负责的区间交给 C。未受影响的点位保持原位置及归属,这才是算法有用的性质。仅说“移动一小段弧”还不够,因为原来某段弧可能就占据整个环的很大一部分。 作为对照,直接用 hash(key) 对节点数量取模来选择数组下标,新增节点会改变除数,很多键随之选到别的下标。四个等容量且平衡的节点扩到五个时,理想哈希环大约需要迁移五分之一的键,但真实迁移比例取决于点位与键分布,不能把这个比例当成所有部署都成立的保证。 ...

九月 19, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明缓存失效靠主动推动,而非祈祷。

缓存失效是主动推进,不是祈祷

源站文件改了,并不意味着每个已经保存响应的缓存都会收到通知。失效操作控制的是哪些已存副本还可以继续复用,而不只是更新磁盘上的内容。可靠的发布要区分到期、重新验证、定点移除以及使用新 URL:它们解决不同范围的问题,也没有哪一种能远程擦掉读者已经下载的所有字节。 到期不是广播通知 源站发生变化后,一份仍然新鲜的响应可能继续被合法使用。新鲜度由缓存寿命和当前年龄共同决定,年龄计算涉及缓存元数据与经过的时间,并非只从源站的 Date 字段开始倒计时。到期改变的是直接复用时必须满足的条件,既不保证存储介质立即删除条目,也不会让无人访问的缓存主动联系源站。 验证器同样不是内容变化的推送通道。请求需要重新验证时,正确的 ETag 才能帮助源站确认表示未变,或者返回替代正文。浏览器手上的响应还新鲜、没有理由回源时,新增验证器并不能强迫它询问。把源站策略改成 no-cache 或 no-store,也无法追溯修改浏览器之前已经收到的缓存指令。 先确定失效覆盖范围 RFC 9111 要求:缓存收到不安全方法对应的成功或重定向响应时,必须使目标 URI 的已存响应失效。这个义务只涉及该请求实际经过的缓存,不是向整个网络广播。通过另一条接口修改数据库,不会自动覆盖所有派生页面、查询参数变体、不同语言表示和搜索列表,应用需要识别这些真实依赖。 CDN 的清理接口也只管理服务商控制的副本,无法直接清空独立浏览器缓存。应按服务商的实际契约确认受影响的主机、路径、变体以及缓存标签。先让替代内容在源站可用,再发起清理、跟踪完成状态,并比较实际响应正文和缓存状态。接口已经接受清理请求,并不能单独证明它已传播,也不能证明源站已经能返回预期的新字节。 坏掉的样子 如果源站尚未更新就先清理边缘,下一次未命中可能立即把旧内容重新填回去。已经在途的回填也可能和失效操作竞争,具体结果取决于缓存实现。反复执行全站清理不能证明这些竞争已经解决,反而会让更多对象变冷、增加回源负担。应准确圈定受影响对象,并评估清理之后源站需要承担的请求量。 对于静态资产,应先发布带内容指纹的新文件名,再切换 HTML 或清单中的引用。在旧文档仍可能引用旧资产期间,旧文件也应继续可用。新 URL 可以避开旧缓存条目,但前提是读者先拿到更新后的引用文档。只生成一个新文件名,却不更新文档,或者文档本身仍被直接复用,都不会使现有读者自动转向新版本。 涉及机密信息删除时,边界还要更严格。删除源站文件或清理 CDN 不能撤销已经下载的字节,缓存策略也不等于访问授权。应从源头防止敏感响应进入不合适的缓存,而不是泄露后依赖清理挽救。对于紧急修复,要先定义“可见”的对象:新访问者、已经打开的标签页、所有曾下载的副本,不能共享同一种到达保证。 快速验证 准备 Python 3.10 或更新版本及 POSIX shell;实际运行使用 Python 3.14.4、CacheControl 0.14.4 和 Requests 2.34.2。安装下列固定版本后,用 "$invalidation_lab/venv/bin/python" 执行 Python 代码。程序在自动分配的回环端口启动一个 HTTP 服务,并建立两个互不共享存储的内存私有缓存;关闭环境代理,不访问外部服务器,最后关闭客户端、线程和服务。只有依赖安装需要联网。 ...

八月 5, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

过期缓存需要明确的重新验证与陈旧响应策略

nginx 代理缓存需要分别作出几项决定:怎样识别已存响应,什么时候允许读取,什么时候允许保存新响应,以及过期副本还能怎样使用。如果把这些都看成一个 TTL 设置,故障就很难解释。验证时应同时观察请求实际经过的路径与交付字节,而不只是确认 nginx 接受了配置文件。 存储容量与新鲜度分开看 proxy_cache_path 声明磁盘存储位置及保存元数据的共享内存 keys_zone,proxy_cache 再为请求启用这个区域。元数据区大小不等于正文缓存容量。max_size 由缓存维护机制管理,不是瞬时硬磁盘配额;inactive 保留策略也不是响应的新鲜度寿命。已经过期的响应仍可能留在存储中,用于后续重新验证。 默认缓存键为 $scheme$proxy_host$request_uri,其中 request_uri 已经包含查询串,后面再加 $is_args$args 就重复拼接了参数。也不能随意替换成规范化后的 URI,或遗漏会改变表示的输入。默认启用 HEAD 转换,因此 GET 与 HEAD 共享相应缓存行为;关闭转换时,就需要在缓存键中考虑请求方法。 核对真正生效的响应策略 proxy_cache_valid 按状态码提供配置寿命,但正常处理的源站新鲜度头具有更高优先级。X-Accel-Expires 还有自己的优先顺序;它不存在时,Cache-Control 和 Expires 才参与相应设置。忽略这些头会改变实际契约。下面的实验里,源站 max-age 为一秒,覆盖了配置的六十秒;no-store 则阻止响应进入存储。 proxy_cache_revalidate 让过期条目使用 ETag、Last-Modified 等验证器发送条件请求。匹配的 304 可以保留正文并更新元数据,变化后的 200 则提供替代字节。但重新验证不等于异步刷新。后台更新是另一项指令,还需要允许在更新期间返回旧内容,不能仅凭某一个开关推断整套刷新方式。 ...

六月 24, 2027 · 3 分钟 · XB Field Notes
观察、决策与行动三块面板,旁边的标题说明穿透读取缓存会在未命中时加载。

读穿透缓存:未命中时取数回填

读穿透缓存给调用者提供统一的读取接口:命中时返回已存值,未命中时调用加载函数,再把结果保存起来。关键边界是由谁拥有这个 loader。把它放到缓存接口后面,可以减少重复的查找逻辑,但并不会自动解决并发、写入、访问授权或不同缓存副本之间的一致性。模式名称不是这些能力已经具备的证明。 找到真正负责加载的一层 在 cache-aside 模式中,应用显式查询缓存,未命中后访问数据源并回填;读穿透则让缓存组件拥有这条未命中路径。它可以是应用进程里的库,也可以是远程缓存服务。两种部署方式各有更新边界:修改进程内库的 loader,仍然需要把变更部署到使用它的进程,不能笼统宣称所有读者都会免部署地自动获得新逻辑。 单独一个 Redis GET 也不是读穿透加载器。字符串键不存在时,它返回 nil,不会自动发现并查询你的数据库。客户端库、缓存服务或应用适配层必须另外提供加载逻辑。下面的实验用带记忆功能的函数包装器提供统一读取接口,缓存和数据源都在当前进程内;这不是 Redis 服务端加载功能的演示。 读取模式没有定义写入模式 读穿透本身没有规定写操作经过哪里,写穿透是另一项安排。即便读写共用一个组件,仍需要定义数据源写入和缓存更新分别失败时的行为。绕过组件的其他写入方,可能让缓存保持旧值。应沿真实业务更新路径检查参与者,不能因为使用了一个模式名称,就假定所有写入都会配合清理。 TTL 限制的是某个缓存条目的复用时间,并不保证下一次重载从滞后副本或有缺陷的 loader 中取到最新真值。过期也不一定立即从内存中释放字节。寿命应依据数据需求选择,而非套用统一的几秒或几分钟;用户数据的 TTL 再短,也不构成权限隔离。命中率很高同样不能证明内容正确。 坏掉的样子 记录不存在与数据源不可用,是两个不同结果。缓存一次合法的“不存在”可以减少重复查询,但如果随后创建了记录,这个否定结果也可能继续遮住新记录,直到到期或失效。若把超时转换成一次正常返回的空值,就可能把故障写成错误的缓存内容。必须明确哪些结果允许保存,以及调用者如何区分缺失和失败。 并发加载是另一项独立契约。Cachetools 明确说明:锁保护缓存对象的访问,但被包装的函数在锁外运行,因此只有锁并不能抑制重复加载。它的 condition 选项另行处理同键的并发执行。下面的实验只运行单线程,刻意不使用这两个参数,不能把代码当成已经具备线程安全或分布式协作的实现。 失效还可能与一个已经读到旧源版本的加载操作竞争。删除某个键,并不天然阻止那个在途结果稍后重新写入。应根据真实的写入和加载协议选择协调方式,并测试操作顺序。也不要把大范围按键模式删除当作默认修复:它会让更多条目变冷,却没有证明源头缺陷已经解决。 快速验证 需要 Python 3.10 或更新版本;本次使用 Python 3.12.14 和 cachetools 7.1.8。安装固定依赖后,用 "$readthrough_lab/venv/bin/python" 执行 Python 代码。缓存存储、到期和函数记忆行为都由现有库负责;测试手动推进专用时钟,无需睡眠,也不修改系统时间。实验不联系 Redis 或外部数据库,只有依赖安装需要联网。 ...

五月 26, 2027 · 2 分钟 · XB Field Notes