标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

LSM 树把前台写入成本转移到后续归并

LSM 树把一部分分散更新的成本转移到内存缓冲与后续归并。这是一种设计取舍,不是“所有磁盘操作从此完全顺序”的承诺,也不保证随机写一定胜过 B 树。真正需要回答的问题,是前台写入、后台压实、读取和保留版本,能否同时放进同一份资源预算。不能用一个没有提供数据生成器的精确耗时,替代对实际工作负载的解释。 缓冲改变工作的发生时机 RocksDB 通常把新写入放入内存表,并可同时写入预写日志。是否启用 WAL、是否同步日志,是另外的持久性选择;仅仅采用 LSM 布局,不保证每个已应答写入都能抵御断电。内存表填满后刷成不可变有序表,产生零层文件,应用则通过其他可用缓冲继续写入。日志追加、刷盘和压实也不是同一个动作。 采用分层压实时,零层文件的键范围可以重叠。更深的层通常各自组成一个有序段,但一个段可以按键范围拆为多个不重叠 SST 文件,不能理解为每层只有一个文件。查找先利用文件范围选择候选,压实则把重叠区间归并成替代文件。动态层大小配置还可能让某些中间层为空,因此不能把所有层永远填满的示意图当作运行事实。 读取仍然需要处理多个版本 同一个键的旧值可能存在于多个段中。读取要选择可见版本,而删除标记必须阻止更老的值重新出现。布隆过滤器可以排除肯定不含目标键的候选文件;返回可能存在后,仍需进一步检查。一次最终成功的查找,也能通过过滤器跳过不相关文件,再到真正包含答案的文件中读取。因此,“过滤器只帮助未命中,对命中毫无作用”并不准确。 压实通过重写数据,降低这种搜索和空间负担。分层策略与通用策略通常在写放大、读放大、空间放大之间作不同选择,但真实结果仍取决于负载、压缩、缓存和配置。统计时必须统一口径:SST 写入字节、WAL 字节、设备写入字节与应用逻辑字节不是同一种量。不写清分子分母的倍率,不能直接用来规划存储吞吐。 坏掉的样子 刷盘或压实跟不上时,不可变缓冲和零层文件可能积累,读取成本上升,引擎还可能减速或暂停前台写入。历史阻塞计数非零,不足以诊断今天的瓶颈。应同时查看最近的阻塞时间、待处理工作、文件数量、存储延迟与应用吞吐。提高阈值可能暂时推迟压力,却把更多工作留到后面,并扩大恢复时需要处理的积压。 删除不会立刻回收所有旧值。只要更低的段仍可能暴露被遮住的值,或保留快照需要相关历史,墓碑就不能随意消失。回收空间取决于压实覆盖了哪些历史,以及版本保留规则是否允许丢弃。若数据已经全部删除,活跃字节数为零,就不能再报告一个有限的“物理字节除以活跃字节”空间放大率;此时应直接报告残留字节与回收条件。 快速验证 把程序保存为 lsm_model.py,用 Python 3 执行。它明确只是标准库内存模型,展示三个刷出段和覆盖全部历史的压实。程序不会执行 RocksDB,不会创建真正 SST,不做磁盘输入输出,不模拟引擎调度,也不测延迟。打印的计数不能被重新包装成生产性能结果;示例的作用是把版本选择与墓碑删除条件具体化。 from pprint import pprint # A semantic model: sorted in-memory runs, not RocksDB or a disk benchmark. runs = [] # newest run first writes = 0 sequence = 0 def flush(operations): global writes, sequence memtable = {} for key, value in operations: sequence += 1 memtable[key] = (sequence, value) run = dict(sorted(memtable.items())) runs.insert(0, run) writes += len(run) def get(key, tables): for run in tables: if key in run: return run[key][1] # None is a tombstone: stop searching. return None flush([("a", "one"), ("b", "old")]) flush([("a", "two"), ("c", "three")]) flush([("b", None)]) assert [get(k, runs) for k in "abc"] == ["two", None, "three"] assert writes == 5 and sequence == 5 # Dropping a tombstone from only the newest run resurrects old b. unsafe = [{k: v for k, v in runs[0].items() if v[1] is not None}] + runs[1:] assert get("b", unsafe) == "old" # Merge ALL history, with no retained snapshots, before dropping tombstones. latest = {} for run in reversed(runs): latest.update(run) merged = {k: v for k, v in sorted(latest.items()) if v[1] is not None} writes += len(merged) assert [get(k, [merged]) for k in "abc"] == ["two", None, "three"] assert (len(merged), writes, sequence) == (2, 7, 5) pprint({"live_keys": list(merged), "logical_operations": sequence, "table_records_written": writes, "record_write_ratio": writes / sequence, "unsafe_partial_merge_resurrects": get("b", unsafe)}) 第一次刷出保存 a 和 b;第二次替换 a 并新增 c;第三次删除 b。跨所有段读取时得到新的 a、缺失的 b 和 c。反例刻意只从最新段移除墓碑,结果旧段中的 b 重新出现。这说明一条表面上没有业务值的删除记录,仍可能承担遮住历史版本的职责,不能因为它“看起来没用了”就局部删除。 ...

