文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。

Ansible 的 playbook 按顺序编排任务。具有状态感知能力的模块可以记录机器应该拥有的状态,在部署中途失败后比较真实现状并补齐差异。收敛来自所选模块与参数,并不是执行引擎保证任何任务重跑都安全。它是一份可以执行的契约:inventory 决定对象,模块实现比对,操作者审阅即将发生的变化。
只写终点,不写每一步 命令式 shell 脚本会说“按顺序执行这些命令”;面向状态的任务则说“这个包应该已安装、这个文件应该是这些字节、这个服务应该启用并运行”。模块在动作前比较目标主机与目标状态,已经满足的任务报告 ok,只有不一致才报告 changed。同一份声明可以用于全新的机器、只配置了一半的机器,也可以用于被人手工改过的机器。
这里的目标不能只是模糊意图。state: present、模板的精确内容、所有者和权限、服务的 enabled/running 都是契约的一部分。把多台机器共用的值放到变量或 role 没问题,但最终值应该对审阅者可见。隐藏的默认值,或者绕过模块直接改变状态的命令,都会让这份契约难以推理。每一个 task 都应回答“运行完成后应该看到什么”,而不是“我习惯先敲哪一条命令”。
模块让重跑逐渐收敛 多数内置模块都围绕幂等性设计。ansible.builtin.copy 会先比较文件内容和元数据,只有不同才替换;ansible.builtin.package 向包管理器询问目标状态;ansible.builtin.service 使用 state: started 时只在运行状态不符时调整;state: restarted 则要求每次运行都重启。第二次运行应该是一份安静的确认,而不是再次重启、追加重复行或再写一条记录。幂等不是“什么都不做”,而是“已经达到目标,所以没有必要再做”。
并非所有动作天然幂等。直接用 command 创建用户、轮换密钥或调用外部 API,若没有条件限制就会重复副作用。优先找专用模块;没有时先 register 查询结果,再用 when 判断是否执行,或者使用精确的 creates/removes 文件条件。changed_when 只决定变更报告和 handler 通知,不控制任务是否执行;设成 false 不能阻止重复变更,还可能隐瞒真实副作用。对外部接口仍要定义超时、重试与部分成功后的恢复方法,不能把网络调用假装成普通文件复制。
...

gRPC 的 HTTP/2 传输允许多个 RPC 尝试共享一条连接,同时保留每条流内部的消息顺序。这对短调用和长时间交换都可能有用,但并不意味着每个调用获得了一条无限且完全独立的传输通道。带宽、TCP 交付、连接级流控以及实现中的资源仍然共享,理解这些边界才能准确判断哪些阻塞能够隔离,哪些会影响其他调用。
让调用形式匹配实际工作 一次基于 HTTP/2 的 RPC 尝试占用一条流;重试会发起另一次尝试,而不是把字节重新写入已经结束的流。复用连接能够摊薄建连和 TLS 的成本,但一个 channel 可以管理多条连接,访问不同后端也不会自动共用同一个 TCP 端点。每次调用仍然包含序列化、元数据、帧处理、调度和业务工作,不能把额外成本说成只剩几个帧头。
gRPC 提供单次请求响应、客户端流、服务器流和双向流四类方法。只有双向流允许双方在同一次 RPC 内独立发送消息序列。每个方向的消息顺序得以保留,但不要求每条请求消息都对应一条回复。应当根据应用契约选择调用形式,而不是认为只要采用 gRPC,所有方法就自然具备双向持续推送能力;什么时候读取和写入,仍需应用合理安排。
HTTP/1.1 可以复用持久连接,WebSocket 本身也支持双向消息,二者都不要求每发送一条应用消息就重新握手。因此,不能用虚构的“一次消息一次连接”来证明 gRPC 总是更省。更有意义的比较包括 RPC 契约、生成接口、状态模型和 HTTP/2 多路复用,再结合完整负载和运维约束测量,才能判断实际延迟、内存和连接开销。
分开调用期限与共享传输 截止时间属于某次 RPC,并不会自动成为同一连接上所有流的统一期限。应通过语言支持的上下文机制向下游传递剩余预算;每经过一层就重新领取完整超时,可能让工作持续到调用方早已不愿等待之后。取消还需要业务代码配合停止工作,已经提交的副作用不会因此自动回滚,客户端未收到成功也不等于服务端没有完成修改。
HTTP/2 对 DATA 帧同时实施流级和连接级流控。某条流的窗口耗尽时,只要连接信用仍足够,其他流就可能继续传输。某个方向的连接信用耗尽,则会限制该方向上各条流继续发送 DATA,但并不禁止所有控制帧。接收端实现可以独立补充连接信用,所以一个慢读者并不必然拖到整条连接都没有额度;应观察真实实现如何管理缓冲和更新。
TCP 丢包仍然可能影响多个 HTTP/2 流共享的字节交付。队列、处理器和内存也会在协议流抽象之外造成耦合。应用向库写入成功,可能只是数据进入了库的缓冲,并不能证明已经送达对端或被业务消费。缓冲应当有界,并在实际客户端和服务器库上验证慢读者场景,不能仅凭连接仍然存活就认为吞吐和资源使用都正常。
...

