逻辑卷的瞬时生成、以空间换时间副本

LVM 快照在时间上定格一块块设备

LVM 快照既不是拷贝,也不是 reflink:它是一个挂在源逻辑卷上的、大小固定的写时复制异常存储。lvcreate -s -L 5G -n snap vg/data 从卷组里划走 5 GiB,之后 device-mapper 的 snapshot 目标会在某个源 chunk 第一次被写时,把它的原始内容记录下来。无论源卷多大,创建都是瞬时的,因为创建时什么都没复制。 这个大小就是你全部的安全余量,也正是最常被写错的参数。按“一周内没什么写入”配的存储,会被一次数据库 checkpoint 或一次 rsync 在一小时内吃光;而它填满时快照不会降级,而是直接失效并开始返回 I/O 错误。恢复点恰好在你最需要它的那一刻消失,所以存储的剩余空间该进监控面板,而不是躺在 runbook 里。 异常存储,不是副本 一个经典的 LVM 快照用到两个 device-mapper 目标:源卷被重新映射为 snapshot-origin,另有一个新的 snapshot 设备把它与 COW 设备组合起来。读未改动的 chunk 直接落到源卷;某个 chunk 第一次被写时,旧 chunk 被复制进 COW 设备,然后新数据才写下去。所以消耗量跟“发生过改动的不同 chunk 数”成正比,而不是跟写入次数成正比:fixture 往 16 个热 chunk 写了一百万次,只消耗 15 个异常,约 60 KiB。 ...

四月 10, 2028 · 1 分钟 · XB Field Notes
瞬间抓取文件系统状态、既能快速恢复又近乎零成本的机制

ZFS 快照给文件系统一个回退键

ZFS 快照就是给某个数据集在某个 transaction group 上的块树起的一个名字。它不复制任何东西、不冻结任何东西,代价只是几个元数据块:zfs snapshot tank/data@before-upgrade 会在 shell 提示符重画完之前就返回。由于 ZFS 从不原地覆盖仍在使用的块,旧块树在磁盘上一直有效,直到最后一个引用消失——而快照就是这个引用。 正是这一点让“一墙的每日历史”变得可行,也正是本文标题最危险的地方。快照给你数据的许多个版本,却不给你“池不会丢”这件事的保障。tank/data 的每一份快照都住在 tank 里,所以带走池的那次故障,会在同一瞬间带走数据集和它的全部历史。多版本与多版本安全是两种不同的性质,后者只能由复制来提供。 为一个一致的树命名 快照建立在 transaction group 边界上,所以它捕获的树内部自洽——数据库的文件与元数据冻结在同一瞬间,而不是相差几微秒。-r 会连同所有后代数据集一起打快照,凡是有子数据集的地方都该这么用;否则一个递归布局会在不同层级留下时间不同的快照,层与层之间的增量复制就变得别扭。 名字由你决定,而它是保留策略唯一的抓手:tank/data@daily-2026-09-16 可以按模式清理,自动生成的名字则需要额外记账。zfs list -t snapshot -o name,used,refer,creation -s creation 列出整个快照集以及每一份钉住的空间。 空间在哪里:zfs list -o space ZFS 用一组列来回答唯一重要的问题:这是谁的数据?zfs list -o space tank/data 会给出 USED、USEDSNAP、USEDDS、USEDREFRESERV、USEDCHILD、REFER 与 AVAIL。REFER 是数据集自身需要的量,USEDSNAP 是快照钉住的量,USED 是二者之和。USEDSNAP 持续上涨而 REFER 保持不变,说明吃掉池子的是快照,不是数据。具体到某一份快照,used 是它单独钉住的块,refer 是它能引用到的全部块,两者之差正是与其他版本共用的那部分。 ...

三月 21, 2028 · 1 分钟 · XB Field Notes
指标标签基数主题封面,展示观察、判断与行动三个步骤。

高基数标签在查询之前就增加存储序列

