标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

对象与块存储提供不同的更新契约

块存储通过设备接口暴露可寻址的数据块,对象存储通过接口暴露带名字的对象。仅凭这两个分类,不能决定耐久性、一致性、共享能力或价格。应先列出应用真正需要执行的操作,再核对具体服务的契约。文件系统、数据库和兼容层可以增加行为,但不能让所有底层接口自动变得可互换;一个路径能够被打开,并不证明它符合应用的恢复要求。 比较实际支持的操作 块卷可以先格式化为文件系统,也可以由软件直接管理数据布局。块服务并不一定预装文件系统。应用可以进行小范围覆盖而不必替换整块卷,但共享访问仍然需要协调。把一个卷同时连接到多台机器,不会让普通单机文件系统自动变成集群文件系统。数据库能否恢复,还依赖块接口之上的写入顺序、同步和持久性约定,不能把能够写入等同于断电以后仍然正确。 对象通过桶或其他服务定义的命名空间寻址。普通 S3 通用桶写入会替换整个对象;读取却可以指定字节范围,所以“对象存储每次必须读完整个对象”并不正确。分段上传把多个部分组装成一个完成的对象,不等于通用文件系统的随机写契约。特殊产品还可能提供追加或其他更新能力,因此必须核对选中的桶类型与接口,不能把下面的小模型当成所有产品永远不变的规则。 对象存储通常适合媒体、导出结果和备份文件,让许多客户端按需读取。块存储通常适合数据库文件与虚拟机磁盘。但这只是常见工作负载,并不是互斥分类:数据库可以专门围绕对象接口设计,共享文件系统也可以建立在块设备之上。选择之前应测量请求大小、并发量、恢复过程,以及请求与数据传输的完整成本,不能只比较每月每单位容量的标价。 坏掉的样子 不要默认对象存储采用最终一致性。Amazon S3 对对象写入、删除及对应读取和列举提供强一致性。客户端手里保留旧下载结果,仍然可能看见旧内容,但这不能证明重新向服务读取也会得到旧值。分发缓存、跨区域复制和跨多个对象的业务更新又是不同边界,必须各自检查保证。把这些现象都叫作“对象存储有一致性窗口”,会让排查方向从一开始就偏离事实。 成功的 S3 PUT 不会暴露半个上传对象。请求失败或结果不明时,应确认实际状态,不能直接推断新版本被截断。单个对象的原子替换也无法防止丢失更新:两个客户端可能读取同一旧值,再分别覆盖它。条件请求可以在接受更新之前比较校验标识,但要遵循服务文档中的条件语义。ETag 并不总是内容校验和,更不是把多个键包进同一个事务的承诺。 生命周期删除与历史版本保留应通过真实恢复演练检验。打开版本控制不等于已经有独立备份,在程序里记一个版本数字,也不等于保存了所有旧载荷。大量小对象即使占用字节不多,请求开销仍可能主导成本。兼容挂载层能够接受数据库文件名,同样需要证明它满足锁、写入顺序与失败恢复语义,才能作为合适的数据库存放位置。 快速验证 保存为 storage_semantics.py,用 Python 3.9 或更新版本执行 python3 storage_semantics.py。这是使用虚构字节的独立内存模型,不访问服务,不测文件系统持久性,也不涉及网络延迟与计费。它只把明确写进类定义的规则变成可检查的操作,方便分辨几种常被混淆的保证。 """In-memory API model, not an implementation of a storage service.""" class BlockVolume: def __init__(self, data): self.data = bytearray(data) def write(self, offset, chunk): if offset < 0 or offset + len(chunk) > len(self.data): raise ValueError("write outside fixed volume") self.data[offset:offset + len(chunk)] = chunk class ObjectStore: def __init__(self): self.current = {} def put(self, key, data, expected=None): old = self.current.get(key) version = old[1] if old else 0 if expected is not None and expected != version: raise ValueError("version conflict") self.current[key] = (bytes(data), version + 1) return version + 1 def get(self, key): return self.current[key] block = BlockVolume(b"ABCDEF") block.write(2, b"XX") assert block.data == b"ABXXEF" try: block.write(5, b"too long") except ValueError: pass else: raise AssertionError("out-of-range write accepted") assert block.data == b"ABXXEF" store = ObjectStore() assert store.put("report", b"ABCDEF", expected=0) == 1 cached = store.get("report") assert store.put("report", b"XYZ", expected=1) == 2 assert store.get("report") == (b"XYZ", 2) assert cached == (b"ABCDEF", 1) try: store.put("report", b"stale overwrite", expected=cached[1]) except ValueError: pass else: raise AssertionError("stale conditional update accepted") assert store.get("report") == (b"XYZ", 2) assert store.get("report")[0][1:3] == b"YZ" print("PASS: bounded overwrite; complete replacement; cached copy differs; stale condition rejected") 块模型最初有六字节,覆盖中间两字节后,相邻内容保持不变。越界写入被拒绝,原数据没有因此变化。对象模型从第一版的六字节内容,整体替换成第二版的三字节内容;新的读取立即得到替换结果。另一个事先保存的元组仍然包含第一版,因为应用保留了自己的旧副本。这是本地缓存陈旧,不是服务读取出现最终一致性延迟。 ...