HTTP/2 通过多路复用解除 HTTP 响应的顺序约束,但不同流仍然共享 TCP 连接及其故障边界。取消就是一个值得单独验证的场景:终止某个请求,不一定需要破坏相邻请求或可复用连接。这是一项有明确作用范围的能力,不能扩写成所有流在任何条件下都互不影响。
取消一条 HTTP/2 请求后,应分别核验相邻正文与原连接复用。共享 TCP 约束依然存在;取消也不能撤销已经发生的业务操作。此图不增加运行证据。 流不是底层套接字 HTTP/2 在连接内部标识单条流。RFC 9113 定义的 RST_STREAM 用于终止某条流,既可以表达取消,也可以报告流错误;它并不是重置整个套接字的 TCP reset。只要连接本身仍然可用,实现就可以停止一次交换,同时让其他流继续推进,而不必为每次取消重新建立连接。
...

GROUPING SETS 让一条 SQL 同时生成多种聚合层级。销售报表可以一起返回地区与商品明细汇总、地区小计和总计。结果中混合了不同种类的行,因此,识别每行属于哪个层级,与算对金额同样重要;如果下游把所有结果行都当成普通明细,即使数据库计算正确,报表仍可能出错。
声明报表需要的层级 GROUPING SETS ((region,product),(region),()) 声明三种分组。第一种为每个实际出现的地区与商品组合输出一行,第二种为每个地区输出一行,空集合则汇总全部选中的输入行。这是在声明结果语义,具体执行方式由优化器选择。它可以在不同层级共享工作,但语法本身不保证固定的物理扫描次数,也不会消除排序和内存成本。
ROLLUP(region,product) 是相同前缀集合的简写,并包含空集合。交换两列顺序,会改变小计维度。CUBE(region,product) 还包括只按商品汇总的层级,覆盖维度的全部子集。六个独立维度对应六十四种分组集合,不是只输出六十四行;每种集合还可能包含很多不同分组,所以结果规模可能远大于层级数。
所有层级都汇总 FROM 和 WHERE 选出的总体。如果过滤条件只保留某个月的销售,总计也只代表该月。如果不同层级需要不同输入总体,直接对一个共享输入声明这些集合,可能并不符合需求。比较另一张报表的总额之前,要先确认日期、业务状态等筛选范围一致,不能只对照列名。
用 GROUPING 标记层级 某个分组集合没有使用的维度列,会在输出中填入 SQL NULL。但原始数据本来就可能含有 NULL,因此仅靠哪些列为空,无法可靠判断这是小计还是普通分组。GROUPING(region,product) 返回位掩码,说明当前集合缺少了哪些分组表达式;最右边的参数对应最低有效位。
在这里,零表示两个维度都参与分组,一表示省略商品,三表示两个维度都省略。真实空地区的小计与全表总计,可能同时显示空地区、空商品,但掩码不同。应把这个标记纳入输出契约,只有层级明确表示跨地区汇总时,才生成“所有地区”等展示标签。
表达式是否参与分组,与表达式的值是否非空,是不同问题。GROUPING 报告前者,其参数必须匹配查询中的分组表达式,并不是测试任意值是否为空的普通函数。向下游提供数值层级或其他无歧义标识,比把每个空值都替换为“小计”更可靠,也能保留真实缺失维度的含义。
坏掉的样子 把结果中所有 total 再相加,会让同一笔销售在每个层级重复计入。需要进一步汇总时,应先选择一个层级,或直接取已经算好的总计。平均值和去重计数更不能随意相加:没有按样本数加权的地区平均值,通常不能直接平均成整体平均值;重叠集合的去重数量也不能简单求和。聚合层级不是彼此独立的新增交易。
空分组集合在没有输入行时仍形成一个分组,但后续 HAVING 条件可以把这行过滤掉;没有这类过滤时,其中 count 为零,sum 为 NULL。因此,金额为空不等于查询没有返回总计行。应明确报表如何展示空总体,而不是把空值自动当作执行失败。还要避免无意重复声明分组集合;重复层级可能产生重复聚合行,并不代表多了一份独立证据。
快速验证 把下面内容保存为 grouping.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f grouping.sql。数据特意包含一个真实空地区,脚本用事务包住临时对象,末尾回滚清理。
...

