队列腾出位置会让生产者继续,但处理完成需要另一项明确确认。

背压只有改变生产者行为,才真正控制了有界接入

背压把消费者的有限容量与生产者行为联系起来。通道已满时,生产者可能等待、拒绝新任务,或执行经过明确选择的丢弃策略。关键是容量限制真正改变上游怎么做。队列不断增长,或仪表盘显示积压,只能说明供需失衡,本身不自动形成控制接入的机制。需要从观察到过载,继续追到生产者是否确实减速或停止提交。 坏掉的样子 无界队列可以吸收短暂突发,却也会隐藏持续过载。如果到达速度长期超过完成速度,未完成任务和等待时间就持续增长。把积压挪到待处理线程、重试任务或另一个无界缓冲,并没有消除压力。容量边界必须覆盖任务真正被保存的位置,不能只限制一个容易观察的队列对象,却允许周围继续无限保留工作。 有界队列本身也没有定义完整过载策略。无限阻塞可能耗尽生产者线程或让关闭流程卡住;立即拒绝时,调用方若误认为成功,就可能丢失工作;每次拒绝后立即重试,又可能加重负载。应依据应用契约明确选择:在截止时间内等待、拒绝并稍后重试,还是有意丢弃。不同选择改变交付语义,不能在实现中含糊处理。 队列容量与处理完成是两件事。消费者取出项目可以释放槽位,但此时可能仍在处理这个项目。等待容量为二,也可能同时还有额外执行中的工作。生产者成功入队,仅说明队列接受了项目,不证明已经执行、持久保存或完成下游副作用。若把所有这些状态都叫作“成功”,过载和失败时就无法判断责任归属。 快速验证 将脚本保存为 check-backpressure.py,运行 python3 check-backpressure.py。它只使用 Python 标准库,在 Python 3.14.4 上执行。线程通过明确事件和有界等待协调,不依赖远端消息代理、数据库或刻意拖慢的外部服务。所有队列和任务都只存在当前进程中,结果用于观察接入与完成的语义,不代表分布式队列的持久化保证: from queue import Queue,Full from threading import Event,Thread import json unbounded=Queue() for item in range(1000):unbounded.put_nowait(item) assert unbounded.qsize()==1000 channel=Queue(maxsize=2) channel.put('a');channel.put('b') try: channel.put('deadline-rejected',timeout=.02) raise AssertionError('a full bounded queue accepted another item') except Full:rejected=True assert channel.qsize()==2 entered,delivered=Event(),Event() def produce(): entered.set();channel.put('c',timeout=2);delivered.set() producer=Thread(target=produce,daemon=True);producer.start();assert entered.wait(1) assert not delivered.wait(.05) and channel.qsize()==2 first=channel.get(timeout=1);assert first=='a' assert delivered.wait(1);producer.join(timeout=1);assert not producer.is_alive() assert channel.qsize()==2 rest=[channel.get(timeout=1),channel.get(timeout=1)];assert rest==['b','c'] assert channel.empty() completed=Event() def join():channel.join();completed.set() waiter=Thread(target=join,daemon=True);waiter.start();assert not completed.wait(.05) for item in [first,*rest]:channel.task_done() assert completed.wait(1);waiter.join(timeout=1);assert not waiter.is_alive() print(json.dumps({'unbounded_backlog':unbounded.qsize(),'bounded_capacity':2,'deadline_rejected':rejected, 'blocked_producer_resumed_after_get':delivered.is_set(),'delivered_items':[first,*rest], 'empty_queue_did_not_mean_processing_complete':True,'join_finished_after_task_done':completed.is_set()})) 第一个队列使用默认无界容量。没有消费者运行时,一千次 put_nowait 全部成功,留下同样数量的积压。这是小规模行为演示,不是耗尽内存测试。它说明接入成功本身无法证明下游已有进展,也不能证明到达速度可持续。监控若只统计提交成功率,就可能在处理能力不足时仍显示表面正常。 ...

八月 12, 2027 · 1 分钟 · XB Field Notes
一个缓存先与原服务重新校验,再决定放行旧条目或替换。

缓存失效是主动推进,不是祈祷