Prometheus 序列由指标名与完整标签集合识别。在计数器上增加客户或请求身份,即使所有面板都会立刻把这些身份求和,也仍可能创建大量序列。查询聚合改变返回结果,不会删除已经入库的原始序列。存储和写入成本在用户打开面板之前就已经产生,因此不能只看面板返回多少行来判断仪表化是否足够节制。 统计实际组合,而不是孤立取值 一个新的标签值,不是在整个数据库中自动对应一条新序列。它在指标名及其他标签形成不同组合的位置上,分别构成新身份。在明确指标与目标范围内,可能取值数的乘积给出笛卡尔组合上界;只有每种组合实际出现时,它才等于观测数量。维度之间存在约束时,上界可能远大于现实。真实数量还受到目标标签与指标族形态影响,不能把理论乘积直接宣称为已经发生的序列数量。 来自少量路由模板的标签可以有边界,包含标识符和查询参数的完整 URL 往往没有。客户身份、请求编号、任意错误文本和时间戳也需要明确审视。带多个桶序列的直方图,还会进一步放大每组标签组合的影响。预算应依据真实输出和预期工作负载,而不是只看某一个维度有多少取值,也不能因为每条样本数值很小便忽略身份成本。 查询少几行,不代表少存几行 按路由求和的表达式,可以读取十二条客户级序列后只返回三个结果,但十二条输入仍然存在。如果指标契约需要的就是路由总量,仪表化可以直接维护路由级计数器,只导出三条序列。这是在源头减少输出身份,不是要求每个消费者都重复隐藏额外维度。两种方式可能显示相同总数,却具有不同的数据保留和成本含义。 入库时删除标签不等于完成上述累计。不同样本可能获得同一个身份,而重新标记不会替它们决定如何相加。应使用明确的仪表化变更,或定义清楚的记录查询;后者会新增派生序列,原始数据仍按照存储策略保留。确实需要详细关联时,可以放入日志或追踪,但它们同样需要各自的权限、保留期限与成本约束,不是完全没有代价的替代存储。 使用正确范围的清单诊断增长 TSDB 状态接口提供头部统计,其中包括按指标名汇总的序列数量。这些值有助于定位大型指标族,但不是完整成本模型。采样间隔、标签长度、身份更替、数据块处理、保留策略和存储行为都会影响资源。不能把一个小实验中减少的序列比例,直接换算成生产内存或磁盘会减少的相同比例,更不能把该比例当成容量压测结果。 当一些头部序列已经没有近期可选择样本时,即时查询计数与头部序列统计可能不同;历史存储块又是另一种范围。比较报告时应记录测量时刻和统计总体。某次发布改变标签值后,旧身份仍可能留在存储中,因此查询当前返回更少结果,不代表之前的全部成本都已经消失。需要分别观察新增速度、当前可见序列和保留历史。 坏掉的样子 新增无边界标签可能持续增加写入与内存压力,直到抓取或查询受到影响。应先定位指标族与仪表化改动,再从源头或经过审阅的样本过滤策略阻止不必要的新身份。立即删除历史数据不是所有场景都必须采用的恢复步骤,改变保留时间也不会瞬间清除全部资源成本。处理方式应依据实际瓶颈,而不是套用统一删除命令。 更隐蔽的失败是查询聚合很成功,面板看起来很小,但原始指标仍持续增长。应同时审阅导出身份和查询输出,为新增维度说明预期取值范围、有用消费者与负责人。生产计数器速率查询还应保留正确的重置处理;本实验用于比较固定总量的即时求和,不能代替先求速率再聚合的运行监控语义。 快速验证 保存程序为 cardinality.py,执行 python3 cardinality.py。需要 Docker 与 Prometheus 3.5.1,必要时下载镜像。本地文本端点为三个路由分别导出四个合成客户身份,同时独立导出已经累计好的路由总量。真实服务器抓取这些样本,并提供查询结果与 TSDB 统计,实验不会使用任何真实客户信息。 import http.client import io import json import subprocess import time import uuid from urllib.parse import quote name = "xbcatm-monitoring-" + uuid.uuid4().hex[:12] class ResponseSocket: def __init__(self, data): self.data = data def makefile(self, mode): return io.BytesIO(self.data) def request(method, path, body="", content_type="application/json"): body = body.encode() headers = (f"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" f"Connection: close\r\nContent-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n\r\n").encode() result = subprocess.run( ["docker", "exec", "-i", name, "/bin/nc", "-w", "3", "127.0.0.1", str(port)], input=headers + body, capture_output=True, timeout=5, ) if result.returncode: raise RuntimeError(result.stderr.decode()) response = http.client.HTTPResponse(ResponseSocket(result.stdout)) response.begin() return response.status, response.read().decode() def get_json(path): status, body = request("GET", path) assert status == 200, (status, body) return json.loads(body) def eventually(check): for attempt in range(150): try: value = check() if value: return value except (RuntimeError, AssertionError, http.client.HTTPException): pass time.sleep(0.1) raise AssertionError("Expected local server state did not appear") def query(expression): response = get_json("/api/v1/query?query=" + quote(expression, safe="")) assert response["status"] == "success" return response["data"]["result"] def value(expression): result = query(expression) assert len(result) == 1, (expression, result) return float(result[0]["value"][1]) port = 9090 # Requires Docker and the Prometheus 3.5.1 image. script = r"""set -eu mkdir -p /tmp/www /tmp/targets cat > /tmp/prom.yml <<'PROM_CONFIG' global: scrape_interval: 1s scrape_timeout: 500ms scrape_configs: - job_name: cardinality metrics_path: /metrics.txt static_configs: - targets: ['127.0.0.1:8080'] PROM_CONFIG cat > /tmp/www/metrics.txt <<'METRIC_DATA' # TYPE raw_requests_total counter # TYPE bounded_requests_total counter raw_requests_total{route="/orders",customer_id="synthetic-1"} 1 raw_requests_total{route="/orders",customer_id="synthetic-2"} 2 raw_requests_total{route="/orders",customer_id="synthetic-3"} 3 raw_requests_total{route="/orders",customer_id="synthetic-4"} 4 bounded_requests_total{route="/orders"} 10 raw_requests_total{route="/cart",customer_id="synthetic-1"} 1 raw_requests_total{route="/cart",customer_id="synthetic-2"} 2 raw_requests_total{route="/cart",customer_id="synthetic-3"} 3 raw_requests_total{route="/cart",customer_id="synthetic-4"} 4 bounded_requests_total{route="/cart"} 10 raw_requests_total{route="/search",customer_id="synthetic-1"} 1 raw_requests_total{route="/search",customer_id="synthetic-2"} 2 raw_requests_total{route="/search",customer_id="synthetic-3"} 3 raw_requests_total{route="/search",customer_id="synthetic-4"} 4 bounded_requests_total{route="/search"} 10 METRIC_DATA /bin/httpd -f -p 127.0.0.1:8080 -h /tmp/www > /tmp/httpd.log 2>&1 & exec /bin/prometheus --config.file=/tmp/prom.yml --storage.tsdb.path=/tmp/data --web.listen-address=127.0.0.1:9090 """ try: subprocess.run( ["docker", "run", "-d", "--rm", "--name", name, "--network", "none", "--entrypoint", "/bin/sh", "prom/prometheus:v3.5.1", "-c", script], check=True, capture_output=True, text=True, timeout=20, ) eventually(lambda: len(query('raw_requests_total')) == 12) assert value('count(raw_requests_total)') == 12 assert value('count(bounded_requests_total)') == 3 assert value('sum(raw_requests_total)') == value('sum(bounded_requests_total)') == 30 raw_by_route = {x["metric"]["route"]: float(x["value"][1]) for x in query('sum by (route) (raw_requests_total)')} bounded_by_route = {x["metric"]["route"]: float(x["value"][1]) for x in query('bounded_requests_total')} assert raw_by_route == bounded_by_route statistics = get_json('/api/v1/status/tsdb')["data"] counts = {x["name"]: x["value"] for x in statistics["seriesCountByMetricName"]} assert counts["raw_requests_total"] == 12 and counts["bounded_requests_total"] == 3 print(json.dumps({"raw_series": 12, "bounded_series": 3, "snapshot_total": 30, "per_route": raw_by_route, "tsdb_counts_confirmed": True}, indent=2)) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 断言确认原始表示有十二条序列,受控表示有三条,两者总量都为三十,每个路由合计十。执行查询聚合之后,TSDB 仍报告十二条原始序列,证明返回结果变小没有擦除输入。隔离容器没有外部网络、宿主机挂载或公开端口,退出时删除。结果验证身份数量和算术,不是生产容量基准。 ...