Readiness 与 liveness 回答不同的运维问题。就绪探针判断当前容器是否应该参与一个能够接收普通 Service 流量的 Pod,存活探针则判断是否应该执行预先配置的容器重启恢复。两者都可以使用 HTTP,但协议相同不代表语义相同。应先确定希望平台采取什么动作,再设计触发该动作的条件,否则一个健康接口可能同时承担互相冲突的责任。
同时观察响应和平台动作 就绪探针失败会让容器变成未就绪,并影响 Pod 的 Ready 条件。对于普通带选择器的 Service,端点协调随后改变哪些后端可以参加正常路由。Pod 可以保持 Running,同时处于未就绪状态,应用进程也可能继续运行。重启次数、Pod 标识和 Ready 条件回答的是不同问题,排查时不能只看其中一个状态就推导全部行为。
存活探针连续失败达到配置阈值后,kubelet 会终止容器并按相应重启规则处理。普通 Always 策略应用 Pod 会在原 Pod 中获得替换容器,而不是必然创建一个新 Pod 对象,也不是发布另一版应用。应在比较 Pod 身份的同时查看容器重启次数与终止状态,避免把一次进程恢复误报为完整工作负载替换。
检查应便宜,而且成功条件要具有实际含义。TCP 连接成功只证明监听端能够接受连接,不证明应用能够完成有用工作。HTTP 可以表达更丰富的问题,但如果它递归调用昂贵的依赖检查,也可能成为额外负载来源。探针自己的执行成本应符合工作负载容量和超时预算,不能让健康检查本身把健康服务压慢。
坏掉的样子 把共享数据库故障当成存活失败,可能导致所有正常应用进程不断重启,却完全无法修复数据库。把同一个依赖直接加入就绪检查也需要判断:撤走全部副本可能同时撤掉本来有价值的降级响应,在局部故障中还可能把流量集中到剩余副本。应依据服务实际契约定义就绪,包括已经明确支持的部分可用行为,而不是照抄统一依赖清单。
就绪状态不是网络访问控制。即使某个 Pod 不再属于 Service 的就绪目标,它的地址仍可能被直接访问。既有连接、其他路由层,以及 publishNotReadyAddresses 等 Service 设置,也需要单独分析。因此,不能把 readiness 失败描述成一个瞬时保证,认为从此没有任何请求能够到达进程。它改变的是对应服务发现和路由流程中的资格。
...

Cookie 是浏览器的存储与传输机制。服务端会话则是一种应用设计,用 Cookie 的值查找服务器保存的状态。分开这两种责任,登录、过期和退出才容易验证。本文沿着不透明会话标识的生命周期展开,不再重复主机、路径或 SameSite 匹配规则;这些发送条件不能回答旧会话是否已经真正失效。
Cookie 期限控制浏览器存储,会话有效性和操作授权由服务端负责。替换并作废旧标识后,还要测试重放。两种计时示例使用原文实验单位,并未证明浏览器或分布式系统行为。 持有标识仍可能获得权限 在这种设计中,服务器生成不可预测的标识,并把它关联到一条会话记录。客户端不需要理解该值,但持有它仍可能获得访问资格:窃取有效 bearer 标识的人,可以尝试直接重放。称它“只是指针”不能让读者以为它无害。不要把它放入 URL、日志、诊断截图或支持材料中,随机字符串也可能是实际凭据。
不能直接信任客户端控制的明文角色字段。不过,Cookie 中含有有意义的数据,也不等于必然可以伪造:经过正确认证和签名的会话格式属于另一种设计。签名保护完整性,不提供保密性;无状态方案仍须明确过期、密钥管理与撤销策略。应选择维护良好的会话框架,不要仅为了省一次服务端查询就自行发明格式。
不透明方案需要使用密码学安全随机生成器和足够熵值。未知标识应被拒绝,而不能用客户端任意指定的值建立特权状态。服务器解析会话后,还须根据当前权限授权每项操作。某个会话已经认证,不代表它可以读取每一个对象、操作每一个账号,或自动获得所有管理功能;会话存在与具体动作获准是两项检查。
两种寿命需要两处控制 Cookie 的 Max-Age 与 Expires 管理客户端存储,不会自动使服务端会话记录过期。非浏览器客户端可以保留这个值,并在浏览器本该删除后继续重放。浏览器会话 Cookie 也可能通过会话恢复机制保留。因此必须在服务端强制执行绝对期限与空闲期限,使用明确的边界比较和一致的时间来源,而不能靠标签页里的倒计时作为安全判断。
退出时既要作废服务端状态,也要按匹配的作用域清除浏览器 Cookie,否则复制出来的令牌仍可能有效。认证后与权限变化后轮换标识,有助于防止会话固定,但前提是旧标识确实失去权限。并发请求和分布式存储需要额外协调,避免一个较晚完成的旧请求悄悄恢复已经撤销的会话;只覆盖单进程正常路径远远不够。
...