十一月 2, 2027 · 2 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

稀疏文件把逻辑长度与分配块分开

稀疏普通文件可以具有很大的逻辑长度,却只为其中部分地址范围分配存储。读取长度范围内的空洞会得到零字节,因此文件长度、字节内容和文件系统分配,是三项不同测量。为虚拟磁盘预算容量、复制备份,或者判断零区域是否已为将来写入保留空间时,都需要理解这种关系,不能用其中一个数字替代另外两个。 坏掉的样子 seek 越过文件结尾,只改变描述符偏移,不改变文件长度。后续写入或显式扩展,才可能创建中间读为零的范围。只调用 seek,就声称已经创建大型稀疏文件,是错误的;同样,逻辑大小很大,也不表示文件系统已经预留足够块,允许以后填满它。偏移位置、逻辑范围与实际容量承诺必须分开。 如果复制读取每个字节并普通写出,就可能把空洞变成分配了块的零区域。最终校验值完全相同,分配空间却大得多。传输或恢复流程若只依据源文件当前分配量做预算,即使正确保留逻辑内容,也可能耗尽目标容量。因此成功复制字节与保持稀疏表示,是需要分别验证的目标。 打洞是有意改变数据的操作。配合 KEEP_SIZE 对已分配范围打洞,会保持长度,却让对应字节读为零,并不是可以随手清理任意文件区域的无害命令。预分配解决另一类问题:它保留文件系统分配,即使未写入的已分配范围也可能读为零。内容都是零,不能证明这两种表示具有相同空间行为。 快速验证 把脚本保存为 sparse.py,在一次性 Linux 系统中使用 Python 3.11 或更新版本运行 python3 sparse.py,临时目录需位于 ext4。实测使用 Linux 6.12.76 和 Python 3.11.2。样本创建三个临时文件,使用约三十二 MiB 完整分配数据,以及少量稀疏分配: from pathlib import Path from tempfile import TemporaryDirectory import ctypes,hashlib,os size=16*1024*1024;first=4*1024*1024;second=12*1024*1024 libc=ctypes.CDLL(None,use_errno=True);libc.fallocate.argtypes=[ctypes.c_int,ctypes.c_int,ctypes.c_longlong,ctypes.c_longlong];libc.fallocate.restype=ctypes.c_int def digest(path): with path.open('rb') as handle:return hashlib.file_digest(handle,'sha256').hexdigest() with TemporaryDirectory(prefix='xbcatm-sparse-') as directory: root=Path(directory);path=root/'sparse';fd=os.open(path,os.O_RDWR|os.O_CREAT|os.O_EXCL,0o600) try: os.lseek(fd,size,os.SEEK_SET);assert os.fstat(fd).st_size==0 os.ftruncate(fd,size);assert os.fstat(fd).st_size==size and os.fstat(fd).st_blocks==0 assert os.pread(fd,16,0)==b'\0'*16 os.pwrite(fd,b'A',first);os.pwrite(fd,b'B',second);os.fsync(fd) allocated=os.fstat(fd).st_blocks*512;assert allocated<size data_start=os.lseek(fd,0,os.SEEK_DATA);hole_start=os.lseek(fd,data_start,os.SEEK_HOLE) assert data_start==first and first<hole_start<=second print('seek alone leaves size zero; truncate creates zero-reading holes; sparse size',size,'allocated',allocated) print('first SEEK_DATA',data_start,'following SEEK_HOLE',hole_start) dense=root/'dense' with path.open('rb') as source,dense.open('wb') as target: while chunk:=source.read(65536):target.write(chunk) target.flush();os.fsync(target.fileno()) assert digest(path)==digest(dense) assert dense.stat().st_blocks*512>=size print('explicitly writing all copied bytes preserves content but allocates the zero-filled ranges') result=libc.fallocate(fd,0x01|0x02,first,4096) # KEEP_SIZE | PUNCH_HOLE assert result==0,os.strerror(ctypes.get_errno()) os.fsync(fd) assert os.fstat(fd).st_size==size and os.pread(fd,1,first)==b'\0' assert os.pread(fd,1,second)==b'B' and os.fstat(fd).st_blocks*512<allocated print('punching the aligned data range releases its blocks and makes it read zero without changing file length') reserved=root/'reserved' with reserved.open('w+b') as handle: os.posix_fallocate(handle.fileno(),0,size);os.fsync(handle.fileno()) info=os.fstat(handle.fileno());assert info.st_size==size and info.st_blocks*512>=size assert handle.read(16)==b'\0'*16 print('preallocation reserves blocks even while the newly allocated range reads as zeros') finally:os.close(fd) 样本先在空文件中 seek 到十六 MiB,确认大小仍为零。随后 truncate 到这个长度,在本次 ext4 环境中得到逻辑十六 MiB、尚无已分配数据块的文件,读取开头为零。这个对照直接区分移动偏移与扩展文件,而不是依据 seek 返回了一个大数字,就推测文件内容范围已经改变。 ...