一月 10, 2028 · 2 分钟 · XB Field Notes
数据库里脏数据与磁盘干净对接的时点。

checkpoint 把脏缓冲刷到磁盘

检查点为 PostgreSQL 崩溃恢复建立一个可用起点。它同步这个检查点所需的数据文件变更,并记录相应恢复信息。依赖某段日志的数据页写出之前,相关 WAL 必须先持久化。这样的顺序让事务提交不必每次都把全部修改过的表页立即刷到磁盘;发生崩溃时,再从恢复起点重放后续必要变更。 坏掉的样子 检查点不是事务提交。数据页可以包含尚未提交事务写入的版本,读者究竟能看到哪些行,仍然由可见性与事务状态决定。检查点完成,不会把这些写入变成已提交,也不会把随手复制一份正在变化的数据目录变成有效基础备份。并发活动还可能再次弄脏页面,所以不能把完成提示理解成缓冲池从此全部干净。 日志保留也有别的限制。最大 WAL 大小是与自动检查点调度有关的软目标,不是磁盘使用硬上限;归档失败或复制槽保留,都可能让日志超过这个值。最小 WAL 大小则涉及回收并预留文件供以后复用,不是另一条强制触发检查点的阈值。如果理解错这些参数,可能一边看到检查点间隔正常,一边忽略实际上还在持续增长的归档积压。 快速验证 准备一台安静的 PostgreSQL 18 可丢弃实例,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。显式检查点和控制文件信息使用具有相应权限的管理角色。保留正常开启的同步落盘与同步提交设置。乙先创建计数器,并保存当前检查点位置: SHOW fsync; SHOW synchronous_commit; CREATE SCHEMA checkpoint_demo; CREATE TABLE checkpoint_demo.items (id integer PRIMARY KEY,n integer); INSERT INTO checkpoint_demo.items VALUES (1,10); CREATE TABLE checkpoint_demo.baseline AS SELECT checkpoint_lsn FROM pg_control_checkpoint(); 显示的两个设置都应为开启,初始已经提交的值是十。甲在一个保持打开的事务中把它改成九十九: ...