三月 17, 2027 · 2 分钟 · XB Field Notes
内核里把 I/O 合并与重排、以换取更高吞吐或更公平的内核队列。

磁盘调度器给 I/O 排好队,让磁头移动更省

blk-mq 给块层准备了两级队列:每个 CPU 一个软件暂存队列,每条设备队列一个硬件派发队列,而 I/O 调度器就夹在两者之间。一个请求会走最短路径——直接进硬件队列——除非该设备挂了调度器,或者这个请求还能被合并;这两种情况下它要先在软件队列里停留。调度器是整个栈里唯一由内核决定“请求以什么顺序到达设备”的地方。 排序在机械盘上价值极大,在闪存上几乎为零,但调度器还有第二件活,而且这件事在 NVMe 时代依然存在:决定谁先等、谁后等。这两件事的方向并不一致——为磁头效率而重排根本不关心公平,而公平本身要付出寻道的代价——所以当前内核里的四种调度器不是拿来排名的,而是拿来匹配设备与负载的。 磁头才是预算 在 7200 RPM 的盘上,平均每个请求的旋转开销是 4.17 ms,传输一个 4 KiB 块只要 0.026 ms,所以几乎全部服务时间都是寻道加旋转。fixture 重放了 2000 个随机 4 KiB 读、覆盖 100000 条磁道:按到达顺序派发时磁头走过 67907411 条磁道、整轮耗时 21954 ms,约 91 IOPS;按磁道升序派发时只走 99977 条、耗时 8645 ms,约 231 IOPS。同样的请求、同样的设备,磁头行程少了 679 倍,吞吐涨了 2.5 倍。 ...

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

停滞的复制槽会继续保留 WAL