源站文件改了,并不意味着每个已经保存响应的缓存都会收到通知。失效操作控制的是哪些已存副本还可以继续复用,而不只是更新磁盘上的内容。可靠的发布要区分到期、重新验证、定点移除以及使用新 URL:它们解决不同范围的问题,也没有哪一种能远程擦掉读者已经下载的所有字节。 到期不是广播通知 源站发生变化后,一份仍然新鲜的响应可能继续被合法使用。新鲜度由缓存寿命和当前年龄共同决定,年龄计算涉及缓存元数据与经过的时间,并非只从源站的 Date 字段开始倒计时。到期改变的是直接复用时必须满足的条件,既不保证存储介质立即删除条目,也不会让无人访问的缓存主动联系源站。 验证器同样不是内容变化的推送通道。请求需要重新验证时,正确的 ETag 才能帮助源站确认表示未变,或者返回替代正文。浏览器手上的响应还新鲜、没有理由回源时,新增验证器并不能强迫它询问。把源站策略改成 no-cache 或 no-store,也无法追溯修改浏览器之前已经收到的缓存指令。 先确定失效覆盖范围 RFC 9111 要求:缓存收到不安全方法对应的成功或重定向响应时,必须使目标 URI 的已存响应失效。这个义务只涉及该请求实际经过的缓存,不是向整个网络广播。通过另一条接口修改数据库,不会自动覆盖所有派生页面、查询参数变体、不同语言表示和搜索列表,应用需要识别这些真实依赖。 CDN 的清理接口也只管理服务商控制的副本,无法直接清空独立浏览器缓存。应按服务商的实际契约确认受影响的主机、路径、变体以及缓存标签。先让替代内容在源站可用,再发起清理、跟踪完成状态,并比较实际响应正文和缓存状态。接口已经接受清理请求,并不能单独证明它已传播,也不能证明源站已经能返回预期的新字节。 坏掉的样子 如果源站尚未更新就先清理边缘,下一次未命中可能立即把旧内容重新填回去。已经在途的回填也可能和失效操作竞争,具体结果取决于缓存实现。反复执行全站清理不能证明这些竞争已经解决,反而会让更多对象变冷、增加回源负担。应准确圈定受影响对象,并评估清理之后源站需要承担的请求量。 对于静态资产,应先发布带内容指纹的新文件名,再切换 HTML 或清单中的引用。在旧文档仍可能引用旧资产期间,旧文件也应继续可用。新 URL 可以避开旧缓存条目,但前提是读者先拿到更新后的引用文档。只生成一个新文件名,却不更新文档,或者文档本身仍被直接复用,都不会使现有读者自动转向新版本。 涉及机密信息删除时,边界还要更严格。删除源站文件或清理 CDN 不能撤销已经下载的字节,缓存策略也不等于访问授权。应从源头防止敏感响应进入不合适的缓存,而不是泄露后依赖清理挽救。对于紧急修复,要先定义“可见”的对象:新访问者、已经打开的标签页、所有曾下载的副本,不能共享同一种到达保证。 快速验证 准备 Python 3.10 或更新版本及 POSIX shell;实际运行使用 Python 3.14.4、CacheControl 0.14.4 和 Requests 2.34.2。安装下列固定版本后,用 "$invalidation_lab/venv/bin/python" 执行 Python 代码。程序在自动分配的回环端口启动一个 HTTP 服务,并建立两个互不共享存储的内存私有缓存;关闭环境代理,不访问外部服务器,最后关闭客户端、线程和服务。只有依赖安装需要联网。 ...

八月 5, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是限制每次等待并共享一个截止点的英文标题。

连接超时阻止无声的停摆