一月 6, 2028 · 1 分钟 · XB Field Notes
逻辑父表把行路由到物理分区,各分区拥有自己的索引与生命周期。

分区把一张表拆成很多看起来还像一张的片段

声明式分区让 PostgreSQL 对外提供一张逻辑表,同时把行存放在不同分区中。父表负责写入路由,并作为统一查询入口;真正持有数据的是物理分区。它的主要收益往往来自生命周期管理和选择性访问,但选择分区时,也会影响键、查询形态和运维锁,不能只把它看作拆文件。 逻辑父表与物理子表 分区父表本身没有行存储。每个已挂载分区保存符合边界的行,可以有自己的存储参数、索引和维护活动。分区索引也类似,是各物理分区索引的集合,并不是在父表上再保存一整份相同数据。理解这一点,有助于区分统一入口与实际占用磁盘的关系。 范围、列表和哈希分区使用不同划分规则。应根据真实访问和保留要求选择键:按月保存事件可能适合范围分区,其他负载可能需要不同分布。声明式分区本身不会把这些分区自动分散到独立数据库服务器。本地分区仍共享所在数据库的资源和事务环境,不能因为表被拆开,就推断它已经实现分布式分片。 保持父表名字稳定,可以简化应用入口,但不能证明应用代码完全不用改。查询需要暴露适合的分区键限制,才可能从裁剪受益;主键与外键引用也可能需要重新设计。原本按全局唯一标识操作的调用,必须保留同样的身份语义,不能在物理拆分过程中悄悄改变其含义。 分区键不只影响路由 分区表上的唯一或主键约束必须包含全部分区键列,而且这些约束要求分区键不含表达式。各分区的独立索引,不能自行证明其他分区中不存在相同键。因此,day 与 id 的复合主键,并不保证 id 在不同日期之间仍然全局唯一。 从单表全局 id 主键迁移时,这个差别尤其重要。只是把日期加入主键,可能弱化原有身份契约。更改之前,应检查调用方、引用关系和重复防护要求。分区键不是可以完全脱离公开数据模型进行调整的纯物理参数,否则原来按 id 更新一行的操作,可能开始匹配多行。 修改分区键可以把行移动到另一个分区。目标分区必须存在并接受该行,索引和约束仍然生效。这是真实数据变更,不是免费的元数据修改。如果保留策略已经移除了目标范围,迟到的修正可能失败,即使逻辑父表仍然存在。生命周期设计应同时考虑正常新增和历史更正。 坏掉的样子 DETACH 把一张表从分区层级移除,但保留该表及其数据。通过父表查询时不再看到它,脱离后的关系仍可单独检查或导出。DROP 则会连同分区数据一起删除。脱离不等于删除,这两种操作也都不会自动生成已经验证过的归档或备份,不能因为维护命令成功就认定数据已妥善保留。 分区维护需要锁。普通 detach 需要父表上的强锁,可能等待长时间运行的操作,也会影响并发访问。PostgreSQL 提供锁行为不同的并发脱离形式,但有额外限制,例如不能放在显式事务块中执行。应按照实际契约选择操作,不能把“元数据操作”理解为“一定不会阻塞”。 分区过多也会增加规划和内存成本,没有一个普遍适用的行数阈值能证明分区一定划算。应测量有代表性的读取和维护操作,在写入需要前创建未来分区,并明确迟到数据与保留策略如何配合。对于较小且索引合理的表,大量细碎分区可能只增加管理工作,而没有带来足够收益。 快速验证 把下面内容保存为 partition-lifecycle.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f partition-lifecycle.sql。实验使用临时分区和普通事务内 detach,最后回滚移除整个测试环境。 ...