十月 11, 2027 · 2 分钟 · XB Field Notes
文章标题旁是标有 OBSERVE、DECIDE、ACT 的三个通用面板,以及箭头和圆形仪表。

PVC 为 Pod 提供容器之外的持久存储

容器本性易失,所以要用 PersistentVolumeClaim 把真实存储钉到它身上,即使 Pod 被重建也不丢数据。这个拆分是刻意的:PersistentVolume 声明已经存在的容量,PersistentVolumeClaim 请求需要的容量,由控制器把两者配上。有了 StorageClass,声明还能通过 provisioner 自己创建卷,于是没人需要预先造好存储。 真正会咬人的是:声明与卷各自有独立的生命周期,由各自的规则管着。finalizer 决定声明什么时候才允许消失,回收策略决定卷之后会怎样。删除顺序搞错,就会得到一个卡在 Terminating 的声明;策略搞错,可能删掉本想保留的数据,也可能留下一份没人在用却仍在计费的存储。 声明、卷与绑定 绑定是一次「全部满足」而不是「最接近」。一块卷只有在容量不小于请求、访问模式包含所请求的模式、storageClassName 匹配,并且没有被别的声明占用时,才是候选。除了这几项,volumeMode(Filesystem 与 Block)也必须一致;而已经释放却还没清理的卷并不会变回可用的备件,它的 claimRef 仍指向上一个声明,所以新的声明根本看不到它。什么都不满足的声明会停在 Pending,并带一条指明究竟哪一项没满足的事件,这比一句笼统的失败有用得多;常见原因是要的访问模式该类交付不了,以及类名根本不存在。 accessModes 没有默认值,必须选一个,而多数单节点块存储 provisioner 实际能交付的就是 ReadWriteOnce。它同时是单节点的,所以副本分布在不同节点上的 StatefulSet 无法共用一份声明。类支持动态供应时,volumeBindingMode 决定时机:Immediate 在声明一出现就创建卷,可能把受拓扑限制的卷放到没有节点能用的可用区;WaitForFirstConsumer 则让声明一直 Pending,直到出现消费 Pod,再结合调度约束协调绑定或供应,然后才能完成放置;它不是先完成调度再随意创建卷。 删除顺序与 finalizer 顺序是 Pod、声明、卷,保护机制由 finalizer 实现。在 Pod 仍挂载着声明时删除声明,什么都不会被删掉:声明拿到删除时间戳、翻成 Terminating,然后停在那里,因为 kubernetes.io/pvc-protection 仍在它的 finalizer 列表里。只有再没有任何 Pod 引用这份声明时,控制器才会摘掉那个 finalizer,所以那个「永远 Terminating」的 PVC 可能仍被 Pod 引用,但控制器延迟和其他 finalizer 也需要调查,不能只看状态就排除控制面问题。 ...

八月 13, 2027 · 1 分钟 · XB Field Notes
把一台主机的目录挂载到别处的网络文件系统。

