洛杉矶 2026 年切换案例:重复的 01:30 分叉到 08:30Z 与 09:30Z;春季 02:30 指向不存在标记。

时间戳需要明确的时间语义

像 2026-11-01 01:30 这样的钟表标签,单独存在时不能确定唯一瞬间。解释它需要知道所在时区,在时钟回拨期间还可能需要说明是哪一次出现。反过来,已经明确纪元和单位的 Unix 时间戳,即使没有显示时区名称,也可以具有完整的瞬间语义。选择字符串格式之前,应先明确系统保存的究竟是哪一种时间值。 偏移不是完整时区规则 带数值偏移的时间戳说明本地字段如何对应 UTC。同一天的 12:00Z 和 20:00+08:00 可以表示同一瞬间。IANA 命名时区还包含历史及未来的民用时间规则,包括夏令时切换。安排下个月的洛杉矶本地约会时,固定的负七小时偏移不能替代 America/Los_Angeles,因为到时可能适用不同偏移。 对于已经发生的事件,可以保留无歧义瞬间,再按照读者需要的时区显示。但“每个工作日早上九点”是重复的民用时间意图,还需要保留本地日程及其适用时区。政策变化可能改变未来规则,需要可复现性时也应记录所用时区数据库版本。把今天的偏移固化成永远使用的日程,会悄悄改变原始业务含义。 UTC 格式并不证明时钟准确,也不证明因果顺序。两台机器都能打印 Z 后缀,但各自系统时钟仍可能有偏差;有限精度还会让多个事件得到相同时间戳。需要排序依据时,应保留事件标识及适当的因果上下文。本机经过时长则应选用合适的单调时钟,不能期待时区转换修复最初就测错的瞬间。 数值接口还必须注明采用秒还是毫秒,以及从哪个纪元开始计数。一个较大的整数本身不能完整说明单位;接收方把单位解释错了,之后再切换显示时区也无法恢复正确含义。单位应属于接口契约,而不是靠字段长度临时猜测。 坏掉的样子 任意时间戳字符串直接排序,可能得到与时间先后相反的结果。RFC 3339 对适合文字排序的条件有明确说明,包括统一的时区表示和小数精度。面对混合偏移,应先解析并归一化到瞬间,再进行比较。视觉上本地小时较晚的记录,换算到 UTC 之后可能反而更早,因此字符串字符比较不能替代时间语义解释。 秋季回拨可能让同一个本地小时出现两次,Python 的 ZoneInfo 通过 fold 区分这两个实例。春季前拨则可能让某个本地时刻根本不存在。仅仅把时区对象附到日期上,并不会自动拒绝缺失时间。接受本地约会输入的程序必须定义拒绝或解决歧义的策略,不能悄悄替用户猜测他想选择哪一个瞬间。 日期运算也要与目标一致。两个 datetime 共享同一个 tzinfo 时,相关运算可能使用钟表字段语义,忽略 fold 所表达的瞬间区别。如果需要比较实际经过时间,应先分别转换到 UTC。这个选择需要明确,因为跨越时钟切换时,本地表盘读数之差可能与真正经过的秒数不同,二者都不能无条件替代另一种含义。 洛杉矶 2026 年案例区分重复时刻与春季不存在时刻;比较经过时长时先转为 UTC。 快速验证 将下面程序保存为 timezone.py,使用 Python 3.11 或更新版本执行 python3 timezone.py,并确保环境中有 IANA 时区数据。原先报告的环境是 Python 3.11.2 和 Debian tzdata 2026b。运行时必须启用断言,不使用 -O 或 PYTHONOPTIMIZE。程序全部使用固定日期,不会修改机器的时钟,也不会更改系统时区;不同数据库版本需要重新检查相应规则。 ...

十一月 29, 2026 · 2 分钟 · XB Field Notes
文件经加载器漏斗进入内核;另一条后续写入者箭头可以改变运行值。

sysctl 在运行中调整内核