十二月 23, 2027 · 2 分钟 · XB Field Notes
Redis AOF 的同步策略,以及从基础文件与增量文件恢复状态。

Redis AOF 用速度换持久性

AOF 记录 Redis 的数据变更,供启动时恢复,但选择 AOF 并不等于承诺任何已确认写入都绝不会丢失。持久性取决于 fsync 策略、实际生效的配置、底层存储,以及讨论的是进程退出、操作系统崩溃还是整块磁盘损坏。应该先明确恢复目标,再决定日志策略,不能仅凭“追加日志”这个名称推导保证。 执行、记录与确认不是同一步 Redis 先在内存数据集上执行命令,再把需要持久化的效果传播到 AOF。它不是“每条命令必须先落盘才能修改内存”的执行模型。使用 appendfsync always 时,Redis 在发送回复前同步追加的数据;一起处理的命令可以共用一次写入和 fsync,并不必然每个客户端命令都对应一个独立系统调用。 执行成功、完成同步、客户端收到回复仍是不同的时刻。连接在回复到达前断开,客户端不能据此判断操作没有发生,也不能因为配置了 always 就放心重复执行扣减或计数操作。持久化协议并没有替业务提供重试去重。 AOF 是用于重建状态的表示,不是永久保存所有原始请求的审计流水。Redis 可以转换用于持久化的命令,重写也只保留重建当前数据所需的信息,而非完整历史。因此,已经删除的值并不保证还能从重写后的文件中找回来。 按确认语义选择同步策略 appendfsync everysec 大约每秒安排同步,通常以约一秒的写入暴露窗口换取性能,但不能把它当成任意存储阻塞下都成立的严格丢失上界。appendfsync no 则把刷盘时机交给操作系统;它表示 Redis 不主动请求 fsync,不表示数据永远不会落到磁盘。这两种策略下,一条成功回复都不等于该写入已经进入稳定存储。 appendfsync always 给出更强的本机写入确认约定,同时增加同步开销,影响延迟与吞吐。它仍不能保护磁盘整体丢失、存储栈失信或应用主动误删。还要检查 no-appendfsync-on-rewrite:如果将其打开,后台持久化工作期间会暂停通常的 fsync,即使主要策略写着 always,实际保证也会变弱。检查有效配置比只检查某一个配置项更重要。 重写产物是一组文件 Redis 7 起,AOF 使用 manifest 清单、基础文件和增量文件。启用 aof-use-rdb-preamble 时,基础文件采用 RDB 格式,其后的修改放在增量 AOF 中。这个基础文件属于 AOF 自己的文件集合,不是另外定期生成的 dump.rdb,二者不能因为扩展名相似就混为一谈。 ...