PostgreSQL 复制槽记录某个消费者需要保留的材料。一旦槽已经预留日志位置,消费者连接消失,并不会自动解除这份要求。主库可能继续生成日志,而槽的保留起点一直不动。实际风险是磁盘保留量增长,以及追赶还是重建越来越难取舍。槽是否可用,取决于必需日志是否仍然存在,以及实际配置的失效规则。 坏掉的样子 新建物理复制槽不一定立即保留 WAL。如果没有请求立即预留,在槽第一次使用之前,重启位置可能始终为空。因此,仅凭不活跃标志不能量化保留量。需要结合槽类型、预留位置、可用状态和消费者归属来理解。对于逻辑槽,已确认刷新位置描述消费者确认的进度,也不能替代对更早重启位置及其保留义务的分析。 删除槽解除的是这个槽的要求,不一定会在同一瞬间删除文件。检查点清理和其他保留条件仍然有效。别的复制槽、归档积压或日志预留,都可能继续保留文件;回收还可能只是重命名供将来复用,并不把全部已分配字节交还文件系统。多个消费者需要的日志区间可以重叠,所以把每个槽的滞后量简单相加也会重复计算。 保留限制是在两种风险之间做交换。槽的最大日志保留量在检查点附近执行,不是瞬间限制磁盘大小的硬配额。落后足够多的槽可能失去必需日志并变得不可用。这样减少无限保留风险,代价是放弃该消费者现有的追赶路径。仅仅静默时间长,不足以证明可以删除一个仍有真实消费者依赖的槽。 快速验证 在 Linux shell 环境里,以非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端和客户端程序。本次实际执行版本是 PostgreSQL 18.6。私有临时集群不打开 TCP 监听,客户端通过私有 Unix 套接字连接,里面只有实验用复制槽。少量有日志写入配合强制切段,使保留现象快速可见;这不是对真实业务日志生成量的性能基准。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-slots.XXXXXX) mkdir "$lab/socket" cluster=$lab/data cleanup() { if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi } trap cleanup EXIT initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$cluster/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica max_replication_slots = 4 max_slot_wal_keep_size = -1 min_wal_size = '32MB' max_wal_size = '64MB' checkpoint_timeout = '1h' CONF pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT * FROM pg_create_physical_replication_slot('unused_slot'); SELECT active,restart_lsn IS NULL FROM pg_replication_slots WHERE slot_name='unused_slot'; DO $$ BEGIN IF (SELECT restart_lsn IS NULL FROM pg_replication_slots WHERE slot_name='unused_slot') IS NOT TRUE THEN RAISE EXCEPTION 'unexpected immediate reservation'; END IF; END $$; SELECT pg_drop_replication_slot('unused_slot'); CREATE TABLE churn (value text); SELECT * FROM pg_create_physical_replication_slot('held_slot',true); SQL segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_walfile_name(restart_lsn) FROM pg_replication_slots WHERE slot_name='held_slot'") psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' DO $$ BEGIN FOR n IN 1..8 LOOP INSERT INTO churn SELECT md5(g::text) FROM generate_series(1,5000) g; PERFORM pg_switch_wal(); END LOOP; END $$; CHECKPOINT; SELECT active,wal_status,pg_wal_lsn_diff(pg_current_wal_lsn(),restart_lsn)>64*1024*1024 FROM pg_replication_slots WHERE slot_name='held_slot'; DO $$ BEGIN IF (SELECT NOT active AND wal_status='extended' AND pg_wal_lsn_diff(pg_current_wal_lsn(),restart_lsn)>64*1024*1024 FROM pg_replication_slots WHERE slot_name='held_slot') IS NOT TRUE THEN RAISE EXCEPTION 'inactive slot did not retain the expected WAL range'; END IF; END $$; SQL test -f "$cluster/pg_wal/$segment" echo inactive_slot_retains_old_segment psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_drop_replication_slot('held_slot')" >/dev/null test -f "$cluster/pg_wal/$segment" echo drop_did_not_immediately_remove_segment psql -X -v ON_ERROR_STOP=1 -qAt -c CHECKPOINT test ! -f "$cluster/pg_wal/$segment" echo checkpoint_released_old_segment_name psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' ALTER SYSTEM SET max_slot_wal_keep_size='32MB'; SELECT pg_reload_conf(); SQL attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT current_setting('max_slot_wal_keep_size')='32MB'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT * FROM pg_create_physical_replication_slot('limited_slot',true); DO $$ BEGIN FOR n IN 1..8 LOOP INSERT INTO churn SELECT md5(g::text) FROM generate_series(1,5000) g; PERFORM pg_switch_wal(); END LOOP; END $$; CHECKPOINT; SELECT wal_status,invalidation_reason FROM pg_replication_slots WHERE slot_name='limited_slot'; DO $$ BEGIN IF (SELECT wal_status='lost' AND invalidation_reason='wal_removed' FROM pg_replication_slots WHERE slot_name='limited_slot') IS NOT TRUE THEN RAISE EXCEPTION 'slot limit did not invalidate the test slot'; END IF; END $$; SELECT pg_drop_replication_slot('limited_slot'); SQL printf 'lab_directory %s\n' "$lab" 第一个物理槽创建时不立即预留。输出中的假与真,分别表示槽不活跃、重启位置为空。脚本对此作出断言,再删除这个尚未使用的夹具。下一个槽明确请求预留,而且始终没有消费者连接。经过写入、切段和检查点后,观察到不活跃、保留范围超出最大 WAL 目标,以及按日志位置差计算落后超过六十四兆二进制字节的状态。 ...

十月 19, 2026 · 2 分钟 · XB Field Notes
用于比较 S3 存储类的观察、决策和执行面板。

S3 存储类用成本换访问方式