在 Linux 上,sysctl 通过 /proc/sys 暴露一部分内核参数。它可以观察当前运行状态;有权限的写入则能在不重启的情况下改变状态。配置文件是另一层东西:它要求某个加载器在特定时刻执行赋值。混淆这两层,就会遇到常见故障:磁盘上的配置看起来正确,内核实际使用的却是另一个值。 本文限定 Linux、procps-ng 和 systemd。BSD、macOS 也提供名为 sysctl 的命令,但接口和持久化方式不同。在 macOS 上数出多少个键,不能作为 Linux procfs 接口的验证证据。 先确认正在读取哪个接口 对普通名称来说,kernel.ostype 对应 /proc/sys/kernel/ostype;procps 也接受斜杠分隔符。若名称本身含有字面意义的点号,应遵循工具的分隔规则,不能盲目把所有点替换成斜杠。可用键取决于内核配置、已加载模块和执行环境。不同机器的键集合不保证相同,同一台机器运行期间也可能变化。 procps 的普通输出是 key = value,-n 则只打印值。一次读取成功,说明调用者在当前环境看到了这个值,并不证明持久策略已经生效。有些值还会在观察期间变化。排查时只读取所需键,不能把所有读取都当成无副作用操作:手册明确说明,读取 vm.stat_refresh 会刷新每个 CPU 的统计,因此普通枚举会跳过它。 持久化由具体加载器完成 运行时赋值不会替你生成持久配置文件;反过来,保存配置片段也不会立即改变内核。讨论优先级之前,先确定系统在启动或重载时由哪个组件应用设置。 procps 的 --system 会先根据目录优先级选出文件:高优先级目录中的同名文件遮蔽低优先级副本。选中的片段随后按文件名的字典序应用,不按目录逐层决定最终值;最后再读取 /etc/sysctl.conf。systemd 的 sysctl.d 加载器也结合目录优先级和文件名排序,但不直接读取 /etc/sysctl.conf。发行版仍可能通过配置目录中的链接把这个文件纳入加载范围,因此要检查实际安装,不能只凭路径名称下结论。 ...

十一月 22, 2026 · 1 分钟 · XB Field Notes
每次调用 logrotate 可把符合条件的活动日志移入第一代,再把前一代变为压缩的第二代。保留两代与大小阈值不会持续限制磁盘占用。

Logrotate 在执行时应用轮转策略

Logrotate 在被调用时读取配置,轮转符合条件的日志,并执行选定的保留与压缩动作。何时检查由定时器或其他调度器决定,它不会持续限制文件大小,改名也不会自动重定向应用的打开描述符。完整设置需要把检查频率、文件策略和写者实际重开行为结合起来,才能判断日志确实按预期流转。 文件框表示代数与描述符目标,不表示时间或字节比例。HUP 在此有效是因为样例显式实现了重开,并非所有应用的共同约定。CLI 结果均为历史记录,本次未重跑。当前文章审核仍待完成;技术与执行检查仍受阻。 坏掉的样子 两次执行之间可能超过 size 阈值,突发写入或调度停止时尤其明显。rotate 限制的是保留代数,不是历史时间,也不覆盖全部临时磁盘使用。因此,阈值乘代数不是硬容量保证;活动文件、检查间隔内增长,以及压缩或复制工作,都需要空间。只看到配置里有两个有限数字,不能证明磁盘不会耗尽。 create 指定替代文件的模式和所有权,不会向应用发送信号。postrotate 可以调用受支持的重开机制,但 HUP 并不普遍表示重载。delaycompress 只把压缩推迟一个轮转周期,无法修复从不放弃旧 inode 的写者。每项指令负责不同动作,需要核对它们是否共同完成实际交接。 脚本可能在文件操作已经成功后失败。Logrotate 返回非零,不会回滚全部改名和创建;未检查文件及写者状态就反复强制轮转,可能再次切分或删除需要的代数。应诊断已经发生的部分结果,而不是把失败理解成没有变化。错误恢复也必须依据当前事实,而不能只看最后退出码。 快速验证 2026-10-19 修复草案:修订后的代码只进行了静态检查与离线命令/文件状态替身测试,没有执行 logrotate 或发送信号。技术与实际运行验证仍然受阻。原始示例的历史运行使用 Linux 6.12.76、Python 3.11.2、logrotate 3.21.0 与 ext4;这些观察都不能验证本次修订。 未来执行时,应把代码保存为 logrotate-cli.py,置于另行授权的一次性 Linux 实验环境,预装 Python 3.11+、logrotate 3.21.0 和 gzip,不挂载宿主目录,也不承载其他工作。独立核实边界后,才可用 root 执行 python3 logrotate-cli.py --isolated-disposable-lab。参数与 root 身份是前提,不是隔离证明;结束后应丢弃整个环境。 ...

十一月 21, 2026 · 4 分钟 · XB Field Notes
三条 cgroup 控制器路径分别表示 CPU 带宽节流、内存回收及可能的 OOM、任务创建拒绝;预算属于同一层次。

cgroup v2 的不同资源预算有不同失败方式