超时首先规定某个操作最多等待多久。它不会自动成为整个请求的总期限,也不能证明远端工作已经停止。选择数值或解释错误之前,要把连接建立、单次读取和调用方的整体预算分开;否则配置看起来都有限,整条调用链仍然可能等得远比预期久。 连上了,不代表能拿到有用的响应 TCP 建连可以成功,而对端应用始终不产生响应。此时问题属于读取阶段,不是 TCP 握手尚未完成。诊断信息应写出发生超时的阶段;含糊的“数据库连接超时”,可能指连接池获取借用失败,也可能指新建套接字失败,或已经连上后等不到查询结果。它们占用的资源、下一步检查和恢复语义并不相同。 Python 套接字上的超时作用于阻塞操作。循环执行多次成功的 recv(),每次都可能获得一份新的等待额度,因此对端只要持续发送小片段,就可能把整个读取循环拖到远超那个超时值。下面特意每次只读一个字节,便于观察单次操作成功与完整响应及时到达之间的差别,而不是把“一次没有超时”当成整个消息已经满足时限。 如果需要整个接收过程共用预算,应先用 time.monotonic() 建立一个截止点。每次开始下一项操作前,计算剩余时间,再取剩余时间与该操作上限中的较小值;预算耗尽后,就不能继续开始新的等待。单调时钟不会因校正系统日期而突然倒退或跳到另一段墙钟时间,但它也不能保证线程恰好在截止的那一刻获得调度,因此不能把它理解成硬实时执行器。 把这个模式扩展到真实客户端之前,仍要核对所用库的契约。域名解析、多个地址尝试、TLS 协商、发送请求以及从连接池排队获取连接,可能有各自的计时方式。本例只访问一个数字形式的回环地址,并使用普通 TCP;它没有证明 DNS 解析已经受限,也没有实现一个覆盖完整 HTTPS 请求流程的通用总超时。 坏掉的样子 已接受连接但保持沉默的对端,会让调用方读到超时;不断滴送少量数据的对端,则能让每次读取都成功,却使完整响应过慢。只有建连超时,并不能处理连接已经成功之后的这两种情况。反过来,设置得过短,也可能把正常的网络波动或线程调度延迟误判成故障,数值需要结合真实调用预算和工作负载来确定。 失败路径需要释放自己拥有的资源,并保留完整数据与部分数据的区别。套接字读取返回空字节串,表示 EOF,并不等于应用消息必然收齐。实验知道目标帧应该有六个字节,所以提前关闭会被当成失败;不能因为连接已经结束,就把收到的半份结果当作正常响应交给业务。 放弃等待也不等于取消远端工作。调用方超时时,对端可能已经执行了某个副作用。重试需要匹配的重放契约,以及原操作还剩下的预算,而不是每次重新获得一整套等待时间。这里的总期限是在操作边界协作检查的约束,仍有调度与清理开销,并不是对任意线程、系统调用或远端执行的瞬时中断保证。 快速验证 将代码保存为 connection_probe.py,执行 python3 connection_probe.py。脚本只依赖标准库,本次在 macOS 的 Python 3.14.4 上验证。每种情况都创建自己的回环监听端口和对端线程,结束时关闭套接字并等待线程退出;不会连接外部主机,也不修改防火墙或生产服务。 import socket import threading import time PAYLOAD = b"abcdef" PER_OPERATION = 0.5 def exercise(trickle, total=None, peer_payload=PAYLOAD): stop = threading.Event() failures = [] with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: listener.bind(("127.0.0.1", 0)) listener.listen(1) listener.settimeout(2.0) def peer(): try: connection, _ = listener.accept() with connection: connection.settimeout(0.5) if not trickle: stop.wait(2.0) return for byte in peer_payload: if stop.wait(0.1): return connection.sendall(bytes([byte])) except (BrokenPipeError, ConnectionResetError): pass # The deadline case deliberately closes the client. except Exception as error: failures.append(error) worker = threading.Thread(target=peer) worker.start() started = time.monotonic() deadline = None if total is None else started + total received = bytearray() phase, outcome = "connect", "complete" def allowance(): if deadline is None: return PER_OPERATION remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("total budget expired") return min(PER_OPERATION, remaining) try: with socket.create_connection(listener.getsockname(), timeout=allowance()) as client: phase = "read" while len(received) < len(PAYLOAD): client.settimeout(allowance()) chunk = client.recv(1) if not chunk: raise EOFError(f"short frame: {len(received)}/{len(PAYLOAD)} bytes") received.extend(chunk) except TimeoutError: outcome = "total" if deadline is not None and time.monotonic() >= deadline else phase finally: elapsed = time.monotonic() - started stop.set() worker.join(2.0) assert not worker.is_alive(), "fixture peer failed to stop" assert not failures, failures return outcome, bytes(received), elapsed quiet = exercise(trickle=False) assert quiet[0] == "read" and quiet[1] == b"" trickle = exercise(trickle=True) assert trickle[0] == "complete" and trickle[1] == PAYLOAD assert trickle[2] >= 0.5 # Each successful recv got a fresh operation timeout. bounded = exercise(trickle=True, total=0.25) assert bounded[0] == "total" and bounded[1] != PAYLOAD try: exercise(trickle=True, peer_payload=b"ab") except EOFError as error: assert str(error) == "short frame: 2/6 bytes" else: raise AssertionError("a truncated frame must not become success") print("early EOF rejects a two-byte fragment of the six-byte frame") for name, result in (("quiet", quiet), ("trickle", trickle), ("bounded", bounded)): outcome, data, elapsed = result print(name, outcome, repr(data), "elapsed", round(elapsed, 3)) quiet 情况先成功建立连接,但没有收到任何数据,大约半秒后报告 read 超时。trickle 情况在发送六个字节之前分别暂停一小段时间,每次读取都落在单次额度内,完整帧却花了超过半秒才到齐。这里的 complete 是指六个预期字节全部到达,不是某一次读取返回了非空内容;这直接展示了逐次操作超时为什么不能代替整个读取循环的预算。 ...