NFS 把远程目录通过网络共享出去

一个 NFS 导出,是服务器上的一个目录加上一份允许访问它的客户端名单;而描述它的那个文件,并不是真正对外服务的东西。/etc/exports 由 exportfs 读取,exportfs 把主导出表写进 /var/lib/nfs/etab,客户端发来 MOUNT 请求时,是 rpc.mountd 去查这张表。三个文件:一个由人编辑,另外两个才决定实际发生什么。 这层间接就是最常见的 NFS 事故的来源:导出列表看着没问题,客户端却被拒,因为文件变了而表没变。剩下的配置归结为两个更难的问题——服务器认为一个请求带着什么身份,以及一个回答“已写入”的回复能不能挺过服务器崩溃。 文件与表 /etc/exports 的一行是 <目录> <客户端>[(<选项>)] ...,所以 /srv/data 10.0.0.0/24(rw,sync,no_subtree_check,root_squash) 把某个目录以四个选项导出给一个网段,同一行后面还能再接一组客户端、带自己的一套选项。客户端可以写成主机名、通配符、CIDR 网段或 netgroup;# 之后到行尾是注释,行尾反斜杠表示续行。文件里的内容在 exportfs -ra 依据它重建表之前一律不生效,而 exportfs -v 打印的是真正被加载的那张表,所以它既能用来核对文件里的每一条,也能用来发现表里多出来的东西。 showmount -e 从客户端一侧展示同一张表:它问的是 mount 守护进程而不是文件,所以它回答的是“服务器认为自己导出了什么”,而不是“我刚才写了什么”。fixture 精确复现了这个陷阱:把 /srv/newshare 追加进导出文件却没有重新导出之后,文件里有四条,表里只有三条,客户端请求这个新共享得到的是 NOENT。 ...

六月 23, 2027 · 1 分钟 · XB Field Notes
RDB 快照与 AOF 文件集合保存不同的可恢复状态。

RDB 与 AOF:两种保住 Redis 的方式

RDB 与 AOF 保存的是 Redis 状态的不同表示。选择之前应回答两个问题:允许丢失多少近期数据,以及可以接受多长恢复时间?单独一个持久化开关无法回答这些问题。真正需要的是可恢复的文件、明确的写入确认策略,以及验证过具体数据值的恢复流程,而不只是能够启动的进程。 快照记录一个时点,不保证固定间隔 RDB 是紧凑的时点数据集。BGSAVE 派生子进程,先写临时文件,完成后再替换旧快照;父进程继续处理请求,但派生进程、写时复制增加的内存以及磁盘工作仍有成本。SAVE 则同步保存并阻塞其他客户端,本文只把它用于很小的隔离测试数据,不应照搬到繁忙服务上。 例如 save 60 1000 同时要求经过指定时间并达到修改次数条件,不是“无论有多少修改,每六十秒都必定生成一份快照”,也不保证保存能按时成功完成。实际恢复暴露窗口取决于最近一份可用快照的状态有多旧;漏掉或失败的保存会扩大这个窗口。因此,要检查成功保存的记录与结果,不能只看配置中的时间数字。 AOF 改变恢复数据的表示 AOF 记录数据变更,并按 appendfsync 策略同步。everysec 通常暴露大约最近一秒的写入,no 把刷盘时机交给操作系统;always 在有效配置允许的情况下先同步再回复,代价是额外的同步开销。这些策略不保护整块存储设备丢失,也不能保证故障切换时被提升的副本包含每一条已经在原节点落盘的写入。 现代 AOF 是 manifest、基础文件和增量文件组成的集合,基础文件可以使用 RDB 格式。因此,“AOF 启动必定从头回放庞大的文本历史”不准确;重写还会去掉重建当前状态不再需要的历史操作。文件大小、启动耗时、重写开销和请求延迟,应结合实际数据集测量,不能仅凭 RDB 或 AOF 的名称排序。 两者同时启用,不等于自动合并 在没有额外启动覆盖的通常配置下,启用了 AOF 的 Redis 会选择 AOF 恢复,而不是先独立加载 dump.rdb,再把另一份日志叠加上去。AOF 内部的 RDB 格式基础文件属于其自身文件集合。另行保留的 RDB 仍可用于备份和明确选择的恢复操作,但不会自动补齐缺损的 AOF 文件。 ...