cgroup v2 用统一层次组织资源控制。一个工作负载的多个进程可以共享合计预算,限制还会沿层次继承,而不是只对某个特定 PID 生效。CPU 带宽、内存用量和任务数量由不同控制器管理,执行方式也不同。读取真实 cgroup 文件和失败计数器,比把所有配置上限都当作同一种“到点就停”更能解释实际行为。 坏掉的样子 cpu.max 按周期限制 CPU 带宽。控制组用完配额后,可运行工作可能被节流,但这不会预留一颗专用处理器,也不保证某个请求延迟。多个线程可以很快消耗共享配额。CPU 权重则影响竞争情况下的相对分配;混淆权重、配额和 CPU 亲和性,会让容量规划建立在错误预期上。 内存上限不会统一把大分配变成可由应用干净处理的错误。到达 memory.max 时,内核可以回收内存;无法降低用量时,可能触发控制组 OOM 机制。某些分配路径还有其他失败方式。memory.high 施加回收压力和节流,本身不会调用 OOM,因此不能把这两个设置解释成同义的硬终止阈值。 任务上限也包括线程,不只是监控面板显示的应用进程数量。达到 pids.max 后,即使 CPU 和内存看似充足,fork 或 clone 仍可能失败。层次限制意味着子组配置更大,也不能简单覆盖祖先更紧的预算。解释可见文件时,需要确认对应路径与控制器委派关系,不能只读取一个数值就断定完整有效上限。 CPU 带宽、内存压力与任务计数使用不同上限;退出码 137 不能单独证明 OOM。 快速验证 先取得固定镜像,再用 Python 和采用 cgroup v2 的 Docker 守护进程运行脚本。实测为 Docker 29.3.1、Linux 6.12.76、Python 3.11.16。所有限制只施加到可丢弃实验容器。脚本执行受限的小规模内存耗尽测试,清理自己的容器,不改变宿主全局 cgroup 设置,也不修改其他服务的预算: ...

十一月 10, 2026 · 2 分钟 · XB Field Notes
精确 /api/echo 路由经过代理到达 /echo,另一条同网络路径绕过代理。

反向代理只对真正经过它的流量执行入口策略

