标题卡写着“时间戳需要明确语义”,配有标注 OBSERVE、DECIDE、ACT 的三个相连方框。

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

像 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。这个选择需要明确,因为跨越时钟切换时,本地表盘读数之差可能与真正经过的秒数不同,二者都不能无条件替代另一种含义。 快速验证 将下面程序保存为 timezone.py,使用 Python 3.11 或更新版本执行 python3 timezone.py,并确保环境中有 IANA 时区数据。实测环境是 Python 3.11.2 和 Debian tzdata 2026b。程序全部使用固定日期,不会修改机器的时钟,也不会更改系统时区;不同数据库版本需要重新检查相应规则。 ...

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

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
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

Logrotate 在执行时应用轮转策略

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

十一月 21, 2026 · 2 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边是关于cgroup v2 资源预算的标题。

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

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

十一月 10, 2026 · 2 分钟 · XB Field Notes
用于审查反向代理路由的观察、决策和执行面板。

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

反向代理接收客户端请求,再与应用进行独立的上游交换。配置相应行为后,它可以集中路由、传输、请求头和准入策略,却不会自动隐藏所有后端接口、认证所有调用者或终止 TLS。真正有用的边界,是流量实际经过哪里、在那里执行什么策略,而不是代理在架构图上画在应用前面。描述能力时应对应配置和真实调用路径。 坏掉的样子 如果后端还能通过另一条路径到达,只放在代理上的控制就可能被绕过。不发布容器端口减少了一种暴露途径,但连接到相同网络的其他工作负载仍可能访问应用。如果后端授权完全依赖“所有流量已经经过公开代理”这个假设,内部直连请求就可能让假设失效。需要检查真正可达的调用者,而不是只检查公网端口列表。 URI 映射可能产生更隐蔽的绕过。代理阻止 /admin,并不代表宽泛的 /api/ 映射不会通过 /api/admin 暴露同一个后端处理器。应审阅最终上游 URI,而不只看客户端可见路径。公开一个明确端点,与在方便的前缀下公开所有处理器是不同决定,不能期待两个外部名称不同就天然对应不同权限边界。 转发元数据需要明确处理。客户端可能发送伪造地址或协议头,代理把它继续转发,并不会让它自动可信。应用需要知道可信一跳设置哪些字段,以及哪些调用者可以直接访问自己。浏览器响应头也不能替代请求授权或网络可达性控制。检查响应头存在,只能证明对应字段被发送,不能推断其他安全责任已经完成。 快速验证 将脚本保存为 check-proxy-entry.py,准备 Python、Docker 和固定镜像后运行。此前记录的环境使用 nginx 1.28.0,以及专用 Docker 网络里的可丢弃回显应用;本轮没有重跑。只有代理绑定宿主回环端口。实验使用 HTTP 检查路由与策略,并没有执行 TLS 部署;结束后清理自己的容器、镜像标签和网络,不改动已有服务: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result 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'] 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('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','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-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).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--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','-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];assert binding['HostIp']=='127.0.0.1';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: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' 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;' bad='events {} http { server { listen not-a-port; } }' 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);assert status==200 and data['path']=='/echo' and data['body']=='hello' and data['method']=='POST' assert data['host']=='edge.example' and data['forwarded_for']!='192.0.2.123' and data['forwarded_proto']=='http' assert h['x-content-type-options']=='nosniff';results={'proxied_request':data} status,h,_=request('/admin');assert status==403 and h['x-content-type-options']=='nosniff';results['admin_at_proxy']=status assert request('/api/echo',method='POST',body=b'x'*2048)[0]==413 assert request('/unknown')[0]==404 and request('/api/admin')[0]==404 results['size_and_route_rejections']=[413,404];results['admin_alias_rejected']=404 details=json.loads(docker('inspect',request.network+'-backend').stdout)[0] assert not details['HostConfig']['PortBindings'] code='import json,urllib.request;print(json.dumps(json.load(urllib.request.urlopen("http://backend:8081/admin"))))' internal=docker('run','--rm','--network',request.network,PYTHON,'python','-c',code) direct=json.loads(internal.stdout);assert direct['path']=='/admin' results['backend_has_host_port_binding']=False;results['internal_direct_path']=direct['path'] probe=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False);assert probe.returncode!=0 assert request('/api/echo')[0]==200;results['invalid_candidate_rejected_live_config_survives']=True print(json.dumps(results,sort_keys=True)) POST 到精确公开路径 /api/echo 后,以原方法和正文到达后端 /echo。提供的 Host 按明确配置转发;实验没有验证主机名允许名单,不能单凭该字段把请求认定为某个可信租户。伪造 X-Forwarded-For 被替换为 nginx 观察到的套接字对端,伪造的 https 协议被替换为这个监听实际使用的 http。响应揭示后端真正收到的字段,而不是只根据指令存在就假定效果正确。 ...

十一月 5, 2026 · 2 分钟 · XB Field Notes
表结构先加后改,分阶段走,流量始终不中断。

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