存储类改变的不只是每月保存字节的价格,还包括取回方式、故障韧性和计费结构。首先应确定业务允许多久恢复数据,以及丢失后是否有其他可重建副本。Standard-IA 可以立即读取,但取回另行收费;Glacier Flexible Retrieval 和 Deep Archive 则需要先恢复,再执行普通读取。One Zone-IA 还有不同故障边界:它不能抵御所在可用区被物理摧毁。相同的宣传持久性数字,不能替代对实际故障域的判断。 先拆清费用,再比较总额 使用桶所在区域的当前价目表,明确存储类、取回档次与请求种类。存储取决于计费容量和持续时间;取回可能同时按字节及请求计费。逐对象迁移请求和归档元数据则随对象数量增长。不能把所有费用都说成只由对象数量决定,也不能从某一条存储单价直接推出整个工作负载的节省比例。同样大小的数据拆成大量小对象,成本模型可能完全不同。 不要把 Flexible Retrieval 和 Deep Archive 的标准取回统一当成免费。AWS 明确提供 Flexible Retrieval 批量取回及其请求免费,Intelligent-Tiering 则有另外的标准和批量取回免费规则。这些例外不能推导出其他存储类和档次也免费。归档恢复后,原归档对象仍在;临时可读副本还会按选定保留时间收取 Standard 存储费。只计算原归档的低价,会漏掉这个并存阶段。 即使对象提前消失,最短存储期限仍然影响费用:Standard-IA 和 One Zone-IA 为三十天,Glacier 即时与灵活取回为九十天,Deep Archive 为一百八十天。提前删除、覆盖或迁出可能产生剩余期限费用。Standard-IA、One Zone-IA 和 Glacier Instant Retrieval 有 128 KB 的最小计费对象大小;灵活取回与深度归档则每对象增加四十 KB 元数据,分别按 Standard 与归档费率计费。 ...

九月 30, 2026 · 2 分钟 · XB Field Notes
关于vacuum 与表空间复用的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

vacuum 让增长中的数据库不虚胖

在多版本存储里,删除只是记录变化、而不立刻释放空间。回收这些空位需要维护工作,一旦被跳过,代价就会悄悄累积。 删除不等于已经回收 UPDATE 或 DELETE 会留下旧行版本,直到相关快照都不再需要它们。长事务可以在其他会话持续修改数据时推迟这个时刻,但长事务本身不会凭空让一张没有写入的表膨胀。提交删除、旧版本不再可见、空间得到回收,是不同边界。不能只看删除语句成功,就认定这些页面已经可以重新分配。 普通 VACUUM 通常只是让表文件内部的空间可供新行复用,不会缩小文件。末尾完全空闲的页面有时能够截断;一般压缩则需要 VACUUM FULL 一类重写操作,付出额外空间和更强锁的代价。清理成功但文件大小不变,并不意味着失败。要把活数据、内部可复用空间以及真正归还操作系统的空间分别观察,才不会对正常状态反复执行昂贵重写。 清理与统计是不同任务 autovacuum 由启动进程和多个工作进程组成,并非一名常驻 worker 按固定时刻清完所有表。它根据活动估计和年龄相关规则决定是否执行 VACUUM 或 ANALYZE。ANALYZE 独立收集规划器分布统计,普通 VACUUM 不能替代它。插入驱动的工作与防回卷工作也说明,死元组数量并不是维护的唯一触发条件,不能因为暂时没有大量删除就忽略事务年龄。 基于代价的延迟,会在估计 I/O 工作累计到一定程度后暂停一段时间。代价上限不是“一次调用最多删掉多少行,达到后就退出”。阈值影响资格,worker 是否有空影响调度,存储能力和限流则影响推进速度。排查需要先判断卡在哪个阶段,不能把各种迟缓都归结为某个比例系数太大,也不能把模拟程序里的清理额度当成真实数据库参数。 坏掉的样子 第一类问题是产生旧版本的速度超过回收速度。连续几个观察窗口中,死元组估计与关系体积不断上涨。应结合已经完成的运行、取消情况、worker 可用性、I/O 与写入速率检查。资源确实成为瓶颈时,额外硬件可能有帮助;但重启数据库本身不会让仍需保留的版本变得可回收。在相同 I/O 上限下,降低阈值或增加 worker 数量,也不保证最终吞吐更高。 第二类问题是清理在跑,却因为旧快照或保留的 xmin 而无法回收特定版本。干预之前要检查事务及复制状态。事务开始得很早只是线索,不能证明它持有这次受影响的可见性边界。结束应用事务或删除复制槽可能中断工作,甚至让消费者需要重建,因此应先找到负责人、确认保留目的与恢复要求,而不是为了让监控数字下降就直接清除对象。 第三类问题是仅凭一个 n_dead_tup 值或一条慢计划断定膨胀。统计数量是估计,页面布局、可见性、选择率和统计过时都可能改变读取成本。应比较趋势、vacuum 报告和真实执行计划。文件较大但内部空间可复用,可能是健康稳态;死元组较少,也不排除版本保留或冻结问题。不能让单一计数替代完整因果判断。 ...