反向代理接收客户端请求,再与应用进行独立的上游交换。配置相应行为后,它可以集中路由、传输、请求头和准入策略,却不会自动隐藏所有后端接口、认证所有调用者或终止 TLS。真正有用的边界,是流量实际经过哪里、在那里执行什么策略,而不是代理在架构图上画在应用前面。描述能力时应对应配置和真实调用路径。 坏掉的样子 如果后端还能通过另一条路径到达,只放在代理上的控制就可能被绕过。不发布容器端口减少了一种暴露途径,但连接到相同网络的其他工作负载仍可能访问应用。如果后端授权完全依赖“所有流量已经经过公开代理”这个假设,内部直连请求就可能让假设失效。需要检查真正可达的调用者,而不是只检查公网端口列表。 URI 映射可能产生更隐蔽的绕过。代理阻止 /admin,并不代表宽泛的 /api/ 映射不会通过 /api/admin 暴露同一个后端处理器。应审阅最终上游 URI,而不只看客户端可见路径。公开一个明确端点,与在方便的前缀下公开所有处理器是不同决定,不能期待两个外部名称不同就天然对应不同权限边界。 转发元数据需要明确处理。客户端可能发送伪造地址或协议头,代理把它继续转发,并不会让它自动可信。应用需要知道可信一跳设置哪些字段,以及哪些调用者可以直接访问自己。浏览器响应头也不能替代请求授权或网络可达性控制。检查响应头存在,只能证明对应字段被发送,不能推断其他安全责任已经完成。 精确反向代理路由:机制与证据边界。 快速验证 保存为 check-proxy-entry.py;需要 Python、本机 Docker 和预先加载的固定镜像。作者报告使用 nginx 1.28.0 与可丢弃回显应用;精确历史输出不可用,本轮没有重跑。显式检查在优化模式下仍执行。只有代理绑定宿主回环,实验只涉及 HTTP。Docker 调用/构建分别限时 30/120 秒,就绪探测期限更短。清理检查自有容器、标签及网络;删除或 daemon 完成状态无法确认时都会失败: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,ipaddress,json,socket,subprocess,time,uuid def require(condition, message): if not condition: raise AssertionError(message) uncertain_commands = [] NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True,timeout=30): try: result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=timeout) except subprocess.TimeoutExpired as error: uncertain_commands.append(args) raise RuntimeError('Docker command timed out: '+args[0]) from error except KeyboardInterrupt: uncertain_commands.append(args) raise if check:require(result.returncode==0,result.stderr) return result def command(*args): return docker(*args).stdout.strip() def cleanup_owned(groups): # Inspect exact owned names; absence is not inferred from a failed inspect. errors = [] for kind, names, format_string in groups: try: listing = command(kind, "ls", *( ["--all"] if kind == "container" else [] ), "--format", format_string).splitlines() present = sorted(set(names) & set(listing)) if present: command(kind, "rm", *( ["--force"] if kind == "container" else [] ), *present) except Exception as error: errors.append(f"{kind} removal: {error}") try: remaining = command(kind, "ls", *( ["--all"] if kind == "container" else [] ), "--format", format_string).splitlines() if set(names) & set(remaining): errors.append(f"{kind} remains: {sorted(set(names) & set(remaining))}") except Exception as error: errors.append(f"{kind} inventory unverified: {error}") if uncertain_commands: errors.append("A Docker command timed out or was interrupted; daemon-side completion remains uncertain") require(not errors, "Cleanup not verified: " + "; ".join(errors)) print(json.dumps({"cleanup": "owned names absent in final inventories"}), flush=True) def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None,extra_ports=()): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend',name+'-check',name+'-direct'] print(json.dumps({"owned_containers":containers,"owned_tags":tags,"owned_network":name}),flush=True) try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('image','inspect',NGINX) if backend is not None:docker('image','inspect',PYTHON) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--pull=false','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root),timeout=120) docker('run','--pull=never','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False,timeout=5).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--pull=false','--network','none','-t',tags[0],str(root),timeout=120) docker('run','--pull=never','--rm','--name',containers[2],'--network',name,tags[0],'nginx','-t') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','--pull=never','-d','--name',containers[0],'--network',name,*published,tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];require(binding['HostIp']=='127.0.0.1','non-loopback binding');port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} yield containers[0],request finally: cleanup_owned((("container", containers, "{{.Names}}"), ("image", tags, "{{.Repository}}:{{.Tag}}"), ("network", (name,), "{{.Name}}"))) def config(locations,extra=''): return ("worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\n" "log_format peer escape=json '{\"id\":\"$request_id\",\"peer\":\"$remote_addr\"," "\"supplied_xff\":\"$http_x_forwarded_for\"}';\n" "access_log /tmp/peer.log peer;\n"+extra+ "\nserver { listen 8080; root /srv;\n"+locations+"\n}\n}\n") def observed_peer(log_text,request_id,forged): require(bool(request_id), 'missing request ID') rows=[json.loads(line) for line in log_text.splitlines() if line.strip()] matches=[row for row in rows if row.get('id')==request_id] if not matches:return None require(len(matches)==1, 'duplicate request ID') row=matches[0] require(row.get('supplied_xff')==forged, 'wrong request matched') peer=str(ipaddress.ip_address(row['peer'])) require(peer!=forged, 'test peer collided with forged control') return peer backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import json class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def respond(self,body=b''): data=json.dumps({'path':self.path,'method':self.command,'body':body.decode(), 'host':self.headers.get('Host'),'forwarded_for':self.headers.get('X-Forwarded-For'), 'forwarded_proto':self.headers.get('X-Forwarded-Proto')}).encode() self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data))) self.end_headers();self.wfile.write(data) def do_GET(self):self.respond() def do_POST(self):self.respond(self.rfile.read(int(self.headers['Content-Length']))) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location = /api/echo { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://backend:8081/echo; } location /admin { deny all; } location / { return 404; } ''' extra='client_max_body_size 1k; add_header X-Content-Type-Options nosniff always; add_header X-Lab-Request-Id $request_id always;' bad='events {} http { xbcatm_invalid_candidate; }' with lab(config(locations,extra),{'invalid.conf':bad},backend) as (proxy,request): status,h,b=request('/api/echo',method='POST',body=b'hello',headers={'Host':'edge.example', 'X-Forwarded-For':'192.0.2.123','X-Forwarded-Proto':'https'}) data=json.loads(b) require(status==200 and data['path']=='/echo' and data['body']=='hello' and data['method']=='POST','route/body mismatch') peer_rows=[] def peer_ready(): peer=observed_peer(docker('exec',proxy,'cat','/tmp/peer.log',timeout=5).stdout, h.get('x-lab-request-id'), '192.0.2.123') if peer is not None:peer_rows.append(peer);return True return False wait(peer_ready) require(data['host']=='edge.example' and data['forwarded_for']==peer_rows[0] and data['forwarded_proto']=='http','forwarded-header mismatch') require(h['x-content-type-options']=='nosniff','success header missing') results={'proxied_request':data,'proxy_observed_peer':peer_rows[0]} status,h,_=request('/admin') require(status==403 and h['x-content-type-options']=='nosniff','admin rejection mismatch') results['admin_at_proxy']=status require(request('/api/echo',method='POST',body=b'x'*2048)[0]==413,'body limit mismatch') require(request('/unknown')[0]==404 and request('/api/admin')[0]==404,'route rejection mismatch') results['size_and_route_rejections']=[413,404];results['admin_alias_rejected']=404 details=json.loads(docker('inspect',request.network+'-backend').stdout)[0] require(not details['HostConfig']['PortBindings'],'backend published to host') code=('import json,http.client; c=http.client.HTTPConnection("backend",8081,timeout=5); ' 'c.request("GET","/admin"); r=c.getresponse(); ' 'print(json.dumps({"status":r.status,"data":json.loads(r.read())})); c.close()') internal=docker('run','--pull=never','--rm','--name',request.network+'-direct', '--network',request.network,PYTHON,'python','-c',code) direct=json.loads(internal.stdout) require(direct['status']==200 and direct['data']['path']=='/admin','direct backend control failed') results['backend_has_host_port_binding']=False;results['internal_direct_path']=direct['data']['path'] require(docker('exec',proxy,'cat','/srv/invalid.conf').stdout==bad,'candidate bytes changed') docker('exec',proxy,'nginx','-t','-c','/etc/nginx/nginx.conf') probe=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False) diagnostic='nginx: [emerg] unknown directive "xbcatm_invalid_candidate" in /srv/invalid.conf:1' require(probe.returncode==1 and diagnostic in probe.stderr.splitlines() and 'nginx: configuration file /srv/invalid.conf test failed' in probe.stderr.splitlines(), 'expected candidate-specific nginx rejection not established: '+probe.stderr) results['invalid_candidate_diagnostic']=probe.stderr require(request('/api/echo')[0]==200,'live config unavailable after candidate check') results['invalid_candidate_rejected_live_config_survives']=True # Results are printed only after the context manager's checked cleanup returns. print(json.dumps(results,sort_keys=True)) 被中断或超时的命令即使随后遇到空清单,状态仍然不确定。清理失败会通过异常链保留原始异常。再次中断或进程突然死亡可能阻止清理完成;最终清单无法排除延迟完成的 daemon 操作或并发变化。 ...