一条语句就重写完繁忙大表的迁移,既耗时长又全程加锁。把它换成一系列小而向后兼容的改动,同一份表结构照样能往前推,而且不需要停机窗口——原因不是技巧,而是每一步都能单独部署、单独回退。 真正被管理的东西是一把锁。每一次结构变更都会取锁;问题只在于锁被拿多久,而答案取决于这条语句要碰多少数据。 先扩展,再回填,后收缩 三个阶段:扩展先加上新列,同时旧结构继续可用;回填负责把数据灌进去,而且必须分批;收缩只在没有任何代码再读旧结构之后,才把它请出去。 夹在扩展和回填之间的是双写阶段:应用会同时写新旧两列,这样回退不丢任何东西,等新列可信了再把读切过去。 旧代码还需要旧结构,于是顺序规则也就定了:加一个可空列是安全的,删列不安全,改名要拆成两步,NOT NULL 永远排在回填之后。在一张 50 万行的 sqlite 表上,ADD COLUMN note TEXT 耗时 2.0 ms,页数仍停在 13926——这是一次只改元数据的编辑。把它改成 NOT NULL 则必须真的重写:115.4 ms,页数从 13926 变成 28532。同一个结构变更,两种代价,区别只在于引擎要不要碰每一行。 分批比阶段顺序更重要。50 万行放在一个事务里回填,写锁被持有 671.2 ms;同样这些行按每批 2 万切分,共 25 个事务,锁持有时间中位数 7.3 ms、p90 是 7.8 ms。总工作量完全一样——不同的只有最长的那一次持锁,而那恰好就是应用真正感受到的数字。 锁才是全部问题 PostgreSQL 的 ACCESS EXCLUSIVE 与所有其他锁模式都冲突,包括一条裸 SELECT 取的 ACCESS SHARE。TRUNCATE、VACUUM FULL、不带 CONCURRENTLY 的 REFRESH MATERIALIZED VIEW,以及很多 ALTER TABLE 形式都会取它。读者和写者一起等。 ...

十一月 3, 2026 · 2 分钟 · 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
用于渐进发布审查的观察、决策和执行面板。

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

渐进发布一边扩大新版本的暴露范围,一边检查行为是否可以接受,可能在所有请求都切换之前发现退化。它不会自动让改动可逆,不保证只有选定比例的用户受到影响,也不一定需要功能开关。流量路由、实例替换和功能暴露是不同控制,可以共同参与一份发布计划。真正需要说明的是谁先接触变化、根据什么证据继续,以及异常发生之后怎样恢复服务。 明确人群及其共享依赖 百分比需要分母:请求、用户、租户和实例并不是一回事。百分之一的实例不一定处理百分之一的流量,一个繁忙客户也可能占据大部分请求样本。应选择能够覆盖相关工作负载的群体,而不是把数量很少当成安全证明。一个工作流需要多次请求都落在同一版本时,还要保持一致的分配,避免新旧行为在流程中交替出现。 新旧人群可能共享数据库、队列、缓存或配额。故障金丝雀能够耗尽共同依赖,也可能写入不兼容数据,使旧版本同样受影响。因此,百分之一的放量并不能把事故影响严格限制在百分之一。确定阶段之前,应盘点共享故障路径,尤其注意跨租户状态、公共连接池和异步消息,不能只看入口分流比例就宣布其他用户完全不会感知。 收集足以支持决定的测量 按金丝雀人群拆分错误率和延迟,与合适的对照群体比较,同时保留绝对的服务目标。如果共同依赖让两个版本都坏了,二者指标相近不能证明健康。发布前就应定义观察窗口、最小流量、相关路由,以及测量缺失或过旧时的处理。十个请求没有错误,与大量代表性请求没有错误,证据强度不同;分母不足时不应把零直接解释成通过。 阶段时长应覆盖准备评估的效应,例如启动、缓存预热、长任务、延迟写入及业务周期。三十秒既不是天然足够,也不是天然没有价值。承接一半流量也不能证明满量容量,因为非线性瓶颈可能在后面才出现。满量之后仍需监控,并明确何时算发布完成。观察不是等待一个固定数字过去,而是取得足够新鲜、相关、可解释的证据。 坏掉的样子 只靠定时器的发布,即使没有取得有效测量也会继续晋升。证据不足时应暂停,由明确负责人在约定时间内调查,不能悄悄按成功处理。自动回滚在触发条件与恢复动作都经过验证时可以缩短响应时间,但有些故障需要值班人员判断。自动化是一种执行手段,不会证明恢复操作本身没有风险;暂停也不能无限无人接手。 另一种失败,是认为恢复旧二进制就能恢复旧状态。数据库模式、已持久化格式、已经发出的消息和外部副作用,都可能在流量切回以后继续存在。应保留仍兼容的旧产物,并演练真实恢复路径。如果反向切换不安全,应准备向前修复或业务对账方案,不要展示一个实际上无法恢复服务的回滚按钮。发布可逆性来自这些设计条件,而不是来自阶段名称。 全量平均会掩盖局部退化。假设旧版本错误率为零,处理十分之一请求的金丝雀出现百分之十五错误,摊到全量后只有百分之一点五。算术上的稀释并没有使故障人群变得可以接受。需要同时观察群体拆分、业务结果和整体健康,而且对照组自身也必须正常。不同人群的流量类型差异,还可能让简单比较失去解释力。 快速验证 保存为 rollout_check.py,用 Python 3.10 或更新版本执行 python3 rollout_check.py。本轮以 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