八月 1, 2027 · 2 分钟 · XB Field Notes
一张排班表,多位工程师轮换共享值班。

值班轮换把负担分得均匀

合理的值班是设计,而不是某个人的宿命。轮换、交接与演练,让被深夜叫醒成为一种共同、可持续的日常。 一份被共同承担的职责 值班是团队的事,不是某一位英雄的。轮班把「谁上次没睡好就谁来」变成一张明确的排班表、一套流程,让被打断分担到每个人头上,而不是让一个人独自背完一整条告警队列。而具体排班好不好,就在于「换人不换上下文」这六个字——交接若只换名字不换现状,同一个故障会在每次轮换时被重新发现一遍。所以轮班的灵魂不是「谁那天晚上在」,而是「责任与上下文如何平稳移交」。 交接是工作的一半 一次值班最容易断的,恰是交接这个环节。当接手的人从零开始、一片黑地接过来,本来该是交接的事就变成了重新破案。堵住这个缝只需要两样:一份书面交接(当前坏在哪、试过哪些、下一棒该试什么),以及一段重叠时间——让接班的人跟着旧的人一起盯岗,旧的人在正式放下前把现场讲完。一张只用来排名字的表,会一次次把同一个事故变成新事故。做出这套的难点,不在排满班,而在排完后还留得住上下文。 在铃声响起前演练 排上一次必须在铃响之前,而不是等它响。低峰期把 runbook 平铺地走一遍,练的是「恢复路径」而不是症状集;每一条 pager 都应映射到一份写好的计划,让雪夜里的铃声到来时,它是 checklist 而不是谜。你提前练熟的那部分是肾上腺素上来后团队唯一不会搞砸的部分;平静时候一遍遍过的常规动作,正是把「到时候再想」变成「按部就班照做」的地方。 坏掉的样子 单人长期值班看似省事,实则先压垮的是人。一个人连续几个月拿着寻呼机,即使没有告警也睡不踏实,看到通知就心慌,最后要么倦怠请辞,要么在一次深夜故障中彻底失手。谷歌站点可靠性工程把这种无边界值班明确称为人员配置缺陷,而不是奉献。解法是设定单次值班上限,并保证下线周真正下线,上周的主值班不再兼任隐形备援。 升级链断裂是第二种常见死法。备援从不确认、联系方式过期,或者所谓升级到经理只是发进一个无人查看的邮箱,深夜三点主值班只能独自拆弹。升级链要像恢复流程一样演练:在工作时间实际寻呼一次备援,确认有人能在几分钟内应答,而不是只在通讯录里写个名字。 告警疲劳是第三种慢性失效。几百条噪声大、无法行动的告警教会团队寻呼机在说谎,于是唯一真实的那条也被顺手划掉。每条告警都应有负责人、有可执行的 runbook 链接、有经过上次误报调优后的阈值,无法行动的告警宁可降级为工单,也不要继续消耗信任。 交接缺失则让轮换只换名字不换上下文。周一换人时没有书面状态,周二就把周一的排查从零再做一遍。必须要求简短交接记录:当前症状、已尝试步骤、下一步动作,并在旧主值班真正下线前保留十五分钟重叠时间,把现场讲完再走。 快速验证 在让任何人牺牲周末之前,先在纸面上验证公平性。下面脚本用五人按周轮换演示:遇到节假日所在周自动跳过且不消耗轮次,最后打印人均负担统计,让管理者一眼看出是否有人被多排。 把代码保存为空目录下的 rota_probe.py。 用 Python 3.9 及以上版本运行 python3 rota_probe.py,仅用标准库,不访问网络,不读写文件。 确认输出逐周排班表、人均次数统计和末尾 PASS 行;若出现断言失败,说明轮换逻辑已经退化,需要先修排班再排人。 from collections import Counter from datetime import date, timedelta TEAM = ["ana", "ben", "cara", "dev", "elin"] START = date(2027, 8, 2) # a Monday WEEKS = 22 HOLIDAYS = {date(2027, 8, 9), date(2027, 8, 30)} # Monday public holidays rota = [] # list of (monday, person or None) cursor = 0 for week in range(WEEKS): monday = START + timedelta(weeks=week) if monday in HOLIDAYS: rota.append((monday, None)) # holiday week: nobody on call, pointer stays continue person = TEAM[cursor % len(TEAM)] rota.append((monday, person)) cursor += 1 assigned = [person for _, person in rota if person is not None] counts = Counter(assigned) for monday, person in rota: label = person if person else "holiday-skip" print(f"{monday.isoformat()} -> {label}") print("counts:", dict(sorted(counts.items()))) # Load-balance statistics: 20 assigned weeks / 5 people = 4 each. assert len(assigned) == WEEKS - len(HOLIDAYS) == 20, len(assigned) assert set(counts) == set(TEAM), counts assert all(count == 4 for count in counts.values()), dict(counts) assert max(counts.values()) - min(counts.values()) == 0, dict(counts) # Holiday weeks consume no turn and hold nobody. assert all(monday not in HOLIDAYS for monday, person in rota if person is not None) assert sum(1 for _, person in rota if person is None) == len(HOLIDAYS) # Nobody works two calendar turns in a row (pointer only advances on assigned weeks). for prev, current in zip(assigned, assigned[1:]): assert prev != current, (prev, current) print("PASS: 20 on-call weeks across 5 people, 4 each, holidays skipped, no back-to-back") 脚本共分配二十个值班周,两个节假日周空出且不推进指针,随后断言三件事:每人恰好四周、前后两周不是同一人、节假日当天无人值班。打印出的计数让不均一目了然,负责人每季度复盘时看的正是这个信号。该断言已在 macOS、Python 3.12 下运行通过。模型只覆盖按周轮换,不含昼夜拆分、跨时区接力与节假日补偿,仍需团队另行约定。 ...