十一月 5, 2026 · 4 分钟 · XB Field Notes
迁移中旧新结构重叠存在,直到收缩边界才退役旧结构。

零停机迁移在旧代码运行时就改表结构

零停机迁移是兼容性与可用性目标,不是把语句变短之后自动获得的保证。数据变化时,新旧应用可能同时运行,因此需要一起安排表结构、写入行为、回填、读取切换以及恢复方式。删除信息并不会因为前面采用了增量步骤就变得可逆;是否还能回退,取决于旧程序需要的数据是否仍然存在且正确。 取锁等待、行级冲突、资源消耗和应用假设都重要。只改元数据的操作也可能被别的事务挡住;回填没有持有排他表锁,也可能耗尽磁盘吞吐或复制容量。验收不能只测一条语句有多快,还需要观察并发业务、队列等待与恢复条件。 先扩展,再回填,后收缩 扩展阶段引入旧读写方能够容忍的新表示。先部署兼容写入方,再依赖新数据;回填存量行并验证不变量后,才切换读取。收缩必须等旧二进制、后台任务和回退需求都不再依赖旧结构。增加可空列经常有用,但仍需取锁和兼容性检查,不能把任何增加列操作都宣布为绝对安全。 双写必须定义失败与先后顺序。同一数据库事务中更新两列,可以维持一致性;分开提交则可能只成功一半。仍然只更新旧列的老写入方,会让已经回填的新列再次过时。因此需要先退役这些写入方,或提供经过验证的同步机制,再宣布已经收敛。回滚只有在它所依赖的旧表示仍然准确时才安全,不是打开双写就自动保证不丢数据。 回填应采用可重启的小批次和稳定进度键。需要防止旧快照覆盖并发应用刚写入的新值,例如事务内检查版本号,或使用符合业务不变量的条件更新。仅检查新列为空并非通用答案,它是否足够取决于写入行为。跳过的行和并发变化的行还要复查,写入方收敛之后再次验证。分批缩短单次事务,却可能增加总工作量,更不能替代正确的发布顺序。 锁是其中一道失败边界 PostgreSQL 的 ACCESS EXCLUSIVE 会与普通读写冲突。许多 ALTER TABLE 形式即使不用重写数据,也要获取它。普通 UPDATE 则获取 ROW EXCLUSIVE 和行锁,不会因此把所有其他写入方都全局排除。具体冲突取决于命中的行和并发操作;长事务会一直持有相关锁,直到事务结束,而不是语句返回后就全部释放。 排队中的强锁可能继续延迟后来的冲突请求。应识别实际阻塞关系、事务边界与等待事件,不能仅凭哪个事务最老就认定它是原因。SQLite 的回滚日志与 WAL 模式属于不同引擎契约。旧文中的毫秒和页数对比,在本次审阅中没有建立精确基准证据;它们不能当作 PostgreSQL 预测,也不是本轮测量结果。 在较弱锁下验证 以 PostgreSQL 17 为例,只有当前写入方已经满足条件时,才添加合适的 CHECK ... NOT VALID。这个选项延后检查旧行,却立即约束新的插入和更新。随后 VALIDATE CONSTRAINT 用 SHARE UPDATE EXCLUSIVE 扫描,它允许普通数据读写,但会与部分维护和结构操作冲突。因此验证并非完全不取锁,也不是对所有并发活动都没有影响。 ...