六月 15, 2027 · 3 分钟 · XB Field Notes
关于UNLOGGED 数据、减少 WAL 与崩溃后内容丢失的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

UNLOGGED 表减少数据日志,也放弃内容的崩溃恢复

PostgreSQL 的 UNLOGGED 表保留正常 SQL 事务行为,但放弃其内容的崩溃恢复能力。它适合能够重复生成的暂存数据,不适合把唯一副本托付给一次提交。判断边界是服务器是否经历了非正常关闭后的恢复,不能简单地把“重启”两个字等同于数据丢失,也不能因为一次正常重启后行还在,就认为它具备持久性保证。 哪些内容跳过 WAL 非日志表的数据变化及其索引变化不写入预写日志。系统目录的变化仍可能产生 WAL,同一个事务还可能修改普通表,所以不能把整个工作负载描述为“完全没有 WAL”。表的数据页仍然占据存储空间,仍要经过缓冲区管理和后续写回;UNLOGGED 并不是内存表,也不是不需要磁盘的缓存。写入收益来自减少特定日志工作,实际改善幅度需要测量,不能承诺固定倍数。 服务器正常运行时,事务、快照、约束和回滚仍然生效。语句被拒绝或应用断开连接,并不意味着此前提交的全部行都会消失。但是后端进程异常退出可能导致服务器重启其他进程并进入恢复,因此也不能声称“应用事件绝不影响非日志表”。排查时应查看 PostgreSQL 日志和恢复过程,而不是仅凭事故最初发生在哪一层就推断数据状态。 重启与复制的边界 干净关闭后,非日志表内容会保留到下次启动。pg_ctl 的 fast 模式会回滚正在执行的事务并正常关闭,immediate 模式则直接中止进程,不完成干净关闭。后者需要崩溃恢复,恢复期间非日志关系会被重置:表定义仍在,但此前提交的行不再存在。主机维护若完成了干净关闭,并不会仅因为“重启”就清空这类表;反过来,不能把正常关闭的测试当成抗崩溃证明。 物理备库不会收到非日志表的数据内容。备库仍处于恢复模式时,读取这类关系会报错,而不是成功返回零行。提升为主库后可以读取它,但没有已经复制过来的行可以恢复。因此故障切换流程必须先重建或重新填充,再允许依赖这些数据的消费者进入服务。主备拥有同一个表定义,只说明目录信息存在,并不代表表中业务数据已经复制。 普通日志表不能用外键引用非日志表。PostgreSQL 会拒绝这样的建表语句,因为持久化的子表引用不能安全依赖可能在恢复中消失的父表行。下面的实验直接检查这一限制,不会把它描述成“外键还在、父表行没了、数据库默默允许不一致”。设计暂存区时,要把关系约束放到符合持久性边界的位置,而不是事后修补孤立记录。 坏掉的样子 一个常见事故是导入程序把文件装入非日志暂存表后就确认成功,并删除唯一的原始文件。随后服务器崩溃,暂存行消失,而输入已无处重取,这就变成不可恢复的数据丢失。原始输入应该至少保留到持久化目标提交成功,重建流程也必须知道需要哪一个输入版本。去重记录和导入检查点要属于持久化契约,不能和暂存内容一起丢掉。 另一个问题出现在报表服务改读备库,或刚完成提升就立即对外服务。只检测行数为零会漏掉恢复模式下的读取错误;无限重试也无法生成不存在的数据。应明确标记“正在重建”,在重建完成且结果验证通过前控制请求准入。演练要测量重建耗时、上游是否可用、记录数及内容是否符合预期;只建回一张空表不算恢复,能运行的脚本也不等于已经证明恢复时间满足要求。 快速验证 把 Bash 代码保存为 unlogged.sh。它要求以非 root 用户执行 PostgreSQL 18 的命令行工具。下面的容器命令提供工具,不发布网络端口,也不挂载宿主机数据;两个实验数据库都位于容器内的新临时目录。 docker run --rm --network none --user postgres --entrypoint bash -i \ postgres:18-alpine < unlogged.sh #!/usr/bin/env bash set -euo pipefail pg_lab=$(mktemp -d /tmp/unlogged-lab.XXXXXX) cleanup() { for cluster in standby primary; do if [ -f "$pg_lab/$cluster/postmaster.pid" ]; then pg_ctl -D "$pg_lab/$cluster" -m immediate -w stop >/dev/null fi done rm -rf -- "$pg_lab" } trap cleanup EXIT primary() { psql -X -h "$pg_lab" -p 55432 -d postgres -v ON_ERROR_STOP=1 "$@"; } standby() { psql -X -h "$pg_lab" -p 55433 -d postgres -v ON_ERROR_STOP=1 "$@"; } start_primary() { pg_ctl -D "$pg_lab/primary" -l "$pg_lab/primary.log" \ -o "-k $pg_lab -p 55432 -c listen_addresses=''" -w start >/dev/null } initdb -D "$pg_lab/primary" --no-locale --encoding=UTF8 --auth=trust >/dev/null start_primary primary <<'SQL' CREATE TABLE durable (id integer PRIMARY KEY); CREATE UNLOGGED TABLE scratch (id integer PRIMARY KEY); INSERT INTO durable VALUES (1); INSERT INTO scratch VALUES (1); BEGIN; INSERT INTO scratch VALUES (2); ROLLBACK; DO $$ BEGIN EXECUTE 'CREATE TABLE durable_ref (id integer REFERENCES scratch(id))'; RAISE EXCEPTION 'a logged table unexpectedly referenced an unlogged table'; EXCEPTION WHEN invalid_table_definition THEN RAISE NOTICE 'logged-to-unlogged foreign key rejected'; END $$; SQL counts='SELECT (SELECT count(*) FROM durable), (SELECT count(*) FROM scratch)' test "$(primary -Atc "$counts")" = '1|1' pg_ctl -D "$pg_lab/primary" -m fast -w stop >/dev/null start_primary test "$(primary -Atc "$counts")" = '1|1' printf 'clean restart: 1|1\n' pg_basebackup -h "$pg_lab" -p 55432 -D "$pg_lab/standby" -R -X stream -c fast pg_ctl -D "$pg_lab/standby" -l "$pg_lab/standby.log" \ -o "-k $pg_lab -p 55433 -c listen_addresses=''" -w start >/dev/null test "$(standby -Atc 'SELECT pg_is_in_recovery()')" = t test "$(standby -Atc 'SELECT count(*) FROM durable')" = 1 if standby -Atc 'SELECT count(*) FROM scratch' 2>"$pg_lab/read-error"; then echo 'unlogged read unexpectedly succeeded on standby' >&2 exit 1 fi grep -F 'cannot access temporary or unlogged relations during recovery' "$pg_lab/read-error" pg_ctl -D "$pg_lab/standby" -w promote >/dev/null test "$(standby -Atc "$counts")" = '1|0' printf 'promoted standby: 1|0\n' pg_ctl -D "$pg_lab/primary" -m immediate -w stop >/dev/null start_primary test "$(primary -Atc "$counts")" = '1|0' printf 'crash recovery: 1|0\n' primary -Atc 'SHOW server_version' echo 'PASS: rollback, foreign key restriction, clean restart, standby, promotion, crash' 主库与备库使用同一个临时目录中的不同 Unix 套接字端口,退出时的 trap 只关闭这两个实验集群并移除其文件。实验故意对临时主库执行 immediate 关闭,不能把这一步改成已有数据库目录。PostgreSQL 18.6 上,回滚和正常重启后的计数都是 1|1;仍处于恢复模式的备库拒绝读取 scratch。备库提升和主库崩溃恢复后分别得到 1|0,表明持久化行还在而暂存内容不在。错误文本断言使用集群的 C locale,避免本地化消息造成误判。 ...