七月 16, 2027 · 2 分钟 · XB Field Notes
浅色网格上的标题为‘三类探针,不同动作’,旁侧是观察、判断、行动三个步骤。

探针告诉平台何时出手

探针结果是控制循环使用的证据,不是平台对应用健康的直接理解。Kubernetes 不会从 healthz 这个路径名称推断你希望怎样恢复服务;真正决定下一步的是探针类型、结果、阈值与容器生命周期。把每次失败都描述成“重启 Pod”,会抹掉直接影响可用性的关键区别。 三个问题,对应不同动作 启动探针为初始化提供独立的检测政策。配置后,在它成功之前,存活和就绪探针都不会执行。启动探针成功后,在当前容器生命周期内便不再持续检查;如果容器重新启动,则需要再次通过启动阶段,不能把上一次成功沿用到新生命周期。 存活检查用于识别适合通过重启当前容器恢复的状态。达到失败阈值后,kubelet 终止受到影响的容器,并应用对应的重启政策。这不是保证重建整个 Pod、改变其身份或修复外部数据库,也不意味着容器之间的依赖与在途工作都能自动安全恢复。 就绪失败则让容器继续运行,并持续检查是否恢复。它影响通常按就绪状态选择目标的 Service 流量,但不是防火墙,也不会取消已经通过连接到达的请求。直接访问实例的客户端和现有连接,仍需要自己的生命周期处理,不能把就绪状态误读成网络阻断保证。 阈值统计连续观察结果 failureThreshold 为三,不是从启动以来累计失败三次就动作,而是连续失败达到三次;一次成功会打断这段失败序列。就绪恢复可以要求多次连续成功,而启动和存活探针的 successThreshold 必须为一。阅读结果轨迹之前,应先明确进入失败和恢复成功两个方向的阈值。 超时、初始延迟、执行周期和终止宽限期同样影响实际表现。阈值乘周期可以辅助估算,但不能当成端到端故障切换的精确秒表;调度、探针执行和路由传播都参与其中。容器尚未就绪时,就绪探针还可能比配置周期更频繁地执行,以便更快恢复就绪。 实验选择四次启动失败、三次存活失败、两次就绪失败,并要求连续两次就绪成功才能恢复。这组数字只是为了看清不同计数器的边界,不是生产推荐。真实门槛应结合启动时间、检查成本、错误恢复方式以及误判代价确定,不能照搬实验参数。 检查机制决定你拿到了什么证据 HTTP 检查使用文档定义的 200 至 399 成功范围。即使响应正文写着“内部错误”,返回 200 也不会因为人类读出了坏消息,就自动变成失败探针。应沿实际探测路径检查认证和重定向,而不是默认平台与交互式浏览器具有完全相同的访问结果。 TCP 探针证明的是连接可以打开,不是查询或事务能够完成。exec 检查则需要镜像里确实存在相应命令,并且退出状态有明确含义;频繁创建进程还会消耗节点资源。应该选择能够回答当前问题的低成本机制,而不是认为检查越复杂、调用越多就越可靠。 就绪成功并不是存活执行的前提。没有启动探针隔离时,初始化较长的应用可能还没来得及就绪,就因为存活失败反复被终止。相反,启动容忍窗口过大,也会推迟发现真正无法完成的启动。两边都需要依据实际启动分布和恢复目标权衡。 坏掉的样子 把外部依赖可用性直接作为存活条件,可能在共享故障期间不断重启健康进程;另一个独立线程上始终绿色的处理器,又可能遗漏业务工作线程的死锁。重启并不天然便宜,它会中断在途工作、重复初始化,也可能进一步增加故障依赖的压力。 如果就绪涵盖每一项可选依赖,本来还能服务的容量也会被撤走;如果状态频繁抖动,负载会在副本之间来回转移,进一步恶化饱和。因此,既要测试明确故障能否被发现,也要测试误判和恢复行为,不能只看一次故障注入能不能把状态变红。 应对照探针结果、容器重启次数、事件记录、就绪变化和真实客户端流量,并确认部署中的配置与路由消费者。一个本地计数模型通过,不代表 kubelet、网络路径或负载均衡器已经正确执行同样规则。平台侧证据不能由应用侧自述替代。 快速验证 保存为 probe_policy_check.py,用 Python 3.10 以上版本执行 python3 probe_policy_check.py,实验已在 Python 3.14.4 上运行。这个确定性模型接收成功或失败两个值,不部署 Pod,不调用 Kubernetes,不安排真实探测时间,也不会重启操作系统进程。 ...