十一月 3, 2026 · 1 分钟 · XB Field Notes
新工作可进入绿色环境,已有会话仍留在蓝色。切回路由不会撤销已接受的写入。

蓝绿部署让两套环境随时待命

蓝绿部署在独立环境中准备新应用版本,同时让旧版本继续服务流量。就绪检查通过后,路由把新的工作导向新环境。保留旧环境可以缩短回滚,因为恢复不必等待重新构建。但这个模式本身并不保证全部依赖都有两份、切换瞬间完成,或每一项数据修改都可以撤销。发布方案必须明确哪些东西被切换,以及哪些状态仍然共享。 路由选择与状态兼容是不同问题。模型解释版本重叠及模式边界,并非真实路由器测量,也不保证恢复安全。 定义切换究竟控制什么 路由机制可能是负载均衡后端、服务选择器,也可能是 DNS。它们的传播与连接语义不同。修改一个控制面对象,不等于所有数据面实例在同一瞬间采用新值。权威配置已经改变后,DNS 缓存与既有连接仍可能访问旧地址,因此配置写入成功只能作为开始观察的时间点,不能直接作为全局完成证据。 应明确路由选择发生在每次请求、每条连接、每个会话,还是队列任务这一层。长连接可能继续留在蓝色环境,新连接则进入绿色;另一些代理会为每个请求重新选择上游。因此,最长客户端连接不一定决定所有部署的排空时间。需要记录实际机制及其超时、终止行为,并知道强制关闭会怎样影响正在执行的工作。 两套版本都应有能力承担分配给自己的流量与依赖,这并不意味着软件版本必须相同,也不意味着整个系统永远精确支付双倍费用。容量可以分阶段准备,数据库或辅助服务也可能共享。节省资源会改变恢复条件:若旧环境已经缩容到很小,切回路由也无法立刻得到原来的完整服务能力。 在重叠窗口内保持数据兼容 共用数据库很常见,但它是设计选择,不是蓝绿部署的定义。使用两个数据库会引入复制、同步和数据对账问题。反转路由不能抹去绿色环境已经接受的写入,也不能安全地凭空补出蓝色环境缺失的事务。应像定义请求流向一样,明确每类数据在切换和回滚时怎样处理。 共用存储时,可以用先扩展再收缩的方式支持版本重叠。先增加兼容字段,部署能容忍两种表示的读写逻辑,必要时回填数据,最后再删除旧结构。仅等待旧 HTTP 连接结束还不够:旧工作进程、定时任务、回滚版本以及外部消费者,都可能仍依赖旧模式。清理条件应覆盖这些实际使用者。 兼容性不仅是列名存在。两个版本可能都能解析字段,却把相同数值解释成不同单位。迁移可能导致查询报错、写入不一致,也可能造成稍后才暴露的数据损坏;不能断言一定损坏,也不能假定总会干净地拒绝操作。宣布回滚安全之前,需要执行具有代表性的读写路径,并检查业务结果而不只是健康检查。 坏掉的样子 切换期间看到两个版本,并不自动说明部署失败。排空、逐步传播或有意分流,都可能产生这种正常现象。只有客户端或共享状态无法容忍重叠时,它才成为故障。可在适当诊断中记录发布标识,然后将错误与路由状态、连接年龄及版本兼容性关联,避免把所有差异都归因于非原子切换。 反过来,同一个客户端连续五十次看到绿色,也不能证明全局原子性。它可能始终访问同一个边缘节点,或复用同一条连接。就绪副本数量也不能证明服务选择器、端点列表、入口控制器以及外部负载均衡器都指向这些副本。需要检查完整请求路径,并从具有代表性的客户端位置验证实际结果。 不要因为切换命令成功,就立即关闭蓝色环境。应预先定义回滚窗口,监控错误和延迟变化,并在不兼容清理前确认旧后台工作已经退出。如果回滚所依赖的状态已经不可逆改变,就需要前向修复或演练过的数据恢复,而不是反复拨动路由。保留一套进程,并不等于保留了所有恢复前提。 快速验证 用 Python 3 执行下面模型,保持断言开启。代码只改变内存字典:两个路由实例分别更新,已有会话保留当初选定的环境,而模式契约决定两个版本能否运行。这是明确写出的模型,并非某个负载均衡器的测量结果,也不是 Kubernetes 集成测试。 routers = {"edge-a": "blue", "edge-b": "blue"} existing = {"session-1": "blue", "session-2": "blue"} routers["edge-a"] = "green" assert set(routers.values()) == {"blue", "green"} routers["edge-b"] = "green" assert set(routers.values()) == {"green"} assert set(existing.values()) == {"blue"} print("new routes green; existing sessions still blue") required = {"blue": {"amount_cents"}, "green": {"amount_cents", "currency"}} def compatible(schema, colour): return required[colour] <= schema schema = {"amount_cents", "currency"} assert all(compatible(schema, colour) for colour in required) routers = dict.fromkeys(routers, "blue") assert compatible(schema, "blue") print("rollback remains schema-compatible") # A destructive replacement can invalidate the old version. required["green"] = {"amount_minor", "currency"} schema = {"amount_minor", "currency"} assert compatible(schema, "green") assert not compatible(schema, "blue") print("routing rollback cannot restore a removed column") 第一个断言暴露了路由实例更新到一半时的混合状态。即使两个实例都选择绿色,模型中的旧会话仍留在蓝色。追加字段后的模式支持切回旧版本,而后来破坏性的字段替换不支持。三行输出分别指出这些边界,不给出并不存在的通用切换时长,也不把进程存活等同于数据兼容。 ...