trap 让 Shell 为某个信号或自身事件安排动作,适合删除临时文件、协调退出,但不会让中断变成原子操作,也无法保证任何终止方式都能完成清理。在 Bash 中,trap 是内建命令,EXIT 是 Shell 事件,而不是操作系统信号。可靠的设计需要明确处理哪些出口,并保留执行为什么结束的证据,不能仅凭注册了一次处理器就断言资源一定会被回收。
这是明确的退出清理策略,不能保证删除成功或立即关停。先保存最初的状态,并单独诊断清理结果。等待同步前台命令与通过 wait 等待异步命令的陷阱时机不同。本轮没有重新运行原文示例。 信号处理与退出清理分开 trap 'cleanup' INT TERM 会替换这些信号的默认动作。如果处理器只是返回,Shell 可能继续执行;安装处理器并不天然等于启动关停。一种明确的 Bash 写法是在信号处理器中主动退出,再让统一的退出处理器负责清理。正常执行到末尾、显式调用退出命令,也会经过这个出口,从而减少各业务分支之间重复的资源回收逻辑。
...

Redis Sentinel 监督非 Cluster 的主从部署,并提供当前主节点地址。它不存储应用数据集,也不代理应用命令。主节点失败后自动提升副本,可以帮助恢复写入可用性,但服务发现、副本数据新鲜度、客户端重连以及阻止旧写者继续写入,仍然是不同的问题,不能由“有哨兵”三个字一并保证。
故障认定与选举授权是两道门 一个 Sentinel 在足够长时间收不到可接受回复后,会把实例标记为主观下线;达到配置的 quorum 都认为主节点不可达,才形成客观下线判断。这个故障判定阈值不等于故障转移领导者获得授权所需的多数。选举还必须达到配置的 quorum,因此将 quorum 设置得更高,也可能进一步提高授权门槛。
例如五个已知 Sentinel 配置 quorum 为二,两个观察者可以同意主节点发生故障,但授权提升仍需要三个构成多数。反过来,quorum 为一也不意味着三个已知观察者只剩一个时,它能独自授权。SENTINEL CKQUORUM 会检查这两项可达性条件;某个 Redis 进程看上去健康,并不能证明观察者满足条件。
稳健部署至少需要三个处在独立故障范围内的 Sentinel。同一台笔记本上的三个进程能测试协议行为,却会随整台机器一起失效。每个 Sentinel 还需要可写的配置文件,持久保存发现的拓扑与纪元状态;这些不是随时可以丢弃的普通静态配置,也应保护其中可能存放的认证材料。
提升副本不会阻止所有旧连接写入 Redis 使用异步复制,被选中的副本可能没有收到旧主已经确认的一条写入。网络分区时,留在旧主一侧的客户端甚至可能继续向它写入,而另一侧已经提升替代节点。Sentinel 协调选举,并不等于撤销所有旧连接的写权限,更不会把 Redis 自动变成强一致存储。
min-replicas-to-write、min-replicas-max-lag 等设置可以在复制健康度下降时限制写入,同时牺牲部分可用性;它们不是通用的隔离旧写者协议。仍需独立备份和明确的应用恢复规则。网络分区、复制落后和单纯杀死一个进程应分别验证,不能用其中一个结果替代其他情形。
客户端需要重新发现并建立连接 支持 Sentinel 的客户端先查询观察者拿到主节点地址,再直接连接 Redis。因此,它要处理暂时发现不到主节点、连接断开和角色改变。只认旧地址的客户端,不会因为某个哨兵已经更新答案就自动恢复;同样,写请求超时也不能证明操作从未执行。
下面的代码在发现新的主节点后,明确断开应用的旧连接池,再检查新连接。这样能够单独验证重连环节,而不是承诺故障转移对调用方完全不可见。切换窗口内不能盲目重复非幂等写操作,节点提升并没有替业务提供去重机制。
...