十一月 17, 2027 · 3 分钟 · XB Field Notes
一个与源共享未改动数据、改动后才分离的时间点视图。

Btrfs 快照是写时复制,不是复制

btrfs 快照不是任何意义上的复制。btrfs subvolume snapshot 建立的新子卷,其根指向与源完全相同的 extent 树,两边共享的每个 extent 被引用两次、只存一份。创建是一次元数据操作:子卷里装的是 1 GiB 还是 10 TiB,耗时都在毫秒级。 这让快照成为机器上最便宜的回滚原语,也正是它最常被过度信任的原因。空间随分岔增长,而不随数据量增长;代价来得晚,落在别人的写操作上,而且落在保存原始数据的那块设备上。从不清理的快照是一场慢动作的磁盘写满事故,而同盘快照无论留多少份都不是备份。 共享 extent,而不是复制数据块 快照就是子卷,而子卷是拥有自己根的子树,所以快照机制与让 reflink 生效的是同一套引用计数。用 btrfs subvolume snapshot -r /data /snap/data-2026-09-16 打一个只读快照;-r 很关键,因为可写快照在任何一次写入发生的那一刻就开始分岔,而备份目标本来就不该可写。快照与源是平级的子卷,清理用 btrfs subvolume delete /snap/data-2026-09-16;正在被挂载使用的子卷删不掉,btrfs subvolume list -t 能把快照按父子关系列成表,按时间做保留时用得上。 真正花钱的是快照之后的那次写入。任意一侧覆盖一个共享 extent 时,文件系统给写入方分配新 extent,把旧 extent 留给另一侧,所以快照的价格由随后的写入支付,计量单位是这次写入触及的 extent 数,而不是快照命令本身。APFS 的 clonefile 遵循同一规则,fixture 量到的形状是:克隆 64 MiB 花费 0 KiB,普通复制 64 MiB 花费 65536 KiB,覆盖克隆文件前 8 MiB 花费 8192 KiB;而把这 8 MiB 再改写一次花费 0 KiB,因为这些 extent 已经分岔过了。 ...

十一月 14, 2027 · 2 分钟 · XB Field Notes
LRU 缓存将被访问条目移到最近端,并从最旧端选择淘汰对象。

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