五月 24, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明存储桶版本控制与生命周期管理成本。

桶版本化与生命周期管理成本

S3 版本化在同一个键下保留多个版本,只要所需版本仍在,就能恢复许多误覆盖和普通删除。它并不防止所有删除,也不会独立构成备份。真正能否恢复应用,取决于保留策略、访问权限、加密密钥是否仍然可用,以及恢复程序是否经过验证。版本存在与业务恢复成功之间,还有需要明确检查的步骤。 分清版本化状态 在启用版本化的桶中,PUT 产生新版本 ID,同时保留之前的数据版本。每个版本存储完整对象,并非只记录变化的字节。因此,反复覆盖一个大对象,即使可见键数量保持不变,存储成本也可能持续增长。这与块级增量快照的空间模型不同,不能只用修改了多少字节估算对象版本的费用。 启用之前已存在的对象保留 null 版本 ID,新产生的版本化写入不会覆盖那个旧版本。一旦启用,桶就不能回到从未版本化的状态,但可以暂停版本化。暂停期间,后续写入使用 null 版本槽位,可能替换已有 null 版本。讨论覆盖是否安全时,必须明确桶当前处于哪一种状态,不能把暂停与启用混为一谈。 null 是真正的版本标识,不是永久保留保证。null 版本变成非当前版本后,可以受到非当前版本过期规则影响;在授权允许时,也能够按明确版本 ID 删除。因此,列表中出现 null 并不能证明那份旧数据不参与生命周期清理,更不能把它当成永远不会消失的恢复基线。 删除标记改变可见性 启用版本化时,不带版本 ID 的普通 DELETE 创建删除标记。标记处于当前位置后,不指定版本的 GET 通常返回 404;显式请求删除标记的版本 ID 则返回 405。较早数据仍可通过其数据版本 ID 获取,但还受到访问权限、存储类别及加密条件影响。看见 404 不能直接推断底下所有版本已经消失。 删除标记没有对象载荷,却不是免费零字节条目,S3 会按它保存的键名字节收费。删除那个明确的当前标记,可以暴露前一版本,但前一版本本身也可能仍是标记。另一种恢复方式是复制明确的历史数据版本,生成新的当前数据版本,同时保留历史。标记没有数据,不能把它当成普通对象复制来恢复。 按指定 ID 删除数据版本,是另一种永久操作,版本化本身不能把被永久删除的版本找回来。配置 MFA Delete 后,永久删除版本和改变版本化状态会增加相应认证要求,但开启版本化并不会自动开启这种保护。AWS 不支持将 MFA Delete 与生命周期配置一起使用,不能假设所有保护开关可以无条件同时启用。 ...