十一月 2, 2026 · 1 分钟 · XB Field Notes
示例阶段按 1%、10%、50%、100% 逐级放量。继续需要证据;证据不足时暂停。

渐进发布需要证据与恢复路径

渐进发布一边扩大新版本的暴露范围,一边检查行为是否可以接受,可能在所有请求都切换之前发现退化。它不会自动让改动可逆,不保证只有选定比例的用户受到影响,也不一定需要功能开关。流量路由、实例替换和功能暴露是不同控制,可以共同参与一份发布计划。真正需要说明的是谁先接触变化、根据什么证据继续,以及异常发生之后怎样恢复服务。 虚构计数和阈值用于解释决策规则,并非部署实验或生产服务目标。真实发布需要合适群体、新鲜且有代表性的测量、观察时长,以及经确认的恢复路径。 明确人群及其共享依赖 百分比需要分母:请求、用户、租户和实例并不是一回事。百分之一的实例不一定处理百分之一的流量,一个繁忙客户也可能占据大部分请求样本。应选择能够覆盖相关工作负载的群体,而不是把数量很少当成安全证明。一个工作流需要多次请求都落在同一版本时,还要保持一致的分配,避免新旧行为在流程中交替出现。 新旧人群可能共享数据库、队列、缓存或配额。故障金丝雀能够耗尽共同依赖,也可能写入不兼容数据,使旧版本同样受影响。因此,百分之一的放量并不能把事故影响严格限制在百分之一。确定阶段之前,应盘点共享故障路径,尤其注意跨租户状态、公共连接池和异步消息,不能只看入口分流比例就宣布其他用户完全不会感知。 收集足以支持决定的测量 按金丝雀人群拆分错误率和延迟,与合适的对照群体比较,同时保留绝对的服务目标。如果共同依赖让两个版本都坏了,二者指标相近不能证明健康。发布前就应定义观察窗口、最小流量、相关路由,以及测量缺失或过旧时的处理。十个请求没有错误,与大量代表性请求没有错误,证据强度不同;分母不足时不应把零直接解释成通过。 阶段时长应覆盖准备评估的效应,例如启动、缓存预热、长任务、延迟写入及业务周期。三十秒既不是天然足够,也不是天然没有价值。承接一半流量也不能证明满量容量,因为非线性瓶颈可能在后面才出现。满量之后仍需监控,并明确何时算发布完成。观察不是等待一个固定数字过去,而是取得足够新鲜、相关、可解释的证据。 坏掉的样子 只靠定时器的发布,即使没有取得有效测量也会继续晋升。证据不足时应暂停,由明确负责人在约定时间内调查,不能悄悄按成功处理。自动回滚在触发条件与恢复动作都经过验证时可以缩短响应时间,但有些故障需要值班人员判断。自动化是一种执行手段,不会证明恢复操作本身没有风险;暂停也不能无限无人接手。 另一种失败,是认为恢复旧二进制就能恢复旧状态。数据库模式、已持久化格式、已经发出的消息和外部副作用,都可能在流量切回以后继续存在。应保留仍兼容的旧产物,并演练真实恢复路径。如果反向切换不安全,应准备向前修复或业务对账方案,不要展示一个实际上无法恢复服务的回滚按钮。发布可逆性来自这些设计条件,而不是来自阶段名称。 全量平均会掩盖局部退化。假设旧版本错误率为零,处理十分之一请求的金丝雀出现百分之十五错误,摊到全量后只有百分之一点五。算术上的稀释并没有使故障人群变得可以接受。需要同时观察群体拆分、业务结果和整体健康,而且对照组自身也必须正常。不同人群的流量类型差异,还可能让简单比较失去解释力。 快速验证 保存为 rollout_check.py,用 Python 3.10 或更新版本执行 python3 rollout_check.py。2026-10-19 以 Python 3.12.14 完整运行原样程序并通过。下面是使用虚构请求计数的确定性决策模型,不是部署控制器;它不会改变路由,不等待真实遥测,也不实际执行回滚。最小请求数和错误阈值都是教学参数,不应直接作为生产服务目标。 from dataclasses import dataclass STAGES = (1, 10, 50, 100) MIN_REQUESTS = 1000 MAX_ERROR_RATE = 0.02 # Illustrative gate, not an SLO error budget. @dataclass(frozen=True) class Sample: requests: int errors: int def decide(sample): if not 0 <= sample.errors <= sample.requests: raise ValueError("invalid request/error counts") if sample.requests < MIN_REQUESTS: return "PAUSE", None rate = sample.errors / sample.requests return ("ROLLBACK_REQUESTED" if rate > MAX_ERROR_RATE else "PROMOTE"), rate def plan(samples): history = [] for stage in STAGES: decision, rate = decide(samples[stage]) history.append((stage, decision, rate)) if decision != "PROMOTE": break return history healthy = plan({stage: Sample(2000, 10) for stage in STAGES}) faulty = plan({stage: Sample(2000, 300) for stage in STAGES}) missing = plan({stage: Sample(0, 0) for stage in STAGES}) sparse = plan({stage: Sample(100, 0) for stage in STAGES}) assert [row[0] for row in healthy] == list(STAGES) assert all(row[1] == "PROMOTE" for row in healthy) assert faulty == [(1, "ROLLBACK_REQUESTED", 0.15)] assert missing == sparse == [(1, "PAUSE", None)] assert decide(Sample(1000, 20)) == ("PROMOTE", 0.02) assert decide(Sample(1000, 21)) == ("ROLLBACK_REQUESTED", 0.021) for sample in (Sample(-1, 0), Sample(10, -1), Sample(10, 11)): try: decide(sample) except ValueError: pass else: raise AssertionError("invalid sample accepted") # A 10% canary at 15%, with a zero-error control, gives 1.5% fleet errors. fleet_rate = 0.1 * 0.15 + 0.9 * 0.0 assert fleet_rate < MAX_ERROR_RATE for label, history in (("healthy", healthy), ("faulty", faulty), ("missing", missing), ("sparse", sparse)): print(label, history) print(f"fleet dilution: {fleet_rate:.1%}; rollback was requested, not executed") 健康样本会通过四个阶段,故障样本在第一级返回请求回滚。没有测量与只有一百个请求的零错误样本都会暂停。边界断言允许恰好百分之二,拒绝百分之二点一;不合法的请求和错误计数则触发校验失败。这些检查说明代码如何决策,没有证明阈值具有统计合理性,也没有测量不同流量分配是否真正落实。 ...

九月 18, 2026 · 2 分钟 · XB Field Notes