
制品仓库的读请求看起来像一次静态文件下载,真正决定延迟和正确性的,往往是文件打开之前的那几步判断。客户端在拿到 JAR、镜像层或 deb 之前,仓库要先确认目标是否存在、是否仍在线、当前身份能否读取、这个坐标有没有被隔离,然后才决定返回索引文档还是文件本身。这些判断如果每次都回到数据库,上传和下载都会被元数据查询拖住;如果把判断结果在内存里放得太久,一次「改为私有」或一次覆盖发布,就会在后续请求上继续生效。
AngusRepo 把缓存做成一套有边界的一致性机制。共享层只加速体积小、可失效、重启后仍希望预热的事实;正确性来自事务提交之后的失效,存活时间只用来减少重复解析。下面先给出结论和用语,再按数据放置、读路径、版本号、失效传播和文件语义展开。
导读
问题。 制品仓库的读请求依赖一串判断,缓存如果只用来加速文件,权限、覆盖写和多进程上的内存会各自漂移。
做法。 共享缓存只放仓库快照和聚合仓库的成员列表;鉴权结论、隔离门禁、派生文档和代理未命中留在本进程。仓库行上的四个版本号标出哪一类事实变了。单节点在事务提交后删除本进程缓存;多实例在同样的删除之外,再给每个其他存活实例一只信箱,并用版本号探测兜底。共享层使用平台已有的进程内缓存和数据库中的缓存行,失效消息走持久化工作队列。要共享的数据很少,要传播的动作是「请删除本进程里的相关缓存」,因此不另引入 Redis。
承诺。 事务提交之后,处理这次写的进程在下一次请求就能看到新状态;事务回滚时,缓存和版本号都停在提交前。覆盖写让读者看到完整的旧文件或完整的新文件。多实例把多份内存拉齐,它不把单节点变成高可用部署。
本文写给设计或评审制品仓库、缓存一致性和多进程部署的工程师。后文会用到托管、代理、聚合三种仓库;默认数值集中在「默认约定」,一条「改为私有」的时间线放在可见性一节。
文中的几个词
仓库分三种,后文按这个差别讨论文件。
| 词 | 在本文中的意思 |
|---|---|
| 托管仓库 | 文件由本仓库保存,是这份制品的权威副本 |
| 代理仓库 | 按需向上游取得制品,并在本地保留一份副本 |
| 聚合仓库 | 按成员顺序选择第一个能返回的仓库,自己不复制成员文件 |
缓存和失效用下面这些词。
| 词 | 在本文中的意思 |
|---|---|
| 仓库快照 | 一份不可变的仓库描述,供在线检查和公开性判断使用,不含上游口令 |
| 隔离门禁 | 某个路径上的制品是否存在、是否被隔离,以及原因和可信度 |
| 派生文档 | 由仓库内容生成的协议响应,例如 Maven 元数据、npm 包文档、索引页,以及体积不大的镜像清单 |
| 新鲜度记录 | 代理仓库对本地副本的描述,包括校验和、上游标识、下次校验时间,以及坐标是否可变 |
| 版本号 | 仓库行上只增不减的四个整数,分别对应元信息、权限、内容、成员 |
| 工作队列 | 一条消息只会被一个消费者取走并确认,不会自动复制给所有进程 |
| 信箱 | 多实例时每个进程专属的一条队列,只由该进程消费 |
| 兜底探测 | 多实例在信箱之外,按间隔读取本进程缓存过的仓库版本号,发现落后就删除过期内存 |
| 成员版本摘要 | 把各成员的内容版本压成定长摘要,放进聚合索引的键里,避免键超长 |
1. 什么进入缓存
共享缓存的值是字符串,键最长 256 个字符,进程内默认大约容纳 1 万条。JAR、镜像层、Helm 包和安装包放不进这块空间,强行放入还会在淘汰时挤掉真正需要长期命中的仓库资料。文件已经在对象存储里,热文件再交给操作系统的页缓存即可。托管仓库中的文件就是这份制品;代理仓库中的文件是上游的一份本地副本,能否直接返回由新鲜度记录决定。进程内不保留第二份文件字节。
适合进入共享缓存的,是体积小、条目少、重启之后仍希望预热的事实:仓库快照,以及聚合仓库的成员列表。快照包含标识、租户、名称、格式、类型、在线状态、是否公开、上游地址、是否允许缓存上游,以及四个版本号。上游账号、口令和令牌留在快照之外。需要回源时按仓库标识临时读取凭证,用完即丢,进程里不会长期持有密钥,快照也不会在后续请求中被原地改写。
条目多、或者结果依赖当前用户的数据,各自留在本进程内存里,容量互不影响。
| 区域 | 留在本机的原因 | 正确性如何维持 |
|---|---|---|
| 鉴权结论 | 按用户和权限展开,数量远多于仓库 | 默认保留 30 秒。仓库自身的公开性和访问规则另有失效路径 |
| 隔离门禁 | 按路径展开,只保留是否存在、是否隔离、原因和可信度 | 默认 60 秒;内容版本变化后,旧键不再被读取 |
| 派生文档 | 协议生成的元数据和索引 | 单条上限 256KB,约 5 万条,合计约 64MB;超出的响应当次生成 |
| 代理未命中 | 记住上游确认不存在的路径,避免反复回源 | 约 10 万条,存活时间跟随仓库配置 |
仓库快照需要在进程重启后从缓存行恢复,所以放在共享缓存里。鉴权结论和索引正文又多又短命,放进同一块有限空间,只会把快照挤出去。两层缓存并存,是因为它们的共享范围和淘汰代价不同。
2. 读请求如何穿过这些层
一次下载或索引读取按固定顺序进行:先取仓库快照,再判断仓库是否在线,然后计算公开读或访问规则,接着做隔离门禁,最后才生成派生文档或打开文件。文件或文档的命中发生在这些判断之后。私有仓库的文件可以留在磁盘上,权限在每次请求时计算,路径本身不携带授权,系统也不签发长期有效的下载地址。
图 2 读请求先完成判断,再返回文档或文件。
公开仓库的读取对匿名和已登录用户都允许,结论直接来自快照,不必写入鉴权缓存。其余请求先区分匿名与已登录,再依次看令牌、管理员、仓库创建者、部署令牌和访问规则。只有访问规则算出来的允许或拒绝进入本机鉴权区。创建者、管理员和部署令牌是否放行,跟这次请求的身份一起判断,不固化进仓库快照,因此身份变化不会被一份旧快照拖住。
聚合仓库是否可读,看聚合仓库自己的快照。选定某个成员的文件之后,仍执行该成员制品的隔离门禁,这样已隔离的制品不会借组仓库的入口被读出去。成员仓库自己的登录规则不再对终端用户重算。用户面对的是组仓库这一个入口,若每一层成员都再拦截一次,同一次拉取会被重复拒绝,而组仓库的权限模型也会变得难以解释。
不存在的仓库名不进入缓存。随机探测若也能占住一条「不存在」记录,有限的快照容量会被无意义的名字填满。快照默认保留 10 分钟,这段时间只为减少重复解析。仓库下线、改为私有或访问规则变化是否立刻可见,取决于失效传播,而不是这段存活时间。
3. 用四个版本号描述失效范围
仓库行上维护四个只增不减的版本号,与业务更新在同一个事务里前进。一次变更只推动它真正影响的那一个或几个数,读路径据此知道哪些缓存还可以用。
| 版本 | 何时前进 |
|---|---|
| 元信息 | 改名、状态、上游地址、格式或类型,以及其他与权限无关的设置 |
| 权限 | 公开性变化、访问规则的增删改、删除仓库 |
| 内容 | 本仓发布、覆盖、删除、隔离或可信度变化;代理仓库回源写入或刷新 |
| 成员 | 聚合仓库的成员增删或调整顺序 |
公开性变化时,元信息版本和权限版本一起前进:快照里的公开标志变了,按旧规则算出的结论也要失效。发布制品只推动内容版本,访问规则的变化只推动权限版本。持续上传时,数据库增加的是一次整数更新,仓库设置不必每次重新装载。这个划分把「设置变了」和「内容变了」拆开,避免一次发布把整份快照打掉。
本进程为每个仓库记下当前生效的四个数。拼门禁、派生文档和成员列表的键时,用的就是这四个数。取值只有一条规则:快照里记下的数,和最近一次失效动作写下的数,取较大的一边。
可以用内容版本看这条规则怎么工作。仓库 R 在数据库里的内容版本是 12,本进程的视图也是 12,门禁键因此带着 12。一次发布提交成功后,库里的数变成 13,写进程随即把视图写成 13。此后的读取使用带着 13 的键,带着 12 的旧结论即使还留在内存里,也不会再被读到。其他进程若仍停在 12,信箱到达或兜底探测读到 13 时,同样把视图抬到 13。即便一份尚未刷新的快照里仍写着 12,拼键也以 13 为准。发布只抬高内容版本,不会为了这次上传把整份快照删掉重读。
图 3 同一次发布前后,拼键用的内容版本。
共享缓存不提供按前缀删除,所以上面采用换键,而不是按路径把旧条目扫掉。成员列表把成员版本放进键里,旧键留到自己的存活时间结束。快照的键里不含这些版本号,元信息或权限变化时要显式删掉快照。门禁、派生文档和成员列表靠换键失效,快照靠显式删除,两种手段合在一起才覆盖全部区域。
索引类响应跟随仓库的内容版本。同一仓库任意一次成功发布,都会让旧的 Maven 元数据或同类索引不再命中。若以后观测到重建成本过高,可以把键收窄到具体坐标,那是在同一套版本模型上的优化,失效方式保持不变。
聚合仓库合并出来的索引还要反映成员。它的键包含组的成员版本,以及成员版本摘要。任一成员发布,或成员顺序调整,摘要随之变化,旧的合并结果不再被读取。组仓库合并的是索引视图,成员文件仍留在各自的对象存储里。
4. 单节点直接失效,多实例用信箱传播
单节点
部署方式显式配置,默认是单节点。此时缓存只存在于这一个进程。写事务修改业务数据并推动版本号,事务成功提交之后,再删除本进程里受影响的缓存。提交失败时版本号停在原处,内存保持原样,下一次请求读到的仍是提交前的状态。单节点不为了确认失效再读一次版本号,也不启动队列消费。
一次删除要覆盖这次变更影响到的全部区域。
| 变更 | 提交后删掉什么 |
|---|---|
| 改名 | 旧名和新名上的快照 |
| 公开性或访问规则 | 快照,以及该仓库的鉴权结论 |
| 发布、覆盖、删除或隔离变化 | 抬高本机内容版本,使该路径的门禁和该仓库的派生文档失效 |
| 聚合仓库的成员变化 | 成员列表和组仓库快照 |
| 代理路径重新落盘,或上游确认删除 | 该路径的未命中记录。可变路径能否直接返回,仍看新鲜度记录 |
共享缓存中的快照在删除时同时去掉内存和缓存行。若只清内存,进程重启会把旧快照从缓存行装回来,一次「改为私有」就会在重启后复现。本机删除如果失败,该仓库的快照立即作废,下一次读取直接查库并记录错误,正确性不寄托在那 10 分钟的存活时间上。
信箱
多个进程各有一份内存,失效必须到达每一份。平台队列是工作队列:一条消息被一个消费者取走并确认之后,其他进程看不到它;按某个键分区,也只是让同一个消费者按顺序处理,不会把消息复制给所有进程。因此所有实例共用一个主题,或者按仓库标识把消息固定送到某一台机器,都到不了其余进程上的那份缓存。
多实例改为给每个存活实例一只信箱,也就是一个只由该实例消费的主题。实例启动时使用自己的编号建立这只信箱,编号在集群内唯一;容器环境使用 Pod 名,因为节点主机名可能重复。
图 4 一次提交如何到达其他进程的内存。
在线名单由心跳维护,默认每 10 秒刷新一次,超过 30 秒没有心跳就视为离线。发送时只给最近仍有心跳、并且不是自己的实例写信。消息和业务数据在同一个事务里提交,业务回滚时消息一并消失。本进程的内存仍在提交成功之后再删,这样提交前的并发读不会把旧数据装回刚刚清掉的缓存。
一次事务里,对同一个仓库、同一个目标实例只发一条消息,把本次的变更范围和相关路径合并进去。批量发布不会按文件数乘以实例数写满消息表。消息体只描述要删除什么,包含租户、仓库、名称变化、影响的版本类别和可选路径,不携带文件字节和上游口令。收到消息的实例执行与单节点相同的本地删除,并把消息里的版本号写进本机视图。正常情况下,其他实例能感知的延迟大约是一轮轮询加上处理时间,轮询间隔是 200 毫秒。
新进程启动时内存是空的,从数据库和共享缓存行加载当前事实,不回放历史信箱。心跳停止超过 30 秒的实例不再接收新消息;它信箱里尚未消费的记录由仓库侧的清理任务删除。队列对已完成消息的清理,清不掉没有消费者的记录,所以这份清扫放在仓库自己的任务里。消费失败会拒绝并短暂重试,超过次数进入死信。死信不自动重放,也不负责把缓存修好,兜底探测在间隔内把状态拉回。同一条消息执行多次也是安全的,因为删除和抬高版本号都可以重复做。
心跳与探测
队列正常时,失效不靠轮询数据库发现。另有一层兜底:对本进程里确实缓存过的仓库,默认每 5 秒读取那四个版本号。库里的数更大,就执行与收到消息相同的本地删除。这一读只覆盖热点仓库上的几个整数,不解析设置,也不查询访问规则。消息丢失、实例当时没能进入心跳名单,或消费停滞时,过期内存的停留时间不超过这个探测间隔。单节点不做这层读取。四个版本号仍然维护,以后改成多实例时不必补历史数据。
心跳是否成功,决定这个进程走信箱还是只走探测。
| 状态 | 本进程如何得知其他写入 |
|---|---|
| 心跳正常 | 以自己的信箱为主,5 秒探测只作兜底 |
| 心跳写不进数据库 | 停止宣称自己在线,改为只按探测间隔读版本号 |
| 超过 30 秒没有心跳 | 其他实例不再给它发新消息;未消费记录由仓库清理任务删除 |
| 消息进入死信 | 不靠重放修复缓存,探测在 5 秒间隔内拉回 |
| 进程刚刚启动 | 内存为空,加载当前事实,不回放历史信箱 |
| 多实例但没有唯一编号 | 拒绝启动,避免两台进程共用一只信箱 |
用户组和部门关系属于统一身份系统,不在这次仓库事务里。两种部署都把按访问规则算出的允许或拒绝限制在默认 30 秒以内。仓库自己的公开标志和访问规则走提交后的直接删除或信箱,不依赖这 30 秒。若以后要求组成员变化立刻生效,由身份系统通知仓库提高相关仓库的权限版本,再走同一套失效。
5. 三种仓库如何对待文件
三种仓库的文件都不进缓存。差别在副本放在哪、何时重新确认,以及失效推动哪个版本号。
| 仓库 | 文件放在哪 | 何时重新确认 | 失效走哪个版本号 |
|---|---|---|---|
| 托管 | 对象存储里的权威副本 | 原子替换完成后,新打开的请求读新文件 | 内容 |
| 代理 | 上游副本落在本地 | 不可变坐标有本地文件就直接返回;可变坐标默认 60 秒后条件回源 | 内容 |
| 聚合 | 不另存一份,按成员顺序现选 | 每次按当前顺序选择,不记住路径属于哪个成员 | 成员;合并索引用成员版本摘要 |
托管仓库
覆盖写把新内容先写入同目录的临时文件,校验完整之后再原子改名。临时文件必须和目标在同一文件系统上,否则改名会退化为复制,并发读者仍可能看到写到一半的内容。已经打开旧文件的下载可以读完旧内容;改名之后新打开的请求读到新文件。同一时刻,一个读者看到的要么是完整的旧文件,要么是完整的新文件。
成功提交后内容版本前进。写进程抬高本机内容版本,并丢掉该路径的门禁和本仓派生文档;多实例同时把这个内容变更送进其他实例的信箱。事务失败时删除临时文件,版本号不变,消息不发出,内存也不改。
代理仓库
新鲜度记录描述本地副本,包括校验和、大小、落盘时间、上游的校验标识和修改时间、下次需要重新校验的时间,以及这份坐标是不可变还是可变。正式版本且坐标不可变时,本地有就直接返回。快照版本、Maven 元数据、latest、镜像标签和各种索引,默认在 60 秒后带条件请求回源。上游回答未修改,只把下次校验时间往后推;上游返回新内容,按托管仓库同样的原子替换更新本地文件,并让该代理仓库的内容版本前进;上游确认不存在,删除本地副本,并写入有容量上限的未命中记录。仓库关闭缓存时不落盘。
上游出现服务错误或超时时,若本地仍有副本,则返回这份副本,在响应和日志里标明正在沿用本地副本,并且不把下次校验时间往后推。这样上游恢复后的下一次请求仍会重新校验,而不是把一次故障延长成一段新的免校验时间。
聚合仓库
聚合仓库不保存成员文件的第二份副本。每次按成员列表的当前顺序,选择第一个能够返回的成员,并且不长期记住「这个路径属于哪个成员」。高优先级成员后来才发布该制品时,若仍返回早先选中的低优先级成员,客户端会稳定地拿到过时的那一份。成员列表的失效走成员版本,合并索引的失效走成员版本摘要。
6. 权限和内容各自何时可见
一次变更何时被下一次请求看见,取决于它推动的是哪个版本,以及请求落在写进程还是其他进程。
| 变更 | 处理写的进程 | 其他进程(多实例) |
|---|---|---|
| 公开性或访问规则 | 提交后的下一次请求 | 约一个轮询间隔;消息丢失时不超过探测间隔 |
| 下线或维护 | 打开文件前拒绝 | 约一个轮询间隔;消息丢失时不超过探测间隔 |
| 覆盖发布或解除隔离 | 新的内容版本立即用于本进程的键 | 信箱到达后同样抬高内容版本 |
| 身份系统中的组成员变化 | 不超过鉴权缓存的 30 秒 | 不超过鉴权缓存的 30 秒 |
把仓库从公开改为私有,可以把上表走成一条时间线。设提交时刻为 T0,权限版本从 7 变为 8。
| 时刻 | 发生的事 |
|---|---|
| T0 | 同一次事务更新公开标志,元信息版本和权限版本一起前进,并为每个其他存活实例写入一条信箱消息 |
| T0 之后,写进程的下一次读 | 本进程的快照和该仓库鉴权结论已经删除。匿名请求不再放行,已登录用户按访问规则重算 |
| 大约 T0 + 200 毫秒 | 其他实例取到自己的信箱,做同样的删除 |
| 信箱没有送达 | 其他实例最迟在约 5 秒的探测里发现权限版本变大,删除旧快照和旧的鉴权结论 |
| 同期只是用户组变化 | 不在这次事务里。仓库规则未改时,原允许结论最多再保留 30 秒 |
私有改为公开、仓库下线或进入维护,走与 T0 相同的路径,并在打开文件之前就拒绝。收回某个用户的读取权也推动权限版本,写进程在提交后丢掉快照和鉴权结论,其他进程仍由信箱或探测跟上。
7. 失败时仍然成立的承诺
可见性的边界是事务已经提交。 在此之前,缓存和版本号都停留在提交前;在此之后,写进程的删除和其他进程的信箱才开始生效。
| 情况 | 结果 |
|---|---|
| 业务事务回滚 | 版本号没有增加,多实例的消息也没有提交,内存保持原样 |
| 提交成功,但进程在删除本机缓存之前退出 | 单节点重启后内存为空,随后从数据库加载。多实例的消息已经和业务一起提交,其他实例仍会删掉自己的内存 |
| 共享缓存行暂时不可写 | 快照退化为只留在本进程内存。本机删除仍然能纠正它,重启后的预热变弱 |
| 派生文档超过单条大小上限 | 不进入缓存,当次生成并返回 |
| 清空全部缓存行 | 操作保持关闭。那张表上还可能有其他使用方的数据,失效按仓库和区域进行 |
观测按区域分开记录:命中、回源、信箱延迟、兜底探测纠正了多少次,以及代理回源时未修改、已更新、沿用旧副本各有多少。共享缓存自带的汇总统计把所有键混在一起,不能单独用来判断仓库缓存是否正确。
8. 默认约定
这些数值是一致性边界,不是调优起点。改短存活时间只会增加读取,改长存活时间不会让公开性或覆盖写更正确,那两件事走的是提交后的删除和版本号。
| 约定 | 默认 | 它约束什么 |
|---|---|---|
| 部署模式 | 单节点 | 提交后只删除本进程缓存,不启动队列 |
| 快照存活 | 10 分钟 | 减少重复解析,不承担权限和下线的正确性 |
| 鉴权结论 | 30 秒,约 10 万条 | 访问规则算出的允许或拒绝 |
| 隔离门禁 | 60 秒,约 10 万条 | 内容版本变化后旧键不再被读取 |
| 派生文档 | 单条 256KB,约 5 万条,合计约 64MB | 超出则当次生成,不进入缓存 |
| 代理未命中 | 约 10 万条,时间随仓库配置 | 记住上游确认不存在的路径 |
| 可变坐标再校验 | 60 秒 | 到期后带条件请求回源 |
| 共享缓存容量 | 键最长 256 字符,内存约 1 万条 | 只放快照和成员列表 |
| 信箱轮询 | 200 毫秒 | 仅多实例;其他进程的正常可见延迟 |
| 心跳 | 10 秒 | 刷新在线名单 |
| 视为离线 | 30 秒无心跳 | 不再给该实例发新消息 |
| 兜底探测 | 5 秒 | 仅多实例,且只探测本进程缓存过的仓库 |
9. 配置偏离时会看到什么
上面的时间线成立,有一个前提:部署模式和真实的进程数一致,多实例的编号唯一,心跳能够写入,删除快照时内存和缓存行一起去掉。偏离这些前提时,系统仍按设计运行,只是可见性和设计者的预期会分开。
| 偏离 | 会看到什么 |
|---|---|
| 多个进程仍配置成单节点 | 其他进程收不到这次删除,只能等到各自的存活时间。这是配置错误,不是失效算法的误差 |
| 多实例没有唯一实例编号 | 进程拒绝启动。若两台进程共用一只信箱,消息只会被其中一台取走 |
| 心跳写失败却仍当作在线 | 其他实例可能不再把消息送给它,它自己的旧权限会留下。设计上此时停止宣称在线,改为按 5 秒读版本号 |
| 删除快照时只清内存 | 重启后旧快照从缓存行装回,公开性变化会复现。删除必须同时去掉缓存行 |
| 代理的可变路径不按新鲜度回源 | 本地副本会一直是旧字节。到期后应带条件请求回源;上游故障时可以沿用副本,但不把下次校验时间往后推 |
10. 边界
共享层停在平台已有的进程内缓存、数据库缓存行和持久化队列上。多实例要解决的是每一台都删掉自己的内存,按实例划分的信箱加上版本号兜底可以闭合这件事,队列继续保持工作队列的取走语义。
文件字节、鉴权结论、未命中记录和大段协议响应留在本进程或对象存储,不写入共享缓存和消息。单节点不在每个请求上读取版本号。不存在的仓库名不占用快照容量。身份系统的用户组变更保持 30 秒的上限,本方案不另做一条同步链路。内容去重解决的是相同字节在磁盘上占多份,覆盖写解决的是这个路径现在指向哪一份完整内容,两者继续分开:去重存储保持原样,路径上的替换采用原子改名。
对运行仓库的人,默认的单节点意味着:提交成功后的下一次请求能看到新的仓库状态、公开性和新上传的字节,回滚则两者都不变。需要多个进程共享同一套数据时,打开多实例模式并给出唯一的实例编号。其他进程在一轮轮询左右跟上,消费停住时由版本号探测在数秒内纠正。文件层保证读者看到的是完整的旧包或完整的新包,代理层对可变坐标重新向上游确认,对不可变坐标继续直接复用本地副本。