八月 16, 2026 · 2 分钟 · XB Field Notes
关于先持久化 WAL 再刷写数据页的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

WAL 让提交持久化不必等待所有数据页刷盘

预写日志允许数据库把事务变成持久状态,而不必立即刷出这个事务修改过的每一个表页与索引页。在 PostgreSQL 中,恢复某个页面所需的日志,必须在这个页面写入存储之前先持久化。正常同步提交还会等待提交记录完成刷盘。因此,缓冲区里的表变更与已经持久化的日志记录,沿着不同路径向前推进。 坏掉的样子 WAL 不是一张稍后再执行 SQL 的预约单。执行过程已经修改内存页面,日志记录描述的是恢复动作。有些日志来自最终没有提交的事务。恢复需要结合重做与事务状态,确保一个未完成事务不会仅因部分日志或数据页已经落盘,就变成其他查询可见的提交结果。把“日志存在”当成“事务已经成功”,会误解恢复后数据的可见性。 持久性设置会改变承诺。关闭同步提交后,成功响应可能先于日志持久化,崩溃时最近已经确认的事务因此可能消失。关闭文件同步的风险更大,系统故障后甚至可能留下不一致存储。整页写入解决的是另一类问题:在页面只写入一部分时,提供重新构造页面的材料。这些机制都无法弥补存储虚假确认同步,也不能补回所有持久副本同时丢失的数据。 客户端没有收到回复,又是另一种不确定性。服务端可能已经成功提交,连接随后才断开。恢复后事务仍然存在,客户端却从未看到成功,此时直接重试业务动作可能造成重复。因此,对需要保证业务操作唯一性的场景,应使用稳定操作标识并核对结果,不能把网络失败一概解释成数据库已经回滚。 快速验证 在 Linux 环境中,以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端与客户端程序。本次实际使用 PostgreSQL 18.6。脚本初始化隔离的临时集群,使用私有 Unix 套接字,并显式开启正常持久性设置。立即停止操作只针对它刚创建的集群。因为演练会故意打断数据库进程,应使用完整测试夹具,不要把路径替换成生产数据目录。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-crash.XXXXXX) mkdir "$lab/socket" cluster=$lab/data writer_pid='' cleanup() { if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi if test -n "$writer_pid"; then wait "$writer_pid" || :; fi } trap cleanup EXIT initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$cluster/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 fsync = on synchronous_commit = on full_page_writes = on checkpoint_timeout = '1h' CONF pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' CREATE TABLE marker (id integer PRIMARY KEY,value text); INSERT INTO marker VALUES (1,'base'); CHECKPOINT; SQL checkpoint_before=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT checkpoint_lsn FROM pg_control_checkpoint()') psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'committed')" commit_end=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_current_wal_flush_lsn()>='$commit_end'::pg_lsn")" = t PGAPPNAME=crash_uncommitted psql -X -v ON_ERROR_STOP=1 -qAt >"$lab/writer.log" 2>&1 <<'SQL' & BEGIN; UPDATE marker SET value='uncommitted' WHERE id=1; SELECT pg_sleep(120); COMMIT; SQL writer_pid=$! attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='crash_uncommitted' AND wait_event='PgSleep')")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'uncommitted writer was not ready' >&2; exit 1; } sleep 0.1 done test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT checkpoint_lsn='$checkpoint_before'::pg_lsn FROM pg_control_checkpoint()")" = t echo committed_wal_flushed_without_new_checkpoint pg_ctl -D "$cluster" -m immediate -w stop >/dev/null if wait "$writer_pid"; then writer_pid='' echo 'interrupted writer unexpectedly succeeded' >&2 exit 1 fi writer_pid='' pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null grep -F 'redo starts at' "$lab/server.log" psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT id,value FROM marker ORDER BY id; DO $$ BEGIN IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker) IS DISTINCT FROM '1:base,2:committed' THEN RAISE EXCEPTION 'crash recovery result mismatch'; END IF; END $$; SQL printf 'lab_directory %s\n' "$lab" 第一行以 base 值完成检查点。随后脚本提交第二行,并核对已经刷出的日志位置覆盖提交之后捕获的位置。另一个后端在事务里修改第一行,然后在提交之前进入休眠。通过统计视图观察该后端真实进入休眠状态,证明这项未完成更新确实执行了,而不是只把一条尚未送达数据库的命令放在客户端等待。 ...

八月 14, 2026 · 2 分钟 · XB Field Notes