六月 18, 2027 · 2 分钟 · XB Field Notes
64 MiB 容器内存边界终止分配,另一个测试 Pod 在最后观察时仍然就绪。

资源限制约束运行,请求参与放置

Kubernetes 的资源 request 与 limit 属于不同决策。请求参与调度和资源共享,限制则配置运行时及操作系统支持的执行约束。把 limit 当成调度器预留的容量,会同时混淆这两个机制。有效的资源策略需要说明放置需求、可以接受的争用,以及运行中的工作负载到达边界后应该发生什么,不能只在清单上填写一组看起来合理的数字。 CPU 与内存具有不同失败方式 在典型 Linux 环境中,CPU 限制会形成带宽约束,繁忙控制组可能被节流,而进程仍然存活。内存限制依赖内存记账和回收;受约束的分配无法满足时,可能触发控制组 OOM 处理。内核不会像推迟 CPU 使用那样,把所有超限内存申请都温和地放慢。两种资源因此需要不同的容量指标和故障判断方式。 内存执行机制具有反应性,不能描述成恰好达到某个字节就调用应用回调,也不能保证使用量绝对不会短暂超过配置值。应用可能收到分配失败,OOM 处理也可能终止进程。应先观察真实终止原因、容器状态和可用的内存事件,再给故障命名,避免仅因为配置了内存限制,就把所有异常退出都解释成越界。 限制覆盖的不只是应用打算保存的业务数据。运行时开销、其他进程,以及被记账的缓存或其他内存,都可能占用控制组额度。容器限制为六十四 MiB,并不意味着它能在已有开销之外,再安全创建同样大小的数据结构。内存支持的卷也可能贡献内存消耗,表现为文件存储并不会让这部分成本消失。 坏掉的样子 退出码 137 对应常见的 SIGKILL 终止表示,本身不足以证明 OOM。应与容器的 OOMKilled 原因以及相关运行时或内核证据结合。节点压力驱逐又是另一种机制,需要检查 Pod 级状态和驱逐决策,不能把一个容器超过自身控制组预算,与 kubelet 为缓解节点压力而移除 Pod 混成同一个过程。 终止后发生什么,由重启策略继续决定。采用 Always 的泄漏程序可能不断重启,而根因始终存在。采用 Never 时,已终止容器保持终止,Pod 可以进入失败状态。限制只约束一种资源维度,既不会修复应用,也不保证邻居在失败发生前完全没有经历争用。是否影响业务延迟,还需要对应观测,不能由限制字段直接推断。 ...