五月 8, 2027 · 2 分钟 · XB Field Notes
在一个 Redis 值里紧凑地存下海量是/否标记的方式。

Redis 位图用位而不是存储数字

Redis 位图是把字符串按单个位解释的用法,并不是一种独立的键类型。它适合稠密的是非标记,例如按用户记录每日活跃状态。一千万个连续标志需要一百二十五万个载荷字节,但这个估算不包括键名、对象元数据、分配器额外开销,也不包括业务标识到位偏移的映射,不能直接当成实例总内存。 先定义偏移的契约 SETBIT 在从零开始的偏移处写入零或一,并返回写入前的位值。已经为一的位置再次写一,不会增加成员数量。偏移零对应第一个字节的最高位,偏移八才进入第二个字节。对同一个键执行 GET,读到的是底层字节,它们未必是有效文本,所以二进制数据不应默认按 UTF-8 解码。 GETBIT 对缺失键或超出现有字符串长度的位置返回零,而且不会因为读取就扩容。因此,零不能区分“明确不活跃”与“尚未记录”,需要先决定业务是否允许合并这两种状态。SETBIT 与 GETBIT 要求键的值是字符串;如果键实际是哈希,会报类型错误,不会自动把哈希字段转换成位。BITOP 则会在目标键写入新的字符串。 内存取决于写入过的最高偏移,而不是当前有多少个位为一。写偏移 n 至少需要 floor(n / 8) + 1 个载荷字节,即使要写入的是零也一样。后来把高位清零,并不会缩短字符串。稀疏外部编号因此可能用极少的有效标记触发巨大分配。应验证允许的偏移范围,并采用稳定且足够稠密的编号映射,而不是直接信任任意业务 ID。SETBIT 要求偏移小于二的三十二次方,对应最大五百一十二 MiB 字符串。这个协议上限不是合理的单次请求分配预算,应用应按业务规模设置更小的允许范围。 计数和组合都要明确范围 BITCOUNT 默认检查全部字节。可选的 start 和 end 是包含两端的字节位置;从 Redis 7.0 开始,可以用 BIT 修饰符把它们改为位位置。切换单位只改变查询区间,不会改变键里实际存储的表示。返回值是为一的位数,不是最大的成员编号,也不是位图所能容纳的全部用户数。 BITOP OR 计算成员并集,AND 计算交集。较短的输入和不存在的键,会被视为补零到最长输入的长度。目标键会被新字符串覆盖,原本附在目标键上的过期时间也会移除。因此,派生结果需要明确的寿命策略,不能以为之前给结果键设置过一次期限,以后的重算就会自动继承。 ...

四月 29, 2027 · 3 分钟 · XB Field Notes