六月 14, 2027 · 3 分钟 · XB Field Notes
标题为‘在工作入队前设限’,旁侧的观察、判断、行动三个步骤由橙色箭头连接。

负载削减保护系统

负载削减是在全部接收工作会威胁服务时,主动拒绝其中一部分。目标是保留容量,让仍有机会完成的工作继续进行,而不是保证每个获准请求都足够快、都足够重要。拒绝本身也会消耗资源,因此在哪个阶段做准入判断,以及拒绝路径有多贵,与阈值大小同样重要。 保护还能产生价值的容量 过载队列既占内存,也增加等待时间。客户端可能在任务被工作线程取走之前就已经放弃,服务器却继续为无人等待的答案花费资源。更长的队列可以吸收某些突发,但在需求长期超过处理能力时,它不会创造额外容量,只会推迟暴露问题。 Google SRE 讨论了队列限制、资源压力信号、选择性拒绝和降低响应质量。削减与降级相关,但并不相同:拒绝一个请求,不等于完成一个较便宜的版本。后者必须有清楚的产品契约,说明剩下的结果仍有什么价值,不能只是悄悄少做一部分工作。 不同保护机制也可能针对不同组件。Envoy 过载管理器保护代理自身资源,而它的熔断机制主要保护上游服务。依赖熔断器不会自动解决本地内存、连接或文件描述符耗尽,所以不能只看到请求被快速拒绝,就认定整个入口已经免于过载。 选择有依据的政策 按优先级拒绝,可以保护关键业务路径,但 HTTP 方法或是否容易重试,本身并不能决定重要性。一次读取可能非常关键,一次写入也可能只是允许丢弃的遥测。应使用可信的应用分类,而不是任由调用方把自己的所有请求都声明成最高优先级。 统一准入上限或按比例拒绝,也可以是合理政策,不能因为没有业务优先级就一概认为它们错误。需要明确公平性与容量之间的取舍,包括低优先级工作是否可能长期得不到执行,以及不同租户是否共用同一个限制。拒绝顺序应该服务于真实契约,而不是凭方法名称猜测。 请求数量、字节、执行时间和 CPU 成本是不同预算。只限制三个任务,如果每个任务都可以保留任意大的正文,仍然不能约束内存。应选择与受保护资源对应的信号,并通过真实请求组合验证行为,不能把单一计数上限解释成对所有资源的统一保证。 限制准入,而不只是线程数量 线程池的工作线程上限约束同时执行的任务数,不一定限制已经接受、正在排队的工作。只在工作线程内部获取信号量,会让它前面的队列继续增长。应在昂贵工作提交之前做有界准入,并在任务生命周期真正结束时归还容量,而不是等到执行阶段才开始计数。 名称类似的代理限制,也需要精确理解。NGINX limit_conn 按文档规定的连接规则计数;在 HTTP/2、HTTP/3 中,每个并发请求都被单独计入。不能默认某个“连接数上限”始终只是在统计 TCP socket,否则复用连接下的真实请求并发可能与预想完全不同。 下面的实验同时约束排队和运行中的任务。被拒提交不会创建 future;完成回调会在成功、失败或执行前取消后归还名额,而向已经关闭的执行器提交失败,也会归还尚未使用的名额。这样准入预算对应完整生命周期,而不是只覆盖正常结束的路径。 坏掉的样子 如果已经解析巨大正文、打开数据库连接或执行了业务写入,才开始拒绝,可能已经来不及保护目标资源。昂贵的日志和错误响应生成,也可能让拒绝路径本身过载。应一起观察拒绝成本、获准吞吐量、排队时长、活动工作量与资源压力,而不是仅统计拒绝了多少次。 HTTP 503 可以表达服务暂时不可用,429 则表示调用方在某种政策下请求过多。Retry-After 只是等待信号,不证明重放写操作安全,也不保证之后一定存在容量。立即重试可能把刚削减的负载原样加回来,因此拒绝与客户端行为必须放在同一条链路中理解。 不要仅因为调用方停止等待,就提前回收仍在运行任务的名额。等待 future 结果超时,不会终止工作线程。提前归还会让新任务与实际上尚未结束的旧任务重叠,破坏原先承诺的容量上限;资源记账应以工作真实结束为依据。 快速验证 保存为 shedding_check.py,用 Python 3.10 以上版本执行 python3 shedding_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。真实线程等待测试协调事件,不访问外部服务,也不制造生产负载。前后两种配置接收的是同样十二个任务的突发,用来比较获准与等待数量。 ...

五月 16, 2027 · 3 分钟 · XB Field Notes
客户端先根据截止期限和重试政策检查等待要求,再安排下一次尝试。

Retry-After 告诉你何时再试

Retry-After 告诉客户端,在发起后续请求之前应当等待多久。它是调度信号,不证明下一次请求一定成功,也不证明重放原操作是安全的。客户端仍然需要截止期限、尝试次数政策,以及哪些操作可以重试的判断,不能把这些责任都交给一个响应头。 结合响应语境解释字段值 RFC 9110 允许非负十进制整数秒数,或者 HTTP 日期。秒数从收到响应之后开始计算,不是从发出原请求时算起;日期则表示一个绝对 UTC 时刻,因此正确解释它依赖客户端墙上时钟足够准确,不能把两个形式都当成“从请求开始算多久”。 发送方必须使用首选的 IMF-fixdate 格式。解析 HTTP 日期的接收方还必须接受两种旧格式,其中旧 RFC 850 格式的两位年份需要遵守五十年解释规则。负数、小数秒和任意文字,都不是合法的 delay-seconds;零和已经过去的日期,则不要求增加服务器指定的等待时间。 配合 503 时,该字段估计服务还会对客户端不可用多久;配合 3xx 响应时,它要求在发送重定向请求之前至少等待相应时间。RFC 6585 另行允许 429 携带 Retry-After,但这里是可选字段,不是每一条限流响应都必须包含的内容。 尊重等待时间,但不无限等待 一个合法等待值可能长于调用方剩余期限。客户端可以停止重试并报告失败,或者在另有授权的流程中安排后续处理;不能只是为了符合本地上限,就缩短服务器要求的等待,然后提前发起请求。很长的值并不自动等于格式错误,也可能是真实的维护安排。 把绝对日期换算成等待时长后,执行调度和经过时间记账应使用单调时钟。真正开始下一次尝试之前,还应重新检查期限,并为这次请求本身保留完成时间。时钟偏差、响应传输和调度延迟,都说明计算出的计划时间与实际请求时间不是同一份证据。 如果字段缺失或无法使用,有界客户端政策可以采用自己的退避规则。但那是客户端的选择,不是服务器给出的恢复估计。必须明确最多尝试几次、总共允许耗费多久,避免一个可选字段让暂时失败变成没有终点的重试循环。 相同等待值仍可能让调用者同时回来 如果成千上万的客户端几乎同时收到相同等待值,它们仍可能在同一个时刻返回。Retry-After 本身不会把这些请求自动摊开到恢复窗口。可以在尊重要求的等待后增加非负抖动,或者用其他不早于该边界的调度方式,但不能向前减掉等待来制造错峰。 组合本地退避和服务器提示时,需要同时保留两者约束。示例取本地退避与解析出的等待值中较大者,再添加正向抖动。注入的偏移量展示的是调度关系,不是实际分布式流量的测量结果,也不是对某个生产环境抖动范围的推荐。 坏掉的样子 当原请求结果未知时,盲目重放写操作可能重复产生副作用。429、503 和 Retry-After 都不会赋予操作幂等保证;重复非幂等工作之前,需要依据应用自己的重试契约,包括操作标识或去重规则,不能只检查响应头是否存在。 ...

五月 4, 2027 · 4 分钟 · XB Field Notes