<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>圆角代码</title>
<link>https://blog.xtang.online</link>
<description>一个关于计算机、AI 与安全的独立技术博客。每天一篇，写能解决问题的笔记、踩过的坑和拿起来就能用的代码。</description>
<language>zh-CN</language>
<lastBuildDate>Wed, 11 Nov 2026 15:46:32 +0000</lastBuildDate>
<atom:link href="https://blog.xtang.online/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<title>从一次 curl 超时说起：DNS、TCP 与 TLS 到底谁慢了</title>
<link>https://blog.xtang.online/posts/curl-timeout-dns-tcp-tls/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/curl-timeout-dns-tcp-tls/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>一个 20 秒的 curl 超时，可能卡在 DNS、TCP 握手、TLS 协商，也可能是 IPv6 在悄悄拖后腿。这篇文章给你一套五分钟内定位链路瓶颈的方法。</description>
<content:encoded><![CDATA[<p>线上排障最怕一句话：「网络慢」。慢在哪一层，决定了你该改 resolv.conf、调 MTU，还是换掉那条半死不活的代理。<code>curl</code> 自带的分阶段计时，其实已经能回答大部分问题。</p>
<h2 id="curl">先让 curl 自己招供<a class="h-anchor" href="#curl" aria-label="锚点">#</a></h2>
<p>不要只看 <code>time_total</code>。用下面这个格式串，把一次请求拆成五个阶段：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>curl<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-sS<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-w<span class="w"> </span><span class="s1">&#39;dns=%{time_namelookup}s tcp=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>https://example.com/health
</code></pre></div></div>

<p>各字段的含义和常见嫌疑：</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
<th>数值异常时优先怀疑</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>time_namelookup</code></td>
<td>DNS 解析完成</td>
<td>本地 DNS、上游解析、搜索域</td>
</tr>
<tr>
<td><code>time_connect</code></td>
<td>TCP 三次握手完成</td>
<td>路由、防火墙、IPv6、SYN 重传</td>
</tr>
<tr>
<td><code>time_appconnect</code></td>
<td>TLS 握手完成</td>
<td>证书链、OCSP、TLS 版本、代理</td>
</tr>
<tr>
<td><code>time_starttransfer</code></td>
<td>收到首字节</td>
<td>服务端处理、数据库、上游依赖</td>
</tr>
<tr>
<td><code>time_total</code></td>
<td>整个请求结束</td>
<td>响应体大小、带宽、连接复用</td>
</tr>
</tbody>
</table></div>
<p>如果 <code>dns</code> 就花了 5 秒，后面的 TCP 和 TLS 再快也没意义。反过来，<code>dns</code> 只有 2ms、<code>tcp</code> 却要 3 秒，问题基本就在网络可达性上。</p>
<div class="callout callout-note"><div class="callout-label">提示</div>
<p>加上 <code>-4</code> 或 <code>-6</code> 分别强制走 IPv4 / IPv6，能快速验证「Happy Eyeballs」是不是在帮倒忙。很多“偶发慢”其实是 AAAA 记录存在、但 IPv6 实际不通。</p>
</div>
<h2 id="_1">分层验证，不要一上来就抓包<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<h3 id="1-dns">1. DNS：先区分缓存与递归<a class="h-anchor" href="#1-dns" aria-label="锚点">#</a></h3>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>dig<span class="w"> </span>+stats<span class="w"> </span>example.com<span class="w"> </span>A
dig<span class="w"> </span>@223.5.5.5<span class="w"> </span>example.com<span class="w"> </span>A
getent<span class="w"> </span>hosts<span class="w"> </span>example.com
</code></pre></div></div>

<ul>
<li><code>dig</code> 默认直接问配置里的 DNS，绕过部分系统缓存；</li>
<li><code>getent hosts</code> 走的是 glibc 的 NSS 链路，更接近程序真实行为；</li>
<li>如果 <code>dig</code> 很快、<code>getent</code> 很慢，检查 <code>/etc/nsswitch.conf</code> 里是否有 <code>mdns</code>、<code>resolve</code> 之类的顺序问题。</li>
</ul>
<h3 id="2-tcp">2. TCP：看握手有没有完成<a class="h-anchor" href="#2-tcp" aria-label="锚点">#</a></h3>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>curl<span class="w"> </span>-v<span class="w"> </span>--connect-timeout<span class="w"> </span><span class="m">5</span><span class="w"> </span>https://example.com/<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sed<span class="w"> </span>-n<span class="w"> </span><span class="s1">&#39;1,20p&#39;</span>
ss<span class="w"> </span>-tanp<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-E<span class="w"> </span><span class="s1">&#39;SYN-SENT|ESTAB&#39;</span>
</code></pre></div></div>

<p>一直停在 <code>Trying 1.2.3.4...</code>，说明 SYN 没有回包。常见原因：安全组没放行、目标端口没监听、运营商丢包、MTU 黑洞。用 <code>mtr -rwzbc 20 example.com</code> 能看到丢包发生在哪一跳。</p>
<h3 id="3-tls">3. TLS：握手卡住还是证书报错<a class="h-anchor" href="#3-tls" aria-label="锚点">#</a></h3>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>openssl<span class="w"> </span>s_client<span class="w"> </span>-connect<span class="w"> </span>example.com:443<span class="w"> </span>-servername<span class="w"> </span>example.com<span class="w"> </span>-brief<span class="w"> </span>&lt;/dev/null
</code></pre></div></div>

<p>如果 TCP 很快、TLS 很慢，重点看证书链是否过大、是否在拉取 OCSP、服务端是否强制了老旧套件。把 <code>time_appconnect</code> 与 <code>time_connect</code> 相减，就是 TLS 的真实开销。</p>
<h2 id="_2">一个容易被忽略的元凶：连接没有复用<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>单次请求很快，并不代表批量请求也快。每次新建 TCP + TLS，在跨地域链路上可能要付出 3 到 5 个 RTT。用 <code>curl</code> 连续请求同一个域名时，可以显式复用连接：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>curl<span class="w"> </span>-sS<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-w<span class="w"> </span><span class="s1">&#39;%{time_connect} %{time_appconnect} %{time_total}\n&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>https://example.com/a<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>https://example.com/b<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>https://example.com/c
</code></pre></div></div>

<p>后两次的 <code>time_connect</code> 和 <code>time_appconnect</code> 应该接近 0。如果仍然很大，检查客户端是否每次都在重建连接池，或者中间代理是否强制关闭了 keep-alive。</p>
<h2 id="_3">把排查固化成脚本<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>临时命令用完就忘，不如把常用指标写进一个脚本，出问题时直接跑：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/usr/bin/env bash</span>
<span class="nb">set</span><span class="w"> </span>-euo<span class="w"> </span>pipefail
<span class="nv">host</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">1</span><span class="p">:?usage: probe.sh &lt;url&gt;</span><span class="si">}</span><span class="s2">&quot;</span>
curl<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-sS<span class="w"> </span>--max-time<span class="w"> </span><span class="m">20</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-w<span class="w"> </span><span class="s2">&quot;url=%{url_effective}\ndns=%{time_namelookup}\ntcp=%{time_connect}\ntls=%{time_appconnect}\nttfb=%{time_starttransfer}\ntotal=%{time_total}\nhttp=%{http_code}\n&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="s2">&quot;</span><span class="nv">$host</span><span class="s2">&quot;</span>
</code></pre></div></div>

<p>再配一条 <code>date -Is</code>，把时间点一起记下来。很多“偶发”问题，只有把时间、目标、各阶段耗时放在一起看，才会露出规律。</p>
<h2 id="_4">两个高频变体<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<h3 id="aaaa-ipv6">AAAA 记录存在，但 IPv6 不通<a class="h-anchor" href="#aaaa-ipv6" aria-label="锚点">#</a></h3>
<p>很多「时快时慢」来自 Happy Eyeballs：客户端先尝试 IPv6，等待一小段时间没有结果，再回退到 IPv4。如果 IPv6 是黑洞——包发得出去、回不来——每次新建连接都要先吃一次等待。验证方法很直接：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>curl<span class="w"> </span>-4<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-sS<span class="w"> </span>-w<span class="w"> </span><span class="s1">&#39;v4=%{time_connect}\n&#39;</span><span class="w"> </span>https://example.com/
curl<span class="w"> </span>-6<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-sS<span class="w"> </span>-w<span class="w"> </span><span class="s1">&#39;v6=%{time_connect}\n&#39;</span><span class="w"> </span>https://example.com/
</code></pre></div></div>

<p>IPv4 很快、IPv6 卡住，就基本确定了。处理方式不是删掉 AAAA 记录（那会误伤真正有 IPv6 的用户），而是修路由、防火墙或上游通告；临时绕行可以在客户端显式 <code>--ipv4</code>，但要把它当成止血，不是长期方案。</p>
<h3 id="_5">代理只处理了部分流量<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h3>
<p><code>http_proxy</code>、<code>https_proxy</code>、<code>all_proxy</code> 可能来自 shell、systemd、容器编排或桌面系统，各管一段；<code>curl</code> 会读环境变量，而你的应用未必读。用 <code>--noproxy '*'</code> 做一次直连对照：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>curl<span class="w"> </span>--noproxy<span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-sS<span class="w"> </span>-w<span class="w"> </span><span class="s1">&#39;direct=%{time_total}\n&#39;</span><span class="w"> </span>https://example.com/
curl<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-sS<span class="w"> </span>-w<span class="w"> </span><span class="s1">&#39;proxy=%{time_total}\n&#39;</span><span class="w"> </span>https://example.com/
</code></pre></div></div>

<p>直连快、代理慢，问题在代理链路或它的出口；反过来，检查 <code>NO_PROXY</code> 是否把内网域名也送进了代理。容器场景还要确认 sidecar 的拦截规则，别只查宿主机的环境变量。</p>
<h2 id="_6">抓包放在最后，不是第一步<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p><code>tcpdump</code> 信息量大，但读起来慢。先有时计和分层结论，再抓一次有针对性的包：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>sudo<span class="w"> </span>tcpdump<span class="w"> </span>-ni<span class="w"> </span>any<span class="w"> </span>-vv<span class="w"> </span><span class="s1">&#39;host example.com and (port 53 or port 443)&#39;</span><span class="w"> </span>-c<span class="w"> </span><span class="m">40</span><span class="w"> </span>-w<span class="w"> </span>/tmp/curl.pcap
</code></pre></div></div>

<p>只看三件事：SYN 有没有重传、TLS ClientHello 之后有没有 ServerHello、连接有没有被 RST。抓包回答的是「到底有没有回包」，不负责解释业务逻辑。把 pcap 和 <code>curl -w</code> 的时间点放在一起，才能判断是链路丢包还是服务端处理慢。</p>
<h2 id="_7">五分钟排查清单<a class="h-anchor" href="#_7" aria-label="锚点">#</a></h2>
<ol>
<li>跑一遍分阶段计时，把耗时归到 DNS、TCP、TLS、TTFB 中的某一段。</li>
<li>DNS 慢：分别用 <code>dig</code> 和 <code>getent hosts</code> 对比，检查搜索域与 NSS 顺序。</li>
<li>TCP 慢：用 <code>curl -4</code> / <code>-6</code> 对比，<code>mtr</code> 看丢包跳数，确认安全组与监听地址。</li>
<li>TLS 慢：<code>openssl s_client -brief</code> 看握手与证书链，排除代理和 OCSP。</li>
<li>TTFB 慢：问题已经不在网络，去看服务端日志、数据库和上游依赖。</li>
<li>仍然偶发：记录时间点，抓 40 个包，别靠重启碰运气。</li>
</ol>
<h2 id="_8">结论<a class="h-anchor" href="#_8" aria-label="锚点">#</a></h2>
<p>排网络问题，顺序比工具重要：<strong>DNS → TCP → TLS → 首字节 → 传输</strong>。每一层都有对应的计时字段和验证命令，先把耗时归到某一层，再决定抓包还是改配置。绝大多数所谓的“网络慢”，最后都能收敛成一句很具体的话，比如“AAAA 记录指向了一台不通的机器”或者“安全组只放行了 IPv4”。</p>
<p>下一次再遇到超时，先别重启服务，跑一遍上面的计时模板；如果再花五分钟做一次 IPv4/IPv6 与代理的对照，大多数“偶发”就会变成可解释的规律。</p>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>CPU 跑到 100% 时，Linux 上到底发生了什么</title>
<link>https://blog.xtang.online/posts/linux-cpu-100-load-context-switch/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/linux-cpu-100-load-context-switch/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>CPU 100% 不一定是故障，load 很高也不一定是 CPU 不够。从 /proc/stat 到 cgroup 限流，拆开看内核到底在忙什么。</description>
<content:encoded><![CDATA[<p>告警只有一行：CPU 使用率 100%。登上机器，<code>top</code> 里某个 Java 进程占着 780%，<code>uptime</code> 却是 load average: 32.15。但接口 P99 只从 80ms 涨到 110ms，业务没挂。这种时候，100% 是需要立刻处理的故障，还是一个被误读的数字，取决于你接下来看哪几个文件。</p>
<p>反过来的事故更常见：CPU 只有 20%，load 到了 40，整台机器都在超时。两个数字指向完全不同的内核行为，先把它们分开。</p>
<h2 id="100">100% 的分母是「一个核」<a class="h-anchor" href="#100" aria-label="锚点">#</a></h2>
<p><code>top</code> 里进程的 %CPU 以单核为分母。16 核机器上一个死循环线程显示 100%，整机理论上限是 1600%。所以看到一个进程 100%，先问：它是单线程打满，还是多线程均摊。</p>
<p>单线程打满通常是代码问题：忙等、正则回溯、序列化大对象。多线程均摊才更像容量问题。用 <code>top -H -p &lt;pid&gt;</code> 展开线程，如果只有一个线程接近 100%，其它线程在睡觉，方向就明确了。</p>
<p>首行的 <code>%Cpu(s)</code> 才是整机视角，按 <code>1</code> 可以展开成每核。<code>nproc</code> 给出可用核数，但容器里它返回的往往是宿主机核数，算容量时要看 <code>cpu.max</code> 而不是 <code>nproc</code>。</p>
<p>另外两个工具的数字经常对不上：<code>top</code> 给的是采样区间内的瞬时占比，<code>ps -o pcpu</code> 给的是进程启动至今的平均值。长跑进程在 <code>ps</code> 里看着温和，实际可能刚刚飙起来，判断当前状态以 <code>top</code> 或 <code>pidstat 1</code> 为准。</p>
<h2 id="load-average-d">load average 里混着 D 状态<a class="h-anchor" href="#load-average-d" aria-label="锚点">#</a></h2>
<p><code>cat /proc/loadavg</code> 输出五个字段：1、5、15 分钟的平均值，当前运行实体数/总进程数，以及最近分配的 PID。前三个数不是 CPU 使用率，而是处于 R（可运行）和 D（不可中断睡眠）状态的任务数的指数衰减平均。</p>
<p>关键在 D。进程卡在磁盘 IO、NFS、某些内核锁上时，一点 CPU 都不消耗，但照样计入 load。所以 load 高、CPU 低，第一件事是找 D 状态进程：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>ps<span class="w"> </span>-eo<span class="w"> </span>stat,pid,comm<span class="w"> </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">&#39;$1 ~ /^D/&#39;</span>
vmstat<span class="w"> </span><span class="m">1</span><span class="w"> </span><span class="m">5</span>
</code></pre></div></div>

<p><code>vmstat</code> 的 r 列是可运行队列长度（不含 D），b 列是阻塞任务数。r 持续大于核数，说明 CPU 真的排满了；r 很小而 b 不小，问题在 IO。</p>
<p>按核数折算的经验值：load 等于核数时队列刚好排满，到两倍开始明显排队。但这只是经验，IO 密集的机器 load 常年偏高，不能直接当 CPU 指标用。</p>
<h2 id="ussywasi">us、sy、wa、si 各说明什么<a class="h-anchor" href="#ussywasi" aria-label="锚点">#</a></h2>
<p><code>vmstat 1</code> 的 CPU 列里，us 是用户态时间，sy 是内核态，wa 是等 IO 的空闲（新内核里统计口径有变化），st 是被虚拟化层偷走的时间。</p>
<p>sy 高通常有三种来源：系统调用太频繁（大量小 read/write）、上下文切换过多、软中断集中。<code>si</code> 那一列高，说明 CPU 在收包，检查 <code>/proc/interrupts</code> 是否集中在 0 号核，网卡多队列或 RPS 没配好是常见原因。</p>
<p>写监控时别直接读 <code>/proc/loadavg</code> 的瞬时值。<code>/proc/stat</code> 第一行的 cpu 字段是 jiffies 累计值，除以 <code>getconf CLK_TCK</code>（多数系统是 100）换算成秒，两次采样做差才是准确的利用率。</p>
<p>想按进程拆开看，用 <code>pidstat 1</code> 的 %usr、%system、%iowait 三列；想看到调用栈级别，用 <code>perf top</code>。如果榜首出现 <code>native_queued_spin_lock_slowpath</code> 这类符号，基本就是锁竞争让 CPU 空转了，加核并不能提高吞吐。</p>
<p>整机平均还会掩盖单核瓶颈。<code>mpstat -P ALL 1</code> 能列出每个逻辑核的占用，如果只有一两个核接近 100%、其余大量 idle，先查中断亲和性和线程绑核，而不是扩容。</p>
<h2 id="_1">上下文切换：在等，还是被抢<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p><code>pidstat -w -p &lt;pid&gt; 1</code> 输出两列：cswch/s 是自愿切换，进程主动让出 CPU，多半在等锁、等 IO、sleep；nvcswch/s 是非自愿切换，时间片用完被内核抢占。</p>
<p>前者高说明在等资源，后者高说明 CPU 不够分。<code>grep ctxt /proc/&lt;pid&gt;/status</code> 能看到累计值，两次相减除以间隔就是速率。<code>cat /proc/&lt;pid&gt;/schedstat</code> 的第三个字段是任务在可运行队列里等待 CPU 的累计纳秒数，它持续增长而进程自身的 CPU 时间不涨，说明线程在排队，而不是在干活。</p>
<p>有个容易被忽略的组合：线程数远大于核数，每个线程都抢同一把锁。nvcswch 飙升，sy 上升，吞吐下降，但 CPU 看着只有 60%。此时加机器没用，要减线程或缩短临界区。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>现象</th>
<th>先看什么</th>
<th>常见原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>单进程 %CPU 100%，只有一核满</td>
<td><code>perf top -p</code>、<code>top -H</code></td>
<td>死循环、正则回溯、单线程热点</td>
</tr>
<tr>
<td>load 高、CPU 低、b 列有值</td>
<td><code>iostat -x 1</code>、D 状态进程</td>
<td>磁盘慢、NFS 卡住、同步写日志</td>
</tr>
<tr>
<td>sy 高、cs 高</td>
<td><code>pidstat -w</code>、<code>perf trace</code></td>
<td>锁竞争、线程过多、频繁系统调用</td>
</tr>
<tr>
<td>si 集中在少数核</td>
<td><code>/proc/interrupts</code></td>
<td>网卡单队列、RPS 未配置</td>
</tr>
<tr>
<td>容器内 CPU 不高但延迟抖动</td>
<td><code>cpu.stat</code> 的 <code>nr_throttled</code></td>
<td>cgroup 配额被打满后限流</td>
</tr>
</tbody>
</table></div>
<h2 id="throttling">容器里还有一层：throttling<a class="h-anchor" href="#throttling" aria-label="锚点">#</a></h2>
<p>cgroup v2 下 <code>cat /sys/fs/cgroup/cpu.stat</code>，<code>nr_throttled</code> 和 <code>throttled_usec</code> 持续增长，说明进程在配额周期内被限流过。<code>cpu.max</code> 写成 <code>200000 100000</code>，意思是每 100ms 周期最多使用 200ms CPU，等价 2 核。</p>
<p>被限流的线程会停顿几十毫秒，表现为 P99 抖动，而平均 CPU 只有 50%——<code>top</code> 完全看不出来。这也是「容器里 CPU 没用满但就是慢」最常见的原因。</p>
<p><code>docker run --cpus=2</code> 最终写进 <code>cpu.max</code> 的也是同样的 <code>200000 100000</code>。注意容器内的 <code>top</code> 仍按宿主机核数算百分比，2 核配额跑满时整机只显示 6%，拿它判断容器压力会严重低估。</p>
<p>想确认热点，用采样而不是猜：<code>perf record -F 99 -g -p &lt;pid&gt; -- sleep 30</code>，再生成火焰图看 on-CPU 的栈。机器上进程很多时把 <code>-p</code> 换成 <code>-a</code>，先看全局热点，再缩小到具体 PID。<code>strace -c</code> 能统计系统调用耗时，但会给目标进程带来明显开销，生产环境只在短时间窗口内用。</p>
<p><code>perf top</code> 的三个列分别是被采样到的比例、所属共享对象和符号名。看到比例集中在 <code>libc</code> 的 <code>memcpy</code>，问题通常不在 <code>memcpy</code> 本身，而在于调用它的地方在做大块复制；加上 <code>-g</code> 打开调用图，才能顺着栈找到真正的业务函数。</p>
<h2 id="steal-time">云端还要看 steal time<a class="h-anchor" href="#steal-time" aria-label="锚点">#</a></h2>
<p>虚拟机里 <code>%Cpu(s)</code> 有一列 <code>st</code>，表示物理核被别的租户占走、本该给你却没给的时间。<code>st</code> 长期大于 0，说明宿主机超卖，这时候容器内怎么调优都没用，该换规格或者换宿主机。<code>top</code> 的 <code>st</code> 只在整机视角显示，单进程看不到。</p>
<h2 id="_2">结论<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<ul>
<li>先分清整机 <code>%Cpu(s)</code> 和单进程 <code>%CPU</code> 的分母，再看是单线程打满还是多核均摊。</li>
<li>load average 包含 D 状态，load 高不等于 CPU 忙；用 <code>vmstat</code> 的 r 和 b 列区分排队与阻塞。</li>
<li>sy 高查系统调用与上下文切换，si 高查中断分布，别一律归因于「代码慢」。</li>
<li>容器场景必须看 cgroup 的 <code>cpu.stat</code>，限流造成的抖动在宿主机监控里是隐形的。</li>
<li>定位热点靠采样（<code>perf</code>），不靠重启和猜测；重启只能清空现场。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>内存不是越多越好：虚拟内存、页缓存与 OOM Killer</title>
<link>https://blog.xtang.online/posts/virtual-memory-page-cache-oom/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/virtual-memory-page-cache-oom/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>free 显示的剩余内存少，往往不是内存不够。把虚拟内存、页缓存、overcommit 和 OOM Killer 的选择逻辑拆开，才知道该加内存还是改参数。</description>
<content:encoded><![CDATA[<p>一台 64G 内存的机器，<code>free -h</code> 显示 available 只剩 1.2G，群里立刻有人提议扩容。同一时刻 <code>dmesg</code> 里没有任何 OOM 记录，所有服务的 RSS 加起来不到 20G。剩下的内存去哪了？答案通常在 buff/cache 那一列。</p>
<p>另一个方向：容器被 OOM Killer 杀掉，监控上的内存曲线却一直很平。因为监控采的是 RSS，而杀进程的是 cgroup 的内存上限，两者根本不是同一个数。</p>
<h2 id="free">free 的四个数不是一回事<a class="h-anchor" href="#free" aria-label="锚点">#</a></h2>
<p><code>free -m</code> 的输出里，total 是物理内存总量，free 是完全没有被使用的页，buff/cache 是块设备缓存和可回收的 slab，available 是内核估算的「不触发 swap 就能分配给新进程」的量。</p>
<p>available 才是判断内存够不够的依据。它由 free 加大部分可回收的 page cache，再减去不可回收部分和低水位保留得来。所以 free 很小、available 很大是健康状态：内存被拿去缓存文件了，需要时立刻能回收。</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>free<span class="w"> </span>-m
grep<span class="w"> </span>-E<span class="w"> </span><span class="s1">&#39;MemTotal|MemAvailable|Cached|Dirty|Writeback|Committed_AS|CommitLimit&#39;</span><span class="w"> </span>/proc/meminfo
</code></pre></div></div>

<p><code>Dirty</code> 是等着回写的页，<code>Writeback</code> 是正在回写的页。Dirty 长期很高说明写入速度超过了磁盘，该查的是 <code>dirty_ratio</code>（默认 20%）和 <code>dirty_background_ratio</code>（默认 10%），而不是加内存。</p>
<h2 id="_1">虚拟内存和常驻内存差在哪<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>进程的 VSZ 可以到几十 G，实际驻留的 RSS 可能只有几百 M。VSZ 包含已映射但从未触碰的匿名页、共享库、以及 <code>mmap</code> 的文件区间。<code>malloc</code> 拿到地址不等于拿到物理页，第一次写才触发缺页中断去分配。</p>
<p>RSS 也有水分：多个进程映射同一个共享库，每个进程的 RSS 都把这段算一遍。要看真实占用，用 PSS（按共享比例分摊）或 USS（独占）：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>smem<span class="w"> </span>-t<span class="w"> </span>-k<span class="w"> </span>-P<span class="w"> </span><span class="s1">&#39;java|nginx&#39;</span>
<span class="c1"># 内核 4.14+ 可以直接读汇总，比逐个解析 smaps 快</span>
awk<span class="w"> </span><span class="s1">&#39;/^Rss/{print $2/1024 &quot; MB&quot;}&#39;</span><span class="w"> </span>/proc/&lt;pid&gt;/smaps_rollup
</code></pre></div></div>

<p>采集脚本里用 <code>smaps_rollup</code>，一次系统调用拿到整个进程的汇总。逐个 <code>smaps</code> 里的每个 VMA 去读，进程映射一多就会明显拖慢采集端本身。</p>
<p>还有一个差异来自运行时是否愿意把内存还给内核。glibc 释放大块内存时会用 <code>madvise(MADV_DONTNEED)</code> 还回去，RES 会掉；JVM 的堆即使在 Full GC 之后通常也不缩，RES 只涨不跌。这解释了为什么同样是「内存泄漏」的曲线，有的服务能自己降下来，有的只能靠重启。</p>
<h2 id="free_1">页缓存：free 少不是浪费<a class="h-anchor" href="#free_1" aria-label="锚点">#</a></h2>
<p>Linux 会把空闲内存尽量用于 page cache。读文件时数据从磁盘进缓存，第二次读直接命中；读还有预读（readahead），顺序读时内核会提前把后面的页拉进来。写文件默认也先进缓存并标记 dirty，由回写线程在后台刷盘。</p>
<p>想知道某个文件有多少页真的在缓存里，用 <code>fincore &lt;file&gt;</code>（util-linux 自带），它按 <code>mincore</code> 的结果统计驻留页。排查「为什么这个读请求还是慢」时，它比看整机 free 有用得多。</p>
<p>这意味着两件事。第一，free 少是设计目标，不是故障。第二，OOM 发生前内核会先回收 page cache，回收不动才动手杀进程——所以「缓存把内存吃光了」这个说法在绝大多数情况下不成立。</p>
<p>真正不可回收的是匿名页：堆、栈、匿名 <code>mmap</code>。它们只能换出到 swap，swap 关闭时就只能被杀。判断方向很简单：<code>/proc/meminfo</code> 里 <code>AnonPages</code> 在涨还是 <code>Cached</code> 在涨，两者的处置方式完全相反。</p>
<p>有人习惯用 <code>echo 3 &gt; /proc/sys/vm/drop_caches</code> 来「释放内存」。它只是把缓存丢掉，下一次读还得从磁盘重新拉，除了做基准测试，生产上找不到用它的理由。</p>
<h2 id="oom-killer">OOM Killer 是怎么挑人的<a class="h-anchor" href="#oom-killer" aria-label="锚点">#</a></h2>
<p>内核在分配内存失败、且回收后仍无法满足时触发 OOM。日志长这样：</p>
<div class="code-card" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>Out of memory: Killed process 28471 (java) total-vm:8388608kB, anon-rss:7518204kB, file-rss:1024kB
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/app,task=java,pid=28471,uid=1000
</code></pre></div></div>

<p>第一行是被杀进程的内存构成，第二行是判定上下文。<code>task_memcg=/app</code> 说明这是 cgroup 级别的 OOM，不是整机 OOM，处理方式完全不同：整机 OOM 要找出真正的大户，cgroup OOM 要调 <code>memory.max</code> 或者修内存泄漏。</p>
<p><code>/proc/&lt;pid&gt;/oom_score</code> 是当前得分，<code>oom_score_adj</code> 可调（-1000 到 1000）。把关键进程设成 -1000 能显著降低被杀概率，但代价是别的进程替它死，不是护身符。</p>
<p>得分是按进程占用内存的比例算的，这带来一个反直觉的结果：一个刚启动、RSS 还很小但马上要吃满内存的进程，得分很低，它会一直膨胀到把别人挤死才被盯上。所以「OOM 杀错了人」这件事，多数时候不是内核判断错，而是被杀时它确实最大。</p>
<p>cgroup v2 下看计数：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>cat<span class="w"> </span>/sys/fs/cgroup/app/memory.max
cat<span class="w"> </span>/sys/fs/cgroup/app/memory.current
cat<span class="w"> </span>/sys/fs/cgroup/app/memory.events<span class="w">   </span><span class="c1"># oom / oom_kill 计数</span>
</code></pre></div></div>

<p><code>memory.events</code> 里 <code>oom</code> 是触发次数，<code>oom_kill</code> 是真正杀掉的次数。前者大于后者，说明出现过「差点被杀、但回收成功」的情况，这是扩容的早期信号，比等进程真死要早得多。</p>
<h2 id="_2">几个容易踩的边界<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p><code>vm.overcommit_memory=0</code> 是启发式，允许适度超额；<code>=1</code> 永远答应 malloc，风险自担；<code>=2</code> 严格按 CommitLimit，容易让正常进程申请失败。Redis 这类会 fork 的进程在 <code>=2</code> 下经常起不来。</p>
<p><code>vm.swappiness</code> 不是「用不用 swap」的开关，而是回收匿名页相对 page cache 的倾向。设 0 也只是降低倾向，不等于禁用。</p>
<p>还有透明大页（THP）。开启 <code>always</code> 时，进程的 RSS 会按 2M 对齐膨胀，<code>top</code> 里的 RES 看着比实际用量大不少。要准确统计就关掉它，或者用 <code>AnonHugePages</code> 单独核对。</p>
<p>cgroup v2 还有一层 <code>memory.high</code>。超过它不会立刻触发 OOM，而是把进程放进强制回收状态，速度变慢但能继续跑。用它做缓冲，比直接把 <code>memory.max</code> 卡死在峰值上更稳。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>指标</th>
<th>含义</th>
<th>怎么用</th>
</tr>
</thead>
<tbody>
<tr>
<td>free</td>
<td>完全空闲的页</td>
<td>小是正常的，别当告警</td>
</tr>
<tr>
<td>available</td>
<td>可安全分配的量</td>
<td>判断够不够看这个</td>
</tr>
<tr>
<td>Cached</td>
<td>页缓存</td>
<td>可回收，涨不一定是坏事</td>
</tr>
<tr>
<td>AnonPages</td>
<td>匿名页</td>
<td>不可回收，涨才是真吃内存</td>
</tr>
<tr>
<td>Dirty</td>
<td>待回写页</td>
<td>高说明盘慢，不是内存问题</td>
</tr>
</tbody>
</table></div>
<h2 id="psi">用 PSI 看真实压力<a class="h-anchor" href="#psi" aria-label="锚点">#</a></h2>
<p><code>/proc/pressure/memory</code> 给出 some 和 full 两行，<code>avg10</code> 是过去 10 秒的阻塞时间占比。some 表示有任务因内存不足停住，full 表示所有可运行任务都被卡住——后者非零，说明内存已经不是紧张而是告急了。</p>
<p>cgroup v2 的每个组也有自己的 <code>memory.pressure</code>。容器内存快不够时，先看这个值再决定扩不扩，比盯 RSS 曲线直观。它只统计本组任务的阻塞，所以能区分「是容器自己撑爆」还是「宿主整体紧张」。</p>
<h2 id="_3">结论<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<ul>
<li>判断内存够不够看 available，不看 free；free 小、cache 大是正常状态。</li>
<li>VSZ 是承诺，RSS 是现状，PSS 才接近真实占用；采集脚本优先用 <code>smaps_rollup</code>。</li>
<li>先区分整机 OOM 和 cgroup OOM，前者查大户，后者改 <code>memory.max</code> 或修泄漏。</li>
<li><code>memory.events</code> 的 oom 计数是扩容前的预警，比等进程被杀更早。</li>
<li>加内存之前先确认占用是匿名页还是可回收缓存，否则扩完还是同一条曲线。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>为什么你的程序在 Docker 里时间不对</title>
<link>https://blog.xtang.online/posts/docker-timezone-clock-namespace/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/docker-timezone-clock-namespace/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>容器里时间差 8 小时，多半不是时钟漂移，而是时区数据缺失。从 /etc/localtime 到 JVM 的时区缓存，逐层排查并给出可复现的修法。</description>
<content:encoded><![CDATA[<p>日志里每条记录都比北京时间晚 8 小时，容器里 <code>date</code> 打出来是 UTC。第一反应是 NTP 没同步，可宿主机上 <code>timedatectl</code> 显示时间准确无误。问题不在时钟，在时区。</p>
<p>数据库里的 <code>created_at</code> 也偏了 8 小时。更麻烦的是它不影响功能测试，只在跨天统计、定时任务、账单日切时冒出来，属于典型的「上线一周后才被发现」的问题。</p>
<h2 id="_1">先确认是不是时区问题<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>容器和宿主机共享内核，<code>CLOCK_REALTIME</code> 是同一个值，容器不可能自己走慢。能不一样的只有用户态对时间的解释：<code>/etc/localtime</code>、<code>TZ</code> 环境变量，以及语言运行时启动时缓存下来的那份副本。</p>
<p>在容器里跑这几条命令，基本能定性：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>date<span class="w"> </span>-u<span class="p">;</span><span class="w"> </span>date<span class="p">;</span><span class="w"> </span>ls<span class="w"> </span>-l<span class="w"> </span>/etc/localtime<span class="p">;</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;TZ=</span><span class="nv">$TZ</span><span class="s2">&quot;</span>
<span class="nv">TZ</span><span class="o">=</span>Asia/Shanghai<span class="w"> </span>date
</code></pre></div></div>

<p>如果 <code>date -u</code> 和宿主机一致、<code>date</code> 差 8 小时，就是时区配置问题，不用碰 NTP。<code>TZ=Asia/Shanghai date</code> 如果仍然输出 UTC，说明镜像里根本没有 zoneinfo 数据，设变量也没用。</p>
<h2 id="_2">三种修法，各有边界<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>做法</th>
<th>命令</th>
<th>适用</th>
<th>坑</th>
</tr>
</thead>
<tbody>
<tr>
<td>设 TZ 环境变量</td>
<td><code>-e TZ=Asia/Shanghai</code></td>
<td>镜像含 tzdata</td>
<td>无数据时静默退回 UTC</td>
</tr>
<tr>
<td>挂载宿主时区文件</td>
<td><code>-v /etc/localtime:/etc/localtime:ro</code></td>
<td>临时调试、单机</td>
<td>宿主换时区后容器不跟随</td>
</tr>
<tr>
<td>镜像内装 tzdata</td>
<td><code>apk add --no-cache tzdata</code></td>
<td>生产镜像</td>
<td>增加几 MB，要清缓存</td>
</tr>
</tbody>
</table></div>
<p>最稳的是第三种：镜像自带 tzdata，再用 <code>TZ</code> 指定。挂载宿主文件适合临时排查，不适合长期方案，因为镜像从此依赖宿主配置，换台机器就可能不一样。</p>
<div class="code-card" data-lang="dockerfile"><div class="code-head"><span class="code-lang">dockerfile</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">FROM</span><span class="w"> </span><span class="s">alpine:3.20</span>
<span class="k">RUN</span><span class="w"> </span>apk<span class="w"> </span>add<span class="w"> </span>--no-cache<span class="w"> </span>tzdata<span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span>cp<span class="w"> </span>/usr/share/zoneinfo/Asia/Shanghai<span class="w"> </span>/etc/localtime<span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;Asia/Shanghai&quot;</span><span class="w"> </span>&gt;<span class="w"> </span>/etc/timezone
<span class="k">ENV</span><span class="w"> </span><span class="nv">TZ</span><span class="o">=</span>Asia/Shanghai
</code></pre></div></div>

<p>注意 <code>apk add</code> 之后要清 <code>/var/cache/apk</code>，否则镜像白白大几 MB。</p>
<p>还有一处语法陷阱：<code>TZ=UTC+8</code> 不是「东八区」。POSIX 的 TZ 字符串里符号是反的，<code>CST-8</code> 才表示 UTC+8。写错方向会得到差 16 小时的结果，而且程序不报错。</p>
<p>验证时别用 <code>docker exec</code> 进去临时 <code>export TZ</code>。exec 启动的 shell 继承镜像的 <code>ENV</code>，你在会话里 export 只影响这个 shell，服务进程仍然用旧值。要验证就重新 <code>docker run</code> 一个容器，或者直接看进程的环境：<code>tr '\0' '\n' &lt; /proc/&lt;pid&gt;/environ | grep TZ</code>。</p>
<h2 id="_3">挂载单文件为什么有时不生效<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p><code>-v /etc/localtime:/etc/localtime:ro</code> 在 Linux 宿主上通常有效，但有两个前提容易忽略。</p>
<p>第一，Docker Desktop（macOS、Windows）跑在一台 Linux 虚拟机里，<code>/etc/localtime</code> 是虚拟机的文件，不是宿主系统的时区，挂进去可能还是 UTC。</p>
<p>第二，镜像里的 <code>/etc/localtime</code> 常常是指向 <code>/usr/share/zoneinfo/Etc/UTC</code> 的软链接。只挂这一个文件不会同步整套 zoneinfo，依赖 <code>/etc/timezone</code> 或按名字查时区的程序仍然拿到旧值，两套配置互相矛盾。</p>
<p>要挂就挂整个目录：<code>-v /usr/share/zoneinfo:/usr/share/zoneinfo:ro</code>，再配合 <code>TZ</code>。这样既不改镜像，也不会破坏软链接结构。</p>
<h2 id="_4">语言运行时各有各的缓存<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>改完系统时区，跑着的进程不一定立刻生效，因为很多运行时在启动时就把时区读走了：</p>
<ul>
<li><strong>Java</strong>：JVM 启动时确定默认时区，之后改 <code>/etc/localtime</code> 无效。用 <code>-Duser.timezone=Asia/Shanghai</code>，或保证容器启动前 <code>TZ</code> 已设置。验证命令：<code>java -XshowSettings:properties -version 2&gt;&amp;1 | grep user.timezone</code>。</li>
<li><strong>Python</strong>：<code>time.tzset()</code> 会重新读 TZ；<code>zoneinfo.ZoneInfo("Asia/Shanghai")</code> 依赖系统 tzdata，缺失时抛 <code>ZoneInfoNotFoundError</code>，可以装 <code>tzdata</code> 包兜底。</li>
<li><strong>Go</strong>：<code>time.Local</code> 启动时读 TZ，静态编译的二进制在 scratch 镜像里没有 zoneinfo，需要 <code>-tags timetzdata</code> 把数据编进去，或把 <code>/usr/share/zoneinfo</code> 拷进镜像。</li>
<li><strong>Node.js</strong>：启动时读 TZ，运行中改 <code>process.env.TZ</code> 的行为在不同版本上并不一致，别依赖它。</li>
</ul>
<p>这些都是「进程启动那一刻」的快照，不是实时查询。所以配置时区的正确时机是容器启动之前，而不是出事之后。</p>
<p>Java 连 MySQL 还有一层：JDBC 的 <code>connectionTimeZone</code>（旧版本叫 <code>serverTimezone</code>）决定驱动怎么解释 <code>TIMESTAMP</code>，和 JVM 时区是两套独立配置。只改了 <code>-Duser.timezone</code> 却没动 JDBC 参数，写进库里的值照样会偏。</p>
<h2 id="_5">时区对了，时间还差几秒<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<p>这类问题的偏差不是 8 小时，而是几百毫秒到几秒，而且只在长时间运行后显现。原因通常是宿主或虚拟机的时钟漂移。</p>
<p>容器共享宿主内核时钟，容器内无权设置时间（需要 <code>CAP_SYS_TIME</code>），只能由宿主同步。在宿主上 <code>chronyc tracking</code> 看当前偏移和漂移率，<code>chronyc sources -v</code> 看时间源是否可达。chrony 多数时候是渐进校正，偏差超过 <code>makestep</code> 阈值时才会跳变，所以你会看到偏移慢慢收敛，而不是「一 synced 就准」。</p>
<p>注意 NTP 校正的是墙钟，<code>CLOCK_MONOTONIC</code> 不受影响。这也是为什么同一个进程里，测耗时用单调时钟更可靠。</p>
<h2 id="_6">时间命名空间管不了时区<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>Linux 有 time namespace，可以偏移 <code>CLOCK_MONOTONIC</code> 和 <code>CLOCK_BOOTTIME</code>，但 <code>CLOCK_REALTIME</code> 是全局的，Docker 默认也不给容器独立的 time namespace。换句话说，容器时间不可能靠命名空间和宿主错开，能错的只有解释方式。</p>
<p>顺带一个容易被忽略的点：<code>CLOCK_MONOTONIC</code> 不受 NTP 调整影响，测耗时应该用它，而不是 <code>time.time()</code> 这类墙钟。墙钟会因为闰秒、NTP 回拨跳变，定时器算出负的间隔就是这么来的。</p>
<h2 id="_7">定时任务为什么在凌晨跑<a class="h-anchor" href="#_7" aria-label="锚点">#</a></h2>
<p>服务里有个每天 00:10 执行的清理任务，上线后总在北京时间 08:10 才跑。日志上看任务确实执行了，只是晚了 8 小时——因为容器是 UTC，cron 按容器的时区解释那行 <code>10 0 * * *</code>。</p>
<p>修复要同时确认三件事：容器 TZ、cron 守护进程自己的时区、调度系统的解析口径。cronie（RHEL 系）支持在 crontab 顶部写 <code>CRON_TZ=Asia/Shanghai</code>，Debian 自带的 cron 不认这个变量，只能依赖容器 TZ。Kubernetes 的 CronJob 默认按 UTC 解析 schedule，新版本可以用 <code>timeZone</code> 字段显式指定，升级集群时记得复查这个字段。</p>
<h2 id="utc">存储用 UTC，展示再转换<a class="h-anchor" href="#utc" aria-label="锚点">#</a></h2>
<p>时区配好只是第一步。数据库里存什么，决定了以后换时区会不会出事。</p>
<p>MySQL 的 <code>TIMESTAMP</code> 会按会话时区转换，<code>DATETIME</code> 不会。用 <code>SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();</code> 看清当前会话的偏移。PostgreSQL 的 <code>timestamptz</code> 存 UTC、显示时按 <code>TimeZone</code> 参数转换，<code>SHOW timezone;</code> 可查。</p>
<p>我倾向的约定是：存储层统一 UTC，接口和日志用带偏移的 ISO 8601（<code>2026-11-11T14:00:00+08:00</code>），只在最终展示时转本地时间。这样跨时区部署、跨年查询都不会出现「少了一天」。</p>
<div class="callout callout-warning"><div class="callout-label">注意</div>
<p>改数据库时区参数会改变已有数据的解释，不是纯配置变更。先在测试库验证，再备份后操作。</p>
</div>
<p>日志时间戳也建议固定格式，别用本地化字符串。Java 的 logback 用 <code>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}</code>，Go 用 <code>time.RFC3339Nano</code>，Python 用 <code>datetime.now(timezone.utc).isoformat()</code>。带偏移的字符串在任何时区的机器上都能还原成同一时刻，排查时不用再问「这条是哪个时区打的」。</p>
<h2 id="_8">结论<a class="h-anchor" href="#_8" aria-label="锚点">#</a></h2>
<ul>
<li>容器时间不对，先比 <code>date -u</code> 和 <code>date</code>，确认是时区解释问题而不是时钟漂移。</li>
<li>生产镜像自带 tzdata 并设 <code>TZ</code>，挂载 <code>/etc/localtime</code> 只适合临时排查。</li>
<li>运行时在启动时缓存时区，配置必须在容器启动前完成。</li>
<li>存储统一 UTC，展示层再转换；<code>TIMESTAMP</code> 和 <code>DATETIME</code> 的语义别混用。</li>
<li>POSIX 的 TZ 字符串符号与直觉相反，不确定就用 IANA 名称。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>文件删了空间没释放？inode 与文件描述符的真相</title>
<link>https://blog.xtang.online/posts/deleted-file-space-inode-fd/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/deleted-file-space-inode-fd/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>rm 之后 df 还是满的，多半是进程还握着文件描述符。从 inode 引用计数讲到 df -i，把「删了没释放」的几种情况一次说清。</description>
<content:encoded><![CDATA[<p><code>rm -f app.log</code> 删掉一个 40G 的日志，<code>df -h</code> 一看使用率还是 96%。<code>du -sh /var/log</code> 却只有 2G。这不是文件系统算错了，是文件还在，只是名字没了。</p>
<p>另一台机器报 <code>No space left on device</code>，<code>df -h</code> 显示还有 30% 空闲，<code>df -i</code> 的 IUse% 却是 100%。空间够、inode 不够，一样写不进新文件。两种「磁盘满」，处置方式完全不同。</p>
<p>还有第三种：Kubernetes 节点上 <code>ephemeral-storage</code> 报警，节点 <code>df</code> 看着正常，实际是被某个容器的可写层吃掉了。这属于 kubelet 的本地存储记账，<code>df</code> 只统计挂载点，看不到这一层。</p>
<h2 id="unlink">unlink 删的是名字，不是数据<a class="h-anchor" href="#unlink" aria-label="锚点">#</a></h2>
<p>Linux 里文件名只是目录项到 inode 的映射。<code>rm</code> 调用 <code>unlink</code>，把目录项去掉，inode 的链接计数减一。只有当链接计数为 0、且没有任何进程持有打开的文件描述符时，内核才释放数据块。</p>
<p>所以进程还开着这个文件时，空间不会回来。<code>lsof +L1</code> 专门找链接数小于 1 的已打开文件：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>lsof<span class="w"> </span>+L1
lsof<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-i<span class="w"> </span>deleted
<span class="c1"># 不装 lsof 时，直接扫 procfs</span>
ls<span class="w"> </span>-l<span class="w"> </span>/proc/*/fd<span class="w"> </span><span class="m">2</span>&gt;/dev/null<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>deleted
</code></pre></div></div>

<p>输出里的 <code>DEL</code> 或 <code>(deleted)</code> 标记就是证据。<code>lsof +L1</code> 比 <code>lsof | grep deleted</code> 快，因为它按链接数过滤，不用遍历所有条目再匹配字符串。</p>
<h2 id="_1">释放空间的两个动作<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>确认了 PID 和 fd，有两条路：</p>
<ol>
<li><strong>重启进程</strong>。最稳，代价是服务中断。</li>
<li><strong>原地截断</strong>。<code>truncate -s 0 /proc/&lt;pid&gt;/fd/&lt;fd&gt;</code>，或者 shell 里 <code>: &gt; /proc/&lt;pid&gt;/fd/&lt;fd&gt;</code>。</li>
</ol>
<p>第二条有前提：这个 fd 必须以可写方式打开，进程有权限，截断的是同一个 inode。另外 fd 的文件偏移不会重置。如果进程不是以 <code>O_APPEND</code> 打开，下一次写会落在旧偏移处，文件变成稀疏文件，<code>du</code> 看着小、<code>ls -l</code> 看着大。日志框架多用 append，一般没事，自己写的 writer 要留意。</p>
<h2 id="_2">为什么日志轮转后空间还是没回来<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>这是线上最常见的一幕：<code>logrotate</code> 配了 <code>create</code> 模式，把 <code>app.log</code> 改名成 <code>app.log.1</code>，再建一个新的空文件。nginx 之类持有旧 fd 的进程继续往 <code>app.log.1</code> 里写，新文件永远是空的。</p>
<p><code>copytruncate</code> 模式看起来能解决，因为它复制完直接截断原文件，但代价是复制和截断之间写入的日志会丢。要在「可能丢日志」和「空间不释放」之间选，我选后者：把轮转配置改成 <code>create</code>，并在轮转后给进程发信号（nginx 用 <code>USR1</code> 重开文件），日志和空间都能保住。</p>
<h2 id="_3">空间被谁吃了：按目录下钻<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>确认不是已删除文件之后，才轮到找大目录。我习惯按这个顺序：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>du<span class="w"> </span>-x<span class="w"> </span>--max-depth<span class="o">=</span><span class="m">1</span><span class="w"> </span>/var<span class="w"> </span><span class="p">|</span><span class="w"> </span>sort<span class="w"> </span>-h<span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-10
find<span class="w"> </span>/<span class="w"> </span>-xdev<span class="w"> </span>-type<span class="w"> </span>f<span class="w"> </span>-size<span class="w"> </span>+1G<span class="w"> </span>-printf<span class="w"> </span><span class="s1">&#39;%s %p\n&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sort<span class="w"> </span>-n<span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-10
stat<span class="w"> </span>-f<span class="w"> </span>/var
</code></pre></div></div>

<p><code>-x</code> 保证不跨文件系统，<code>sort -h</code> 按人类可读单位排序。<code>find</code> 的 <code>-size +1G</code> 只匹配大文件，比全量 <code>du</code> 快得多，适合在几百万文件的机器上快速定位。<code>ncdu</code> 交互式浏览更直观，但遍历开销一样，大目录上要等。</p>
<p><code>stat -f</code> 给出块大小、总块数和可用块数。当 <code>df</code> 和 <code>du</code> 死活对不上时，先确认两条命令看的是不是同一个挂载点——路径上多一层 bind mount，结论就会完全不同。</p>
<h2 id="inode">inode 也会被用光<a class="h-anchor" href="#inode" aria-label="锚点">#</a></h2>
<p><code>df -i</code> 看 inode 使用率。ext4 在 <code>mkfs</code> 时按 <code>-i</code> 参数固定（默认每 16KB 一个 inode），之后只能靠扩容按比例增加，不能凭空变多。XFS 动态分配，相对不容易撞上。</p>
<p>inode 耗尽的典型场景：海量小文件、session 文件、邮件队列、容器层里成千上万的碎文件。表现和磁盘满一模一样，但 <code>df -h</code> 正常，只有 <code>df -i</code> 能看出来。</p>
<p>这个方法主要对 ext4 这类预分配 inode 的文件系统有意义。btrfs 和 xfs 的 inode 是动态分配的，<code>df -i</code> 的数字会一直涨，拿它做告警阈值会误报。</p>
<h2 id="df-du">df 和 du 对不上，还有这几种原因<a class="h-anchor" href="#df-du" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>验证方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>df 满、du 小</td>
<td>进程持有已删除文件</td>
<td><code>lsof +L1</code></td>
</tr>
<tr>
<td>df 满、du 也满</td>
<td>真有大文件</td>
<td><code>du -x --max-depth=1 /</code></td>
</tr>
<tr>
<td>df 有空间但写入报 ENOSPC</td>
<td>inode 耗尽</td>
<td><code>df -i</code></td>
</tr>
<tr>
<td>root 能写、普通用户不能</td>
<td>ext4 保留块</td>
<td><code>tune2fs -l</code> 看 Reserved block count</td>
</tr>
<tr>
<td>df 满但找不到大文件</td>
<td>挂载点遮住了原目录</td>
<td><code>findmnt</code>、<code>du -x</code></td>
</tr>
<tr>
<td>删了目录仍报忙</td>
<td>有进程 cwd 在里面</td>
<td><code>ls -l /proc/*/cwd</code></td>
</tr>
</tbody>
</table></div>
<p><code>du -x</code> 的 <code>-x</code> 很重要：不加它，<code>du</code> 会跨文件系统统计，把挂载进来的网络盘也算进去，结果自然对不上。</p>
<p>硬链接是另一种「删不干净」。文件有两个名字时，删掉一个只是链接计数从 2 变 1，数据还在。用 <code>find / -xdev -inum &lt;inode号&gt;</code> 能把剩下的名字找出来。</p>
<h2 id="_4">另一个上限：文件描述符<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>inode 是文件系统的配额，fd 是进程的配额，两者都会以「资源不够」的形式报错。报 <code>Too many open files</code> 时按顺序看三个值：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="nb">ulimit</span><span class="w"> </span>-n<span class="w">                      </span><span class="c1"># 当前 shell 的软限，常见 1024</span>
cat<span class="w"> </span>/proc/sys/fs/file-nr<span class="w">       </span><span class="c1"># 已分配 / 未用 / 内核上限</span>
cat<span class="w"> </span>/proc/&lt;pid&gt;/limits<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span><span class="s1">&#39;open files&#39;</span>
</code></pre></div></div>

<p>systemd 服务要改 <code>LimitNOFILE</code>，注意写成 <code>infinity</code> 时实际会被内核截到 <code>fs.nr_open</code>（默认约 100 万），不是真的无限。调大之前先确认不是 fd 泄漏：<code>ls /proc/&lt;pid&gt;/fd | wc -l</code> 隔一段时间看两次，只涨不跌就是泄漏。</p>
<h2 id="_5">删掉的文件还能找回吗<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<p>有进程开着的时候最简单：<code>cp /proc/&lt;pid&gt;/fd/&lt;n&gt; /tmp/recover.log</code> 直接把数据拷出来。所以发现误删的第一件事是别重启进程。</p>
<p>没进程持有就麻烦了。ext4 上可以试 <code>debugfs -R 'lsdel' /dev/sdX</code>，但延迟分配、<code>discard</code> 和 inode 复用会让成功率很低。这也是为什么备份比恢复工具靠谱——文件系统不承诺删除可恢复。</p>
<h2 id="_6">进程的根目录也会骗人<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>容器里的问题经常漏看：在宿主上跑 <code>lsof</code>，看到的只是宿主的 mount namespace。容器内已删除但仍被持有的文件，要么不出现，要么路径指向一个宿主上不存在的位置，<code>du</code> 遍历自然也走不到。</p>
<p>对这类进程，<code>nsenter -t &lt;pid&gt; -m lsof +L1</code> 才能看到容器视角的文件表；<code>ls -l /proc/&lt;pid&gt;/root/</code> 能看到它的根目录挂的是什么。这也是「节点磁盘满了，<code>du /</code> 却找不到大户」的常见解释。</p>
<h2 id="_7">被遮住的挂载点<a class="h-anchor" href="#_7" aria-label="锚点">#</a></h2>
<p>如果往一个非空目录上 <code>mount</code> 了别的文件系统，原目录里的文件并没有删，只是被遮住了。<code>df</code> 统计的是挂载后的文件系统，<code>du</code> 也看不到被遮住的内容，但空间确实被占着。卸载挂载点，或者 <code>mount --bind</code> 到别处就能看到。这类问题通常出现在手工运维过的机器上。</p>
<p>ext4 默认给 root 保留 5% 的空间，<code>df</code> 对普通用户显示的 available 会比 root 少。数据盘上这个比例纯属浪费，<code>tune2fs -m 1 /dev/sdX</code> 可以调到 1%，但系统盘不建议动。</p>
<h2 id="_8">结论<a class="h-anchor" href="#_8" aria-label="锚点">#</a></h2>
<ul>
<li><code>rm</code> 只删目录项，进程还开着文件时空间不释放；用 <code>lsof +L1</code> 定位，别急着重启。</li>
<li>原地截断可行，但要确认 fd 可写和偏移量行为，否则会得到稀疏文件。</li>
<li><code>No space left on device</code> 先看 <code>df -i</code>，inode 和容量是两套配额。</li>
<li><code>df</code> 与 <code>du</code> 不一致时按表逐项排除，<code>du</code> 记得加 <code>-x</code>。</li>
<li>长期方案是日志轮转配合正确的重开信号，而不是出事之后手工找文件。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>从输入 URL 到页面出现：一次完整的请求之旅</title>
<link>https://blog.xtang.online/posts/url-to-page-request-journey/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/url-to-page-request-journey/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>地址栏回车之后，浏览器做了十几件事。用 Navigation Timing 把 DNS、TLS、TTFB 和渲染拆开，白屏三秒才能定位到具体哪一段。</description>
<content:encoded><![CDATA[<p>在地址栏敲下回车，三秒后页面才出现。这三秒可能花在 DNS、TLS、服务端处理、下载 HTML，也可能 HTML 早就到了，卡在 CSS 和同步脚本上。不拆开看，优化就是碰运气。</p>
<p>浏览器也不是单进程。地址栏属于 browser 进程，网络请求交给 network service，页面解析和渲染在 renderer 进程。一次导航要在这三个进程之间走一圈，任何一环都能成为瓶颈。</p>
<h2 id="_1">从按键到发出请求<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>browser 进程先判断输入是 URL 还是搜索词。是 URL 就做规范化：补 scheme、处理 IDNA（中文域名转 punycode）、查 HSTS 列表决定是否强制 https。</p>
<p>如果域名在 HSTS 预加载列表里，即使用户输入 <code>http://</code>，浏览器也会直接走 https，不会发出明文请求。这一步在 DevTools 的 Network 面板里看不到，但在 <code>chrome://net-export</code> 的日志里能看到 HSTS 决策记录。</p>
<p>然后是找连接：先看 socket 池里有没有到同源的可复用连接，没有才走 DNS。DNS 缓存有好几层——浏览器自身、操作系统 resolver，可能还有 DoH。任何一层命中，<code>domainLookupEnd - domainLookupStart</code> 都会接近 0，此时纠结 DNS 优化没有意义。</p>
<h2 id="_2">握手成本取决于物理距离<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>TCP 三次握手一个 RTT，TLS 1.3 一次往返完成，TLS 1.2 要两次。同城机房 RTT 1ms 时这点开销可以忽略；跨洋 RTT 150ms 时，新建连接光握手就 300ms 以上，还没算 HTTP 请求本身。</p>
<p>这就是连接复用的价值。HTTP/2 在一条连接上多路复用，多个资源共享同一次握手；HTTP/3 基于 QUIC，把传输握手和 TLS 合并，还支持 0-RTT 恢复。<code>performance.getEntriesByType('navigation')[0].nextHopProtocol</code> 会告诉你实际用的是 <code>h2</code>、<code>h3</code> 还是 <code>http/1.1</code>。</p>
<p>重定向是另一个隐形开销。一次 302 意味着新的请求，若跨域还要重新 DNS 加握手。登陆后跳转、<code>http</code> 到 <code>https</code>、带不带尾斜杠的规范化，都可能在关键路径上叠两三个 RTT。<code>nav.redirectCount</code> 大于 0 时就该查。</p>
<p>第三方域名可以用 <code>preconnect</code> 提前完成 DNS、TCP 和 TLS，用 <code>dns-prefetch</code> 只做解析。但 preconnect 会占用连接槽，给七八个域都加上，收益可能变成负数。</p>
<h2 id="_3">首字节之后才是大头<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>HTML 到达后，renderer 开始解析。这里有个常被忽略的机制：preload scanner 会在主解析器被阻塞时，提前扫描后面的 HTML，发现 <code>&lt;script src&gt;</code> 和 <code>&lt;link rel=stylesheet&gt;</code> 就去预加载。它不执行脚本，只负责尽早发起请求。</p>
<p>所以关键路径通常是：CSS 阻塞渲染，同步脚本阻塞解析。给脚本加 <code>defer</code> 会延后到解析完成、按顺序执行；<code>async</code> 是下载完就执行，顺序不保证。两者都不阻塞 HTML 解析，但 <code>defer</code> 脚本会在 <code>DOMContentLoaded</code> 之前跑完。</p>
<p><code>DOMContentLoaded</code> 和 <code>load</code> 是两回事：前者是 DOM 就绪（defer 脚本执行完），后者要等图片、iframe 等所有资源。用 <code>load</code> 衡量白屏是错的，白屏对应的是首次绘制，也就是 FCP。</p>
<p>字体是常见的隐性阻塞点。自定义字体没加载完时，浏览器可能先不显示文字（FOIT），<code>font-display: swap</code> 能让它先用后备字体渲染，代价是可能闪一下。</p>
<p>还有一个顺序问题：CSS 里的 <code>@import</code> 是串行加载的，A 里的 <code>@import B</code> 意味着解析到 A 之后才发现 B。首屏关键样式直接内联，能省掉一次往返；首屏用不到的样式用 <code>media</code> 属性拆出去，别一股脑塞进主样式表。</p>
<h2 id="_4">服务端那段时间在做什么<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>TTFB 是 <code>requestStart</code> 到 <code>responseStart</code>，中间包含服务端从内核 accept 到写回第一个字节的全过程。常见的排队点有四个：accept 队列、线程或进程池、数据库连接池、上游依赖。</p>
<p>判断方法是对比服务端自己的埋点。如果应用日志显示处理只花了 20ms，而 TTFB 是 800ms，那时间不在业务代码里，八成卡在入口排队或回程网络。<code>ss -lnt</code> 的 Recv-Q 持续非 0，说明 accept 队列有堆积，这时候调大 backlog 只是缓解，真正的问题在消费速度。</p>
<h2 id="_5">预加载不是银弹<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<p><code>&lt;link rel=preload&gt;</code> 只是告诉浏览器「这个资源马上要用，提前下载」，它不改变阻塞关系，也不会自动提升执行优先级。用错了反而抢走关键资源的带宽。</p>
<p>现代浏览器支持 <code>fetchpriority="high"</code>，给 LCP 图片或首屏字体标上，比堆一堆 preload 更直接。判断标准很简单：被 preload 的资源必须在首屏渲染的关键路径上，否则只是提前占带宽。</p>
<h2 id="_6">二次访问为什么快<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>回访时看 <code>nav.type</code>：正常导航是 <code>navigate</code>，从历史记录返回可能是 <code>back_forward</code>。bfcache 会把整页状态冻结在内存里，返回时几乎瞬间恢复；资源命中强缓存时 <code>transferSize</code> 为 0。</p>
<p>「用户说快、测试说慢」的争论，很多时候就出在这里：测试用的是无缓存的新会话，用户走的是 bfcache。</p>
<h2 id="navigation-timing">用 Navigation Timing 量化每一段<a class="h-anchor" href="#navigation-timing" aria-label="锚点">#</a></h2>
<p>在页面控制台跑这段，直接得到各阶段耗时：</p>
<div class="code-card" data-lang="js"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kd">const</span><span class="w"> </span><span class="nx">nav</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">performance</span><span class="p">.</span><span class="nx">getEntriesByType</span><span class="p">(</span><span class="s1">&#39;navigation&#39;</span><span class="p">)[</span><span class="mf">0</span><span class="p">];</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">ms</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nx">a</span><span class="p">,</span><span class="w"> </span><span class="nx">b</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">nav</span><span class="p">[</span><span class="nx">b</span><span class="p">]</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nx">nav</span><span class="p">[</span><span class="nx">a</span><span class="p">]);</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">table</span><span class="p">({</span>
<span class="w">  </span><span class="nx">DNS</span><span class="o">:</span><span class="w"> </span><span class="nx">ms</span><span class="p">(</span><span class="s1">&#39;domainLookupStart&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;domainLookupEnd&#39;</span><span class="p">),</span>
<span class="w">  </span><span class="nx">TCP</span><span class="o">:</span><span class="w"> </span><span class="nx">ms</span><span class="p">(</span><span class="s1">&#39;connectStart&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;connectEnd&#39;</span><span class="p">),</span>
<span class="w">  </span><span class="c1">// 复用连接时 secureConnectionStart 为 0，必须单独判断</span>
<span class="w">  </span><span class="nx">TLS</span><span class="o">:</span><span class="w"> </span><span class="nx">nav</span><span class="p">.</span><span class="nx">secureConnectionStart</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">nav</span><span class="p">.</span><span class="nx">connectEnd</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nx">nav</span><span class="p">.</span><span class="nx">secureConnectionStart</span><span class="p">)</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="mf">0</span><span class="p">,</span>
<span class="w">  </span><span class="nx">TTFB</span><span class="o">:</span><span class="w"> </span><span class="nx">ms</span><span class="p">(</span><span class="s1">&#39;requestStart&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;responseStart&#39;</span><span class="p">),</span>
<span class="w">  </span><span class="s1">&#39;HTML 下载&#39;</span><span class="o">:</span><span class="w"> </span><span class="nx">ms</span><span class="p">(</span><span class="s1">&#39;responseStart&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;responseEnd&#39;</span><span class="p">),</span>
<span class="w">  </span><span class="s1">&#39;DOM 就绪&#39;</span><span class="o">:</span><span class="w"> </span><span class="nx">ms</span><span class="p">(</span><span class="s1">&#39;responseEnd&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;domContentLoadedEventEnd&#39;</span><span class="p">),</span>
<span class="w">  </span><span class="s1">&#39;总耗时&#39;</span><span class="o">:</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">nav</span><span class="p">.</span><span class="nx">duration</span><span class="p">),</span>
<span class="p">});</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;协议:&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">nav</span><span class="p">.</span><span class="nx">nextHopProtocol</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;重定向:&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">nav</span><span class="p">.</span><span class="nx">redirectCount</span><span class="p">);</span>

<span class="ow">new</span><span class="w"> </span><span class="nx">PerformanceObserver</span><span class="p">((</span><span class="nx">list</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">e</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="nx">list</span><span class="p">.</span><span class="nx">getEntries</span><span class="p">())</span><span class="w"> </span><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;LCP&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">startTime</span><span class="p">),</span><span class="w"> </span><span class="nx">e</span><span class="p">.</span><span class="nx">element</span><span class="p">);</span>
<span class="p">}).</span><span class="nx">observe</span><span class="p">({</span><span class="w"> </span><span class="nx">type</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;largest-contentful-paint&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">buffered</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">});</span>
</code></pre></div></div>

<p>如果 TTFB 就有 2.5 秒，问题在服务端或网络，前端优化没意义。如果 TTFB 200ms 而 LCP 在 3 秒，就是资源加载或渲染阻塞。跨域资源的详细 timing 需要对方返回 <code>Timing-Allow-Origin</code>，否则很多字段是 0。</p>
<h2 id="_7">一次白屏的排查顺序<a class="h-anchor" href="#_7" aria-label="锚点">#</a></h2>
<p>线上反馈「打开很慢」时，我不会先开 Performance 面板，而是按固定顺序走：</p>
<ol>
<li>Network 面板按时间排序，看第一个请求的 TTFB。超过 800ms 就先查后端，前端先不动。</li>
<li>看 Waterfall 里有没有被阻塞的 CSS 或同步脚本，<code>Content Download</code> 段很长说明包太大。</li>
<li>Console 里跑上面的脚本，确认 <code>nextHopProtocol</code> 和 LCP 对应的元素。</li>
<li>最后才看火焰图和长任务，此时已经知道该看哪一段。</li>
</ol>
<p>这套顺序的价值是排除：每一步都可能直接结束排查，不用在面板里翻十分钟。顺手记一下 <code>document.readyState</code>，它只有 loading、interactive、complete 三个值，和 <code>DOMContentLoaded</code>、<code>load</code> 大致对应；写自动化脚本判断页面可用，用它比等固定秒数可靠。</p>
<h2 id="_8">按阶段对照优化手段<a class="h-anchor" href="#_8" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>阶段</th>
<th>指标</th>
<th>常见原因</th>
<th>手段</th>
</tr>
</thead>
<tbody>
<tr>
<td>DNS</td>
<td><code>domainLookup</code></td>
<td>解析慢、无缓存</td>
<td>预连接、减少域名、DoH</td>
</tr>
<tr>
<td>连接</td>
<td><code>connectStart</code>→<code>connectEnd</code></td>
<td>跨地域、无复用</td>
<td>HTTP/2、连接池、CDN</td>
</tr>
<tr>
<td>等待</td>
<td>TTFB</td>
<td>后端慢、排队</td>
<td>缓存、索引、流式输出</td>
</tr>
<tr>
<td>下载</td>
<td><code>responseEnd</code></td>
<td>包太大</td>
<td>压缩、分包、按需加载</td>
</tr>
<tr>
<td>渲染</td>
<td>FCP / LCP</td>
<td>CSS 阻塞、大图、字体</td>
<td>内联关键 CSS、<code>fetchpriority</code></td>
</tr>
</tbody>
</table></div>
<h2 id="_9">结论<a class="h-anchor" href="#_9" aria-label="锚点">#</a></h2>
<ul>
<li>白屏先量 TTFB：它慢就是后端和网络，别在前端打转。</li>
<li>复用连接能省一整个 RTT 以上，跨地域时尤其明显；确认 <code>nextHopProtocol</code> 是不是 <code>h2</code>。</li>
<li><code>DOMContentLoaded</code> 不等于用户看到内容，用 FCP 和 LCP 衡量首屏。</li>
<li>同步脚本和 CSS 是渲染阻塞的主要来源，<code>defer</code> 只解决前者。</li>
<li>采集脚本要处理 <code>secureConnectionStart</code> 为 0 的情况，否则 TLS 耗时是错的。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>为什么 0.1 + 0.2 不等于 0.3</title>
<link>https://blog.xtang.online/posts/float-precision-money/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/float-precision-money/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>0.1 + 0.2 得到 0.30000000000000004，不是语言有 bug，而是二进制浮点的必然结果。讲清表示误差、舍入规则和金额的正确存法。</description>
<content:encoded><![CDATA[<p>对账脚本差了 1 分钱，查了两小时，问题出在一行 <code>0.1 + 0.2</code>。控制台打印 <code>0.30000000000000004</code>。这不是 JavaScript 的毛病，Python、Java、Go 里结果一样，因为大家用的都是 IEEE 754 双精度。</p>
<p>更麻烦的是它只在特定数值上暴露。测试用例里的 0.1 和 0.2 看着正常，换成 19.99 加 0.01 就少一分。误差是否显现，取决于二进制表示和后续运算，所以「本地测过没问题」完全不能作为依据。</p>
<h2 id="01">0.1 在二进制里是无限循环<a class="h-anchor" href="#01" aria-label="锚点">#</a></h2>
<p>双精度（binary64）用 1 位符号、11 位指数、52 位尾数表示，加上隐含的前导 1，有效精度 53 位。它能精确表示 1/2、1/4 这类分母是 2 的幂的数，表示不了 1/10——就像十进制写不出 1/3。</p>
<p>判断一个小数能否精确表示有个简单办法：不断乘 2 取小数部分，能归零就能精确表示。0.5 → 1.0，一步到零；0.1 → 0.2 → 0.4 → 0.8 → 1.6 取 0.6 → 1.2 取 0.2，进入 0.2 → 0.4 → 0.8 → 0.6 的循环，永远到不了 0。0.1 的二进制是 <code>0.0001100110011...</code>，存进去的是最接近的可表示值：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="o">&gt;&gt;&gt;</span> <span class="mf">0.1</span>
<span class="mf">0.1</span>
<span class="o">&gt;&gt;&gt;</span> <span class="kn">from</span> <span class="nn">decimal</span> <span class="kn">import</span> <span class="n">Decimal</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">Decimal</span><span class="p">(</span><span class="mf">0.1</span><span class="p">)</span>
<span class="n">Decimal</span><span class="p">(</span><span class="s1">&#39;0.1000000000000000055511151231257827021181583404541015625&#39;</span><span class="p">)</span>
<span class="o">&gt;&gt;&gt;</span> <span class="mf">0.1</span> <span class="o">+</span> <span class="mf">0.2</span>
<span class="mf">0.30000000000000004</span>
</code></pre></div></div>

<p>解释器打印的是「能唯一还原这个浮点数的最短表示」，误差就被藏起来了。<code>Decimal(0.1)</code> 接收的是浮点数，于是展开出那个精确的二进制值；想从十进制字面量构造，得写 <code>Decimal('0.1')</code>。</p>
<p>0.1 + 0.2 的结果本身没有「算错」：两个近似值相加，再舍入到最近的可表示值，得到 0.30000000000000004。这是表示误差，不是运算误差。IEEE 754 规定了加法的正确舍入，所以同样的输入在任何平台上结果一致。</p>
<h2 id="_1">比较和累加都会踩坑<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>直接用 <code>==</code> 比较浮点几乎总是错的。常见写法是设一个容差：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">almost_equal</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">rel</span><span class="o">=</span><span class="mf">1e-9</span><span class="p">):</span>
    <span class="k">return</span> <span class="nb">abs</span><span class="p">(</span><span class="n">a</span> <span class="o">-</span> <span class="n">b</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">rel</span> <span class="o">*</span> <span class="nb">max</span><span class="p">(</span><span class="nb">abs</span><span class="p">(</span><span class="n">a</span><span class="p">),</span> <span class="nb">abs</span><span class="p">(</span><span class="n">b</span><span class="p">),</span> <span class="mf">1.0</span><span class="p">)</span>
</code></pre></div></div>

<p><code>Number.EPSILON</code>（约 2.22e-16）是 1 和下一个可表示数之间的间隔，不是通用容差。数值大时绝对误差也会变大，所以上面用相对误差。标准库其实已经提供了 <code>math.isclose</code>，语义就是相对误差比较，没必要自己写；<code>numpy.allclose</code> 默认容差是 1e-5，对金额来说太松，要显式传 <code>rtol</code>。</p>
<p>累加更隐蔽：一百万次加 0.0001，结果可能偏出好几分钱。大数吃小数更典型——<code>1e16 + 1 == 1e16</code>，因为 1 小于该量级的间隔。整数的安全范围是 <code>Number.MAX_SAFE_INTEGER</code>，即 9007199254740991，超过就只能近似。需要高精度累加时，Kahan 求和能把误差从 O(n) 降到 O(1)，但金额场景直接上定点数更省事。</p>
<h2 id="_2">金额用整数分或十进制<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>两条路都可行，选一条并贯彻到底：</p>
<ul>
<li><strong>整数分</strong>：内部全部用 <code>long</code> / <code>BigInt</code> 存「分」，展示时除以 100。适合交易、账单、库存计价。</li>
<li><strong>十进制定点</strong>：数据库 <code>DECIMAL(18,2)</code>，语言里用 <code>Decimal</code> / <code>BigDecimal</code>，从字符串构造。</li>
</ul>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">from</span> <span class="nn">decimal</span> <span class="kn">import</span> <span class="n">Decimal</span><span class="p">,</span> <span class="n">ROUND_HALF_UP</span>

<span class="n">total</span> <span class="o">=</span> <span class="n">Decimal</span><span class="p">(</span><span class="s1">&#39;100.00&#39;</span><span class="p">)</span>
<span class="n">ratios</span> <span class="o">=</span> <span class="p">[</span><span class="n">Decimal</span><span class="p">(</span><span class="s1">&#39;0.3333&#39;</span><span class="p">),</span> <span class="n">Decimal</span><span class="p">(</span><span class="s1">&#39;0.3333&#39;</span><span class="p">)]</span>
<span class="n">head</span> <span class="o">=</span> <span class="p">[(</span><span class="n">total</span> <span class="o">*</span> <span class="n">r</span><span class="p">)</span><span class="o">.</span><span class="n">quantize</span><span class="p">(</span><span class="n">Decimal</span><span class="p">(</span><span class="s1">&#39;0.01&#39;</span><span class="p">),</span> <span class="n">rounding</span><span class="o">=</span><span class="n">ROUND_HALF_UP</span><span class="p">)</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">ratios</span><span class="p">]</span>
<span class="n">parts</span> <span class="o">=</span> <span class="n">head</span> <span class="o">+</span> <span class="p">[</span><span class="n">total</span> <span class="o">-</span> <span class="nb">sum</span><span class="p">(</span><span class="n">head</span><span class="p">)]</span>     <span class="c1"># 最后一份兜底，保证求和等于总额</span>
<span class="nb">print</span><span class="p">(</span><span class="n">parts</span><span class="p">,</span> <span class="nb">sum</span><span class="p">(</span><span class="n">parts</span><span class="p">))</span>               <span class="c1"># 和一定等于 100.00</span>
</code></pre></div></div>

<p>分摊、拆单、优惠券这类场景，最后一项用「总额减前几项」兜底，是唯一能保证求和相等的做法。每项独立四舍五入，累计误差迟早暴露。</p>
<p>用整数分还有两个细节。单位换算要先乘后除：算百分比分成时写成 <code>amount * rate / 100</code>，别先 <code>amount / 100</code> 再乘，整数除法会提前截断。负数舍入方向也要固定，<code>Math.round(-0.5)</code> 得到的是 <code>-0</code>，不是 <code>-1</code>，退款金额会差一分。</p>
<p>Decimal 的默认上下文是 28 位精度，除法要显式 <code>quantize</code>，否则可能抛 <code>InvalidOperation</code> 或者留下超出预期的位数。</p>
<h2 id="_3">定点数其实很简单<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>整数分本质就是定点数：把 1 元当成 100，小数位固定两位。加减直接算；乘法之后要除一次 scale，除法之前要先乘 scale，顺序反了精度就先丢了。每次运算后检查溢出——64 位有符号整数上限约 9.2e18，按分算能表示九亿亿元，金额够用，但中间乘法很容易越界。</p>
<p>Go 的 decimal 库、Java 的 <code>BigDecimal</code>、Python 的 <code>Decimal</code> 都是这个思路的通用实现，区别只在 scale 是否随运算自动调整。自己封装定点类型时把 scale 固定成常量，比让每次运算自己推导简单，也更容易和数据库列对齐。格式化输出时再除，别在中间结果里引入小数。</p>
<h2 id="_4">什么时候必须用精确算术<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>判断标准不是「涉不涉及小数」，而是「结果要不要和账目逐分对上」。进度条、图表坐标、压测的百分比，double 完全够用；计费、发票、税额、退款、分摊、汇率换算，必须精确。</p>
<p>最糟糕的是混用：一部分金额用 double，一部分用 BigDecimal，在转换处又引入一次误差，最后连错在哪都定位不到。选一条路，在接口边界上就完成转换。</p>
<p>还有一类常被忽略：跨币种结算的汇率。汇率本身是小数，相乘后再截断，方向错一位就是几十块。汇率保留几位、按什么规则舍入，要在业务规则里写死，不能留给代码里的默认行为。</p>
<h2 id="_5">常见框架的坑<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<p>Java 里字段声明成 <code>double</code>、数据库列是 <code>DECIMAL</code>，MyBatis 会做隐式转换，读出来就可能少一分。实体类统一用 <code>BigDecimal</code>，JDBC 的 <code>getBigDecimal</code> 才不会经过浮点。</p>
<p>JSON 序列化更隐蔽：<code>BigDecimal</code> 默认输出成数字，浏览器 <code>JSON.parse</code> 又把它变成 double。要么序列化成字符串，要么前端引入 decimal 库，别指望「后端精确就够了」。</p>
<h2 id="_6">舍入规则要说清楚<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>Python 的 <code>round()</code> 是银行家舍入：<code>round(2.5)</code> 得 2，<code>round(3.5)</code> 得 4。财务场景通常要四舍五入，用 <code>ROUND_HALF_UP</code> 显式指定。</p>
<p>浮点还会让 <code>round</code> 更不可靠：<code>round(2.675, 2)</code> 得 2.67，因为 2.675 实际存的是 2.67499999...。JavaScript 的 <code>toFixed</code> 同理，<code>(1.005).toFixed(2)</code> 是 <code>"1.00"</code>。凡是和钱有关的舍入，都不要依赖默认行为。</p>
<p>有些团队还会在数据库层再做一次舍入，这可能和业务层的规则冲突。舍入规则只能有一个定义处，通常放在领域层，数据库只负责存。</p>
<h2 id="_7">存储和传输也要对齐<a class="h-anchor" href="#_7" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>场景</th>
<th>别用</th>
<th>建议</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据库金额列</td>
<td><code>FLOAT</code> / <code>DOUBLE</code></td>
<td><code>DECIMAL(18,2)</code> / <code>NUMERIC</code></td>
</tr>
<tr>
<td>接口传输</td>
<td>JSON number 传大整数</td>
<td>字符串传金额，或整数分</td>
</tr>
<tr>
<td>前端计算</td>
<td>直接浮点相加</td>
<td>整数分，或引入 decimal 库</td>
</tr>
<tr>
<td>跨语言约定</td>
<td>各写各的</td>
<td>明确「单位是分」并写进文档</td>
</tr>
</tbody>
</table></div>
<p>JSON 没有整数类型，<code>9007199254740993</code> 在 JavaScript 里解析出来会变成 ...992。金额一般到不了这个量级，但 19 位雪花 ID 会，必须用字符串传。</p>
<h2 id="_8">结论<a class="h-anchor" href="#_8" aria-label="锚点">#</a></h2>
<ul>
<li>0.1 + 0.2 的误差来自二进制表示，不是语言 bug；换语言不解决问题。</li>
<li>浮点比较用相对误差，别用 <code>==</code>，也别把 <code>EPSILON</code> 当万能容差。</li>
<li>金额统一用整数分或十进制定点，从字符串构造，全链路贯彻。</li>
<li>分摊场景最后一项用总额兜底，别让每项独立舍入。</li>
<li>舍入规则显式指定，数据库用 <code>DECIMAL</code>，接口用字符串。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>Git 的三种对象与一次 commit 的真实结构</title>
<link>https://blog.xtang.online/posts/git-objects-commit-structure/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/git-objects-commit-structure/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>git cat-file -p HEAD 打印的到底是什么？从 blob、tree、commit 的字节结构讲起，手工造一个 commit，理解哈希链和引用。</description>
<content:encoded><![CDATA[<p><code>git cat-file -p HEAD</code> 会打印出一段很像配置文件的内容：tree、parent、author、committer，空行之后是提交信息。看着像 Git 的私有格式，其实就是一个纯文本对象，前面加了一行类型和长度，整体 zlib 压缩后按内容哈希命名。</p>
<p>理解这一层，很多现象就不用背了：为什么 amend 会改变哈希、为什么 rebase 后所有 commit id 都变了、为什么 <code>.git/objects</code> 里是两位目录加 38 位文件名。</p>
<h2 id="_1">四种对象，一种存法<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>Git 仓库里只有四类对象：blob 存文件内容，tree 存目录结构，commit 存一次提交，tag 存附注标签。它们的存储方式完全一致：<code>&lt;type&gt; &lt;content-length&gt;\0&lt;content&gt;</code>。</p>
<p>这段字节做 SHA-1（新仓库可选 SHA-256），得到 40 位十六进制，前两位当目录名，后 38 位当文件名，落在 <code>.git/objects/ab/cdef...</code>。内容用 zlib 压缩，所以直接 <code>cat</code> 是乱码，必须走 <code>git cat-file</code>。</p>
<p>一个能自己验证的例子：空树的哈希是 <code>4b825dc642cb6eb9a060e54bf8d69288fbee4904</code>，全世界所有仓库都一样。用 <code>git hash-object -t tree /dev/null</code> 算一遍，得到同样的值——哈希只由内容决定，没有随机成分。</p>
<p>注意 blob 只存内容，不存文件名和权限——这些在 tree 里。所以同一份内容出现在十个路径下，仓库里仍然只有一个 blob。</p>
<h2 id="commit">手搓一个 commit<a class="h-anchor" href="#commit" aria-label="锚点">#</a></h2>
<p>在临时仓库里跑，能看到每一步的中间产物：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>mkdir<span class="w"> </span>/tmp/git-lab<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="nb">cd</span><span class="w"> </span>/tmp/git-lab<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span>git<span class="w"> </span>init<span class="w"> </span>-q
<span class="nv">blob</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span><span class="w"> </span><span class="s1">&#39;hello\n&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>git<span class="w"> </span>hash-object<span class="w"> </span>-w<span class="w"> </span>--stdin<span class="k">)</span>
git<span class="w"> </span>cat-file<span class="w"> </span>-t<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$blob</span><span class="s2">&quot;</span><span class="w">            </span><span class="c1"># blob</span>
git<span class="w"> </span>cat-file<span class="w"> </span>-p<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$blob</span><span class="s2">&quot;</span><span class="w">            </span><span class="c1"># hello</span>

<span class="nv">tree</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span><span class="w"> </span><span class="s1">&#39;100644 blob %s\thello.txt\n&#39;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$blob</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>git<span class="w"> </span>mktree<span class="k">)</span>
<span class="nv">commit</span><span class="o">=</span><span class="k">$(</span>git<span class="w"> </span>commit-tree<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$tree</span><span class="s2">&quot;</span><span class="w"> </span>-m<span class="w"> </span><span class="s1">&#39;manual commit&#39;</span><span class="k">)</span>
git<span class="w"> </span>cat-file<span class="w"> </span>-p<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$commit</span><span class="s2">&quot;</span>
</code></pre></div></div>

<p><code>git hash-object -w</code> 写入对象并返回哈希；<code>git mktree</code> 从 <code>ls-tree</code> 格式的文本造出 tree 对象；<code>git commit-tree</code> 用当前 <code>user.name</code>、<code>user.email</code> 和当前时间生成 commit。所以同样的内容在不同时间跑，哈希不同。</p>
<p>commit 对象的正文由五个部分组成：</p>
<div class="code-card" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>tree 7a8b9c...（指向包含 hello.txt 的那棵 tree）
parent 8f3a1c...（首个 commit 没有这一行）
author 张三 &lt;zhangsan@example.com&gt; 1731200000 +0800
committer 张三 &lt;zhangsan@example.com&gt; 1731200000 +0800

manual commit
</code></pre></div></div>

<p>时间是 Unix 秒加时区偏移。同一个提交在不同时区生成，字节不同，哈希也不同——这就是为什么 <code>GIT_AUTHOR_DATE</code> 能用来复现历史提交。</p>
<p>另外注意 author 和 committer 是两个字段。前者是原始作者和写作时间，后者是最后一次应用这个改动的人和时间。rebase、cherry-pick、amend 都会保留 author，只更新 committer。用 <code>git log --format='%an %ad / %cn %cd'</code> 能看出哪些提交被人动过。</p>
<h2 id="tree">tree 里存了什么<a class="h-anchor" href="#tree" aria-label="锚点">#</a></h2>
<p><code>git ls-tree HEAD</code> 的输出格式和 <code>mktree</code> 的输入一致：</p>
<div class="code-card" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>100644 blob a1b2c3...    README.md
100755 blob d4e5f6...    deploy.sh
040000 tree 7a8b9c...    src
</code></pre></div></div>

<p>第一列是 mode：<code>100644</code> 普通文件，<code>100755</code> 可执行，<code>040000</code> 子目录，<code>120000</code> 符号链接，<code>160000</code> 是 submodule 的 commit 引用。子目录是一个嵌套的 tree，所以一个 commit 的 tree 是一棵完整的目录树。</p>
<p>文件名不参与 blob 的哈希，这带来一个副作用：重命名在 Git 看来是「删一个 blob 引用、加一个 blob 引用」，<code>git log --follow</code> 只能靠内容相似度猜，猜错很正常。</p>
<h2 id="tag">tag 也是一个对象<a class="h-anchor" href="#tag" aria-label="锚点">#</a></h2>
<p>附注标签（<code>git tag -a v1.0 -m 'release'</code>）会真正写出一个 tag 对象。<code>git cat-file -p v1.0</code> 输出 object、type、tag、tagger 和说明文字，<code>object</code> 指向被标记的 commit。</p>
<p>轻量标签（<code>git tag v1.0</code>）不生成对象，只是往 <code>refs/tags/</code> 写一个指向 commit 的文件。两者在 <code>git describe</code>、GPG 签名和发布记录上有区别，正式发版应该用附注标签。</p>
<h2 id="index">index 是第三棵树<a class="h-anchor" href="#index" aria-label="锚点">#</a></h2>
<p>工作区、暂存区（index）、HEAD 常被叫做 Git 的三棵树。index 本身不是对象，而是一个二进制文件 <code>.git/index</code>，记录每个路径的 mode、对象哈希和 stage。</p>
<p><code>git ls-files -s</code> 的输出格式和 <code>ls-tree</code> 完全一致，形如 <code>100644 a1b2c3... 0  README.md</code>。第三列是 stage，冲突时同一路径会出现 1、2、3 三个条目。<code>git add</code> 做的事就是写 blob、更新 index；<code>git commit</code> 再把 index 序列化成 tree 并写 commit。所谓提交，本质是 index 的一次快照。</p>
<h2 id="index_1">冲突时 index 里有什么<a class="h-anchor" href="#index_1" aria-label="锚点">#</a></h2>
<p>合并冲突时，<code>git ls-files -u</code> 会列出同一路径的三个 stage：1 是共同祖先，2 是当前分支（ours），3 是对方（theirs）。三份 blob 哈希同时存在 index 里，工作区文件则被写入冲突标记。</p>
<p>解决冲突就是选一份或者手改，然后 <code>git add</code>。它会把三个条目合并回一个 stage 0，冲突才算结束。<code>git checkout --ours &lt;file&gt;</code> 只是把工作区文件换成自己那份，不会自动 add，很多人以为执行完就完事了。</p>
<p>理解 stage，就不会被 <code>git status</code> 里那句 both modified 绕晕：它只是在说这个路径有三个索引条目，还没归一。</p>
<h2 id="_2">哈希链和引用<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>commit 之间靠 parent 字段串成链。每个 commit 的哈希包含父提交的哈希，所以改动历史中任何一个字节，它之后所有 commit id 都会变。这就是 rebase 后必须 force push 的原因——不是 Git 想为难你，是哈希真的全变了。</p>
<p><code>HEAD^</code> 是父提交，<code>HEAD~3</code> 是往上三代，<code>HEAD^{tree}</code> 直接取出 commit 指向的 tree。这些语法本质都是在对象图上走边。</p>
<p>引用只是文件：<code>.git/refs/heads/main</code> 里存着 40 位哈希，<code>HEAD</code> 里通常写 <code>ref: refs/heads/main</code>。detached HEAD 时，HEAD 直接存哈希。</p>
<p>删除分支只是删掉这个文件，commit 对象还在，靠 reflog 能找回来。先用 <code>git reflog</code> 找到位置，再用 <code>git cat-file -p HEAD@{3}</code> 看内容；实在找不到，用 <code>git fsck --lost-found</code> 扫一遍不可达对象。</p>
<p>reflog 默认保留 90 天，过期后 <code>git gc</code> 才会真正清理不可达对象。</p>
<h2 id="packfile">packfile：从松散对象到压缩包<a class="h-anchor" href="#packfile" aria-label="锚点">#</a></h2>
<p><code>git gc</code> 之后，松散对象会被打包进 <code>.git/objects/pack/*.pack</code>，并在其中用 delta 存储——相似的文件只存差异。这时候单个对象不再有独立文件，<code>git cat-file</code> 会去 pack 里查。</p>
<p><code>git count-objects -vH</code> 能看松散对象的数量和体积；<code>git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n | tail -5</code> 按大小列出 pack 里的对象。第三列是字节数，按它排序就是最大的几个对象，仓库体积异常时这是最快的入口。后面的 depth 列是 delta 链深度，链太长会拖慢读取，<code>git repack -adf</code> 可以重建打包。</p>
<p>需要批量查对象时用 <code>git cat-file --batch</code>，一次进程处理多个哈希。脚本里循环调用 <code>git cat-file -p</code> 的写法，在几万个对象上会慢一个数量级，瓶颈全在反复 fork。</p>
<p>不知道对象大小时，<code>git cat-file -s &lt;hash&gt;</code> 直接给出解压后的字节数，比 <code>-p</code> 打出来再数省事。</p>
<p>除了松散对象和 packfile，还会看到一个 <code>commit-graph</code> 文件。它是提交图的索引，<code>git commit-graph write</code> 生成，能让 <code>git log</code>、<code>git rev-list</code> 在大仓库上快很多。它不存内容，只是一份派生索引，删掉也能重建。</p>
<p>想统计对象构成，用 <code>git cat-file --batch-all-objects --batch-check='%(objecttype)' | sort | uniq -c</code>，一眼就能看出这个仓库是 blob 多还是 tree 多。</p>
<h2 id="_3">排查大仓库<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>git<span class="w"> </span>rev-list<span class="w"> </span>--objects<span class="w"> </span>--all<span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>git<span class="w"> </span>cat-file<span class="w"> </span>--batch-check<span class="o">=</span><span class="s1">&#39;%(objecttype) %(objectname) %(objectsize) %(rest)&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>sort<span class="w"> </span>-k3<span class="w"> </span>-n<span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-10
</code></pre></div></div>

<p>这条会把每个对象的类型、哈希、大小和路径打出来，通常是某个历史版本里的大二进制文件。注意它只报告，不删除。清理历史要用 <code>git filter-repo</code>，而且必须先在副本上验证、通知所有协作者重新克隆——历史改写后，旧克隆推上来会把垃圾带回来。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>对象</th>
<th>存什么</th>
<th>关键字段</th>
</tr>
</thead>
<tbody>
<tr>
<td>blob</td>
<td>文件内容</td>
<td>无，纯字节</td>
</tr>
<tr>
<td>tree</td>
<td>一层目录</td>
<td>mode、类型、哈希、文件名</td>
</tr>
<tr>
<td>commit</td>
<td>一次提交</td>
<td>tree、parent、author、committer、message</td>
</tr>
<tr>
<td>tag</td>
<td>附注标签</td>
<td>object、type、tag、tagger、message</td>
</tr>
</tbody>
</table></div>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ul>
<li>Git 对象就是「类型 + 长度 + 内容」做哈希，内容寻址决定了去重和不可变性。</li>
<li>blob 不带文件名，目录结构全在 tree 里，权限也记在 tree 的 mode 上。</li>
<li>commit 的哈希包含父提交，改历史必然改掉后续所有 id。</li>
<li>分支和 HEAD 只是文本引用，删分支不等于删数据，reflog 还能救。</li>
<li>排查仓库体积先用 <code>rev-list --objects</code> 排序，别直接 <code>git gc</code> 了事。</li>
</ul>]]></content:encoded>
<category>计算机</category>
</item><item>
<title>Nginx 502 排查手册：从 upstream 到文件描述符</title>
<link>https://blog.xtang.online/posts/nginx-502-upstream-fd/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/nginx-502-upstream-fd/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>502 不是一种错误，而是一类。本文按上游连接、worker 容量、文件描述符三条线索，把 error.log 里的关键字翻译成可执行的排查步骤和修复参数。</description>
<content:encoded><![CDATA[<p>502 是 Nginx 说得最含糊的一句话。它只知道上游没有给出合法响应，至于是连不上、连上又被关掉，还是自己已经没有连接槽可用，全靠 <code>error.log</code> 里那一行。遇到 502，第一件事不是重启，而是把那行日志读成一句人话。</p>
<p>下面这套流程按上游连接、上游进程、worker 容量、文件描述符四层展开。顺序不能颠倒：先证伪最外层，再往里收。每一层都有对应的命令和日志关键字，跑完基本能收敛到一个具体参数，而不是靠猜。</p>
<h2 id="502">现象：502 至少有四种写法<a class="h-anchor" href="#502" aria-label="锚点">#</a></h2>
<p>同样返回 502，错误日志完全不同。先把它们归类，后面每一步才有方向。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>日志关键字</th>
<th>含义</th>
<th>优先怀疑</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>connect() failed (111: Connection refused)</code></td>
<td>到上游的 TCP 连接被拒</td>
<td>进程没监听、端口写错、只监听回环地址</td>
</tr>
<tr>
<td><code>upstream prematurely closed connection</code></td>
<td>上游接了连接立刻关掉</td>
<td>上游超时自杀、崩溃重启、协议不匹配</td>
</tr>
<tr>
<td><code>no live upstreams</code></td>
<td>健康检查后无可用节点</td>
<td>节点全被标记 down、<code>max_fails</code> 被打满</td>
</tr>
<tr>
<td><code>worker_connections are not enough</code></td>
<td>worker 的连接槽耗尽</td>
<td>并发过高、长连接不回收、fd 上限太低</td>
</tr>
</tbody>
</table></div>
<p>第四类最容易被误判。它看起来像纯容量问题，实际常常是文件描述符先被打满：Nginx 既无法 accept 新连接，也无法向上游发起连接，于是两类错误交替出现，越重启越频繁。</p>
<p>顺带区分 502、504 与 499。502 是拿不到合法响应，504 是等太久超过 <code>proxy_read_timeout</code>，499 是客户端自己先断开、Nginx 记下的状态码。三者经常同时出现：上游变慢导致 504 增多，客户端等不及断开产生 499，连接被占满后新请求直接 502。看到这种组合，容量和超时才是主线，不必从 fd 查起。</p>
<h2 id="_1">定位：三条命令把问题归层<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>先确认上游到底有没有在监听，再看 Nginx 自己的账本。在 Nginx 所在机器上执行：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 上游是否在监听，本机能否直连</span>
ss<span class="w"> </span>-lntp<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span><span class="s1">&#39;:8080&#39;</span>
curl<span class="w"> </span>-sv<span class="w"> </span>--max-time<span class="w"> </span><span class="m">3</span><span class="w"> </span>http://127.0.0.1:8080/health<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-20

<span class="c1"># Nginx 的连接与 fd 账本</span>
nginx<span class="w"> </span>-T<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-E<span class="w"> </span><span class="s1">&#39;worker_connections|worker_rlimit_nofile|keepalive&#39;</span>
ss<span class="w"> </span>-s
cat<span class="w"> </span>/proc/<span class="k">$(</span>cat<span class="w"> </span>/run/nginx.pid<span class="k">)</span>/limits<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-i<span class="w"> </span><span class="s1">&#39;open files&#39;</span>
ls<span class="w"> </span>/proc/<span class="k">$(</span>cat<span class="w"> </span>/run/nginx.pid<span class="k">)</span>/fd<span class="w"> </span><span class="p">|</span><span class="w"> </span>wc<span class="w"> </span>-l
</code></pre></div></div>

<p>本机 curl 通、经 Nginx 不通，问题在 Nginx 到上游这一段；本机 curl 也不通，先修上游，别在 Nginx 上折腾。如果是容器环境，注意 <code>127.0.0.1</code> 指的是 Nginx 自己所在的网络命名空间，不是宿主机。</p>
<p><code>ss -s</code> 第一行的 <code>TCP: ... estab ..., timewait ...</code> 很有信息量。<code>timewait</code> 数量持续上万，说明短连接太多，keepalive 大概率没生效；<code>estab</code> 接近 <code>worker_connections × worker 数</code>，说明连接槽快满了。这两个数字比 5xx 比例更早预警。</p>
<p><code>worker_connections</code> 乘 worker 进程数只是理论上限，真正卡死你的往往是 <code>worker_rlimit_nofile</code> 和系统 <code>ulimit -n</code>。fd 使用率超过 80% 时，accept 与 connect 会同时失败，日志里两种错误交替出现。</p>
<p>还有一点常被漏算：<code>proxy_next_upstream</code> 默认在 error、timeout 时换节点重试。一次客户端请求可能占用两到三个上游连接，容量估算要按倍数放大。并发高时，把 <code>proxy_next_upstream_tries</code> 设成 2 比默认的无限重试安全得多。</p>
<p>要在测试环境复现「连上就断」，可以用一个收完连接立刻关闭的假上游：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 终端 A：监听但不回任何数据，收到就断</span>
<span class="k">while</span><span class="w"> </span>true<span class="p">;</span><span class="w"> </span><span class="k">do</span><span class="w"> </span>nc<span class="w"> </span>-l<span class="w"> </span>-p<span class="w"> </span><span class="m">8080</span><span class="w"> </span>-q<span class="w"> </span><span class="m">0</span><span class="w"> </span>&lt;<span class="w"> </span>/dev/null<span class="p">;</span><span class="w"> </span><span class="k">done</span>
<span class="c1"># 终端 B：反复请求，观察 error.log 的签名</span>
<span class="k">for</span><span class="w"> </span>i<span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="k">$(</span>seq<span class="w"> </span><span class="m">20</span><span class="k">)</span><span class="p">;</span><span class="w"> </span><span class="k">do</span><span class="w"> </span>curl<span class="w"> </span>-s<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-w<span class="w"> </span><span class="s1">&#39;%{http_code} &#39;</span><span class="w"> </span>http://127.0.0.1/<span class="p">;</span><span class="w"> </span><span class="k">done</span>
</code></pre></div></div>

<p>日志里出现 <code>upstream prematurely closed connection</code>，就说明问题在这一层，与 fd、容量无关。</p>
<h2 id="_2">根因：三个高频组合<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p><strong>上游只监听回环地址。</strong> 容器化之后 upstream 写成 <code>127.0.0.1:8080</code>，应用却只监听容器 IP，或者反过来。特征是稳定复现的 502，换台机器就正常，和并发无关。<code>ss -lntp</code> 的输出能直接看到监听地址，<code>0.0.0.0:8080</code> 与 <code>127.0.0.1:8080</code> 是两回事。</p>
<p><strong>keepalive 写了但没生效。</strong> upstream 块里加了 <code>keepalive 32</code>，却漏了 <code>proxy_http_version 1.1</code> 和清空 <code>Connection</code> 头。每个请求仍然新建连接，TIME_WAIT 堆积，本地端口耗尽后 connect 直接失败。验证方法是在 Nginx 上 <code>curl -v</code> 请求两次，看第二次是否出现 <code>Re-using existing connection</code>；没有这行，就说明连接没复用。</p>
<p><strong>fd 只增不减。</strong> 发版、日志切割、热加载后老 worker 没退出，或某模块打开文件不关闭。<code>ls -l /proc/&lt;pid&gt;/fd | grep deleted</code> 出现大量已删除仍被持有的文件，就是典型信号。</p>
<p>还有一种情况是「时好时坏」：上游的 accept 队列（backlog）太小，突发连接被内核直接丢弃，表现像随机丢包。<code>ss -lnt</code> 的 <code>Send-Q</code> 列就是 backlog，配合 <code>netstat -s | grep -i listen</code> 的溢出计数可以验证。</p>
<p>另有一种误判：所有节点因为连续失败被 <code>max_fails</code> 打满，健康检查窗口内 Nginx 直接返回 <code>no live upstreams</code>，而上游其实早已恢复。这时先看 <code>fail_timeout</code>，再决定要不要临时摘除标记。<code>max_fails=0</code> 会关闭被动健康检查，适合上游只有单点、否则容易误伤。</p>
<h2 id="_3">修复：按顺序改，别一次动五个参数<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>配置改动建议自上而下：先抬 fd 上限，再抬连接数，最后才动超时和重试。一次改五个参数，出了问题说不清是哪一个起的作用。</p>
<div class="code-card" data-lang="nginx"><div class="code-head"><span class="code-lang">nginx</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># nginx.conf 全局段</span>
<span class="k">worker_rlimit_nofile</span><span class="w"> </span><span class="mi">65535</span><span class="p">;</span>

<span class="k">events</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">worker_connections</span><span class="w"> </span><span class="mi">16384</span><span class="p">;</span>
<span class="w">    </span><span class="kn">multi_accept</span><span class="w"> </span><span class="no">on</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">http</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">upstream</span><span class="w"> </span><span class="s">app</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kn">server</span><span class="w"> </span><span class="n">10.0.0.11</span><span class="p">:</span><span class="mi">8080</span><span class="w"> </span><span class="s">max_fails=3</span><span class="w"> </span><span class="s">fail_timeout=10s</span><span class="p">;</span>
<span class="w">        </span><span class="kn">server</span><span class="w"> </span><span class="n">10.0.0.12</span><span class="p">:</span><span class="mi">8080</span><span class="w"> </span><span class="s">max_fails=3</span><span class="w"> </span><span class="s">fail_timeout=10s</span><span class="p">;</span>
<span class="w">        </span><span class="kn">keepalive</span><span class="w"> </span><span class="mi">64</span><span class="p">;</span><span class="w">            </span><span class="c1"># 空闲长连接池大小</span>
<span class="w">        </span><span class="kn">keepalive_timeout</span><span class="w"> </span><span class="s">60s</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="kn">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kn">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="kn">proxy_http_version</span><span class="w"> </span><span class="mi">1</span><span class="s">.1</span><span class="p">;</span><span class="w">          </span><span class="c1"># keepalive 的前提</span>
<span class="w">            </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Connection</span><span class="w"> </span><span class="s">&quot;&quot;</span><span class="p">;</span><span class="w">   </span><span class="c1"># 清掉 close</span>
<span class="w">            </span><span class="kn">proxy_connect_timeout</span><span class="w"> </span><span class="s">2s</span><span class="p">;</span><span class="w">         </span><span class="c1"># 快速失败，别让请求排队</span>
<span class="w">            </span><span class="kn">proxy_read_timeout</span><span class="w"> </span><span class="s">30s</span><span class="p">;</span>
<span class="w">            </span><span class="kn">proxy_next_upstream</span><span class="w"> </span><span class="s">error</span><span class="w"> </span><span class="s">timeout</span><span class="w"> </span><span class="s">http_502</span><span class="p">;</span>
<span class="w">            </span><span class="kn">proxy_next_upstream_tries</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span><span class="w">      </span><span class="c1"># 限制重试放大</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>一个常被忽略的关系：每个上游连接也要占一个 fd，Nginx 文档建议 <code>worker_rlimit_nofile</code> 至少设成 <code>worker_connections</code> 的两倍，否则配置里的连接数只是纸面数字。</p>
<p><code>proxy_connect_timeout</code> 从默认 60 秒降到 2 秒是关键一步。上游半死不活时，长超时会把 worker 连接长期占住，健康节点也会被拖成 502。</p>
<p>系统层同步放开，否则 Nginx 配置再大也会被内核挡住：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># systemd unit 里加 LimitNOFILE=65535，或改 /etc/security/limits.conf</span>
sysctl<span class="w"> </span>-w<span class="w"> </span>net.ipv4.ip_local_port_range<span class="o">=</span><span class="s2">&quot;10240 65000&quot;</span>
</code></pre></div></div>

<p>改完 <code>nginx -t &amp;&amp; nginx -s reload</code>，并确认旧 worker 在 <code>worker_shutdown_timeout</code> 内退出，而不是挂着继续占 fd。</p>
<h2 id="502_1">防复发：让 502 可归因<a class="h-anchor" href="#502_1" aria-label="锚点">#</a></h2>
<ul>
<li>对 <code>no live upstreams</code> 和 <code>worker_connections are not enough</code> 单独告警，它们比 5xx 比例更早暴露容量问题。</li>
<li>采集 worker 的 fd 使用率，超过 70% 预警，而不是等 502 发生。</li>
<li>监控 <code>ss -s</code> 里的 TIME_WAIT 数量，持续偏高就回头查 keepalive。</li>
<li>健康检查探真实业务路径，别只探 <code>/</code>，否则上游依赖挂了仍然算健康。</li>
<li>发版后检查 <code>ps -ef | grep 'nginx: worker'</code>，确认旧进程全部回收。</li>
<li>把 <code>$upstream_addr</code>、<code>$upstream_status</code>、<code>$upstream_response_time</code> 写进访问日志，事后能直接定位到节点。</li>
</ul>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>排查顺序应该是：读日志归类，验证上游监听，核对 worker 与 fd 容量，最后检查 keepalive 与重试放大。四步之内基本能收敛到一个具体参数，而不是靠重启碰运气。</p>
<p>可以直接落地的动作有四条：把 <code>proxy_connect_timeout</code> 降到秒级；配 keepalive 时同时配好 HTTP/1.1 与 <code>Connection</code> 头；把 fd 使用率纳入监控；把 upstream 三元组写进访问日志。</p>
<p>还要记住 502、504、499 是一组信号：同时上涨说明堵在容量或超时上，盯单一指标容易被误导。</p>
<p>一句话收尾：重启能缓解的 502，通常是容量或泄漏问题，重启只是把计时器归零。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>Python 里最隐蔽的坑：可变默认参数与闭包晚绑定</title>
<link>https://blog.xtang.online/posts/python-mutable-default-closure/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/python-mutable-default-closure/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>同一个默认参数被所有调用共享，循环里的 lambda 全部输出最后一个值——两个看起来无关的 bug，根因都是「什么时候求值」。本文给出复现、定位命令和四种修复写法。</description>
<content:encoded><![CDATA[<p>有个接口上线后出现怪事：前一个请求带的分页参数，出现在了下一个请求的返回里。单独测每个请求都正常，并发一上来就串数据。另一处是批量注册的回调，全部用了循环里最后一个值。</p>
<p>这两个 bug 表面无关，根因是同一件事：Python 在什么时候对表达式求值，以及捕获的是值还是变量。两者的共同点是——代码看起来完全正常，review 时也很难靠肉眼发现。</p>
<h2 id="_1">现象：两个最小复现<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>先看默认参数，再看闭包：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 1) 默认参数：默认值在 def 执行时创建，只创建一次</span>
<span class="k">def</span> <span class="nf">collect</span><span class="p">(</span><span class="n">item</span><span class="p">,</span> <span class="n">bucket</span><span class="o">=</span><span class="p">[]):</span>
    <span class="n">bucket</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">bucket</span>

<span class="nb">print</span><span class="p">(</span><span class="n">collect</span><span class="p">(</span><span class="s2">&quot;a&quot;</span><span class="p">))</span>   <span class="c1"># [&#39;a&#39;]</span>
<span class="nb">print</span><span class="p">(</span><span class="n">collect</span><span class="p">(</span><span class="s2">&quot;b&quot;</span><span class="p">))</span>   <span class="c1"># [&#39;a&#39;, &#39;b&#39;]  ← 期望是 [&#39;b&#39;]</span>

<span class="c1"># 2) 闭包：捕获变量，不是值</span>
<span class="n">funcs</span> <span class="o">=</span> <span class="p">[</span><span class="k">lambda</span><span class="p">:</span> <span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span>
<span class="nb">print</span><span class="p">([</span><span class="n">f</span><span class="p">()</span> <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">funcs</span><span class="p">])</span>   <span class="c1"># [2, 2, 2]，不是 [0, 1, 2]</span>
</code></pre></div></div>

<p><code>bucket</code> 没有在每次调用时新建，而是复用了函数对象上挂着的那个列表。进程活得越久，这个列表越大，还会持有元素引用，形成慢性内存增长。</p>
<p>三个 lambda 捕获的是同一个变量 <code>i</code>，循环结束后它已经是 2。函数定义得早，取值取得晚。</p>
<p>在 Web 框架里，默认参数问题会变成跨请求污染：视图函数签名写成 <code>def index(filters={})</code>，第一个请求写入的过滤条件会一直留在进程里，下一个请求直接带着它跑。单进程单请求测试很难复现，多 worker 也只是降低撞上的概率。</p>
<p>这两类 bug 还有个共同特征：单元测试如果每次只调用一次、或者只验证「不抛异常」，就永远测不出来。测试通过，线上照样串数据。</p>
<h2 id="_2">定位：把「隐藏的共享」打印出来<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>默认参数挂在函数对象的 <code>__defaults__</code> 上，闭包的自由变量则存在 cell 里，两处都能直接打印：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">collect</span><span class="p">(</span><span class="n">item</span><span class="p">,</span> <span class="n">bucket</span><span class="o">=</span><span class="p">[]):</span>
    <span class="n">bucket</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">bucket</span>

<span class="n">collect</span><span class="p">(</span><span class="s2">&quot;a&quot;</span><span class="p">);</span> <span class="n">collect</span><span class="p">(</span><span class="s2">&quot;b&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="n">collect</span><span class="o">.</span><span class="vm">__defaults__</span><span class="p">)</span>          <span class="c1"># ([&#39;a&#39;, &#39;b&#39;],) 已经脏了</span>
<span class="nb">print</span><span class="p">(</span><span class="nb">id</span><span class="p">(</span><span class="n">collect</span><span class="o">.</span><span class="vm">__defaults__</span><span class="p">[</span><span class="mi">0</span><span class="p">]))</span>   <span class="c1"># 多次调用 id 不变</span>

<span class="n">funcs</span> <span class="o">=</span> <span class="p">[</span><span class="k">lambda</span><span class="p">:</span> <span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span>
<span class="nb">print</span><span class="p">(</span><span class="n">funcs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="vm">__closure__</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="ow">is</span> <span class="n">funcs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="vm">__closure__</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span>   <span class="c1"># True</span>
<span class="nb">print</span><span class="p">(</span><span class="n">funcs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="vm">__closure__</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">cell_contents</span><span class="p">)</span>                <span class="c1"># 2（循环结束后）</span>
</code></pre></div></div>

<p>默认值对象被复用，所以 <code>id</code> 不变、内容在长；两个闭包指向同一个 cell，就证明它们共享变量而不是各自持有快照。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>线索</th>
<th>说明</th>
<th>结论</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>f.__defaults__[0]</code> 每次调用内容在变</td>
<td>默认值是同一个可变对象</td>
<td>命中默认参数问题</td>
</tr>
<tr>
<td>两次调用的默认值 <code>id()</code> 相同</td>
<td>对象被复用</td>
<td>命中默认参数问题</td>
</tr>
<tr>
<td><code>f.__closure__[0] is g.__closure__[0]</code></td>
<td>共享 cell</td>
<td>命中闭包晚绑定</td>
</tr>
<tr>
<td>单独调用正常、并发或循环后异常</td>
<td>状态跨调用残留</td>
<td>优先查上面两条</td>
</tr>
</tbody>
</table></div>
<p>线上不方便打断点时，在可疑函数入口加一行 <code>print(id(bucket), len(bucket))</code>，连续两次调用 id 相同、长度递增，基本可以定性。要追内存增长，用 <code>tracemalloc</code> 对默认值对象前后各拍一次快照，看增长落在哪个分配点。</p>
<p>如果只是想先缩小范围，静态搜索比重读代码快：<code>grep -rn "def .*= *\[\]"</code>、<code>= *{}</code>、<code>= *set()</code> 三条命令，能把可疑函数列个七七八八。</p>
<h2 id="_3">根因：求值时机与捕获对象<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p><strong>默认参数在 <code>def</code> 执行时求值一次。</strong> 函数是对象，默认值是它的属性，所有调用共享。写 <code>def f(x, acc=[])</code> 相当于在模块加载时执行了一次 <code>acc = []</code>，然后每次调用都传同一个引用。注意这和「调用时传参」完全不同：传参是每次调用重新求值，默认值不是。</p>
<p><strong>闭包捕获变量，不捕获值。</strong> Python 的闭包按名字查找自由变量，最终落到一个 cell 上。循环里的 <code>i</code> 在函数作用域只有一个 cell，每轮只是给它赋新值，所以所有闭包看到的是最后一次赋值。</p>
<p><strong>边界要说清。</strong> 默认值是不可变对象（<code>int</code>、<code>str</code>、<code>tuple</code>、<code>None</code>）时没有这个问题。可变对象只要从不修改也安全，但这是隐式约定，下一个人加一行 <code>append</code> 就会炸。用默认参数做 memo 缓存是合法模式，代价是要自己考虑线程安全和容量上限。</p>
<p><strong>同类问题还有三个。</strong> 类属性 <code>class A: cache = {}</code> 被所有实例共享；<code>dataclass</code> 字段写 <code>items: list = []</code> 会直接报错；<code>except E as e</code> 里的 <code>e</code> 在 Python 3 中出块即删除，闭包引用它会得到 <code>NameError</code>。</p>
<p>理解这层机制之后，判断标准很简单：这个对象在多次调用之间应不应该保持状态。不应该，就别让它出现在默认值里。</p>
<h2 id="_4">修复：哨兵、绑定、工厂<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>默认参数用 <code>None</code> 哨兵，在函数体内创建：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Optional</span><span class="p">,</span> <span class="n">List</span>

<span class="k">def</span> <span class="nf">collect</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">bucket</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">List</span><span class="p">[</span><span class="nb">str</span><span class="p">]]</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">List</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
    <span class="k">if</span> <span class="n">bucket</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>          <span class="c1"># 必须用 is None</span>
        <span class="n">bucket</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">bucket</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">bucket</span>
</code></pre></div></div>

<p>判断条件不能写 <code>if not bucket</code>。调用方传一个空列表进来时，<code>not []</code> 为真，函数会新建列表，调用方拿到的仍是空列表，这个 bug 比原来更隐蔽。</p>
<p>闭包用「定义时绑定」：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 方式一：默认参数把当前值绑进去</span>
<span class="n">funcs</span> <span class="o">=</span> <span class="p">[</span><span class="k">lambda</span> <span class="n">i</span><span class="o">=</span><span class="n">i</span><span class="p">:</span> <span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span>

<span class="c1"># 方式二：functools.partial，意图更清楚</span>
<span class="kn">from</span> <span class="nn">functools</span> <span class="kn">import</span> <span class="n">partial</span>

<span class="k">def</span> <span class="nf">tag</span><span class="p">(</span><span class="n">i</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">i</span>

<span class="n">funcs</span> <span class="o">=</span> <span class="p">[</span><span class="n">partial</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span>
<span class="nb">print</span><span class="p">([</span><span class="n">f</span><span class="p">()</span> <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">funcs</span><span class="p">])</span>   <span class="c1"># [0, 1, 2]</span>
</code></pre></div></div>

<div class="table-wrap"><table>
<thead>
<tr>
<th>场景</th>
<th>错误写法</th>
<th>正确写法</th>
</tr>
</thead>
<tbody>
<tr>
<td>累积容器</td>
<td><code>def f(x, acc=[])</code></td>
<td><code>acc=None</code> + 函数内初始化</td>
</tr>
<tr>
<td>循环建回调</td>
<td><code>lambda: i</code></td>
<td><code>lambda i=i: i</code> 或 <code>partial</code></td>
</tr>
<tr>
<td>类级缓存</td>
<td><code>class A: cache = {}</code></td>
<td>实例属性，或按 key 分开</td>
</tr>
<tr>
<td>dataclass 可变字段</td>
<td><code>items: list = []</code></td>
<td><code>field(default_factory=list)</code></td>
</tr>
<tr>
<td>手写缓存</td>
<td><code>def f(x, cache={})</code></td>
<td><code>functools.lru_cache</code></td>
</tr>
</tbody>
</table></div>
<div class="callout callout-warning"><div class="callout-label">注意</div>
<p>修复默认参数时，顺手检查所有调用点。如果历史代码依赖「共享」这个副作用做缓存，改成 <code>None</code> 哨兵会悄悄改变行为，先补测试再改。</p>
</div>
<p>如果是做缓存，<code>functools.lru_cache</code> 比自己拿默认参数当字典更稳：它带淘汰策略、有 <code>cache_info()</code> 可观测，线程安全也是明确的。自己用 <code>{}</code> 当缓存，既没有上限也没有统计，出问题时连命中率都拿不到。</p>
<h2 id="_5">防复发<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>CI 里打开静态检查：<code>flake8-bugbear</code> 的 B006、<code>pylint</code> 的 W0102、<code>ruff</code> 的 B006，都能直接抓出可变默认值。</li>
<li>单测里连续调用两次，断言第二次结果不包含第一次的数据。这一条能拦住绝大多数跨请求污染。</li>
<li>类型标注统一写 <code>Optional[X] = None</code>，review 时一眼能看出是哨兵。</li>
<li>循环里创建函数、线程、协程并延后执行时，先问一句「它捕获的是哪个变量」。</li>
<li>需要绑定时优先用 <code>functools.partial</code>，比手写 <code>i=i</code> 更难被误读。</li>
<li>存量代码全仓扫一遍：<code>grep -rn "def .*= *\[\]\|def .*= *{}"</code>，把可疑签名列出来逐个确认。</li>
</ul>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>默认参数的问题出在「定义时求值一次，所有调用共享」；闭包的问题出在「捕获变量而非值」。两者都不是语言缺陷，是求值模型的必然结果。</p>
<p>落地四条：可变默认值一律换成 <code>None</code> 哨兵并用 <code>is None</code> 判断；循环里创建闭包要么用默认参数绑定，要么用 <code>partial</code>；把 B006 和 W0102 加进 CI；单测至少调用两次。</p>
<p>再补一句判断标准：默认值适合放不可变常量，需要可变状态就交给显式参数或工厂函数。把这条写进团队规范，比事后排查便宜得多。</p>
<p>下次再遇到「数据串了」或者「回调全用了最后一个值」，先打印 <code>__defaults__</code> 和 <code>__closure__</code>，比读十遍代码快。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>MySQL 死锁现场：一条 UPDATE 引发的连锁反应</title>
<link>https://blog.xtang.online/posts/mysql-deadlock-update/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/mysql-deadlock-update/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>死锁不是数据库故障，而是两个事务互相等锁。本文讲清 1213 与 1205 的区别，教你把 InnoDB 的死锁现场读出来，并用统一的加锁顺序和重试策略收口。</description>
<content:encoded><![CDATA[<p>业务日志里每隔几天冒出一条 <code>Deadlock found when trying to get lock</code>，重试一次就过去了。也有人半夜被 <code>Lock wait timeout exceeded</code> 叫醒，接口整整卡了五十秒。</p>
<p>这两个报错经常被混为一谈，处理方式却完全不同。前者是 InnoDB 主动挑了一个事务回滚，后者是某个事务一直等不到锁。先把它们分开，再谈修复。</p>
<h2 id="_1">现象：两个错误码，两套逻辑<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>错误码</th>
<th>报错文本</th>
<th>含义</th>
<th>处理方向</th>
</tr>
</thead>
<tbody>
<tr>
<td>1213</td>
<td><code>Deadlock found when trying to get lock</code></td>
<td>检测到循环等待，InnoDB 回滚代价较小的一方</td>
<td>分析两个事务的加锁顺序</td>
</tr>
<tr>
<td>1205</td>
<td><code>Lock wait timeout exceeded</code></td>
<td>等锁超过 <code>innodb_lock_wait_timeout</code>（默认 50 秒）</td>
<td>找长事务或未提交事务</td>
</tr>
<tr>
<td>—</td>
<td><code>Trx ... has been waiting for ... sec</code></td>
<td>只是等待记录，未必成环</td>
<td>看持锁者是谁</td>
</tr>
</tbody>
</table></div>
<p>1213 通常能靠重试消化，但如果频率上升，说明并发路径的加锁顺序已经乱了。1205 更危险，它会把连接和线程一起占住，拖垮整个连接池。</p>
<p>被回滚的那个事务叫牺牲者（victim），InnoDB 一般选择修改行数较少、代价较小的一个。这意味着：重试必须安全。如果业务在事务里已经做了不可回滚的副作用（发消息、调第三方），死锁回滚之后数据就可能不一致。看到 1213，先确认这段逻辑是否幂等，再谈重试次数。</p>
<h2 id="_2">定位：把死锁现场读出来<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>InnoDB 会把最近一次死锁留在内存里，直接翻出来；想把历史死锁都留下，再打开全局开关（会增加错误日志量，排障期临时开）：</p>
<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">SHOW</span><span class="w"> </span><span class="n">ENGINE</span><span class="w"> </span><span class="n">INNODB</span><span class="w"> </span><span class="n">STATUS</span><span class="err">\</span><span class="k">G</span><span class="w">   </span><span class="c1">-- 看 LATEST DETECTED DEADLOCK 段</span>
<span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">innodb_print_all_deadlocks</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ON</span><span class="p">;</span>

<span class="c1">-- 实时看谁在等谁（MySQL 8.0，需开启 performance_schema）</span>
<span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">performance_schema</span><span class="p">.</span><span class="n">data_lock_waits</span><span class="err">\</span><span class="k">G</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">trx_id</span><span class="p">,</span><span class="w"> </span><span class="n">trx_state</span><span class="p">,</span><span class="w"> </span><span class="n">trx_started</span><span class="p">,</span><span class="w"> </span><span class="n">trx_rows_locked</span><span class="p">,</span><span class="w"> </span><span class="n">trx_query</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">innodb_trx</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">trx_started</span><span class="p">;</span>
</code></pre></div></div>

<p>这一段里有两个事务块，各自列出 <code>HOLDS THE LOCK(S)</code> 和 <code>WAITING FOR THIS LOCK TO BE GRANTED</code>，以及最后执行的 SQL。关键是看两边的 <code>index</code> 和 <code>lock_mode</code>：如果都在等同一张表的不同行，但加锁顺序相反，那就是典型的顺序死锁。</p>
<p><code>lock_mode</code> 值得单独认一下：</p>
<ul>
<li><code>X locks rec but not gap</code>：只锁记录本身，没有间隙锁，通常是唯一索引等值命中。</li>
<li><code>X</code>：记录锁加间隙锁，REPEATABLE READ 下范围条件常见。</li>
<li><code>X locks gap before rec</code>：只锁间隙，多出现在插入或范围扫描时。</li>
</ul>
<p>两边都是 <code>X</code> 且 <code>index</code> 不同，说明同一批数据走了两条不同的索引路径，加锁顺序自然不一致。</p>
<p><code>trx_rows_locked</code> 是个好指标：一个只更新几行的语句却锁了几万行，说明索引没走对，锁的范围被放大了。<code>trx_started</code> 很早而 <code>trx_query</code> 为空，通常是应用开了事务却没提交。</p>
<h2 id="_3">根因：死锁的四个必要条件加上三个常见触发<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>死锁需要互斥、持有并等待、不可剥夺、循环等待同时成立。数据库能做的只是检测并回滚，真正的解法是打断循环等待。生产上常见三种触发方式。</p>
<p><strong>加锁顺序不一致。</strong> 两个事务分别先锁 A 再锁 B、先锁 B 再锁 A。批量更新时尤其隐蔽：<code>WHERE id IN (1,2)</code> 看起来顺序固定，但优化器可能走不同索引，实际加锁顺序并不保证。</p>
<p><strong>锁范围被放大。</strong> 更新条件没有索引，或者索引选择性太差，InnoDB 只能扫大量行并逐行加锁。在 REPEATABLE READ 下，范围扫描还会加 next-key lock 和间隙锁，连「不存在的行」都被锁住，插入操作也跟着排队。</p>
<p><strong>唯一键与二级索引的顺序差异。</strong> <code>INSERT ... ON DUPLICATE KEY UPDATE</code> 先加唯一索引的锁，再改聚簇索引；普通 UPDATE 若修改了二级索引列，加锁顺序又不一样。两条路径交叉时就会成环。</p>
<p>还有一种不算死锁但症状相似的：长事务不提交，锁一直不释放，别人等满 <code>innodb_lock_wait_timeout</code> 后报 1205。它和死锁的区别是没有人成环，只是持锁方太慢。</p>
<p>最小复现，两个会话交叉更新两行：</p>
<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1">-- 会话 A</span>
<span class="k">BEGIN</span><span class="p">;</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="c1">-- 会话 B</span>
<span class="k">BEGIN</span><span class="p">;</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span>
<span class="c1">-- 会话 A（此时等待 B 持有的 id=2）</span>
<span class="k">UPDATE</span><span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span>
<span class="c1">-- 会话 B（触发死锁，一方被回滚）</span>
<span class="k">UPDATE</span><span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="_4">修复：先统一顺序，再缩短事务<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>顺序问题用顺序解决。批量操作先按主键排序，把锁一次性按固定次序拿全：</p>
<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">BEGIN</span><span class="p">;</span>
<span class="c1">-- 先按主键升序锁定目标行，所有事务走同一顺序</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">account</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</span><span class="p">;</span>

<span class="k">UPDATE</span><span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="k">UPDATE</span><span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">balance</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span>
<span class="k">COMMIT</span><span class="p">;</span>
</code></pre></div></div>

<p>配套动作：</p>
<ol>
<li>用 <code>EXPLAIN</code> 确认更新条件走了索引，<code>rows</code> 估算不应远超实际更新行数。</li>
<li>事务里禁止 HTTP、RPC、消息发送，把外部调用挪到事务外。</li>
<li>大事务拆小，单事务控制在百行级别，提交越早锁持有越短。</li>
<li>死锁回滚是安全的，业务层对 1213 做有限重试；1205 不要盲目重试，先查持锁者。</li>
</ol>
<p>重试要带退避和上限，避免雪崩：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">import</span> <span class="nn">random</span><span class="o">,</span> <span class="nn">time</span>

<span class="k">def</span> <span class="nf">run_with_retry</span><span class="p">(</span><span class="n">fn</span><span class="p">,</span> <span class="n">attempts</span><span class="o">=</span><span class="mi">3</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">attempts</span><span class="p">):</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">fn</span><span class="p">()</span>
        <span class="k">except</span> <span class="n">DeadlockError</span><span class="p">:</span>                 <span class="c1"># 仅重试 1213</span>
            <span class="k">if</span> <span class="n">i</span> <span class="o">==</span> <span class="n">attempts</span> <span class="o">-</span> <span class="mi">1</span><span class="p">:</span>
                <span class="k">raise</span>
            <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mf">0.05</span> <span class="o">*</span> <span class="mi">2</span> <span class="o">**</span> <span class="n">i</span> <span class="o">+</span> <span class="n">random</span><span class="o">.</span><span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mf">0.01</span><span class="p">)</span>
</code></pre></div></div>

<div class="callout callout-note"><div class="callout-label">提示</div>
<p>把隔离级别降到 READ COMMITTED 能减少间隙锁，从而降低一部分死锁概率，但会改变一致性语义，还要确认 binlog 格式为 ROW。这是一项需要评估的取舍，不是默认答案。</p>
</div>
<p>重试上限要和接口超时放在一起算：50ms、100ms、200ms 三次退避加起来不到 400ms，才不会把一次请求拖成几秒。退避时间写死成大常数，等于把死锁放大成超时。</p>
<h2 id="_5">防复发<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>常开或按需开启 <code>innodb_print_all_deadlocks</code>，把死锁写进日志采集，别等用户投诉。</li>
<li>监控 <code>SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'</code> 的增速，突增就是加锁顺序变了的信号。</li>
<li>所有批量更新收敛到一个 DAO 方法，顺序由这一处保证，而不是每个调用方各自拼 SQL。</li>
<li>代码规范里写明「事务内不做外部调用」，用中间件或静态检查兜底。</li>
<li>压测时构造并发更新同一批行的场景，比读代码更容易复现死锁。</li>
<li>给死锁日志配一条检索规则，按表名和 SQL 指纹聚合，重复出现的模式优先治理。</li>
</ul>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>1213 是检测到循环等待后的主动回滚，1205 是等待超时，两者要分开处理。定位的核心是 <code>SHOW ENGINE INNODB STATUS</code> 里的两个事务块，看谁持有什么锁、又在等什么锁。</p>
<p>修复优先级：统一加锁顺序，缩小锁范围（补索引、避免无索引更新），缩短事务，最后才是重试和降隔离级别。重试只对 1213 有意义，且必须限次数、带退避。</p>
<p>还有一条容易被忽略的前提：重试安全依赖幂等。事务里如果已经产生外部副作用，先把它挪出去，再加循环。</p>
<p>死锁不会消失，只会变少。目标不是零死锁，而是让每一次死锁都能在日志里被解释清楚。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>前端白屏十分钟：一次 Source Map 与缓存叠加事故</title>
<link>https://blog.xtang.online/posts/frontend-white-screen-sourcemap-cache/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/frontend-white-screen-sourcemap-cache/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>发版后部分用户白屏，控制台报 ChunkLoadError。旧 HTML、已删除的 chunk 与错位的 Source Map 叠加，让定位一开始就走偏。本文还原过程并给出缓存兜底方案。</description>
<content:encoded><![CDATA[<p>发版五分钟后，监控开始报错，客服收到零星反馈：页面白屏。刷新一下就好了，但过一会儿又有人中招。</p>
<p>控制台只有一行 <code>Uncaught ChunkLoadError: Loading chunk 37 failed</code>。更麻烦的是，错误监控里还原出来的源码位置在一段毫不相干的代码上——因为 Source Map 和线上 JS 不是同一次构建。三件事叠在一起，排查绕了远路。</p>
<h2 id="_1">现象：白屏的四种报错长相<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>报错</th>
<th>常见原因</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ChunkLoadError: Loading chunk N failed</code></td>
<td>chunk 文件 404，或 CDN 尚未同步</td>
</tr>
<tr>
<td><code>Cannot read properties of undefined (reading 'call')</code></td>
<td>新旧 chunk 混用，模块 ID 对不上</td>
</tr>
<tr>
<td><code>Unexpected token '&lt;'</code></td>
<td>请求 JS 拿到了 HTML（404 页面）</td>
</tr>
<tr>
<td>完全空白且无报错</td>
<td>入口 JS 未执行、CSP 拦截、或渲染时抛错被吞</td>
</tr>
</tbody>
</table></div>
<p><code>Unexpected token '&lt;'</code> 这条特别有指示性：说明请求的路径返回了 <code>index.html</code>，通常是路由回退把所有 404 都兜成了首页。</p>
<p>白屏的表现也不止一种：整页空白、只剩导航壳、某个区块一直转圈。整页空白多半是入口 chunk 失败，连框架都没起来；只剩壳说明主包加载成功，失败的是路由对应的异步 chunk。先分清是哪一种，能直接把范围缩到一个文件。</p>
<h2 id="html">定位：先把 HTML 与静态资源的缓存策略分开看<a class="h-anchor" href="#html" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 首页 HTML 的缓存头</span>
curl<span class="w"> </span>-sI<span class="w"> </span>https://example.com/<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-iE<span class="w"> </span><span class="s1">&#39;cache-control|etag|age|last-modified&#39;</span>
<span class="c1"># 带 hash 的静态资源</span>
curl<span class="w"> </span>-sI<span class="w"> </span>https://example.com/assets/index.a1b2c3.js<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-iE<span class="w"> </span><span class="s1">&#39;cache-control|etag&#39;</span>
<span class="c1"># 旧版本 chunk 还在不在</span>
curl<span class="w"> </span>-so<span class="w"> </span>/dev/null<span class="w"> </span>-w<span class="w"> </span><span class="s1">&#39;%{http_code}\n&#39;</span><span class="w"> </span>https://example.com/assets/old-hash.js
</code></pre></div></div>

<p>如果 HTML 带 <code>max-age=600</code> 且中间有 CDN，那么发版后十分钟内，一部分浏览器仍在用旧 HTML，里面引用的 chunk 名字已经被新版本删掉了。这就是「刷新就好、过一会儿又坏」的来源——不同边缘节点的缓存状态不一致。</p>
<p>判断响应到底是 JS 还是 HTML，看头比看内容快：URL 以 <code>.js</code> 结尾、响应却是 <code>content-type: text/html</code>，基本可以确认路由回退把 404 兜成了首页。再对照状态码，200 的 HTML 比 404 更麻烦，因为浏览器和 CDN 都会把它当正常资源缓存下来。</p>
<p>再确认 Source Map 是否与线上产物同源：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 线上 JS 末尾一般有 sourceMappingURL 注释，指到哪个 hash 就是哪次构建</span>
curl<span class="w"> </span>-s<span class="w"> </span>https://example.com/assets/index.a1b2c3.js<span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-c<span class="w"> </span><span class="m">200</span>
<span class="c1"># 本地构建产物的 map 文件名必须与线上 hash 完全一致</span>
ls<span class="w"> </span>dist/assets/*.map<span class="w"> </span><span class="p">|</span><span class="w"> </span>head
</code></pre></div></div>

<p>两者对不上，说明你手里的 map 是另一次构建的。用它还原堆栈，行列号会指向错误的源码位置，越查越偏。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>检查项</th>
<th>期望</th>
<th>不期望</th>
</tr>
</thead>
<tbody>
<tr>
<td>HTML <code>Cache-Control</code></td>
<td><code>no-cache</code> 或 <code>max-age=0, must-revalidate</code></td>
<td><code>max-age=600</code> 且无 CDN 刷新</td>
</tr>
<tr>
<td>静态资源 <code>Cache-Control</code></td>
<td><code>public, max-age=31536000, immutable</code></td>
<td><code>no-cache</code>（失去缓存意义）</td>
</tr>
<tr>
<td>旧 chunk 保留</td>
<td>至少保留 N-1 个版本</td>
<td>发版即删</td>
</tr>
<tr>
<td><code>.map</code> 公开访问</td>
<td>404</td>
<td>200（源码泄漏）</td>
</tr>
</tbody>
</table></div>
<p>一个低成本验证：直接让浏览器打开旧 HTML 里引用的 chunk URL。打不开就是 404，打得开但内容是一段 HTML，就是回退规则写错了。这两种情况的修法完全不同。</p>
<h2 id="_2">根因：三个版本同时在线上<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p><strong>第一层：HTML 被缓存。</strong> 浏览器或 CDN 存了旧 <code>index.html</code>，它引用的 chunk 名属于上一版。新版本部署后旧 chunk 被删除，浏览器请求 404，动态导入失败，<code>ChunkLoadError</code> 抛出，页面停在白屏。</p>
<p><strong>第二层：发布没有保留历史产物。</strong> 每次构建清空 <code>dist</code> 再上传，CDN 又做了目录同步删除。任何一个还持有旧 HTML 的客户端都会踩空。灰度期间两个版本并存时，这个问题会被放大。</p>
<p><strong>第三层：Source Map 与 release 没有绑定。</strong> 构建产物的 hash 每次变，但 map 是按「最新一次构建」上传的。错误上报里只带了 <code>release</code> 字符串，没有带构建号；还原时用的是最新 map，位置自然对不上。</p>
<p>再加一个常见放大器：Service Worker 缓存了旧 <code>index.html</code>，且没有 <code>skipWaiting</code>。用户不关掉所有标签页，就永远拿不到新版本。</p>
<p>发布顺序也会制造这个窗口：先传静态资源、再刷 CDN、最后更新 HTML。顺序反了，就会出现新 HTML 指向尚未上传的 chunk，症状与缓存问题一模一样。</p>
<p>还有一种隐蔽情况：响应头里根本没写 <code>Cache-Control</code>。这时浏览器会做启发式缓存，用 <code>Last-Modified</code> 推算一个缓存时长，看起来「什么都没配」，实际 HTML 被缓存了几分钟到几小时不等。不配置不等于不缓存。</p>
<h2 id="html_1">修复：让 HTML 不缓存，让产物可追溯<a class="h-anchor" href="#html_1" aria-label="锚点">#</a></h2>
<p>缓存策略按资源类型分开设置，这是最有效的一刀：</p>
<div class="code-card" data-lang="nginx"><div class="code-head"><span class="code-lang">nginx</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 入口 HTML：每次都回源校验</span>
<span class="k">location</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">/index.html</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">add_header</span><span class="w"> </span><span class="s">Cache-Control</span><span class="w"> </span><span class="s">&quot;no-cache,</span><span class="w"> </span><span class="s">must-revalidate&quot;</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1"># 带 contenthash 的静态资源：永久缓存</span>
<span class="k">location</span><span class="w"> </span><span class="s">/assets/</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">add_header</span><span class="w"> </span><span class="s">Cache-Control</span><span class="w"> </span><span class="s">&quot;public,</span><span class="w"> </span><span class="s">max-age=31536000,</span><span class="w"> </span><span class="s">immutable&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="kn">try_files</span><span class="w"> </span><span class="nv">$uri</span><span class="w"> </span><span class="p">=</span><span class="mi">404</span><span class="p">;</span><span class="w">   </span><span class="c1"># 找不到就 404，别回退到 index.html</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code>try_files ... =404</code> 这一行值得单独说：SPA 的路由回退只应该作用于页面导航，不应该作用于 <code>/assets/</code> 下的 JS。否则 chunk 缺失时浏览器会拿到 HTML，报出更难懂的 <code>Unexpected token '&lt;'</code>。</p>
<p>发布侧保留最近两到三个版本的 chunk，或者让文件名带构建号、旧目录延迟清理。同时给动态导入加一次兜底刷新：</p>
<div class="code-card" data-lang="js"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1">// chunk 加载失败时自动刷新一次，用时间戳防死循环</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;error&#39;</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">msg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">String</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">message</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">);</span>
<span class="w">  </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="sr">/ChunkLoadError|Loading chunk/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">msg</span><span class="p">))</span><span class="w"> </span><span class="k">return</span><span class="p">;</span>

<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;chunk-reload-at&#39;</span><span class="p">;</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">last</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Number</span><span class="p">(</span><span class="nx">sessionStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="mf">0</span><span class="p">);</span>
<span class="w">  </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nx">last</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mf">60</span><span class="nx">_000</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="p">;</span><span class="w">   </span><span class="c1">// 一分钟内不重复刷新</span>
<span class="w">  </span><span class="nx">sessionStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="nx">key</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()));</span>
<span class="w">  </span><span class="nx">location</span><span class="p">.</span><span class="nx">reload</span><span class="p">();</span>
<span class="p">},</span><span class="w"> </span><span class="kc">true</span><span class="p">);</span>
</code></pre></div></div>

<p>Source Map 侧用 <code>hidden-source-map</code>，构建后上传到错误监控平台并带上构建号，不放进公开目录。每次发布同时归档一份「构建号 — 产物 hash — map」的清单，出问题时按 release 精确匹配。</p>
<p>Service Worker 的更新要显式做三步：新版本 install 后 <code>skipWaiting</code>，activate 时清掉旧缓存，再 <code>clients.claim</code>。少做一步，用户可能几天都停在旧版本，而服务端已经找不到对应的 chunk。</p>
<h2 id="_3">防复发<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<ul>
<li>发布后用无头浏览器打开首页，断言关键 DOM 存在、控制台无 error，持续观察几分钟。</li>
<li>把构建号写进页面（如 <code>&lt;meta name="build"&gt;</code>），错误上报时一起带上，避免「release 对、构建不对」。</li>
<li>监控按构建号分组，同一个构建号的 <code>ChunkLoadError</code> 突增就告警。</li>
<li>固定发布顺序：静态资源 → CDN 刷新 → HTML，并写进发布脚本，而不是靠人记。</li>
<li>灰度环境演练一次「删掉一个线上 chunk」，确认兜底刷新和降级页真的生效。</li>
<li>Service Worker 更新策略写进 checklist：<code>skipWaiting</code>、<code>clients.claim</code>、旧缓存清理。</li>
<li>回滚时同样保留新版本产物，否则回滚会制造一次反向的「旧 HTML 找新 chunk」。</li>
<li>把 <code>.map</code> 的访问权限纳入安全扫描，别让构建产物把源码带到公网。</li>
</ul>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>白屏先看报错类型：<code>ChunkLoadError</code> 是产物缺失，<code>Unexpected token '&lt;'</code> 是路由把 404 兜成了 HTML，无报错则往 CSP 和入口脚本查。</p>
<p>根因往往是版本错配：旧 HTML 遇到新产物。修复的关键是三条——HTML 不缓存、<code>/assets/</code> 不回退到首页、旧 chunk 保留若干版本。</p>
<p>Source Map 必须和构建号绑定，否则它会把排查带偏。最后加一层 <code>ChunkLoadError</code> 的有限次自动刷新兜底，让用户不必手动按 F5。</p>
<p>衡量是否做对了，看一个指标就够：发版后 <code>ChunkLoadError</code> 在错误监控里是否还按构建号聚集。如果还聚集，说明旧 HTML 仍在流通。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>Docker 容器启动即退出：ENTRYPOINT、信号与 PID 1</title>
<link>https://blog.xtang.online/posts/docker-exit-entrypoint-pid1/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/docker-exit-entrypoint-pid1/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>本地 docker run 正常，部署后容器秒退；或者跑着跑着停了，日志最后一行是 Starting。从退出码、ENTRYPOINT 写法到 PID 1 的信号语义，一次讲清。</description>
<content:encoded><![CDATA[<p><code>docker ps -a</code> 里一行 <code>Exited (0) 3 seconds ago</code>，日志最后一句是 <code>Starting nginx...</code>。本地 <code>docker run</code> 明明能起来，一进 CI 或生产就秒退。</p>
<p>还有一种更迷惑的表现：容器跑了几个小时，突然停掉，退出码 143，日志里没有任何错误。这两种现象指向同一条链路——谁是 PID 1，以及它有没有正确处理信号。</p>
<h2 id="_1">现象：先读退出码<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>退出码</th>
<th>含义</th>
<th>常见原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>主进程正常结束</td>
<td>命令后台化，前台 shell 返回</td>
</tr>
<tr>
<td>1</td>
<td>应用自身报错</td>
<td>配置缺失、连不上依赖、参数错误</td>
</tr>
<tr>
<td>126</td>
<td>命令不可执行</td>
<td>文件没有执行权限、挂载覆盖了二进制</td>
</tr>
<tr>
<td>127</td>
<td>命令不存在</td>
<td>PATH 不对、拼写错误、壳脚本缺解释器</td>
</tr>
<tr>
<td>137</td>
<td>被 SIGKILL（128+9）</td>
<td>内存超限被 OOM kill，或 stop 超时后被强杀</td>
</tr>
<tr>
<td>143</td>
<td>被 SIGTERM（128+15）</td>
<td>正常 stop；若伴随未优雅退出，说明信号没被处理</td>
</tr>
<tr>
<td>139</td>
<td>段错误（128+11）</td>
<td>原生扩展崩溃、架构不匹配</td>
</tr>
</tbody>
</table></div>
<p><code>docker stop</code> 的流程是：向容器内 PID 1 发 SIGTERM，等 <code>--time</code>（默认 10 秒），还没退出就 SIGKILL。所以 143 和 137 的差别，往往就是「优雅退出有没有生效」。</p>
<p>还有一种退出码 0 但服务没起来的情况：容器显示 Running，健康检查却一直失败，编排层反复重启。这不是启动问题，是就绪判断缺失。日志里可能只有一句启动成功，实际业务端口并没有监听。</p>
<p>重启策略会把问题藏起来：<code>restart: always</code> 让容器反复退出又拉起，<code>docker ps</code> 里一直显示 Up，只有 <code>docker inspect -f '{{.RestartCount}}'</code> 的数字在涨。排查前先看这个计数。</p>
<h2 id="_2">定位：四条命令分清楚<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>docker<span class="w"> </span>ps<span class="w"> </span>-a<span class="w"> </span>--format<span class="w"> </span><span class="s1">&#39;table {{.Names}}\t{{.Status}}\t{{.Command}}&#39;</span>
docker<span class="w"> </span>inspect<span class="w"> </span>-f<span class="w"> </span><span class="s1">&#39;exit={{.State.ExitCode}} oom={{.State.OOMKilled}} err={{.State.Error}}&#39;</span><span class="w"> </span>&lt;容器&gt;
docker<span class="w"> </span>inspect<span class="w"> </span>-f<span class="w"> </span><span class="s1">&#39;{{json .Config.Entrypoint}} {{json .Config.Cmd}}&#39;</span><span class="w"> </span>&lt;容器&gt;
docker<span class="w"> </span>logs<span class="w"> </span>--tail<span class="w"> </span><span class="m">100</span><span class="w"> </span>&lt;容器&gt;
</code></pre></div></div>

<p><code>OOMKilled=true</code> 就把方向转到内存限制；<code>err</code> 非空通常是运行时层面的错误，比如挂载失败、权限问题；<code>Config.Entrypoint</code> 与 <code>Config.Cmd</code> 是数组还是字符串，直接暴露了 shell form 还是 exec form。</p>
<p>启动即退的，绕过 ENTRYPOINT 进容器手动跑一遍，能直接看到第一行报错：<code>docker run --rm -it --entrypoint sh &lt;镜像&gt;</code>。进不去说明镜像里没有 shell（比如 distroless），那就得靠日志和本地复现。</p>
<p>想看时间线，用 <code>docker events --filter container=&lt;容器名&gt;</code> 盯着，退出、重启、OOM 都会打点，比翻日志更直观。</p>
<p>要区分「启动即退」和「运行中退出」：前者几乎总是命令写错或依赖缺失；后者要看信号与 PID 1。判断方法是看日志有没有走到「服务已就绪」那一行。</p>
<p>日志停在 <code>Starting</code> 之后再无输出，说明主进程已经退到后台；日志完全为空但退出码是 0，说明 CMD 本身就是个前台跑完即结束的脚本，容器自然跟着退出。</p>
<h2 id="_3">根因：四种写法，一个信号问题<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p><strong>命令后台化了。</strong> <code>CMD service nginx start</code> 这类写法，启动脚本把服务 fork 到后台后自己退出，容器的主进程结束，容器随之停止。Docker 只关心 PID 1，不关心后台子进程。</p>
<p><strong>shell form 让 sh 变成 PID 1。</strong> <code>CMD python app.py</code> 实际执行的是 <code>/bin/sh -c "python app.py"</code>。多一层 shell，信号先到 sh，sh 未必转发给 python。应用收不到 SIGTERM，stop 只能等超时后强杀。</p>
<p><strong>脚本末尾没有 <code>exec</code>。</strong> 常见于 entrypoint 脚本：先做迁移、改权限，最后 <code>python app.py</code>。此时 PID 1 仍是 bash，SIGTERM 打到 bash 上，bash 不转发，应用被 137 强杀。加一个 <code>exec</code> 就能让应用替换掉 shell，直接接管 PID 1。</p>
<p><strong>PID 1 不回收僵尸进程。</strong> 内核不会给 PID 1 的孤儿进程自动找父进程回收。shell 脚本作为 PID 1 时，子进程退出后会留下僵尸，长跑容器里进程表会被慢慢占满。</p>
<p>再多说一句信号语义：Linux 内核对 PID 1 有特殊处理——没有注册处理函数的信号，默认动作会被忽略。所以在容器里，SIGTERM 发给 PID 1 时，如果 PID 1 没装处理器，它不会像普通进程那样直接死掉，而是被忽略，直到 SIGKILL。</p>
<p>下面是最小对照：</p>
<div class="code-card" data-lang="dockerfile"><div class="code-head"><span class="code-lang">dockerfile</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c"># 错误一：shell form + 后台启动，主进程立刻退出</span>
<span class="k">CMD</span><span class="w"> </span>service<span class="w"> </span>nginx<span class="w"> </span>start

<span class="c"># 错误二：shell form，sh 是 PID 1，信号到不了应用</span>
<span class="k">CMD</span><span class="w"> </span>python<span class="w"> </span>app.py

<span class="c"># 正确：exec form，应用直接成为 PID 1</span>
<span class="k">CMD</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;nginx&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;-g&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;daemon off;&quot;</span><span class="p">]</span>
</code></pre></div></div>

<p>entrypoint 脚本则统一这样收尾：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/bin/sh</span>
<span class="nb">set</span><span class="w"> </span>-euo<span class="w"> </span>pipefail

<span class="c1"># 初始化：等依赖、跑迁移、修权限</span>
/app/wait-for-db.sh

<span class="c1"># 关键一行：用 exec 让应用替换当前 shell，成为 PID 1</span>
<span class="nb">exec</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$@</span><span class="s2">&quot;</span>
</code></pre></div></div>

<p><code>exec "$@"</code> 依赖 CMD 传入参数，所以 ENTRYPOINT 用 exec form 时，CMD 也要用数组形式，两者配合才能把参数原样传下去。</p>
<h2 id="_4">修复：前台运行，信号可达<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ol>
<li>应用一律前台运行。nginx 加 <code>daemon off;</code>，gunicorn 不要加 <code>-D</code>，supervisor 类工具要么不用，要么只托管一个前台进程。</li>
<li>CMD / ENTRYPOINT 用 exec form（JSON 数组），避免 sh 包一层。</li>
<li>entrypoint 脚本最后一行必须是 <code>exec "$@"</code>。</li>
<li>需要优雅退出时显式声明 <code>STOPSIGNAL</code>，例如 nginx 用 SIGQUIT。</li>
<li>加 <code>HEALTHCHECK</code>，让编排层区分「进程还在」和「服务可用」。</li>
</ol>
<div class="code-card" data-lang="dockerfile"><div class="code-head"><span class="code-lang">dockerfile</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">FROM</span><span class="w"> </span><span class="s">nginx:stable</span>
<span class="k">COPY</span><span class="w"> </span>nginx.conf<span class="w"> </span>/etc/nginx/nginx.conf
<span class="k">STOPSIGNAL</span><span class="w"> </span><span class="s">SIGQUIT</span>
<span class="k">HEALTHCHECK</span><span class="w"> </span>--interval<span class="o">=</span>10s<span class="w"> </span>--timeout<span class="o">=</span>2s<span class="w"> </span>--retries<span class="o">=</span><span class="m">3</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="k">CMD</span><span class="w"> </span>curl<span class="w"> </span>-fsS<span class="w"> </span>http://127.0.0.1/health<span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">1</span>
<span class="k">CMD</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;nginx&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;-g&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;daemon off;&quot;</span><span class="p">]</span>
</code></pre></div></div>

<p>不想改镜像的话，运行时加 <code>--init</code> 也能解决大半问题：Docker 会注入一个小型 init 作为 PID 1，由它转发信号并回收僵尸进程。代价是多一个进程，好处是脚本类入口不用逐个改造。</p>
<p>改完后验证一次信号链路：<code>docker stop</code> 应该让应用走完优雅退出，退出码是 143 而不是 137。如果容器里有长连接或正在处理的请求，观察日志里有没有「等待连接排空」这类记录。</p>
<p>如果 <code>docker inspect</code> 显示 <code>OOMKilled=true</code>，处理方向完全不同：先调内存限制，再排查泄漏，反复重启只会把问题变成周期性抖动。</p>
<h2 id="_5">防复发<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>CI 里加一步冒烟：<code>docker run -d</code> 后等 5 秒，用 <code>docker inspect</code> 断言容器仍在运行。</li>
<li>再断言 <code>docker stop</code> 后退出码为 143，说明 SIGTERM 被应用处理了，而不是超时强杀。</li>
<li>Dockerfile review 检查清单：CMD 是否 exec form、是否前台、是否有 <code>exec "$@"</code>。</li>
<li>日志全部输出到 stdout/stderr，不写文件，否则容器退出后什么都看不到。</li>
<li>编排层设置合理的 <code>terminationGracePeriodSeconds</code>，与应用自身的优雅退出时限对齐。</li>
<li>用 <code>ps -ef | grep defunct</code> 抽查长跑容器，僵尸进程数量应该稳定在 0。</li>
</ul>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>容器「启动即退」先看退出码：127 是命令找不到，0 是主进程结束得太早，1 是应用自己报错。运行中停掉则看 143 与 137 的差别。</p>
<p>九成的信号问题来自三件事：命令后台化、CMD 用了 shell form、entrypoint 脚本结尾忘了 <code>exec</code>。修法固定：前台运行、exec form、<code>exec "$@"</code>。</p>
<p>还需要理解一条内核语义：PID 1 没有默认信号动作。信号发了但没反应，不是 Docker 的问题，是容器里那个人没装处理器。</p>
<p>最后补一层验证——CI 里断言「起来后 5 秒还在跑」和「stop 后退出码 143」，比任何文档都可靠。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>JavaScript 的 this 到底指向谁：六种绑定一次讲清</title>
<link>https://blog.xtang.online/posts/javascript-this-binding/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/javascript-this-binding/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>同一个方法，直接调用正常，作为回调传给 setTimeout 就报 undefined。this 由调用点决定，不是定义位置。本文用六种绑定、一张优先级表和可运行的例子把这件事定死。</description>
<content:encoded><![CDATA[<p>同一段代码，<code>user.greet()</code> 返回 <code>hi ada</code>，把 <code>user.greet</code> 传给 <code>setTimeout</code> 就变成 <code>hi undefined</code>，严格模式下直接抛 <code>TypeError</code>。</p>
<p>很多人背过「this 指向调用它的对象」，但这句话只对了一半。this 在函数被调用时确定，取决于调用点的形式，和函数写在哪里基本无关——箭头函数是唯一的例外。理解这一点，大部分「this 是 undefined」的报错都能当场解释。</p>
<h2 id="_1">现象：同一个函数，三种结果<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="js"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kd">const</span><span class="w"> </span><span class="nx">user</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;ada&#39;</span><span class="p">,</span>
<span class="w">  </span><span class="nx">greet</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="sb">`hi </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span>
<span class="w">  </span><span class="p">},</span>
<span class="p">};</span>

<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">greet</span><span class="p">());</span><span class="w">        </span><span class="c1">// &#39;hi ada&#39;</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">g</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">user</span><span class="p">.</span><span class="nx">greet</span><span class="p">;</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">g</span><span class="p">());</span><span class="w">                 </span><span class="c1">// 非严格：&#39;hi undefined&#39;；严格/模块：TypeError</span>

<span class="nx">setTimeout</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">greet</span><span class="p">,</span><span class="w"> </span><span class="mf">0</span><span class="p">);</span><span class="w">        </span><span class="c1">// 回调里的 this 已不是 user</span>
</code></pre></div></div>

<p>把方法取出来单独调用，就丢掉了「谁在调用」这条信息。事件监听、数组遍历、Promise 回调都是同一个道理。</p>
<p>事件监听是第二个高发场景：<code>el.addEventListener('click', this.handleClick)</code> 里，普通函数的 this 是那个 DOM 元素；数组遍历也一样，<code>items.map(obj.format)</code> 的 this 是 undefined。只要方法离开了「被哪个对象调用」的位置，this 就断了线索。</p>
<p>第三个场景是 class。把 <code>counter.inc</code> 解构出来赋值给变量、再作为回调传出去，<code>this</code> 就没了；报错信息通常是 <code>Cannot read properties of undefined (reading 'count')</code>，看起来像字段没初始化，实际是 this 不对。</p>
<p>Promise 链也常中招：<code>promise.then(obj.handle)</code>，then 只负责传值，不负责绑定接收者。需要 this 时写成 <code>promise.then((v) =&gt; obj.handle(v))</code>，行为才确定。</p>
<h2 id="_2">定位：先判断三件事<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>第一，看调用形式：是 <code>fn()</code> 还是 <code>obj.fn()</code>？有没有经过 <code>call</code>、<code>bind</code>、<code>new</code>？</p>
<p>第二，看函数是不是箭头函数。箭头函数没有自己的 this，<code>bind</code> 也改不了它。</p>
<p>第三，看运行在严格模式还是模块里。ES Module 和 class 内部默认严格，此时默认绑定的 this 是 <code>undefined</code>，不是全局对象。</p>
<p>运行时可用的几个探针：</p>
<div class="code-card" data-lang="js"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kd">function</span><span class="w"> </span><span class="nx">probe</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="k">this</span><span class="p">);</span><span class="w">                          </span><span class="c1">// 先看实际值</span>
<span class="w">  </span><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="ow">typeof</span><span class="w"> </span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="k">this</span><span class="o">?</span><span class="p">.</span><span class="kr">constructor</span><span class="o">?</span><span class="p">.</span><span class="nx">name</span><span class="p">);</span>
<span class="p">}</span>
<span class="nx">probe</span><span class="p">();</span><span class="w">                    </span><span class="c1">// undefined（模块中）或 globalThis（脚本中）</span>
<span class="nx">probe</span><span class="p">.</span><span class="nx">call</span><span class="p">({</span><span class="w"> </span><span class="nx">a</span><span class="o">:</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="p">});</span><span class="w">       </span><span class="c1">// { a: 1 }</span>
</code></pre></div></div>

<p>如果 this 是 <code>undefined</code>，报错通常是 <code>Cannot read properties of undefined</code>；如果是全局对象，症状是不报错但读到 <code>undefined</code> 字段——后者更难查，因为代码不崩，只是数据不对。</p>
<p>在浏览器里更快的方式是断点：在可疑函数第一行下断点，看右侧 call stack 里的调用形式，<code>obj.fn()</code> 和 <code>fn()</code> 长得完全不一样。再配合 <code>console.trace()</code>，能直接看到是谁调用的。</p>
<p>顺带记一下顶层 this：模块顶层的 this 在 ESM 里是 undefined，在 CommonJS 里是 module.exports，在普通 script 里是 window。同一段代码换个加载方式，结果就变了。</p>
<h2 id="_3">根因：六种绑定与一条优先级<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>调用形式</th>
<th>this 指向</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fn()</code></td>
<td><code>undefined</code>（严格/模块）或 <code>globalThis</code></td>
<td>默认绑定</td>
</tr>
<tr>
<td><code>obj.fn()</code></td>
<td><code>obj</code></td>
<td>隐式绑定，看调用点左侧</td>
</tr>
<tr>
<td><code>const g = obj.fn; g()</code></td>
<td>同默认绑定</td>
<td>隐式丢失，最常见的坑</td>
</tr>
<tr>
<td><code>fn.call(x)</code> / <code>apply</code> / <code>bind</code></td>
<td><code>x</code></td>
<td>显式绑定，<code>bind</code> 生成新函数且不可再改</td>
</tr>
<tr>
<td><code>new Fn()</code></td>
<td>新创建的实例</td>
<td>new 绑定，优先级最高</td>
</tr>
<tr>
<td><code>() =&gt; this</code></td>
<td>外层词法作用域的 this</td>
<td>箭头函数，无法被改变</td>
</tr>
</tbody>
</table></div>
<p>优先级从高到低：<code>new</code> &gt; <code>bind</code> &gt; <code>call/apply</code> &gt; 隐式 &gt; 默认。一个直观的验证：</p>
<div class="code-card" data-lang="js"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kd">function</span><span class="w"> </span><span class="nx">Person</span><span class="p">(</span><span class="nx">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">name</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>

<span class="kd">const</span><span class="w"> </span><span class="nx">Bound</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">Person</span><span class="p">.</span><span class="nx">bind</span><span class="p">({</span><span class="w"> </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;bound&#39;</span><span class="w"> </span><span class="p">});</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="ow">new</span><span class="w"> </span><span class="nx">Bound</span><span class="p">(</span><span class="s1">&#39;ada&#39;</span><span class="p">).</span><span class="nx">name</span><span class="p">);</span><span class="w">   </span><span class="c1">// &#39;ada&#39;，new 忽略了 bind 的 this</span>

<span class="kd">const</span><span class="w"> </span><span class="nx">arrow</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="k">this</span><span class="p">;</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">arrow</span><span class="p">.</span><span class="nx">call</span><span class="p">({</span><span class="w"> </span><span class="nx">a</span><span class="o">:</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="o">===</span><span class="w"> </span><span class="k">this</span><span class="p">);</span><span class="w">   </span><span class="c1">// true，箭头函数不认 call</span>

<span class="ow">new</span><span class="w"> </span><span class="p">(()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{})();</span><span class="w">                     </span><span class="c1">// TypeError: not a constructor</span>
</code></pre></div></div>

<p>机制上可以这么理解：<code>obj.fn()</code> 在规范里叫「引用」，调用时会把 <code>obj</code> 作为 this 传入。一旦把这个引用赋给变量、作为参数传出去，引用就退化成普通值，this 的线索随之丢失。<code>(obj.fn)()</code> 因为括号不改变引用，仍然是隐式绑定；而 <code>(0, obj.fn)()</code> 经过逗号运算符，引用就没了。</p>
<p>严格模式是另一个变量。非严格模式下，默认绑定的 this 会被替换成全局对象，于是 <code>this.name</code> 读到 undefined 而不报错；严格模式下保持 undefined，调用 <code>this.xxx</code> 立即抛错。同一份代码在不同打包配置下表现不同，多半就是这个原因。</p>
<div class="callout callout-note"><div class="callout-label">提示</div>
<p>class 内部默认严格模式，所以 <code>const inc = counter.inc; inc()</code> 不会静默读到全局变量，而是直接抛 TypeError。这算是好事：错误更早暴露。</p>
</div>
<h2 id="_4">修复：四种写法，各有取舍<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="js"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kd">const</span><span class="w"> </span><span class="nx">user</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;ada&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">greet</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="sb">`hi </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">};</span>

<span class="c1">// 1) bind：生成一个 this 固定的新函数</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">bound</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">user</span><span class="p">.</span><span class="nx">greet</span><span class="p">.</span><span class="nx">bind</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>

<span class="c1">// 2) 箭头包装：延迟到调用时再取 user.greet</span>
<span class="nx">setTimeout</span><span class="p">(()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nx">user</span><span class="p">.</span><span class="nx">greet</span><span class="p">(),</span><span class="w"> </span><span class="mf">0</span><span class="p">);</span>

<span class="c1">// 3) 显式传入：适合工具函数</span>
<span class="kd">function</span><span class="w"> </span><span class="nx">greetWith</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="sb">`hi </span><span class="si">${</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nx">greetWith</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>

<span class="c1">// 4) class 字段箭头：实例方法自带 this，适合事件处理器</span>
<span class="kd">class</span><span class="w"> </span><span class="nx">Counter</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nx">count</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mf">0</span><span class="p">;</span>
<span class="w">  </span><span class="nx">inc</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">count</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mf">1</span><span class="p">;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">count</span><span class="p">;</span><span class="w"> </span><span class="p">};</span>
<span class="p">}</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">c</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">Counter</span><span class="p">();</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">inc</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">c</span><span class="p">.</span><span class="nx">inc</span><span class="p">;</span>
<span class="nx">inc</span><span class="p">();</span><span class="w">                       </span><span class="c1">// 1，不再依赖调用点</span>
</code></pre></div></div>

<p>取舍很清楚：<code>bind</code> 在构造函数里绑一次，开销小，但容易被忘记；class 字段箭头每个实例都创建一份函数，实例多时内存占用更高；箭头包装每次调用都新建闭包，适合回调但别放进热循环。</p>
<p>事件处理器用箭头函数最省事：<code>el.addEventListener('click', () =&gt; this.handleClick())</code>，this 来自定义处，与谁触发无关。反过来，如果确实需要触发元素，用普通函数配合 <code>event.currentTarget</code>，别依赖 this。</p>
<p>框架之间的差异也要记住：Vue 的 <code>methods</code> 会自动绑定到组件实例，React class 组件不会，必须自己 bind 或用箭头字段。跨框架迁移时，这类问题会成片出现。</p>
<p>还有一条现代替代：不再用 <code>const self = this</code> 这种写法，直接用箭头函数捕获外层 this，语义更清楚，也少一个变量。</p>
<p><code>call</code> 与 <code>apply</code> 的区别只在传参：前者逐个传，后者传数组；<code>bind</code> 不执行函数，只返回一个新函数。临时借用一次用 <code>call</code>，需要长期固定 this 才用 <code>bind</code>。</p>
<h2 id="_5">防复发<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>ESLint 打开 <code>no-invalid-this</code>，能拦住把 this 用在普通函数里的写法。</li>
<li>TypeScript 项目打开 <code>@typescript-eslint/unbound-method</code>，它会直接报「方法被解构后丢失 this」。这条规则依赖类型信息，配置成本略高，但收益最大。</li>
<li>单测里专门构造一次「把方法当回调传出去」的调用，断言不抛错。这一步比读代码有效。</li>
<li>事件监听、定时器、Promise 链是三个高发位置，review 时重点看这里是否直接传了 <code>this.xxx</code>。</li>
<li>公共方法如果需要被外部当回调使用，就在类里同时提供绑定好的箭头版本，别让调用方猜。</li>
<li>回调密集的模块（事件总线、定时任务、Promise 链）集中做一次 review，比逐处猜更有效。</li>
</ul>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>this 由调用点决定：<code>fn()</code> 走默认绑定，<code>obj.fn()</code> 走隐式绑定，<code>call/apply/bind</code> 走显式绑定，<code>new</code> 走构造绑定，箭头函数则完全看外层作用域。</p>
<p>优先级记成一句话：new 最高，bind 次之，call/apply 再次，然后是隐式，最后是默认。</p>
<p>最常见的 bug 不是「this 指错了」，而是「方法被取出来当回调，隐式绑定丢了」。修法优先用箭头包装或 class 字段箭头，配合 <code>unbound-method</code> 这条 lint 规则，基本可以把它挡在上线之前。</p>
<p>最后提醒一句：this 是运行时概念，类型系统只能帮你到「这个方法可能被解构」这一层。真正兜底的，还是那句「谁在调用」——写回调时多问一遍，比事后调试省事。</p>
<p>箭头函数也不能当构造函数，没有 <code>arguments</code>，选它之前先确认这两点不影响使用场景。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>定时任务没执行？cron 环境变量与 PATH 的坑</title>
<link>https://blog.xtang.online/posts/cron-env-path/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/cron-env-path/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>手动跑正常，crontab 里却静默失败。cron 不读你的 .bashrc，PATH 只有两个目录，工作目录还是家目录。本文给出三分钟定位方法和一个自带环境的脚本模板。</description>
<content:encoded><![CDATA[<p>凌晨三点的备份任务，手动执行一次成功，放进 crontab 之后连续一周没有产物。翻 <code>/var/log/cron</code>，执行记录明明白白写着「已运行」。</p>
<p>问题不在 cron 有没有触发，而在它以什么环境触发。cron 启动的是一个非交互、非登录的 shell，你 <code>~/.bashrc</code> 里那些 <code>export PATH</code>、<code>conda activate</code>、别名，它一概看不到。</p>
<h2 id="_1">现象：三种失败，三种原因<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>现象</th>
<th>大概率原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>日志里完全没有执行记录</td>
<td>cron 服务没启动、语法错误、文件权限或 CRLF 换行</td>
</tr>
<tr>
<td>有执行记录，但没有任何产物</td>
<td>工作目录不对、相对路径失效、权限不足</td>
</tr>
<tr>
<td>报 <code>command not found</code></td>
<td>PATH 里没有 <code>/usr/local/bin</code>、nvm、conda</td>
</tr>
<tr>
<td>脚本中途退出，日志只有半截</td>
<td>用了 <code>set -e</code> 且某条命令失败；或 <code>%</code> 未转义被截断</td>
</tr>
<tr>
<td>输出消失</td>
<td>stdout/stderr 没重定向，被当成邮件投递或丢弃</td>
</tr>
</tbody>
</table></div>
<p>第二种最坑：脚本里写 <code>gzip &gt; backup.gz</code>，cron 的工作目录是家目录，文件其实生成了，只是不在你以为的地方。</p>
<p>容器里跑 cron 还有两个额外问题：多数基础镜像没有 syslog，日志必须自己重定向；镜像默认时区往往不是 UTC+8，<code>0 3 * * *</code> 实际会在本地时间上午十一点执行，而日志看起来完全正常。</p>
<p>还有一种「假成功」：任务跑了，退出码 0，但业务数据没变。常见于脚本里某条命令失败但没有 <code>set -e</code>，后面继续执行，最后正常退出。cron 只记「跑过了」，不记「跑对了」。</p>
<p>权限问题也常被忽略：用 root 的 crontab 跑本应属于业务用户的脚本，产物属主变成 root，后续普通用户读不到；反过来，普通用户的 crontab 访问不了 <code>/root</code> 下的密钥文件。</p>
<h2 id="cron">定位：把 cron 的真实环境导出来<a class="h-anchor" href="#cron" aria-label="锚点">#</a></h2>
<p>最直接的办法是让 cron 自己交代。临时加一行，一分钟内就能拿到：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 每分钟把 cron 的环境落盘，观察完记得删掉</span>
*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>env<span class="w"> </span>&gt;<span class="w"> </span>/tmp/cron.env<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="p">;</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;PWD=</span><span class="nv">$PWD</span><span class="s2">&quot;</span><span class="w"> </span>&gt;&gt;<span class="w"> </span>/tmp/cron.env
<span class="c1"># 对比交互式登录 shell 的环境</span>
diff<span class="w"> </span>&lt;<span class="o">(</span>sort<span class="w"> </span>/tmp/cron.env<span class="o">)</span><span class="w"> </span>&lt;<span class="o">(</span>env<span class="w"> </span>-i<span class="w"> </span>bash<span class="w"> </span>-lc<span class="w"> </span><span class="s1">&#39;env&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sort<span class="o">)</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>head<span class="w"> </span>-40
</code></pre></div></div>

<p>差异通常集中在 <code>PATH</code>、<code>HOME</code>、<code>SHELL</code>、<code>LANG</code> 这几项。看到 <code>PATH=/usr/bin:/bin</code> 就基本能确认：你的 <code>node</code>、<code>python3</code>、<code>mysqldump</code> 如果装在 <code>/usr/local/bin</code> 或用户目录下，cron 找不到。</p>
<p>再看 cron 自己的日志，确认有没有触发：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>grep<span class="w"> </span>CRON<span class="w"> </span>/var/log/syslog<span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-20<span class="w">      </span><span class="c1"># Debian / Ubuntu</span>
journalctl<span class="w"> </span>-u<span class="w"> </span>cron<span class="w"> </span>--since<span class="w"> </span><span class="s1">&#39;10 min ago&#39;</span><span class="w">    </span><span class="c1"># systemd 系统</span>
tail<span class="w"> </span>-50<span class="w"> </span>/var/log/cron<span class="w">                     </span><span class="c1"># RHEL / CentOS</span>
systemctl<span class="w"> </span>status<span class="w"> </span>cron<span class="w">                       </span><span class="c1"># 或 crond</span>
crontab<span class="w"> </span>-l
</code></pre></div></div>

<p>如果日志里根本没有这一行，先查服务状态和语法；如果行尾是 <code>CMD (...)</code>，说明命令已经交给 shell 了，问题在环境或脚本内部。</p>
<p>按这张清单逐项对照，一轮就能定位：</p>
<ul>
<li>PATH 是否包含脚本里用到的命令（<code>which node</code>、<code>which mysqldump</code>）</li>
<li>脚本是否显式 <code>cd</code> 到正确目录</li>
<li>输出是否重定向到了可写路径</li>
<li>容器时区是否与预期一致</li>
<li>上一次是否还没结束，导致本次被锁挡住</li>
</ul>
<p>想要执行轨迹而不只是结果，把命令换成 <code>/bin/bash -x /opt/scripts/x.sh</code>，或者干脆在脚本里用 <code>set -x</code> 临时打开。cron 的日志只告诉你「执行了」，<code>-x</code> 才告诉你「执行到哪一步」。</p>
<h2 id="cron_1">根因：cron 用的是最小环境<a class="h-anchor" href="#cron_1" aria-label="锚点">#</a></h2>
<p>cron 执行任务时只注入很少的变量：<code>SHELL</code> 一般是 <code>/bin/sh</code>，<code>PATH</code> 通常是 <code>/usr/bin:/bin</code>，<code>HOME</code> 是用户家目录，<code>LOGNAME</code> 是用户名。它不会加载 <code>.bashrc</code>、<code>.bash_profile</code>、<code>.profile</code>。</p>
<p>由此引出四个连锁问题：</p>
<ol>
<li>版本管理工具装的可执行文件不在 PATH 里，<code>node</code>、<code>npm</code>、<code>python</code> 全部找不到。</li>
<li>工作目录不是脚本所在目录，相对路径写到别处，或者直接因目录不存在而失败。</li>
<li>时区取系统时区。容器里常是 UTC，你以为的凌晨三点其实是上午十一点。</li>
<li>环境变量没有继承。数据库密码、代理设置、<code>LANG</code> 全部缺失，程序可能因编码问题报错。</li>
</ol>
<p>还有三个纯配置坑。<code>%</code> 在 crontab 里有特殊含义，未转义时被当成换行符，命令从中间截断，而语法检查不会报错；文件用 Windows 编辑器保存、带 CRLF 换行时，每行末尾多一个 <code>\r</code>，命令变成 <code>xxx.sh\r</code>；<code>/etc/cron.d</code> 下的文件名带点通常会被忽略，配置放了却不生效。</p>
<p>命令没输出也别急着断定没跑。默认情况下 cron 会把输出通过邮件发给用户，机器上没配 MTA 时就直接丢弃。加一行 <code>MAILTO=""</code> 或者重定向到文件，才能留住现场。</p>
<h2 id="_2">修复：让脚本自带环境<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>原则是：不依赖调用者的环境，脚本自己把 PATH、目录、日志、锁全部定死。</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/usr/bin/env bash</span>
<span class="nb">set</span><span class="w"> </span>-euo<span class="w"> </span>pipefail

<span class="c1"># 1) 显式 PATH，覆盖 cron 的最小环境</span>
<span class="nb">export</span><span class="w"> </span><span class="nv">PATH</span><span class="o">=</span><span class="s2">&quot;/usr/local/bin:/usr/bin:/bin:/opt/app/bin&quot;</span>
<span class="nb">export</span><span class="w"> </span><span class="nv">LANG</span><span class="o">=</span><span class="s2">&quot;C.UTF-8&quot;</span>

<span class="c1"># 2) 固定工作目录，相对路径才有意义</span>
<span class="nb">cd</span><span class="w"> </span>/opt/app

<span class="c1"># 3) 所有输出进日志，不依赖邮件</span>
<span class="nb">exec</span><span class="w"> </span>&gt;&gt;/var/log/app-backup.log<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;[</span><span class="k">$(</span>date<span class="w"> </span>-Is<span class="k">)</span><span class="s2">] start&quot;</span>

<span class="c1"># 4) 防重叠：上一次没跑完就跳过，并说明原因</span>
flock<span class="w"> </span>-n<span class="w"> </span>/tmp/app-backup.lock<span class="w"> </span>-c<span class="w"> </span><span class="s1">&#39;</span>
<span class="s1">  /usr/bin/mysqldump --defaults-file=/opt/app/my.cnf app \</span>
<span class="s1">    | gzip &gt; /opt/app/backup/db-$(date +%F).sql.gz</span>
<span class="s1">&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;skip: previous run still active&quot;</span>
</code></pre></div></div>

<p>对应的 crontab 用绝对路径调用，并显式重定向。环境变量也可以写在文件顶部，Vixie cron 与 cronie 都支持：</p>
<div class="code-card" data-lang="cron"><div class="code-head"><span class="code-lang">cron</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code># crontab 顶部声明环境
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=&quot;&quot;

# 分 时 日 月 周  命令
30 2 * * *  /bin/bash /opt/scripts/app-backup.sh &gt;&gt; /var/log/app-backup.cron.log 2&gt;&amp;1
</code></pre></div></div>

<p>脚本里用 <code>flock -n</code> 而不是 <code>flock -w</code>，是为了让重叠的那一次直接跳过，而不是排队堆积。备份任务排队几次之后，往往就跑不完了。</p>
<p>调用时用绝对路径的解释器更稳：<code>/bin/bash /opt/scripts/x.sh</code> 不依赖 shebang 和执行位，挂载卷导致的权限丢失也不会让任务静默失败。</p>
<p>日志本身也要管：<code>exec &gt;&gt;</code> 追加写入的文件不会自动切割，跑一年就是几个 GB。用 logrotate 给它配一份规则，或者按月分文件，别等磁盘满了才发现。</p>
<p>密码不要直接写进脚本。<code>mysqldump</code> 用 <code>--defaults-file</code> 指向权限 600 的配置文件，比在命令行里写 <code>-p密码</code> 安全，也不会出现在 <code>ps</code> 的输出里。</p>
<h2 id="_3">防复发<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<ul>
<li>统一脚本模板：PATH、<code>cd</code>、日志重定向、<code>flock</code>、<code>set -euo pipefail</code> 五件套。</li>
<li>上线前用最小环境模拟一次：<code>env -i /bin/sh -c '/opt/scripts/app-backup.sh'</code>，能过再进 crontab。</li>
<li>加心跳：脚本成功结束前打一条日志或请求一个探针地址，监控没收到就说明没跑。</li>
<li>能换就换：systemd timer 支持 <code>Environment=</code>、<code>WorkingDirectory=</code>、<code>OnCalendar=</code>，日志进 journal，排查成本低得多。</li>
<li>容器场景显式设置 <code>TZ</code>，并在脚本里打印 <code>date</code> 用于核对时区。</li>
<li>把 crontab 纳入版本管理，别只在服务器上 <code>crontab -e</code>。</li>
</ul>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>cron 不执行，先分清是「没触发」还是「触发了但失败」。看日志有没有记录，两分钟就能判断。</p>
<p>触发了但失败，九成是环境问题：PATH 只有 <code>/usr/bin:/bin</code>，工作目录是家目录，<code>.bashrc</code> 根本没被读取。让脚本自带 PATH 和 <code>cd</code>，把输出重定向到文件，问题就收敛了。</p>
<p>两个高频细节别忘：crontab 里的 <code>%</code> 必须转义成 <code>\%</code>；容器里先确认时区，否则任务会在错误的时间「成功」执行。</p>
<p>判断任务是否真的成功，别只看 exit code。加一条心跳上报，比翻日志可靠。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>Redis 连接池耗尽：一次连接泄漏的完整复盘</title>
<link>https://blog.xtang.online/posts/redis-connection-pool-leak/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/redis-connection-pool-leak/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>接口 P99 涨到秒级，日志出现 ConnectionError。Redis 很闲，客户端却借不到连接。从连接数曲线到异常路径的 finally，讲清泄漏怎么找、怎么修。</description>
<content:encoded><![CDATA[<p>流量没涨、Redis 的 CPU 和内存都很平稳，接口延迟却从几十毫秒涨到两秒多，日志里开始刷 <code>Could not get a resource from the pool</code>。重启应用立刻恢复，几个小时后复发。</p>
<p>这种形态基本可以排除 Redis 本身：服务端很闲，是客户端借不到连接。要么池太小，要么借出去没还。</p>
<h2 id="_1">现象：三种表现指向同一处<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>现象</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Too many connections</code> 或池等待超时</td>
<td>池里没有可用连接，borrow 失败</td>
</tr>
<tr>
<td><code>connected_clients</code> 阶梯上涨、不回落</td>
<td>连接只增不减，先怀疑泄漏</td>
</tr>
<tr>
<td><code>blocked_clients</code> 持续大于 0</td>
<td>有阻塞命令（BLPOP 等）长期占用连接</td>
</tr>
<tr>
<td>Redis 报 <code>ERR max number of clients reached</code></td>
<td>服务端 <code>maxclients</code> 被打满，新连接全拒</td>
</tr>
<tr>
<td>应用线程堆栈停在 <code>borrowObject</code></td>
<td>大量线程在等连接，典型的池饥饿</td>
</tr>
</tbody>
</table></div>
<p>前两条最关键。<code>connected_clients</code> 是判断泄漏最直接的指标：正常应该跟着流量波动，泄漏则是一条只会往上爬的台阶。</p>
<p>还有一种没有报错的表现：延迟整体抬升、QPS 下降，日志干净。这往往是池已满、线程在排队，但等待超时设得足够长，所以还没抛异常。等到开始报错，说明排队已经严重到触及超时了。</p>
<p>池耗尽还常和线程池放大叠加：请求线程各自等连接，线程池被占满，健康检查、心跳这类本不需要 Redis 的请求也排不上队，服务看起来整体卡死。这时先看线程池活跃数，别只盯 Redis。</p>
<h2 id="_2">定位：先看服务端，再看应用侧<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 客户端连接数与阻塞数</span>
redis-cli<span class="w"> </span>info<span class="w"> </span>clients
<span class="c1"># 拒绝连接统计：rejected_connections 增长说明服务端上限被打到</span>
redis-cli<span class="w"> </span>info<span class="w"> </span>stats<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-E<span class="w"> </span><span class="s1">&#39;rejected|total_connections&#39;</span>
<span class="c1"># 每条连接的 addr / age / idle / cmd，重点找 idle 很大的</span>
redis-cli<span class="w"> </span>client<span class="w"> </span>list<span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-oE<span class="w"> </span><span class="s1">&#39;addr=[^ ]+|age=[0-9]+|idle=[0-9]+|cmd=[^ ]+&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>paste<span class="w"> </span>-d<span class="s1">&#39; &#39;</span><span class="w"> </span>-<span class="w"> </span>-<span class="w"> </span>-<span class="w"> </span>-<span class="w"> </span><span class="p">|</span><span class="w"> </span>head<span class="w"> </span>-20
<span class="c1"># 应用侧粗看：ESTAB 数量应接近 实例数 × 池上限</span>
ss<span class="w"> </span>-tn<span class="w"> </span>state<span class="w"> </span>established<span class="w"> </span><span class="s1">&#39;( dport = :6379 )&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>wc<span class="w"> </span>-l
</code></pre></div></div>

<p><code>client list</code> 里重点看三列：<code>age</code>（连接存活秒数）、<code>idle</code>（空闲秒数）、<code>cmd</code>（最后一次命令）。大量 <code>idle</code> 数百秒、<code>cmd</code> 停在 <code>client</code> 或 <code>ping</code> 的连接，通常就是借出后没有归还的。如果这些连接的 <code>addr</code> 集中在同一小段端口范围，说明它们来自同一个池，而不是散落的客户端。</p>
<p>应用侧要看的还有连接池的 active、idle、等待线程数。没有这些指标时，用上面的 ESTAB 数量粗看：明显大于「实例数 × 池上限」，说明有代码绕过了池，直接建连。</p>
<p>线程卡在哪也要看：Java 用 <code>jstack</code>，Python 用 <code>py-spy dump</code>，找停在 <code>borrowObject</code>、<code>get_connection</code>、<code>acquire</code> 上的线程。数量等于池上限，基本就是池饥饿。</p>
<p>用最小代码复现泄漏，比读业务代码快：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">import</span> <span class="nn">redis</span>

<span class="n">pool</span> <span class="o">=</span> <span class="n">redis</span><span class="o">.</span><span class="n">ConnectionPool</span><span class="p">(</span><span class="n">host</span><span class="o">=</span><span class="s2">&quot;127.0.0.1&quot;</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">6379</span><span class="p">,</span>
                            <span class="n">max_connections</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">socket_timeout</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>

<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
    <span class="n">conn</span> <span class="o">=</span> <span class="n">pool</span><span class="o">.</span><span class="n">get_connection</span><span class="p">(</span><span class="s2">&quot;PING&quot;</span><span class="p">)</span>     <span class="c1"># 手动借出</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">conn</span><span class="o">.</span><span class="n">send_command</span><span class="p">(</span><span class="s2">&quot;SET&quot;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;k</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">,</span> <span class="s2">&quot;v&quot;</span><span class="p">)</span>
        <span class="n">conn</span><span class="o">.</span><span class="n">read_response</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">i</span> <span class="o">==</span> <span class="mi">2</span><span class="p">:</span>
            <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&quot;业务异常&quot;</span><span class="p">)</span>   <span class="c1"># 异常路径提前跳出</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="k">pass</span>                                 <span class="c1"># 忘了 pool.release(conn)</span>

<span class="c1"># 第 5 次开始抛 ConnectionError: Too many connections</span>
</code></pre></div></div>

<p>把 <code>pass</code> 换成 <code>pool.release(conn)</code>，或者干脆用客户端封装，问题立刻消失。</p>
<h2 id="_3">根因：五条泄漏路径<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p><strong>借还不成对。</strong> 手动 <code>get_connection()</code> 之后，正常路径归还了，异常路径、提前 <code>return</code>、<code>continue</code> 都漏掉了。这是最常见的一条。</p>
<p><strong>每个请求新建客户端。</strong> 在函数里 <code>redis.Redis(host=...)</code>，每次调用都建一个连接池和一条连接，请求结束也不关。<code>connected_clients</code> 会跟 QPS 成正比，涨得飞快。</p>
<p><strong>阻塞命令独占连接。</strong> <code>BLPOP</code>、<code>BRPOP</code>、<code>SUBSCRIBE</code> 会一直占着连接不释放。如果它们从共享池里借连接，几个消费者就能把池吃光。</p>
<p><strong>池上限小于真实并发。</strong> 池大小拍脑袋定成 10，实际并发 200，其余线程全在排队。加上等待超时设为无限，请求就堆在那里，表现为整体延迟上升而不是明确报错。</p>
<p><strong>连接没有超时或健康检查。</strong> 网络设备静默断开空闲连接，池里留着死连接，借出来第一次用就失败，重试又去借同样的死连接。</p>
<p>还有一类隐蔽的：事务或管道开了没结束。<code>MULTI</code> 之后异常退出，连接上残留着未完成的事务状态，归还到池里继续被下一个请求借走，报错五花八门。这类问题在池里看不出来，只能靠统一封装避免。</p>
<p>池大小可以按这个公式估：<code>并发 ≈ QPS × 平均持有时间(秒) × 1.5</code>。算完再和 Redis 的 <code>maxclients</code> 对一遍，所有实例的池上限之和不要超过它的七成。</p>
<h2 id="_4">修复：统一入口，参数兜底<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>先保证「借了一定还」，再谈池多大。</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">import</span> <span class="nn">redis</span>

<span class="c1"># 全进程一个池，参数按并发估算</span>
<span class="n">pool</span> <span class="o">=</span> <span class="n">redis</span><span class="o">.</span><span class="n">ConnectionPool</span><span class="p">(</span>
    <span class="n">host</span><span class="o">=</span><span class="s2">&quot;10.0.0.5&quot;</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">6379</span><span class="p">,</span>
    <span class="n">max_connections</span><span class="o">=</span><span class="mi">64</span><span class="p">,</span>          <span class="c1"># 上限要小于 maxclients / 实例数</span>
    <span class="n">socket_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span>          <span class="c1"># 单次命令超时，避免线程被拖死</span>
    <span class="n">socket_connect_timeout</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span>
    <span class="n">health_check_interval</span><span class="o">=</span><span class="mi">30</span><span class="p">,</span>    <span class="c1"># 定期 PING，剔除死连接</span>
    <span class="n">retry_on_timeout</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>      <span class="c1"># 超时不要盲目重试，先确认幂等</span>
<span class="p">)</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">redis</span><span class="o">.</span><span class="n">Redis</span><span class="p">(</span><span class="n">connection_pool</span><span class="o">=</span><span class="n">pool</span><span class="p">)</span>

<span class="c1"># 业务代码只用 client，由它负责归还</span>
<span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="s2">&quot;k&quot;</span><span class="p">,</span> <span class="s2">&quot;v&quot;</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="mi">60</span><span class="p">)</span>
</code></pre></div></div>

<p>确有需要手动借出时，用 <code>try/finally</code> 保证归还：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="n">conn</span> <span class="o">=</span> <span class="n">pool</span><span class="o">.</span><span class="n">get_connection</span><span class="p">(</span><span class="s2">&quot;PING&quot;</span><span class="p">)</span>
<span class="k">try</span><span class="p">:</span>
    <span class="n">conn</span><span class="o">.</span><span class="n">send_command</span><span class="p">(</span><span class="s2">&quot;PING&quot;</span><span class="p">)</span>
    <span class="n">conn</span><span class="o">.</span><span class="n">read_response</span><span class="p">()</span>
<span class="k">finally</span><span class="p">:</span>
    <span class="n">pool</span><span class="o">.</span><span class="n">release</span><span class="p">(</span><span class="n">conn</span><span class="p">)</span>          <span class="c1"># 异常路径同样会执行</span>
</code></pre></div></div>

<p>再配三条约束：阻塞命令使用独立的小池，不占业务池；池的等待超时设成有限值（比如 200ms），借不到就快速失败并降级；服务端设置 <code>timeout</code>，主动踢掉长时间空闲的连接。</p>
<p>降级要提前设计。池满时返回缓存或默认值，比让请求挂两秒再失败体验更好，也能避免雪崩。关键是降级开关要有上限，别让所有请求都走降级。</p>
<p>Java 侧同理，Jedis 的 <code>maxTotal</code>、<code>maxWaitMillis</code> 与 <code>testOnBorrow</code> 要一起配；Lettuce 默认共享连接，模型不同，别把 Jedis 的经验直接套过去。</p>
<h2 id="_5">防复发<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>埋点四项：池 active、idle、等待线程数、borrow 耗时。饱和率超过 80% 持续五分钟就告警。</li>
<li>对 <code>connected_clients</code> 做基线告警，偏离基线 30% 就查，不要等报错。</li>
<li>定期采样 <code>CLIENT LIST</code> 存档，出问题时能回溯是哪类连接在涨。</li>
<li>代码规范写死：业务代码只能通过统一封装的 client 访问 Redis，禁止裸调 <code>get_connection</code>。</li>
<li>压测时注入异常，专门验证 finally 分支和降级逻辑，而不是只跑正常路径。</li>
<li>把连接池上限写进部署配置的校验项：实例数 × 上限 &lt; <code>maxclients</code> × 0.7，启动时检查一次。</li>
</ul>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<p>连接池耗尽的表象是「借不到连接」，根因几乎都在「借了没还」或「根本没用池」。</p>
<p>定位顺序：先 <code>INFO clients</code> 看总量趋势，再 <code>CLIENT LIST</code> 看 idle 与 cmd，最后回到应用侧核对池指标和借还代码。</p>
<p>修复三件事：统一走客户端封装、异常路径用 <code>try/finally</code> 归还、池大小按 <code>QPS × 持有时间</code> 估算并设有限等待。最后加一条 <code>connected_clients</code> 的基线告警，泄漏就不会拖到用户感知才被发现。</p>
<p>还有一个反向教训：重启应用只是把泄漏的计时器归零。如果重启后曲线依旧阶梯上涨，问题没修，只是被延后了。</p>
<p>最后一条成本提醒：池上限不是越大越好。连接数远超 Redis 单线程的处理能力，只是把排队从客户端搬到服务端，延迟反而更高。</p>]]></content:encoded>
<category>故障排查</category>
</item><item>
<title>用 80 行 Python 写一个可靠的定时任务调度器</title>
<link>https://blog.xtang.online/posts/python-scheduler-80-lines/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/python-scheduler-80-lines/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>只靠标准库写一个进程内调度器：秒级间隔触发、异常隔离、失败指数退避、上一轮没跑完就跳过，并说清它和 cron 各自该用在什么场景。</description>
<content:encoded><![CDATA[<p>对账任务要求每 90 秒拉一次上游文件，cron 只能给到分钟级；更麻烦的是服务部署了三个副本，谁都不该多跑一遍。</p>
<p>这类需求不需要引入 Celery 或 APScheduler。<code>threading</code> 加 <code>heapq</code>，八十行以内就能得到一个行为可预期的进程内调度器。前提是先想清楚它不负责什么。</p>
<h2 id="_1">先划清能力边界<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>要解决的是四个具体问题：</p>
<ul>
<li><strong>触发间隔</strong>：30 秒、90 秒这类非整分钟周期，cron 表达不了。</li>
<li><strong>重叠执行</strong>：上一轮跑了 3 分钟，下一轮又起来，两个线程写同一个临时文件。</li>
<li><strong>失败处理</strong>：上游偶发 502，等到下个周期太久，立刻重试又会把对方打挂。</li>
<li><strong>可观测</strong>：日志里要能看出是哪个任务、连续失败了几次。</li>
</ul>
<p>如果只是「每天凌晨跑一次备份」，cron 配 <code>flock</code> 更省事。进程内调度器的价值在于秒级精度、能复用进程里的连接池和缓存，以及能直接读写内存状态。</p>
<p>顺带说一句为什么不用现成库。APScheduler 功能完整，代价是多一份依赖和一套配置心智；<code>threading.Timer</code> 每次触发都要重建对象，还会随任务数线性堆积线程。如果你的需求就是本文列的四条，自己写反而更容易读懂、更容易改。代码全部用标准库，粘贴到任何 Python 3.9 以上的环境都能跑。</p>
<h2 id="_2">用最小堆管理下一次触发时间<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>堆顶永远是最早该执行的任务。每条记录带上 <code>next_run</code>（<code>time.monotonic()</code> 时间戳）和自增的 <code>seq</code>，后者用来打破同一时刻的平局。</p>
<p>这里有个必须知道的细节：<code>dataclass(order=True)</code> 会把所有字段拉进比较，而函数对象不能比大小。给 <code>fn</code>、<code>name</code> 加上 <code>compare=False</code>，否则注册第二个任务时就会抛 <code>TypeError</code>。</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># scheduler.py —— 仅标准库，Python 3.9+</span>
<span class="kn">import</span> <span class="nn">heapq</span>
<span class="kn">import</span> <span class="nn">logging</span>
<span class="kn">import</span> <span class="nn">threading</span>
<span class="kn">import</span> <span class="nn">time</span>
<span class="kn">from</span> <span class="nn">dataclasses</span> <span class="kn">import</span> <span class="n">dataclass</span><span class="p">,</span> <span class="n">field</span>

<span class="n">log</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="s2">&quot;sched&quot;</span><span class="p">)</span>

<span class="nd">@dataclass</span><span class="p">(</span><span class="n">order</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">Job</span><span class="p">:</span>
    <span class="n">next_run</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">seq</span><span class="p">:</span> <span class="nb">int</span>
    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">field</span><span class="p">(</span><span class="n">compare</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
    <span class="n">interval</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">field</span><span class="p">(</span><span class="n">compare</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
    <span class="n">fn</span><span class="p">:</span> <span class="nb">object</span> <span class="o">=</span> <span class="n">field</span><span class="p">(</span><span class="n">compare</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
    <span class="n">running</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="n">field</span><span class="p">(</span><span class="n">compare</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
    <span class="n">failures</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="n">field</span><span class="p">(</span><span class="n">compare</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>

<span class="k">class</span> <span class="nc">Scheduler</span><span class="p">:</span>
    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tick</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.0</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">_seq</span> <span class="o">=</span> <span class="p">[],</span> <span class="mi">0</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_tick</span> <span class="o">=</span> <span class="n">tick</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_stop</span> <span class="o">=</span> <span class="n">threading</span><span class="o">.</span><span class="n">Event</span><span class="p">()</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span> <span class="o">=</span> <span class="n">threading</span><span class="o">.</span><span class="n">Condition</span><span class="p">()</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_thread</span> <span class="o">=</span> <span class="n">threading</span><span class="o">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">_loop</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&quot;sched&quot;</span><span class="p">,</span> <span class="n">daemon</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">every</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">seconds</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">catch_up</span><span class="o">=</span><span class="kc">False</span><span class="p">):</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;注册固定间隔任务；catch_up=True 表示启动后先立刻跑一次。&quot;&quot;&quot;</span>
        <span class="k">def</span> <span class="nf">register</span><span class="p">(</span><span class="n">fn</span><span class="p">):</span>
            <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="p">:</span>
                <span class="bp">self</span><span class="o">.</span><span class="n">_seq</span> <span class="o">+=</span> <span class="mi">1</span>
                <span class="n">first</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="p">(</span><span class="mf">0.0</span> <span class="k">if</span> <span class="n">catch_up</span> <span class="k">else</span> <span class="nb">float</span><span class="p">(</span><span class="n">seconds</span><span class="p">))</span>
                <span class="n">heapq</span><span class="o">.</span><span class="n">heappush</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">,</span>
                               <span class="n">Job</span><span class="p">(</span><span class="n">first</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">_seq</span><span class="p">,</span> <span class="n">name</span> <span class="ow">or</span> <span class="n">fn</span><span class="o">.</span><span class="vm">__name__</span><span class="p">,</span> <span class="nb">float</span><span class="p">(</span><span class="n">seconds</span><span class="p">),</span> <span class="n">fn</span><span class="p">))</span>
                <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="o">.</span><span class="n">notify</span><span class="p">()</span>
            <span class="k">return</span> <span class="n">fn</span>
        <span class="k">return</span> <span class="n">register</span>
</code></pre></div></div>

<p>注册只做两件事：算出首次触发时间，塞进堆并唤醒正在等待的循环。用 <code>Condition</code> 而不是 <code>time.sleep</code> 轮询，新任务注册后能立刻被感知。</p>
<h2 id="_3">调度循环：三个判断缺一不可<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 片段：以下方法接在 Scheduler 类内部</span>
    <span class="k">def</span> <span class="nf">_loop</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
            <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="p">:</span>
                <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">_stop</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
                    <span class="k">return</span>
                <span class="k">if</span> <span class="ow">not</span> <span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">:</span>
                    <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="o">.</span><span class="n">wait</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_tick</span><span class="p">)</span>
                    <span class="k">continue</span>
                <span class="n">job</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
                <span class="n">wait</span> <span class="o">=</span> <span class="n">job</span><span class="o">.</span><span class="n">next_run</span> <span class="o">-</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span>
                <span class="k">if</span> <span class="n">wait</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
                    <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="o">.</span><span class="n">wait</span><span class="p">(</span><span class="nb">min</span><span class="p">(</span><span class="n">wait</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">_tick</span><span class="p">))</span>  <span class="c1"># 有新任务注册会被提前唤醒</span>
                    <span class="k">continue</span>
                <span class="n">heapq</span><span class="o">.</span><span class="n">heappop</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">)</span>
                <span class="k">if</span> <span class="n">job</span><span class="o">.</span><span class="n">running</span><span class="p">:</span>  <span class="c1"># 上一轮没跑完：跳过本次，不排队</span>
                    <span class="n">job</span><span class="o">.</span><span class="n">next_run</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="n">job</span><span class="o">.</span><span class="n">interval</span>
                    <span class="n">heapq</span><span class="o">.</span><span class="n">heappush</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">,</span> <span class="n">job</span><span class="p">)</span>
                    <span class="k">continue</span>
                <span class="n">job</span><span class="o">.</span><span class="n">running</span> <span class="o">=</span> <span class="kc">True</span>
            <span class="n">threading</span><span class="o">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">_run</span><span class="p">,</span> <span class="n">args</span><span class="o">=</span><span class="p">(</span><span class="n">job</span><span class="p">,),</span> <span class="n">daemon</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">start</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">start</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_thread</span><span class="o">.</span><span class="n">start</span><span class="p">()</span>
</code></pre></div></div>

<p>这三个判断分别对应三类故障：</p>
<ol>
<li><strong>等待要有上限</strong>。<code>wait(min(wait, tick))</code> 里的 <code>tick</code> 是兜底，防止堆顶任务在很远的将来时，注册新任务也无法及时生效；<code>notify()</code> 负责让新注册立刻打断等待。</li>
<li><strong>运行中直接跳过</strong>。不排队是刻意的：如果任务稳定慢于周期，排队只会把内存和时间差一起堆爆，日志里全是过期执行。</li>
<li><strong>执行必须离开锁</strong>。<code>_loop</code> 只负责派发，真正的函数体放到新线程里跑，否则一个慢任务会把整个调度循环按住。</li>
</ol>
<p>派发之后的执行体是这样收尾的：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 片段：以下方法同样接在 Scheduler 类内部</span>
    <span class="k">def</span> <span class="nf">_run</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">job</span><span class="p">):</span>
        <span class="n">started</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">job</span><span class="o">.</span><span class="n">fn</span><span class="p">()</span>
            <span class="n">job</span><span class="o">.</span><span class="n">failures</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
            <span class="n">job</span><span class="o">.</span><span class="n">failures</span> <span class="o">+=</span> <span class="mi">1</span>
            <span class="n">log</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&quot;job </span><span class="si">%s</span><span class="s2"> failed (attempt </span><span class="si">%d</span><span class="s2">)&quot;</span><span class="p">,</span> <span class="n">job</span><span class="o">.</span><span class="n">name</span><span class="p">,</span> <span class="n">job</span><span class="o">.</span><span class="n">failures</span><span class="p">)</span>
        <span class="k">finally</span><span class="p">:</span>
            <span class="c1"># 失败退避：间隔 × 2^n，封顶 8 倍；成功后退回原间隔</span>
            <span class="n">delay</span> <span class="o">=</span> <span class="n">job</span><span class="o">.</span><span class="n">interval</span> <span class="o">*</span> <span class="nb">min</span><span class="p">(</span><span class="mi">2</span> <span class="o">**</span> <span class="n">job</span><span class="o">.</span><span class="n">failures</span><span class="p">,</span> <span class="mi">8</span><span class="p">)</span>
            <span class="n">job</span><span class="o">.</span><span class="n">running</span> <span class="o">=</span> <span class="kc">False</span>
            <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="p">:</span>
                <span class="n">job</span><span class="o">.</span><span class="n">next_run</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="n">started</span> <span class="o">+</span> <span class="n">delay</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">())</span>
                <span class="n">heapq</span><span class="o">.</span><span class="n">heappush</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_heap</span><span class="p">,</span> <span class="n">job</span><span class="p">)</span>
                <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="o">.</span><span class="n">notify</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">stop</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">timeout</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">10.0</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_stop</span><span class="o">.</span><span class="n">set</span><span class="p">()</span>
        <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="p">:</span>
            <span class="bp">self</span><span class="o">.</span><span class="n">_cv</span><span class="o">.</span><span class="n">notify_all</span><span class="p">()</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_thread</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">timeout</span><span class="p">)</span>
</code></pre></div></div>

<p>三个判断之后是清理工作。无论成功还是异常，<code>finally</code> 都要把任务重新压回堆里，否则这个任务从此消失；<code>running=False</code> 也要在这里复位，不然下一轮会被永久跳过。</p>
<p>退避公式是 <code>interval × 2^failures</code>，最长 8 倍。它适合「上游短时间不可用」，不适合「必须准点」的场景——后者应该让任务失败并告警，而不是悄悄推迟。这套策略的代价是延迟不可控：连续失败三次后，一次重试可能排到 40 秒开外，用之前先确认业务能接受。</p>
<h2 id="_4">跑起来<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&quot;__main__&quot;</span><span class="p">:</span>
    <span class="n">logging</span><span class="o">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="o">.</span><span class="n">INFO</span><span class="p">,</span>
                        <span class="nb">format</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">%(asctime)s</span><span class="s2"> </span><span class="si">%(levelname)s</span><span class="s2"> </span><span class="si">%(message)s</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="n">sched</span> <span class="o">=</span> <span class="n">Scheduler</span><span class="p">(</span><span class="n">tick</span><span class="o">=</span><span class="mf">0.2</span><span class="p">)</span>

    <span class="nd">@sched</span><span class="o">.</span><span class="n">every</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&quot;sync-ledger&quot;</span><span class="p">,</span> <span class="n">catch_up</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
    <span class="k">def</span> <span class="nf">sync_ledger</span><span class="p">():</span>
        <span class="n">log</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;pull ledger ...&quot;</span><span class="p">)</span>

    <span class="n">sched</span><span class="o">.</span><span class="n">start</span><span class="p">()</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">30</span><span class="p">)</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="n">sched</span><span class="o">.</span><span class="n">stop</span><span class="p">()</span>
</code></pre></div></div>

<p>启动后会立刻补跑一次，之后每 5 秒触发。<code>catch_up=True</code> 的语义仅仅是「进程起来先跑一次」，不是把停机期间漏掉的周期全部补上——这一点容易被误解。</p>
<p>调试时建议再加一行观测：记录 <code>actual_start - job.next_run</code> 的差值，也就是实际开始时间比计划晚了多少。这个值持续偏大，说明线程池已被占满或者锁竞争严重；偶尔尖刺则属于正常的 GC 和调度抖动。<code>tick</code> 决定了精度上限，默认 1 秒，要做到亚秒级就把它调小，代价是空转次数变多。</p>
<div class="callout callout-warning"><div class="callout-label">注意</div>
<p><code>stop()</code> 只是不再派发新任务。已经在跑的任务会在 <code>join</code> 超时后被守护线程直接终止，写文件到一半就可能留下半成品。所有任务都应设计成可重入、可重试，落盘用「临时文件 + <code>os.replace</code>」。</p>
</div>
<h2 id="_5">上线前还得补三件事<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<p>进程内调度器最大的风险不在代码，在部署形态。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>场景</th>
<th>cron + flock</th>
<th>本文调度器</th>
</tr>
</thead>
<tbody>
<tr>
<td>整点、每天固定时刻</td>
<td>合适</td>
<td>不擅长，需自己算时间</td>
</tr>
<tr>
<td>秒级、亚分钟间隔</td>
<td>做不到</td>
<td>合适</td>
</tr>
<tr>
<td>复用进程内连接池</td>
<td>做不到</td>
<td>合适</td>
</tr>
<tr>
<td>多副本只跑一次</td>
<td>需文件锁</td>
<td>做不到，必须外部锁</td>
</tr>
<tr>
<td>崩溃后补齐漏跑</td>
<td>做不到</td>
<td>只能补一次，不能补齐</td>
</tr>
</tbody>
</table></div>
<ul>
<li><strong>多副本</strong>：三台机器就是三份执行。用 Redis <code>SET key val NX EX ttl</code> 或数据库行锁，锁的 TTL 要大于单次执行时间，否则锁过期后仍会并发。</li>
<li><strong>卡死</strong>：Python 线程杀不掉。给任务加「开始时间 + 心跳」，超时就告警；真正的硬超时只能把任务丢进子进程。</li>
<li>
<p><strong>重启丢失</strong>：堆在内存里，重启即清空。要精确补偿，得把 <code>last_run</code> 落库，启动时按「漏了几个周期」决定补几次。</p>
</li>
<li>
<p><strong>重活别放进线程</strong>：这里的并发模型是「一个任务一个线程」，适合 IO 等待。真要做视频转码、大批量计算，线程会被 GIL 串行化，应该把任务提交给进程池，调度器只负责按时投递。</p>
</li>
</ul>
<p>还有个容易忽略的坑：不要用 <code>time.time()</code> 做调度。NTP 回拨几分钟，调度器就会静默停摆同样长的时间；<code>monotonic()</code> 单调递增，只适合测量间隔，不能直接当墙钟时间写日志。</p>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<ul>
<li>用 <code>heapq</code> + <code>Condition</code> + 守护线程，80 行足够覆盖秒级周期的进程内调度。</li>
<li><code>dataclass(order=True)</code> 里的函数字段必须 <code>compare=False</code>，这是最先撞上的坑。</li>
<li>重叠执行选择「跳过」而不是「排队」，失败选择「指数退避」而不是「立即重试」。</li>
<li>调度器不解决多副本去重和持久化，这两件事要在部署层补。</li>
<li>用 <code>monotonic()</code> 计算间隔，用 <code>time.time()</code> 打日志，两者不要混用。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>手写一个带重试与退避的 HTTP 客户端</title>
<link>https://blog.xtang.online/posts/python-http-retry-backoff/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/python-http-retry-backoff/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>重试不是 for 循环套三次。用标准库写一个带全抖动指数退避、超时预算、Retry-After 支持与幂等判断的 HTTP 客户端，并说清哪些请求根本不该重试。</description>
<content:encoded><![CDATA[<p>凌晨的批量任务把上游打挂了。原因很简单：一次 502 之后，代码立刻用 <code>for</code> 循环重试三次，几千个请求在同一秒涌向已经过载的服务。</p>
<p>更糟的是日志：每个失败都打了三条 error，值班同学第一反应是「上游挂了」，实际上是我们自己把对方压垮的。后来我们把重试逻辑收进一个客户端，明确「等多久、试几次、什么时候放弃」，这类事故再没出现过。</p>
<p>重试本身不难，难的是「重试几次、等多久、哪些请求能重试、总时长怎么收口」。下面这版客户端只用标准库，四个问题都给出了默认答案。</p>
<h2 id="_1">先判断：这个请求值得重试吗<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>三条规则，按优先级排：</p>
<ol>
<li><strong>只重试幂等请求</strong>。<code>GET</code>、<code>HEAD</code>、<code>PUT</code>、<code>DELETE</code>、<code>OPTIONS</code> 重复发送不会产生额外副作用；<code>POST</code> 会，默认只发一次。如果上游支持幂等键，可以带上 <code>Idempotency-Key</code> 再放开。</li>
<li><strong>只重试可恢复的状态码</strong>。<code>408</code>（请求超时）、<code>425</code>（过早）、<code>429</code>（限流）、<code>500</code>、<code>502</code>、<code>503</code>、<code>504</code> 值得再试；<code>400</code>、<code>401</code>、<code>403</code>、<code>404</code>、<code>422</code> 是请求本身有问题，重试一百次结果一样。</li>
<li><strong>只重试传输层失败</strong>。连接被拒、连接重置、读超时、TLS 握手失败，这些都能重试。DNS 解析失败通常也可以，但要先确认不是域名打错了。</li>
</ol>
<p>判断顺序建议从右往左：先看总预算还剩多少，再看状态码是否可恢复，最后才看方法是否幂等。反过来做，很容易在一个已经超时的请求上继续排队等待。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>情况</th>
<th>处理</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>POST</code> 创建订单</td>
<td>不重试</td>
<td>可能重复下单</td>
</tr>
<tr>
<td><code>429</code> 带 <code>Retry-After: 30</code></td>
<td>等待 30 秒后重试</td>
<td>服务端明确告知节奏</td>
</tr>
<tr>
<td><code>503</code> 无 <code>Retry-After</code></td>
<td>指数退避</td>
<td>对方在过载，退让</td>
</tr>
<tr>
<td><code>401</code> token 过期</td>
<td>刷新 token 后重发一次</td>
<td>与网络无关</td>
</tr>
<tr>
<td>读超时 5 秒</td>
<td>重试</td>
<td>请求可能只是慢</td>
</tr>
<tr>
<td>总耗时超过预算</td>
<td>立即失败</td>
<td>不再放大故障</td>
</tr>
</tbody>
</table></div>
<h2 id="_2">退避：为什么要加抖动<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>固定间隔（比如每次等 1 秒）最糟：所有客户端会在同一时刻再次齐射，把刚缓过来的服务再打趴。指数退避把间隔拉长，全抖动（full jitter）再把时间打散到 <code>[0, cap]</code> 区间内。</p>
<p>公式是 <code>random.uniform(0, min(cap, base × 2^attempt))</code>。第 0 次失败最多等 0.5 秒，第 1 次 1 秒，第 2 次 2 秒，封顶 8 秒。看起来比「等满 2 秒」激进，但期望值更小，整体恢复更快，这是 AWS 架构博客里推荐的默认策略。</p>
<p>顺便算一下重试的收益。假设单次失败率是 1%，独立重试 3 次全部失败的概率约为 <code>0.01³ = 10⁻⁶</code>；但如果是持续性故障（实例整个挂了），单次失败率就是 100%，重试几次都是 100%。所以重试只对「偶发、独立」的失败有效，对容量问题无效，后者要靠限流和扩容。</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># retry_client.py —— 仅标准库，Python 3.9+</span>
<span class="kn">import</span> <span class="nn">http.client</span>
<span class="kn">import</span> <span class="nn">random</span>
<span class="kn">import</span> <span class="nn">socket</span>
<span class="kn">import</span> <span class="nn">ssl</span>
<span class="kn">import</span> <span class="nn">time</span>
<span class="kn">import</span> <span class="nn">urllib.error</span>
<span class="kn">import</span> <span class="nn">urllib.request</span>

<span class="n">RETRY_STATUS</span> <span class="o">=</span> <span class="p">{</span><span class="mi">408</span><span class="p">,</span> <span class="mi">425</span><span class="p">,</span> <span class="mi">429</span><span class="p">,</span> <span class="mi">500</span><span class="p">,</span> <span class="mi">502</span><span class="p">,</span> <span class="mi">503</span><span class="p">,</span> <span class="mi">504</span><span class="p">}</span>
<span class="n">IDEMPOTENT</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;GET&quot;</span><span class="p">,</span> <span class="s2">&quot;HEAD&quot;</span><span class="p">,</span> <span class="s2">&quot;PUT&quot;</span><span class="p">,</span> <span class="s2">&quot;DELETE&quot;</span><span class="p">,</span> <span class="s2">&quot;OPTIONS&quot;</span><span class="p">}</span>

<span class="k">class</span> <span class="nc">RetryExhausted</span><span class="p">(</span><span class="ne">RuntimeError</span><span class="p">):</span>
    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">attempts</span><span class="p">,</span> <span class="n">last</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s2"> 重试 </span><span class="si">{</span><span class="n">attempts</span><span class="si">}</span><span class="s2"> 次仍失败: </span><span class="si">{</span><span class="n">last</span><span class="si">!r}</span><span class="s2">&quot;</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">last</span> <span class="o">=</span> <span class="n">last</span>

<span class="k">def</span> <span class="nf">backoff</span><span class="p">(</span><span class="n">attempt</span><span class="p">,</span> <span class="n">base</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span> <span class="n">cap</span><span class="o">=</span><span class="mf">8.0</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;全抖动指数退避：第 n 次最多等 min(cap, base*2^n) 秒。&quot;&quot;&quot;</span>
    <span class="k">return</span> <span class="n">random</span><span class="o">.</span><span class="n">uniform</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">min</span><span class="p">(</span><span class="n">cap</span><span class="p">,</span> <span class="n">base</span> <span class="o">*</span> <span class="mi">2</span> <span class="o">**</span> <span class="n">attempt</span><span class="p">))</span>

<span class="k">def</span> <span class="nf">retry_after</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">fallback</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;只解析秒数；HTTP-date 形式退回退避值，宁可保守也不乱等。&quot;&quot;&quot;</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">value</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">fallback</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="nb">float</span><span class="p">(</span><span class="n">value</span><span class="o">.</span><span class="n">strip</span><span class="p">()))</span>
    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">fallback</span>
</code></pre></div></div>

<h2 id="_3">主循环：用总预算收口<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">method</span><span class="o">=</span><span class="s2">&quot;GET&quot;</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mf">5.0</span><span class="p">,</span>
            <span class="n">attempts</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">budget</span><span class="o">=</span><span class="mf">20.0</span><span class="p">,</span> <span class="n">retry_non_idempotent</span><span class="o">=</span><span class="kc">False</span><span class="p">):</span>
    <span class="n">method</span> <span class="o">=</span> <span class="n">method</span><span class="o">.</span><span class="n">upper</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">method</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">IDEMPOTENT</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">retry_non_idempotent</span><span class="p">:</span>
        <span class="n">attempts</span> <span class="o">=</span> <span class="mi">1</span>                       <span class="c1"># 非幂等请求默认只发一次</span>
    <span class="n">headers</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">headers</span> <span class="ow">or</span> <span class="p">{})</span>
    <span class="n">started</span><span class="p">,</span> <span class="n">last</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">(),</span> <span class="kc">None</span>

    <span class="k">for</span> <span class="n">attempt</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">attempts</span><span class="p">):</span>
        <span class="n">remain</span> <span class="o">=</span> <span class="n">budget</span> <span class="o">-</span> <span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span> <span class="o">-</span> <span class="n">started</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">remain</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">raise</span> <span class="n">RetryExhausted</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">attempt</span><span class="p">,</span> <span class="n">last</span><span class="p">)</span>
        <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">,</span> <span class="n">method</span><span class="o">=</span><span class="n">method</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">)</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="k">with</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="nb">min</span><span class="p">(</span><span class="n">timeout</span><span class="p">,</span> <span class="n">remain</span><span class="p">))</span> <span class="k">as</span> <span class="n">resp</span><span class="p">:</span>
                <span class="k">return</span> <span class="n">resp</span><span class="o">.</span><span class="n">status</span><span class="p">,</span> <span class="nb">dict</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">headers</span><span class="p">),</span> <span class="n">resp</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
        <span class="k">except</span> <span class="n">urllib</span><span class="o">.</span><span class="n">error</span><span class="o">.</span><span class="n">HTTPError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">body</span> <span class="o">=</span> <span class="n">e</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>                <span class="c1"># 必须读完，连接才能回收</span>
            <span class="n">last</span> <span class="o">=</span> <span class="n">e</span>
            <span class="k">if</span> <span class="n">e</span><span class="o">.</span><span class="n">code</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">RETRY_STATUS</span> <span class="ow">or</span> <span class="n">attempt</span> <span class="o">==</span> <span class="n">attempts</span> <span class="o">-</span> <span class="mi">1</span><span class="p">:</span>
                <span class="k">return</span> <span class="n">e</span><span class="o">.</span><span class="n">code</span><span class="p">,</span> <span class="nb">dict</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">headers</span><span class="p">),</span> <span class="n">body</span>
            <span class="n">wait</span> <span class="o">=</span> <span class="n">retry_after</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;Retry-After&quot;</span><span class="p">),</span> <span class="n">backoff</span><span class="p">(</span><span class="n">attempt</span><span class="p">))</span>
        <span class="k">except</span> <span class="p">(</span><span class="n">urllib</span><span class="o">.</span><span class="n">error</span><span class="o">.</span><span class="n">URLError</span><span class="p">,</span> <span class="n">socket</span><span class="o">.</span><span class="n">timeout</span><span class="p">,</span> <span class="ne">ConnectionError</span><span class="p">,</span>
                <span class="n">ssl</span><span class="o">.</span><span class="n">SSLError</span><span class="p">,</span> <span class="n">http</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">HTTPException</span><span class="p">)</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">last</span> <span class="o">=</span> <span class="n">e</span>
            <span class="n">wait</span> <span class="o">=</span> <span class="n">backoff</span><span class="p">(</span><span class="n">attempt</span><span class="p">)</span>
        <span class="n">remain</span> <span class="o">=</span> <span class="n">budget</span> <span class="o">-</span> <span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span> <span class="o">-</span> <span class="n">started</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">remain</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">break</span>
        <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="nb">max</span><span class="p">(</span><span class="mf">0.05</span><span class="p">,</span> <span class="nb">min</span><span class="p">(</span><span class="n">wait</span><span class="p">,</span> <span class="n">remain</span><span class="p">)))</span>   <span class="c1"># 至少让出 50ms，避免空转</span>
    <span class="k">raise</span> <span class="n">RetryExhausted</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">attempts</span><span class="p">,</span> <span class="n">last</span><span class="p">)</span>
</code></pre></div></div>

<p>几个刻意的选择：</p>
<ul>
<li><strong><code>timeout=min(timeout, remain)</code></strong>。单次超时不能超过剩余预算，否则一次卡死会吃掉全部重试机会。</li>
<li><strong><code>Retry-After</code> 优先</strong>。服务端说 30 秒就等 30 秒，仍然受预算约束；解析失败才退回退避值。</li>
<li><strong>4xx 直接返回不抛异常</strong>。调用方需要读 <code>401</code> 的错误体和 <code>429</code> 的限流头，包成异常反而碍事。</li>
<li><strong><code>e.read()</code> 不能省</strong>。HTTPError 的响应体不读完，底层连接不会释放。</li>
</ul>
<div class="callout callout-note"><div class="callout-label">提示</div>
<p><code>urllib</code> 的重试是「重新建立连接」，不是复用连接池。高并发场景下应该换 <code>httpx</code> 或 <code>requests.Session</code>，退避逻辑可以原样搬过去。</p>
</div>
<h2 id="_4">验证：起一个坏两次的本地服务<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">import</span> <span class="nn">threading</span>
<span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">BaseHTTPRequestHandler</span><span class="p">,</span> <span class="n">HTTPServer</span>

<span class="n">hits</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;n&quot;</span><span class="p">:</span> <span class="mi">0</span><span class="p">}</span>

<span class="k">class</span> <span class="nc">Flaky</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">hits</span><span class="p">[</span><span class="s2">&quot;n&quot;</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="n">code</span> <span class="o">=</span> <span class="mi">503</span> <span class="k">if</span> <span class="n">hits</span><span class="p">[</span><span class="s2">&quot;n&quot;</span><span class="p">]</span> <span class="o">&lt;</span> <span class="mi">3</span> <span class="k">else</span> <span class="mi">200</span>       <span class="c1"># 前两次失败，第三次成功</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">send_header</span><span class="p">(</span><span class="s2">&quot;Retry-After&quot;</span><span class="p">,</span> <span class="s2">&quot;0.2&quot;</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">send_header</span><span class="p">(</span><span class="s2">&quot;Content-Length&quot;</span><span class="p">,</span> <span class="s2">&quot;2&quot;</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">wfile</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="sa">b</span><span class="s2">&quot;ok&quot;</span> <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="mi">200</span> <span class="k">else</span> <span class="sa">b</span><span class="s2">&quot;no&quot;</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">log_message</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">):</span>
        <span class="k">pass</span>

<span class="n">srv</span> <span class="o">=</span> <span class="n">HTTPServer</span><span class="p">((</span><span class="s2">&quot;127.0.0.1&quot;</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span> <span class="n">Flaky</span><span class="p">)</span>
<span class="n">threading</span><span class="o">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">srv</span><span class="o">.</span><span class="n">serve_forever</span><span class="p">,</span> <span class="n">daemon</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">start</span><span class="p">()</span>
<span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&quot;http://127.0.0.1:</span><span class="si">{</span><span class="n">srv</span><span class="o">.</span><span class="n">server_port</span><span class="si">}</span><span class="s2">/health&quot;</span>
<span class="nb">print</span><span class="p">(</span><span class="n">request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">attempts</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">budget</span><span class="o">=</span><span class="mf">10.0</span><span class="p">))</span>
<span class="n">srv</span><span class="o">.</span><span class="n">shutdown</span><span class="p">()</span>
</code></pre></div></div>

<p>跑出来是 <code>(200, {...}, b'ok')</code>，总耗时约 0.4 秒——两次 <code>Retry-After: 0.2</code> 的结果。把 <code>Retry-After</code> 改成 <code>30</code>，预算 10 秒会直接抛 <code>RetryExhausted</code>，这比干等 30 秒再失败更符合调用方的预期。</p>
<p>这个本地服务值得留在测试里。它能在不依赖外网的情况下覆盖三条关键路径：可恢复的 5xx、<code>Retry-After</code> 生效、预算耗尽。把失败次数改成 10，再断言 <code>hits["n"] == attempts</code>，就能确认重试次数没有失控。</p>
<h2 id="_5">生产环境还要考虑什么<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li><strong>重试放大</strong>。客户端重试 3 次、网关重试 3 次、服务内部再重试 3 次，一次请求会变成 27 次。只在一层做重试，其他层快速失败。</li>
<li><strong>熔断</strong>。连续失败超过阈值就停止请求一段时间，否则退避只是把雪崩拉长。重试解决「偶发」，熔断解决「持续故障」。</li>
<li><strong>可观测</strong>。至少记录 <code>url</code>、<code>attempt</code>、<code>status</code>、<code>wait</code>、<code>total</code>。只统计最终成功率会掩盖重试风暴。</li>
<li><strong>不要重试到超时上限</strong>。上游 SLA 是 2 秒，你的预算是 20 秒，重试成功也晚了。预算应该由调用方的 deadline 反推。</li>
<li><strong>请求体要可重放</strong>。<code>data</code> 是 bytes 才能重发；如果传的是文件流，第二次发送时指针已在末尾，会发出空 body。</li>
<li><strong>区分错误类型再决定告警</strong>。<code>RetryExhausted</code> 里的 <code>last</code> 保留了最后一次异常，据此区分「全是 5xx」「全是超时」「混合」，排障时能少走很多弯路。</li>
<li><strong>别把重试写进业务代码</strong>。同一个项目里出现五套重试参数是常态，最后没人说清哪个请求被放大了几次。统一收口到客户端，参数按接口配置。</li>
</ul>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<ul>
<li>幂等性决定能不能重试，状态码决定值不值得重试，超时预算决定允许重试多久。</li>
<li>退避用全抖动指数，<code>base=0.5s</code>、<code>cap=8s</code> 是个稳妥起点。</li>
<li><code>Retry-After</code> 优先于自己的退避算法，但仍要受总预算约束。</li>
<li>4xx 不重试（<code>429</code> 除外），连接层错误才值得重连。</li>
<li>一次请求只能在一层重试，多层叠加会把故障放大成风暴。</li>
<li>重试是给偶发失败留的余量，不是给容量不足兜底；持续故障要靠限流、扩容和熔断。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>用 Shell 写一个生产可用的日志清理脚本</title>
<link>https://blog.xtang.online/posts/shell-log-cleanup-production/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/shell-log-cleanup-production/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>磁盘被打满的往往不是日志本身，而是没人清理的日志。这个脚本按天压缩、按期删除，带锁、带 dry-run、带空格文件名防护，并解释 rm 之后空间为什么不释放。</description>
<content:encoded><![CDATA[<p>磁盘告警到了 92%，<code>/var/log</code> 下面躺着 400GB 文件。最刺眼的是一个 sidecar 的日志，从上线那天起就没配 rotation，单文件 120GB。</p>
<p>临时 <code>rm</code> 掉它，<code>df</code> 却没有任何变化。原因是进程还持有这个文件的句柄，inode 没释放。这件事之后，我把清理做成了一个每分钟可以放心跑的脚本。</p>
<h2 id="_1">先定策略，再写命令<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>按文件年龄分三段处理，不要用文件名里的日期——文件名是应用写的，不可信；<code>mtime</code> 才是文件系统的事实。三段的比例可以按业务调，但顺序不能反：先压缩再删除，能保证删掉的每一份都还有一份压缩副本可供回溯。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>文件年龄</th>
<th>动作</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>3 天以内</td>
<td>不动</td>
<td>可能正在写，也可能还要排查</td>
</tr>
<tr>
<td>3–30 天</td>
<td><code>gzip -9</code></td>
<td>压缩比通常在 10:1 以上</td>
</tr>
<tr>
<td>30 天以上</td>
<td>删除</td>
<td>包括已压缩的 <code>.gz</code></td>
</tr>
<tr>
<td>文件名含 <code>audit</code></td>
<td>永不删除</td>
<td>合规日志单独走归档</td>
</tr>
</tbody>
</table></div>
<p><code>find -mtime +N</code> 的边界要心里有数：它按 24 小时取整，<code>+3</code> 实际匹配 4 天以上的文件。合规场景别依赖这个精度，用 <code>-newermt</code> 或专门的归档任务。</p>
<p>压缩工具用 <code>gzip</code>，因为它几乎每台机器都有。如果发行版自带 <code>zstd</code>，同样的文本日志压缩率相当、速度能快好几倍，把脚本里的 <code>gzip -9</code> 换成 <code>zstd -10 --rm</code> 即可；代价是排查时要多记一个解压命令。压缩级别也别迷信 <code>-9</code>：对已经接近熵上限的日志，<code>-9</code> 相比 <code>-6</code> 只多省几个百分点，CPU 却要多花一倍。</p>
<h2 id="_2">脚本主体<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/usr/bin/env bash</span>
<span class="nb">set</span><span class="w"> </span>-euo<span class="w"> </span>pipefail

<span class="nv">LOG_DIR</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">LOG_DIR</span><span class="k">:-</span><span class="p">/var/log/app</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nv">KEEP_DAYS</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">KEEP_DAYS</span><span class="k">:-</span><span class="nv">3</span><span class="si">}</span><span class="s2">&quot;</span><span class="w">        </span><span class="c1"># 超过几天开始压缩</span>
<span class="nv">DELETE_DAYS</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">DELETE_DAYS</span><span class="k">:-</span><span class="nv">30</span><span class="si">}</span><span class="s2">&quot;</span><span class="w">   </span><span class="c1"># 超过几天直接删除</span>
<span class="nv">PATTERN</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">PATTERN</span><span class="k">:-</span><span class="p">*.log</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nv">DRY_RUN</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">DRY_RUN</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nv">LOCK_FILE</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">LOCK_FILE</span><span class="k">:-</span><span class="p">/tmp/log-cleanup.lock</span><span class="si">}</span><span class="s2">&quot;</span>

<span class="o">[</span><span class="w"> </span>-d<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$LOG_DIR</span><span class="s2">&quot;</span><span class="w"> </span><span class="o">]</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;目录不存在: </span><span class="nv">$LOG_DIR</span><span class="s2">&quot;</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>
<span class="o">[[</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$KEEP_DAYS</span><span class="s2">&quot;</span><span class="w"> </span><span class="o">=</span>~<span class="w"> </span>^<span class="o">[</span><span class="m">0</span>-9<span class="o">]</span>+$<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$DELETE_DAYS</span><span class="s2">&quot;</span><span class="w"> </span><span class="o">=</span>~<span class="w"> </span>^<span class="o">[</span><span class="m">0</span>-9<span class="o">]</span>+$<span class="w"> </span><span class="o">]]</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="o">||</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;KEEP_DAYS / DELETE_DAYS 必须是整数&quot;</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>
<span class="o">((</span><span class="w"> </span>KEEP_DAYS<span class="w"> </span>&lt;<span class="w"> </span>DELETE_DAYS<span class="w"> </span><span class="o">))</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="o">||</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;KEEP_DAYS 必须小于 DELETE_DAYS&quot;</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>

<span class="c1"># 同一时间只跑一个实例；没有 flock 的环境退化为目录锁</span>
<span class="k">if</span><span class="w"> </span><span class="nb">command</span><span class="w"> </span>-v<span class="w"> </span>flock<span class="w"> </span>&gt;/dev/null<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">  </span><span class="nb">exec</span><span class="w"> </span><span class="m">9</span>&gt;<span class="s2">&quot;</span><span class="nv">$LOCK_FILE</span><span class="s2">&quot;</span>
<span class="w">  </span>flock<span class="w"> </span>-n<span class="w"> </span><span class="m">9</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;已有实例在运行&quot;</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">0</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>
<span class="k">else</span>
<span class="w">  </span><span class="nv">LOCK_DIR</span><span class="o">=</span><span class="s2">&quot;</span><span class="nv">$LOCK_FILE</span><span class="s2">.d&quot;</span>
<span class="w">  </span>mkdir<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$LOCK_DIR</span><span class="s2">&quot;</span><span class="w"> </span><span class="m">2</span>&gt;/dev/null<span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;已有实例在运行&quot;</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span><span class="p">;</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">0</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>
<span class="w">  </span><span class="nb">trap</span><span class="w"> </span><span class="s1">&#39;rmdir &quot;$LOCK_DIR&quot; 2&gt;/dev/null || true&#39;</span><span class="w"> </span>EXIT
<span class="k">fi</span>

run<span class="o">()</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="o">[</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$DRY_RUN</span><span class="s2">&quot;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">&quot;1&quot;</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">then</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;[dry-run] </span><span class="nv">$*</span><span class="s2">&quot;</span><span class="p">;</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$@</span><span class="s2">&quot;</span><span class="p">;</span><span class="w"> </span><span class="k">fi</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>
</code></pre></div></div>

<p>锁这一段不是装饰。清理任务被 cron 每分钟拉起一次，压缩一个 2GB 文件可能要几十秒，没有锁就会同时出现多个 <code>gzip</code> 抢同一个文件，轻则报错，重则留下 <code>xxx.log.gz.gz</code>。<code>set -e</code> 在这里也有副作用：<code>gzip</code> 失败会让整个脚本退出，后面的删除一个都不做——这是对的，宁可少清理，也不要带着未知状态继续删。</p>
<h2 id="_3">压缩与删除<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="nv">before</span><span class="o">=</span><span class="k">$(</span>du<span class="w"> </span>-sm<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$LOG_DIR</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>cut<span class="w"> </span>-f1<span class="k">)</span>

<span class="c1"># 1) 压缩：够老但还没到删除线，且跳过已压缩文件</span>
<span class="k">while</span><span class="w"> </span><span class="nv">IFS</span><span class="o">=</span><span class="w"> </span><span class="nb">read</span><span class="w"> </span>-r<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;&#39;</span><span class="w"> </span>f<span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">  </span>run<span class="w"> </span>nice<span class="w"> </span>-n<span class="w"> </span><span class="m">10</span><span class="w"> </span>gzip<span class="w"> </span>-9<span class="w"> </span>--<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$f</span><span class="s2">&quot;</span>
<span class="k">done</span><span class="w"> </span>&lt;<span class="w"> </span>&lt;<span class="o">(</span>find<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$LOG_DIR</span><span class="s2">&quot;</span><span class="w"> </span>-xdev<span class="w"> </span>-type<span class="w"> </span>f<span class="w"> </span>-name<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PATTERN</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">           </span>-mtime<span class="w"> </span>+<span class="s2">&quot;</span><span class="nv">$KEEP_DAYS</span><span class="s2">&quot;</span><span class="w"> </span>-mtime<span class="w"> </span>-<span class="s2">&quot;</span><span class="nv">$DELETE_DAYS</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">           </span>!<span class="w"> </span>-name<span class="w"> </span><span class="s1">&#39;*.gz&#39;</span><span class="w"> </span>!<span class="w"> </span>-name<span class="w"> </span><span class="s1">&#39;audit*&#39;</span><span class="w"> </span>-print0<span class="o">)</span>

<span class="c1"># 2) 删除：超过删除线，含 .gz</span>
<span class="k">while</span><span class="w"> </span><span class="nv">IFS</span><span class="o">=</span><span class="w"> </span><span class="nb">read</span><span class="w"> </span>-r<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;&#39;</span><span class="w"> </span>f<span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">  </span>run<span class="w"> </span>rm<span class="w"> </span>-f<span class="w"> </span>--<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$f</span><span class="s2">&quot;</span>
<span class="k">done</span><span class="w"> </span>&lt;<span class="w"> </span>&lt;<span class="o">(</span>find<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$LOG_DIR</span><span class="s2">&quot;</span><span class="w"> </span>-xdev<span class="w"> </span>-type<span class="w"> </span>f<span class="w"> </span><span class="se">\</span>
<span class="w">           </span><span class="se">\(</span><span class="w"> </span>-name<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PATTERN</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>-name<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PATTERN</span><span class="s2">.gz&quot;</span><span class="w"> </span><span class="se">\)</span><span class="w"> </span><span class="se">\</span>
<span class="w">           </span>!<span class="w"> </span>-name<span class="w"> </span><span class="s1">&#39;audit*&#39;</span><span class="w"> </span>-mtime<span class="w"> </span>+<span class="s2">&quot;</span><span class="nv">$DELETE_DAYS</span><span class="s2">&quot;</span><span class="w"> </span>-print0<span class="o">)</span>

<span class="nv">after</span><span class="o">=</span><span class="k">$(</span>du<span class="w"> </span>-sm<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$LOG_DIR</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>cut<span class="w"> </span>-f1<span class="k">)</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;清理完成: </span><span class="si">${</span><span class="nv">before</span><span class="si">}</span><span class="s2">MB -&gt; </span><span class="si">${</span><span class="nv">after</span><span class="si">}</span><span class="s2">MB&quot;</span>
</code></pre></div></div>

<p>三处细节值得单独说：</p>
<ul>
<li><strong><code>-print0</code> + <code>read -d ''</code></strong>。日志文件名里带空格、中文、甚至换行都不罕见。用 <code>$(find ...)</code> 接 <code>for</code> 会在空格处断词，直接删错文件。</li>
<li><strong><code>-xdev</code></strong>。不跨越挂载点。<code>/var/log</code> 上如果挂了独立分区或网络盘，少了这个参数可能一路走进别的文件系统。</li>
<li><strong><code>nice -n 10</code></strong>。压缩是 CPU 密集操作，降优先级避免和业务抢核。IO 压力大时还可以套 <code>ionice -c2 -n7</code>。</li>
</ul>
<p><code>--</code> 也不能省。文件名以 <code>-</code> 开头时，<code>gzip -9 -- -foo.log</code> 才不会把文件名当成选项。</p>
<h3 id="dry-run">先在 dry-run 里看一遍<a class="h-anchor" href="#dry-run" aria-label="锚点">#</a></h3>
<p>脚本里所有写操作都走 <code>run()</code>，<code>DRY_RUN=1</code> 时只打印命令。上线前拿一个临时目录跑一遍：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>mkdir<span class="w"> </span>-p<span class="w"> </span>/tmp/logtest<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="nb">cd</span><span class="w"> </span>/tmp/logtest
<span class="nb">echo</span><span class="w"> </span>keep<span class="w"> </span>&gt;<span class="w"> </span>current.log
<span class="nb">echo</span><span class="w"> </span>old<span class="w"> </span>&gt;<span class="w"> </span>old.log
<span class="nb">echo</span><span class="w"> </span>space<span class="w"> </span>&gt;<span class="w"> </span><span class="s2">&quot;with space.log&quot;</span>
<span class="nb">echo</span><span class="w"> </span>gone<span class="w"> </span>&gt;<span class="w"> </span>ancient.log
touch<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;5 days ago&#39;</span><span class="w"> </span>old.log<span class="w"> </span><span class="s2">&quot;with space.log&quot;</span>
touch<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;40 days ago&#39;</span><span class="w"> </span>ancient.log

<span class="nv">LOG_DIR</span><span class="o">=</span>/tmp/logtest<span class="w"> </span><span class="nv">LOCK_FILE</span><span class="o">=</span>/tmp/logtest.lock<span class="w"> </span><span class="nv">DRY_RUN</span><span class="o">=</span><span class="m">1</span><span class="w"> </span>./cleanup.sh
</code></pre></div></div>

<p>预期输出只有两条 <code>gzip</code> 和一条 <code>rm</code>：<code>old.log</code>、<code>with space.log</code> 被压缩，<code>ancient.log</code> 被删除，<code>current.log</code> 不动。确认无误后把 <code>DRY_RUN</code> 换成 0 再跑一次，检查目录里是不是多了 <code>.gz</code>、少了 <code>.log</code>。</p>
<p>这一步值得自动化。它同时验证了空值保护、空格文件名、年龄判断三条路径，比在生产上「小心一点」可靠得多。</p>
<h2 id="rm"><code>rm</code> 之后空间不释放，怎么办<a class="h-anchor" href="#rm" aria-label="锚点">#</a></h2>
<p>这是清理日志最常见的认知错误：删掉文件不等于释放磁盘。只要还有进程持有该文件的 fd，inode 和 block 就一直在。</p>
<p>三种处理方式：</p>
<ol>
<li><strong>清空而不是删除</strong>：<code>: &gt; /var/log/app/current.log</code>。文件还在，fd 继续有效，空间立即释放。适合正在写的单个大文件。</li>
<li><strong>让进程重开文件</strong>：<code>kill -HUP</code>（前提是应用实现了 reopen），配合 logrotate 的 <code>create</code> 模式。</li>
<li><strong>删完再检查</strong>：<code>lsof +L1</code> 能列出所有「已删除但仍被占用」的文件。发现大块空间挂在某个进程上，先重启它，或者按第 1 条清空。</li>
</ol>
<p>具体排查时，<code>lsof +L1 | sort -k7 -n | tail -20</code> 按文件大小排序，能直接看到哪个进程占着几个 GB 的「幽灵文件」。<code>df -h</code> 和 <code>du -sh</code> 对不上账时，先跑这一条，基本都能定位到。</p>
<p>所以脚本只处理「够老」的文件：<code>-mtime +3</code> 天然排除了正在写的日志。真要清理当天的超大日志，应该用清空，而不是删除。</p>
<h2 id="_4">跑在什么节奏上<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ul>
<li><strong>频率</strong>：每 5–10 分钟一次足够，<code>*/5 * * * *</code>。不要 <code>@daily</code> 攒到凌晨一次干完，那会在业务低峰制造一个 IO 尖峰。</li>
<li><strong>限速</strong>：单次处理文件数设上限（<code>head -z -n 200</code>），剩下的下一轮继续。</li>
<li><strong>观测</strong>：脚本自身要输出处理了多少个文件、释放了多少 MB。没有输出的清理脚本，出问题时你无法判断它到底跑没跑。</li>
<li><strong>权限</strong>：别用 root 跑。给专用用户对日志目录的写权限，<code>set -u</code> 兜住空变量，避免 <code>rm -rf "$LOG_DIR"</code> 变成 <code>rm -rf /</code>。</li>
</ul>
<p>对应的 crontab 大致是这样，注意用绝对路径，cron 的 <code>PATH</code> 和你登录时不一样：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>*/5<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span><span class="nv">LOG_DIR</span><span class="o">=</span>/var/log/app<span class="w"> </span>/opt/ops/log-cleanup.sh<span class="w"> </span>&gt;&gt;<span class="w"> </span>/var/log/app/.cleanup.log<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span>
</code></pre></div></div>

<p>把脚本自己的输出也写进日志目录，等于给自己埋了一个自监控点：清理任务失败时，<code>.cleanup.log</code> 里会留下 <code>set -e</code> 触发的那条错误。</p>
<ul>
<li><strong>压缩后抽检</strong>：<code>gzip -t</code> 能验证压缩包完整性。对大文件做一次抽检，比事后发现 30 天前的日志解不开要强。</li>
</ul>
<div class="callout callout-warning"><div class="callout-label">注意</div>
<p>目录锁在没有 <code>flock</code> 的系统上不是强一致的：进程被 <code>kill -9</code> 时 <code>trap</code> 不会执行，残留的锁目录会让后续任务永久跳过。生产环境优先用 <code>flock</code>，或者加一个「锁超过 30 分钟则强制清理」的兜底。</p>
</div>
<h2 id="_5">结论<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>用 <code>mtime</code> 分段（压缩线 / 删除线），别信文件名里的日期。</li>
<li>文件名用 <code>-print0</code> 传递，<code>--</code> 结束选项，<code>-xdev</code> 限制范围。</li>
<li>正在写的日志用清空（<code>: &gt; file</code>），不要 <code>rm</code>，否则空间不释放。</li>
<li>加锁、加 <code>dry-run</code>、加数值校验，让脚本可以被安全地重复执行。</li>
<li>清理频率高于一次清空，避免制造 IO 尖峰，同时保留可观测的输出。</li>
<li>上线前先用 <code>DRY_RUN=1</code> 在临时目录走一遍，别在生产上做第一次试运行。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>20 行 JavaScript 实现丝滑的滚动进度条</title>
<link>https://blog.xtang.online/posts/javascript-scroll-progress/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/javascript-scroll-progress/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>滚动监听本身不慢，慢的是每帧读写布局。用 requestAnimationFrame 合并事件、transform 代替 width，再处理 iOS 回弹和短页面，20 行就能做到不掉帧。</description>
<content:encoded><![CDATA[<p>长文页面顶部加一条阅读进度条，实现只要十几行。但很多人写出来的版本一滚动就掉帧，尤其在手机上。</p>
<p>问题几乎不在监听器数量，而在「每帧都触发一次布局计算，再改一个会触发重排的属性」。把这两件事换掉，代码反而更短。</p>
<p>先说明适用范围：下面这套适合「整页滚动」的长文页。如果你的正文在某个 <code>overflow: auto</code> 的区域里，或者用的是虚拟滚动，逻辑要换——文末会提。</p>
<h2 id="_1">先算清楚进度<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>公式只有一行：</p>
<div class="code-card" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>ratio = scrollTop / (scrollHeight - clientHeight)
</code></pre></div></div>

<p>三个数都从滚动容器上取。文档滚动时容器是 <code>document.scrollingElement</code>，它比 <code>document.documentElement</code> 可靠——后者在 quirks 模式下是 <code>&lt;body&gt;</code>。</p>
<p>这个除法有四个边界，线上都会遇到：</p>
<ul>
<li><strong>内容比视口还短</strong>：分母为 0，得到 <code>NaN</code> 或 <code>Infinity</code>，<code>scaleX(NaN)</code> 会让进度条直接消失。</li>
<li><strong>iOS 橡皮筋回弹</strong>：<code>scrollTop</code> 会变成负数，进度条反向溢出。</li>
<li><strong>移动端地址栏伸缩</strong>：<code>clientHeight</code> 变了，但没有 <code>scroll</code> 事件，进度停在旧值。</li>
<li><strong>锚点跳转和浏览器前进后退</strong>：<code>pageshow</code> 恢复页面时不会触发 <code>scroll</code>。</li>
</ul>
<p>对应的处理是 <code>Math.min(1, Math.max(0, ...))</code> 夹紧，分母为 0 时返回 0，再监听 <code>resize</code>、<code>pageshow</code> 和 <code>ResizeObserver</code>。</p>
<p>还有一个容易被忽略的量：<code>scrollHeight</code> 包含内边距但不含边框和外边距，而 <code>clientHeight</code> 包含内边距、不含滚动条。所以分母是「可滚动的高度」，不是「内容总高度」。如果你在容器上加了 <code>border: 1px</code>，算出来的最大值会多出两像素，进度永远差一点点到 100%。这类误差肉眼看不出来，但会让「读完自动标记」之类的逻辑永远不触发。</p>
<h2 id="20">20 行实现<a class="h-anchor" href="#20" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="javascript"><div class="code-head"><span class="code-lang">javascript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1">// scroll-progress.js —— 无依赖，现代浏览器</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">bar</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;progress-bar&#39;</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="nx">queued</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="kd">let</span><span class="w"> </span><span class="nx">lastRatio</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">-</span><span class="mf">1</span><span class="p">;</span>

<span class="kd">function</span><span class="w"> </span><span class="nx">update</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nx">queued</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">el</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">scrollingElement</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">;</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">max</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">scrollHeight</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">clientHeight</span><span class="p">;</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">ratio</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">max</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mf">0</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(</span><span class="mf">1</span><span class="p">,</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">max</span><span class="p">(</span><span class="mf">0</span><span class="p">,</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">scrollTop</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="nx">max</span><span class="p">))</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="mf">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">ratio</span><span class="w"> </span><span class="o">===</span><span class="w"> </span><span class="nx">lastRatio</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="p">;</span><span class="w">          </span><span class="c1">// 没变化就不碰 DOM</span>
<span class="w">  </span><span class="nx">lastRatio</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">ratio</span><span class="p">;</span>
<span class="w">  </span><span class="nx">bar</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">transform</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sb">`scaleX(</span><span class="si">${</span><span class="nx">ratio</span><span class="si">}</span><span class="sb">)`</span><span class="p">;</span>
<span class="w">  </span><span class="nx">bar</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;aria-valuenow&#39;</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="p">(</span><span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">ratio</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mf">100</span><span class="p">)));</span>
<span class="p">}</span>

<span class="kd">function</span><span class="w"> </span><span class="nx">schedule</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">queued</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="p">;</span><span class="w">                       </span><span class="c1">// 一帧只更新一次</span>
<span class="w">  </span><span class="nx">queued</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span>
<span class="w">  </span><span class="nx">requestAnimationFrame</span><span class="p">(</span><span class="nx">update</span><span class="p">);</span>
<span class="p">}</span>

<span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;scroll&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">schedule</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">passive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">});</span>
<span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;resize&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">schedule</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">passive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">});</span>
<span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;pageshow&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">schedule</span><span class="p">);</span>
<span class="ow">new</span><span class="w"> </span><span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">schedule</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">);</span>

<span class="nx">schedule</span><span class="p">();</span>
</code></pre></div></div>

<p>三个细节撑起了「丝滑」：</p>
<ol>
<li><strong><code>requestAnimationFrame</code> 合并</strong>。滚动事件在一帧内可以触发多次（触控板、高刷屏更明显）。用 <code>queued</code> 标记把这一帧的所有事件折叠成一次 DOM 写入，渲染前只算一次。</li>
<li><strong><code>passive: true</code></strong>。告诉浏览器这个监听器不会调用 <code>preventDefault()</code>，滚动线程不必等 JS 执行完再合成画面。少这一行，移动端的滚动手感会明显变「粘」。</li>
<li><strong><code>lastRatio</code> 去重</strong>。滚到顶部或底部后，后续事件算出的比例不变，直接返回，省掉无意义的样式写入。</li>
</ol>
<p><code>queued</code> 这个布尔量看起来多余，其实关键：<code>requestAnimationFrame</code> 本身不合并，连续调用会在一帧后执行多次。用「已排队」标记保证一帧只有一次回调，剩下的滚动事件全部丢弃——反正这一帧的最终位置就是最后一个事件的位置。</p>
<p>最后一行 <code>schedule()</code> 不是可选项。页面从缓存或前进后退恢复时，可能已经处于滚动状态，但不会补发 <code>scroll</code> 事件，不主动算一次就会停在错误的比例上。</p>
<h2 id="transform-width">样式：用 transform，不要用 width<a class="h-anchor" href="#transform-width" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="css"><div class="code-head"><span class="code-lang">css</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="p">#</span><span class="nn">progress-bar</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">fixed</span><span class="p">;</span>
<span class="w">  </span><span class="k">top</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">left</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">width</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="k">height</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="kt">px</span><span class="p">;</span>
<span class="w">  </span><span class="k">background</span><span class="p">:</span><span class="w"> </span><span class="mh">#2563eb</span><span class="p">;</span>
<span class="w">  </span><span class="k">transform</span><span class="p">:</span><span class="w"> </span><span class="nb">scaleX</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
<span class="w">  </span><span class="k">transform-origin</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="mi">50</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="k">will-change</span><span class="p">:</span><span class="w"> </span><span class="k">transform</span><span class="p">;</span>
<span class="w">  </span><span class="k">pointer-events</span><span class="p">:</span><span class="w"> </span><span class="kc">none</span><span class="p">;</span>
<span class="w">  </span><span class="k">z-index</span><span class="p">:</span><span class="w"> </span><span class="mi">9999</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>进度条本身是纯装饰，<code>pointer-events: none</code> 必须加，否则它会挡住顶部 3px 内的点击——移动端尤其容易踩到，比如顶部的返回按钮。</p>
<p><code>transform: scaleX()</code> 只影响合成层，不触发 layout 和 paint。改 <code>width</code> 则每帧都要重新计算布局，页面越长、节点越多，成本越高。两者的差距在低端安卓机上非常明显。</p>
<p><code>will-change: transform</code> 会让浏览器提前把元素提升为独立合成层，代价是常驻占用一点显存。一个 3px 的条子无所谓，但别把它加到列表项那种成百上千个节点上，否则显存会被吃光，反而更卡。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>更新方式</th>
<th>每帧成本</th>
<th>主要风险</th>
</tr>
</thead>
<tbody>
<tr>
<td>直接改 <code>width</code></td>
<td>布局 + 绘制</td>
<td>长页面掉帧</td>
</tr>
<tr>
<td><code>scroll</code> 里改 <code>transform</code></td>
<td>合成</td>
<td>事件过密时重复写入</td>
</tr>
<tr>
<td><code>rAF</code> 合并 + <code>transform</code></td>
<td>合成</td>
<td>几乎无</td>
</tr>
<tr>
<td>章节式 <code>IntersectionObserver</code></td>
<td>无滚动计算</td>
<td>只有离散进度，不适合连续条</td>
</tr>
</tbody>
</table></div>
<p>如果设计上只需要「读到第几节」，用 <code>IntersectionObserver</code> 更省，完全不用监听滚动。要做连续进度条，还是得回到上面这套。</p>
<h2 id="_2">三个容易忽略的坑<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<ul>
<li><strong>安全区</strong>：iPhone 刘海屏上 <code>top: 0</code> 会被状态栏压住，用 <code>top: env(safe-area-inset-top)</code> 才不会被裁掉。</li>
<li><strong>打印</strong>：打印长文时进度条会印在每一页顶部。加 <code>@media print { #progress-bar { display: none } }</code>。</li>
<li><strong>自定义滚动容器</strong>：如果正文在一个 <code>overflow: auto</code> 的 <code>div</code> 里，<code>document.scrollingElement</code> 永远是 0。这时把 <code>el</code> 换成那个容器，监听器也挂到容器上，公式不变。</li>
</ul>
<p>还有可访问性。进度条对读屏用户价值有限，最简单是 <code>aria-hidden="true"</code>；如果确实想暴露，就给容器加 <code>role="progressbar"</code>、<code>aria-valuemin="0"</code>、<code>aria-valuemax="100"</code>，代码里已经在同步 <code>aria-valuenow</code>。不要在滚动过程中朗读它，否则会打断阅读。</p>
<h2 id="_3">怎么验证它真的不掉帧<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>三步，都在浏览器里完成：</p>
<ol>
<li><strong>边界值</strong>：控制台执行 <code>document.scrollingElement.scrollTop = 1e9</code>，进度应该是 1；设成 <code>-100</code> 应该夹到 0；把窗口拉到比内容还高，进度应保持 0 而不是 <code>NaN</code>。</li>
<li><strong>性能录制</strong>：DevTools 的 Performance 面板录一段连续滚动，看是否有 Layout 事件。有 Layout 说明你改的还是 <code>width</code> 或 <code>left</code>。</li>
<li><strong>高刷屏</strong>：在 120Hz 设备上滚一段，如果进度条明显滞后于内容，通常是 <code>rAF</code> 外面又套了 <code>setTimeout</code> 节流。</li>
</ol>
<p>进度条最容易「看起来对、实际错」的地方是首屏：图片还没加载完，<code>scrollHeight</code> 偏小，进度被算得虚高；图片加载完高度变了，却没有 <code>scroll</code> 事件。这也是为什么代码里用 <code>ResizeObserver</code> 观察 <code>body</code>，而不是只在 <code>load</code> 时算一次。</p>
<p>自动化测试也可以做：用 jsdom 或一个假的 <code>document</code> 桩跑 <code>update()</code>，断言三个边界值分别是 <code>0</code>、<code>0.5</code>、<code>1</code>。这类纯计算的回归测试成本很低，值得加。</p>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ul>
<li>进度公式只有一个，难点全在边界：短页面、负 <code>scrollTop</code>、视口变化、缓存恢复。</li>
<li>用 <code>requestAnimationFrame</code> 合并滚动事件，用 <code>passive: true</code> 让出滚动线程。</li>
<li>改 <code>transform: scaleX()</code>，不要改 <code>width</code>；两者差一整个布局阶段。</li>
<li>监听 <code>resize</code>、<code>pageshow</code> 和 <code>ResizeObserver</code>，覆盖地址栏伸缩与异步内容撑高。</li>
<li>进度条是装饰，记得 <code>pointer-events: none</code>、打印隐藏和安全区适配。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>用 SQL 窗口函数解决“每组取最新一条”</title>
<link>https://blog.xtang.online/posts/sql-window-function-latest-per-group/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/sql-window-function-latest-per-group/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>“每个用户取最新一条订单”有四种写法，结果却可能不一样。用 ROW_NUMBER 加确定性排序一次写对，并说清并列、NULL、索引和跨库差异。</description>
<content:encoded><![CDATA[<p>「每个用户取最新一条订单」是面试题，也是线上天天要写的查询。它看着简单，但 <code>GROUP BY</code> 配 <code>MAX(created_at)</code> 会取到错的行，<code>JOIN</code> 回原表遇到同一秒的两条记录会变成两行。</p>
<p>窗口函数是这个问题最稳的解法，前提是把排序写完整。下面用一张订单表把几种写法都跑一遍。</p>
<h2 id="_1">先看错误写法长什么样<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>假设表结构是 <code>orders(id, user_id, amount, status, created_at)</code>，目标是每个用户一行，取未取消订单里最新的一条。</p>
<p>最常见的写法是 <code>SELECT user_id, MAX(created_at) AS latest FROM orders GROUP BY user_id</code>。这样只能拿到时间，拿不到那一行的 <code>amount</code> 和 <code>status</code>。于是有人补上 <code>amount</code>：在 <code>ONLY_FULL_GROUP_BY</code> 模式下直接报错；在关闭该模式的 MySQL 或某些 SQLite 版本里，它会返回「组内任意一行」的值——时间是最新的，金额可能来自三年前，而且不报错。这是最危险的一种：结果错了，但看起来像对的。</p>
<p>第二种错法是 <code>GROUP BY</code> 之后再 <code>JOIN</code> 回原表：</p>
<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="o">*</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span>
<span class="k">JOIN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="n">m</span>
<span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="n">m</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">m</span><span class="p">.</span><span class="n">t</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">created_at</span><span class="p">;</span>
</code></pre></div></div>

<p>同一用户在同一秒下了两单，就会返回两行；而且原表被扫了两遍。</p>
<p><code>ONLY_FULL_GROUP_BY</code> 是 MySQL 5.7 起默认开启的 SQL 模式，它要求 <code>SELECT</code> 里的非聚合列必须出现在 <code>GROUP BY</code> 中。这不是刁难，而是在阻止「随便挑一行」的不确定行为。关掉它能让旧代码跑起来，但代价是查询结果依赖存储引擎的实现细节，升级或换引擎时可能悄悄改变。</p>
<h2 id="row_number">ROW_NUMBER：先编号，再取第一<a class="h-anchor" href="#row_number" aria-label="锚点">#</a></h2>
<p>先准备数据。时间用 ISO8601 字符串，比较结果和真实时间一致；生产上更推荐 <code>timestamptz</code> 或毫秒时间戳。</p>
<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span>
<span class="w">    </span><span class="n">id</span><span class="w">         </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span>
<span class="w">    </span><span class="n">user_id</span><span class="w">    </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span>
<span class="w">    </span><span class="n">amount</span><span class="w">     </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span>
<span class="w">    </span><span class="n">status</span><span class="w">     </span><span class="nb">TEXT</span><span class="w">    </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="s1">&#39;paid&#39;</span><span class="p">,</span>
<span class="w">    </span><span class="n">created_at</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">    </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span>
<span class="p">);</span>

<span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span>
<span class="w">    </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">12800</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;paid&#39;</span><span class="p">,</span><span class="w">      </span><span class="s1">&#39;2026-01-03T09:00:00&#39;</span><span class="p">),</span>
<span class="w">    </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">  </span><span class="mi">9900</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;paid&#39;</span><span class="p">,</span><span class="w">      </span><span class="s1">&#39;2026-02-11T18:20:00&#39;</span><span class="p">),</span>
<span class="w">    </span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">  </span><span class="mi">9900</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;refunded&#39;</span><span class="p">,</span><span class="w">  </span><span class="s1">&#39;2026-02-11T18:20:00&#39;</span><span class="p">),</span><span class="w">  </span><span class="c1">-- 与 2 同一时刻</span>
<span class="w">    </span><span class="p">(</span><span class="mi">4</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">45600</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;paid&#39;</span><span class="p">,</span><span class="w">      </span><span class="s1">&#39;2026-01-20T10:05:00&#39;</span><span class="p">),</span>
<span class="w">    </span><span class="p">(</span><span class="mi">5</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">12000</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;cancelled&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-01T08:00:00&#39;</span><span class="p">),</span>
<span class="w">    </span><span class="p">(</span><span class="mi">6</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">  </span><span class="mi">3000</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;paid&#39;</span><span class="p">,</span><span class="w">      </span><span class="s1">&#39;2026-02-28T23:59:59&#39;</span><span class="p">);</span>

<span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_orders_user_latest</span>
<span class="w">    </span><span class="k">ON</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="k">DESC</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">DESC</span><span class="p">);</span>
</code></pre></div></div>

<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">WITH</span><span class="w"> </span><span class="n">ranked</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span>
<span class="w">    </span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">,</span>
<span class="w">           </span><span class="n">ROW_NUMBER</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span>
<span class="w">               </span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span>
<span class="w">               </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="k">DESC</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">DESC</span>
<span class="w">           </span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">rn</span>
<span class="w">    </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span>
<span class="w">    </span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;cancelled&#39;</span>
<span class="p">)</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">ranked</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">rn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span>
<span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">user_id</span><span class="p">;</span>
</code></pre></div></div>

<p>三点必须理解：</p>
<ul>
<li><strong>窗口函数在 <code>WHERE</code>、<code>GROUP BY</code> 之后才计算</strong>，所以不能在同一层写 <code>WHERE rn = 1</code>，必须套一层 CTE 或子查询。</li>
<li><strong><code>ORDER BY</code> 要写完整</strong>。只按 <code>created_at DESC</code> 排序，同一秒的两条记录谁排第一由执行计划决定，今天跑和明天跑可能不同。加上 <code>id DESC</code> 做兜底，结果才稳定可复现。</li>
<li><strong><code>ROW_NUMBER</code> 一定给唯一编号</strong>。如果业务上「并列最新」的两条都要，用 <code>RANK()</code> 再筛 <code>rn = 1</code>，会返回多行；<code>ROW_NUMBER</code> 只会保留一条。</li>
</ul>
<p>在 SQLite 里跑上面的语句，结果是 <code>(3, 1, 9900, refunded)</code>、<code>(4, 2, 45600, paid)</code>、<code>(6, 3, 3000, paid)</code>。用户 2 的 <code>cancelled</code> 订单虽然更新，但在窗口计算前已被过滤，符合预期。</p>
<h2 id="null">并列、NULL 与跨库差异<a class="h-anchor" href="#null" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>写法</th>
<th>支持范围</th>
<th>并列时</th>
<th>主要代价</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ROW_NUMBER</code> 窗口</td>
<td>MySQL 8+、PostgreSQL、SQLite 3.25+、SQL Server</td>
<td>只留一条，排序需确定</td>
<td>每个分区排序</td>
</tr>
<tr>
<td><code>RANK</code> 窗口</td>
<td>同上</td>
<td>并列全留，行数可能变多</td>
<td>同上</td>
</tr>
<tr>
<td><code>DISTINCT ON</code></td>
<td>仅 PostgreSQL</td>
<td>按 <code>ORDER BY</code> 取第一条</td>
<td>需要匹配的索引</td>
</tr>
<tr>
<td>相关子查询</td>
<td>几乎全部</td>
<td>由子查询 <code>LIMIT 1</code> 决定</td>
<td>无索引时退化为 O(n²)</td>
</tr>
<tr>
<td><code>GROUP BY</code> + <code>JOIN</code></td>
<td>几乎全部</td>
<td>会重复</td>
<td>扫两遍，易写错</td>
</tr>
</tbody>
</table></div>
<p>两个细节：</p>
<p><strong><code>created_at</code> 可能为 NULL</strong>。PostgreSQL 在 <code>DESC</code> 时默认 <code>NULLS FIRST</code>，MySQL 反过来，<code>DESC</code> 时 NULL 排在最后。同一条 SQL 换个库，结果可能完全不同。要么建表时 <code>NOT NULL</code>，要么显式写 <code>ORDER BY created_at DESC NULLS LAST</code>（MySQL 不支持这个语法，用 <code>ORDER BY created_at IS NULL, created_at DESC</code> 代替）。</p>
<p><strong><code>DISTINCT ON</code> 的排序要和它对齐</strong>。PostgreSQL 的写法是 <code>SELECT DISTINCT ON (user_id) ... ORDER BY user_id, created_at DESC, id DESC</code>，第一列必须是 <code>user_id</code>，否则报错。它比窗口函数短，但只此一家，换库就得重写。</p>
<h2 id="_2">性能：索引比写法更重要<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>窗口函数要对每个分区排序。如果 <code>(user_id, created_at DESC, id DESC)</code> 上有索引，数据库可以直接按顺序读，省掉排序；没有索引，就是全表扫加临时 B 树排序，百万行级别会明显变慢。</p>
<p>看执行计划确认：SQLite 用 <code>EXPLAIN QUERY PLAN</code>，PostgreSQL 用 <code>EXPLAIN (ANALYZE, BUFFERS)</code>。如果窗口那一步仍然出现 <code>USE TEMP B-TREE FOR ORDER BY</code>，说明索引没覆盖分区排序；最外层的 <code>ORDER BY user_id</code> 另算，通常只排几十行，影响不大。</p>
<p>排序本身也有成本上限。排序数据超过 <code>work_mem</code>（PostgreSQL）或 <code>sort_buffer_size</code>（MySQL）时会落到磁盘，查询从几十毫秒变成几秒。如果每个用户只有几十单，分区排序的内存压力不大；如果某个用户有几百万单，那一个分区就能把临时空间打满——这类「超级用户」在生产上真实存在，值得先按 <code>user_id</code> 查一次最大分组。窗口函数不可用的老版本（如 MySQL 5.7）只能用相关子查询，写法见下一节。</p>
<p>另外，能提前过滤的条件一定放进 CTE 里，先缩小数据集再开窗。如果过滤后仍要排序几十万行，说明「每人一条」这个需求本身就该配一张宽表，或者定时预计算，而不是每次查询都现算。</p>
<p>如果表很大而用户很少，相关子查询配合索引有时更快：它按 <code>user_id</code> 逐行走索引取一条，不用对全表排序。反过来，用户量几十万、每人只有几单时，它可能变成几十万次索引查找。两种写法都该在真实数据量上 <code>EXPLAIN</code> 一次再定。</p>
<h2 id="_3">替代写法不是不能用<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>相关子查询的可读性其实最好：</p>
<div class="code-card" data-lang="sql"><div class="code-head"><span class="code-lang">sql</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">amount</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">created_at</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">status</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;cancelled&#39;</span>
<span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span>
<span class="w">      </span><span class="k">SELECT</span><span class="w"> </span><span class="n">o2</span><span class="p">.</span><span class="n">id</span>
<span class="w">      </span><span class="k">FROM</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o2</span>
<span class="w">      </span><span class="k">WHERE</span><span class="w"> </span><span class="n">o2</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span>
<span class="w">        </span><span class="k">AND</span><span class="w"> </span><span class="n">o2</span><span class="p">.</span><span class="n">status</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;cancelled&#39;</span>
<span class="w">      </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">o2</span><span class="p">.</span><span class="n">created_at</span><span class="w"> </span><span class="k">DESC</span><span class="p">,</span><span class="w"> </span><span class="n">o2</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="k">DESC</span>
<span class="w">      </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">1</span>
<span class="w">  </span><span class="p">)</span>
<span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">user_id</span><span class="p">;</span>
</code></pre></div></div>

<p>它和窗口函数的结果一致，但依赖 <code>(user_id, created_at DESC)</code> 上的索引。没有索引时，外层每一行都要扫一遍子查询，复杂度是平方级的。数据量小的时候无所谓，涨到百万行就会成为事故。</p>
<div class="callout callout-note"><div class="callout-label">提示</div>
<p>如果 <code>created_at</code> 存的是带时区的时间戳，跨库比较时注意时区归一化；存字符串（如 <code>ISO8601</code>）只在同一种时区下可靠，混入本地时间就会排错序。</p>
</div>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ul>
<li><code>ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)</code> 加外层 <code>rn = 1</code> 是最通用、最不容易错的写法。</li>
<li>排序必须补一个唯一列（如 <code>id</code>）做 tie-breaker，否则结果不可复现。</li>
<li>过滤条件放在窗口计算之前，别让取消的订单参与排名。</li>
<li>建 <code>(user_id, created_at DESC, id DESC)</code> 索引，否则窗口排序会成为瓶颈。</li>
<li>NULL 的排序方向各库不同，建表时 <code>NOT NULL</code> 或显式声明 <code>NULLS LAST</code>。</li>
<li>没有窗口函数的旧版本，用相关子查询配 <code>(user_id, created_at DESC)</code> 索引，别用 <code>GROUP BY</code> + <code>JOIN</code>。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>写一个零依赖的 JSON 配置文件校验器</title>
<link>https://blog.xtang.online/posts/zero-dep-json-config-validator/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/zero-dep-json-config-validator/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>配置文件写错一个类型，服务启动十分钟后才崩在某个分支上。用两百行标准库代码做一次启动校验：类型、范围、枚举、未知字段、默认值，错误一次报全。</description>
<content:encoded><![CDATA[<p>配置文件是那种「改的人不写代码、写代码的人不看」的东西。上周有人把 <code>port</code> 写成了字符串，服务照常启动，直到健康检查连不上才暴露。</p>
<p>引入 <code>jsonschema</code> 或 <code>pydantic</code> 当然可以，但一个只读一次配置的小服务，为校验多背一个依赖并不划算——<code>pydantic</code> 还会把配置对象变成模型，改动时要连模型一起改。下面这版只依赖标准库，管住四种最常见的错：类型不对、值越界、字段拼错、默认值缺失。</p>
<h2 id="_1">先想清楚要校验什么<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>配置校验和 API 参数校验的目标不同。API 要挡住恶意输入，配置只需要挡住手误，外加给出人能看懂的错误位置。所以下面这套 schema 故意做小，只保留够用的关键字：</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>关键字</th>
<th>作用</th>
<th>例子</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>type</code></td>
<td>基本类型</td>
<td><code>object</code>、<code>array</code>、<code>string</code>、<code>integer</code>、<code>number</code>、<code>boolean</code></td>
</tr>
<tr>
<td><code>required</code></td>
<td>必填字段名</td>
<td><code>["server"]</code></td>
</tr>
<tr>
<td><code>properties</code></td>
<td>子字段 schema</td>
<td><code>{"port": {"type": "integer"}}</code></td>
</tr>
<tr>
<td><code>additionalProperties</code></td>
<td>是否允许未知字段</td>
<td>设为 <code>false</code> 时拼错的键会报错</td>
</tr>
<tr>
<td><code>enum</code></td>
<td>枚举值</td>
<td><code>["debug", "info", "warn"]</code></td>
</tr>
<tr>
<td><code>pattern</code></td>
<td>字符串正则（整串匹配）</td>
<td><code>"^[a-z][a-z0-9_]*$"</code></td>
</tr>
<tr>
<td><code>minimum</code> / <code>maximum</code></td>
<td>数值边界</td>
<td><code>1</code> / <code>65535</code></td>
</tr>
<tr>
<td><code>minLength</code></td>
<td>字符串最小长度</td>
<td><code>1</code></td>
</tr>
<tr>
<td><code>items</code></td>
<td>数组元素 schema</td>
<td><code>{"type": "string"}</code></td>
</tr>
<tr>
<td><code>default</code></td>
<td>缺省值</td>
<td><code>"127.0.0.1"</code></td>
</tr>
</tbody>
</table></div>
<p>不实现 <code>$ref</code>、<code>oneOf</code>、<code>format</code>。这些一上，代码量翻倍，而配置里几乎用不到。真需要组合校验，写几行 Python 比写 schema 清楚。</p>
<p>还有一个经常被忽略的目标：错误信息要能直接贴进群里。<code>$.server.port: 不能大于 65535</code> 比 <code>ValidationError: 1 validation error for Config</code> 有用得多，也不需要对方装工具来解读。这是手写校验器唯一能赢过现成库的地方，值得认真做。</p>
<h2 id="bool-int">类型判断有个坑：bool 是 int 的子类<a class="h-anchor" href="#bool-int" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># jsonconf.py —— 仅标准库，Python 3.9+</span>
<span class="kn">import</span> <span class="nn">copy</span>
<span class="kn">import</span> <span class="nn">json</span>
<span class="kn">import</span> <span class="nn">re</span>

<span class="k">class</span> <span class="nc">ConfigError</span><span class="p">(</span><span class="ne">ValueError</span><span class="p">):</span>
    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">errors</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">errors</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">errors</span><span class="p">)</span>
        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">(</span><span class="s2">&quot;；&quot;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">p</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">m</span><span class="si">}</span><span class="s2">&quot;</span> <span class="k">for</span> <span class="n">p</span><span class="p">,</span> <span class="n">m</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">errors</span><span class="p">))</span>

<span class="n">_TYPES</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;string&quot;</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="s2">&quot;integer&quot;</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="s2">&quot;number&quot;</span><span class="p">:</span> <span class="p">(</span><span class="nb">int</span><span class="p">,</span> <span class="nb">float</span><span class="p">),</span>
          <span class="s2">&quot;boolean&quot;</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span> <span class="s2">&quot;array&quot;</span><span class="p">:</span> <span class="nb">list</span><span class="p">,</span> <span class="s2">&quot;object&quot;</span><span class="p">:</span> <span class="nb">dict</span><span class="p">}</span>

<span class="k">def</span> <span class="nf">_is_type</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">name</span><span class="p">):</span>
    <span class="k">if</span> <span class="n">name</span> <span class="o">==</span> <span class="s2">&quot;integer&quot;</span><span class="p">:</span>          <span class="c1"># True 也会通过 isinstance(x, int)</span>
        <span class="k">return</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="nb">int</span><span class="p">)</span> <span class="ow">and</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="nb">bool</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">name</span> <span class="o">==</span> <span class="s2">&quot;number&quot;</span><span class="p">:</span>
        <span class="k">return</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="p">(</span><span class="nb">int</span><span class="p">,</span> <span class="nb">float</span><span class="p">))</span> <span class="ow">and</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="nb">bool</span><span class="p">)</span>
    <span class="k">return</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">_TYPES</span><span class="p">[</span><span class="n">name</span><span class="p">])</span>

<span class="k">def</span> <span class="nf">_join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
    <span class="k">return</span> <span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">.</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">&quot;</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">[</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">]&quot;</span>
</code></pre></div></div>

<p><code>isinstance(True, int)</code> 在 Python 里是 <code>True</code>。不显式排除布尔值，<code>"workers": true</code> 会被当成合法的整数 1，然后在某个循环里静默跑一个 worker。</p>
<p>反过来，<code>1.0</code> 这种浮点数不该通过 <code>integer</code> 校验，但 <code>1</code> 可以通过 <code>number</code>。这两个判断的严格程度不同，写反了会出现「配置里写 <code>1.0</code> 也能过」的宽松校验，最后在需要整数索引的地方报 <code>TypeError</code>。</p>
<h2 id="_2">校验函数：收集全部错误再抛<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">check</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">schema</span><span class="p">,</span> <span class="n">path</span><span class="p">,</span> <span class="n">errors</span><span class="p">):</span>
    <span class="n">t</span> <span class="o">=</span> <span class="n">schema</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;type&quot;</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">t</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">_is_type</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">t</span><span class="p">):</span>
        <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;期望 </span><span class="si">{</span><span class="n">t</span><span class="si">}</span><span class="s2">，实际是 </span><span class="si">{</span><span class="nb">type</span><span class="p">(</span><span class="n">value</span><span class="p">)</span><span class="o">.</span><span class="vm">__name__</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">))</span>
        <span class="k">return</span>
    <span class="k">if</span> <span class="s2">&quot;enum&quot;</span> <span class="ow">in</span> <span class="n">schema</span> <span class="ow">and</span> <span class="n">value</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">schema</span><span class="p">[</span><span class="s2">&quot;enum&quot;</span><span class="p">]:</span>
        <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;必须是 </span><span class="si">{</span><span class="n">schema</span><span class="p">[</span><span class="s1">&#39;enum&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> 之一&quot;</span><span class="p">))</span>
    <span class="k">if</span> <span class="n">t</span> <span class="o">==</span> <span class="s2">&quot;string&quot;</span><span class="p">:</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">schema</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;minLength&quot;</span><span class="p">,</span> <span class="mi">0</span><span class="p">):</span>
            <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;长度不能小于 </span><span class="si">{</span><span class="n">schema</span><span class="p">[</span><span class="s1">&#39;minLength&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">))</span>
        <span class="k">if</span> <span class="s2">&quot;pattern&quot;</span> <span class="ow">in</span> <span class="n">schema</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">re</span><span class="o">.</span><span class="n">fullmatch</span><span class="p">(</span><span class="n">schema</span><span class="p">[</span><span class="s2">&quot;pattern&quot;</span><span class="p">],</span> <span class="n">value</span><span class="p">):</span>
            <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;不匹配 </span><span class="si">{</span><span class="n">schema</span><span class="p">[</span><span class="s1">&#39;pattern&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">))</span>
    <span class="k">elif</span> <span class="n">t</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&quot;integer&quot;</span><span class="p">,</span> <span class="s2">&quot;number&quot;</span><span class="p">):</span>
        <span class="k">if</span> <span class="s2">&quot;minimum&quot;</span> <span class="ow">in</span> <span class="n">schema</span> <span class="ow">and</span> <span class="n">value</span> <span class="o">&lt;</span> <span class="n">schema</span><span class="p">[</span><span class="s2">&quot;minimum&quot;</span><span class="p">]:</span>
            <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;不能小于 </span><span class="si">{</span><span class="n">schema</span><span class="p">[</span><span class="s1">&#39;minimum&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">))</span>
        <span class="k">if</span> <span class="s2">&quot;maximum&quot;</span> <span class="ow">in</span> <span class="n">schema</span> <span class="ow">and</span> <span class="n">value</span> <span class="o">&gt;</span> <span class="n">schema</span><span class="p">[</span><span class="s2">&quot;maximum&quot;</span><span class="p">]:</span>
            <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;不能大于 </span><span class="si">{</span><span class="n">schema</span><span class="p">[</span><span class="s1">&#39;maximum&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">))</span>
    <span class="k">elif</span> <span class="n">t</span> <span class="o">==</span> <span class="s2">&quot;object&quot;</span><span class="p">:</span>
        <span class="n">props</span> <span class="o">=</span> <span class="n">schema</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;properties&quot;</span><span class="p">,</span> <span class="p">{})</span>
        <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">schema</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;required&quot;</span><span class="p">,</span> <span class="p">[]):</span>
            <span class="k">if</span> <span class="n">key</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">value</span><span class="p">:</span>
                <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">_join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">key</span><span class="p">),</span> <span class="s2">&quot;缺少必填字段&quot;</span><span class="p">))</span>
        <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">sub</span> <span class="ow">in</span> <span class="n">props</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
            <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">value</span><span class="p">:</span>
                <span class="n">check</span><span class="p">(</span><span class="n">value</span><span class="p">[</span><span class="n">key</span><span class="p">],</span> <span class="n">sub</span><span class="p">,</span> <span class="n">_join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">key</span><span class="p">),</span> <span class="n">errors</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">schema</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;additionalProperties&quot;</span><span class="p">)</span> <span class="ow">is</span> <span class="kc">False</span><span class="p">:</span>
            <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">value</span><span class="o">.</span><span class="n">keys</span><span class="p">()</span> <span class="o">-</span> <span class="n">props</span><span class="o">.</span><span class="n">keys</span><span class="p">():</span>
                <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">_join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">key</span><span class="p">),</span> <span class="s2">&quot;未知字段&quot;</span><span class="p">))</span>
    <span class="k">elif</span> <span class="n">t</span> <span class="o">==</span> <span class="s2">&quot;array&quot;</span> <span class="ow">and</span> <span class="s2">&quot;items&quot;</span> <span class="ow">in</span> <span class="n">schema</span><span class="p">:</span>
        <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">item</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">value</span><span class="p">):</span>
            <span class="n">check</span><span class="p">(</span><span class="n">item</span><span class="p">,</span> <span class="n">schema</span><span class="p">[</span><span class="s2">&quot;items&quot;</span><span class="p">],</span> <span class="n">_join</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">i</span><span class="p">),</span> <span class="n">errors</span><span class="p">)</span>
</code></pre></div></div>

<p>几个决定值得解释：</p>
<ul>
<li><strong>一次收集全部错误</strong>。改配置的人希望一次看到所有问题，而不是改一个、重启、再看到下一个。代价是第一次失败后会返回一长串，所以每条都带路径。</li>
<li><strong>路径用 <code>$.server.port</code> 的形式</strong>。它和 JSON Pointer 不完全一样，但对人更友好，复制到日志里也能直接定位。</li>
<li><strong>类型错误后立即 <code>return</code></strong>。继续对错误类型的值做范围比较会抛 <code>TypeError</code>，把「配置错了」变成「程序崩了」。</li>
<li><strong><code>additionalProperties: false</code> 才检查未知字段</strong>。默认不检查，方便灰度期加字段；想严格就显式打开，拼错 <code>prot</code> 会立刻暴露。</li>
</ul>
<p>路径拼接用 <code>_join</code> 而不是字符串加法，是为了让数组下标长成 <code>$.models[0]</code> 而不是 <code>$.models.0</code>。两者都能定位，但后者容易被误认成名为 <code>0</code> 的字段，尤其是模型名里带点号的时候。</p>
<h2 id="_3">默认值与加载入口<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">apply_defaults</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">schema</span><span class="p">):</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">value</span>
    <span class="n">out</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">sub</span> <span class="ow">in</span> <span class="n">schema</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;properties&quot;</span><span class="p">,</span> <span class="p">{})</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
        <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">out</span><span class="p">:</span>
            <span class="n">out</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">apply_defaults</span><span class="p">(</span><span class="n">out</span><span class="p">[</span><span class="n">key</span><span class="p">],</span> <span class="n">sub</span><span class="p">)</span>
        <span class="k">elif</span> <span class="s2">&quot;default&quot;</span> <span class="ow">in</span> <span class="n">sub</span><span class="p">:</span>
            <span class="n">out</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">copy</span><span class="o">.</span><span class="n">deepcopy</span><span class="p">(</span><span class="n">sub</span><span class="p">[</span><span class="s2">&quot;default&quot;</span><span class="p">])</span>  <span class="c1"># 避免共享可变默认值</span>
    <span class="k">return</span> <span class="n">out</span>

<span class="k">def</span> <span class="nf">_reject_const</span><span class="p">(</span><span class="n">name</span><span class="p">):</span>
    <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;不允许的常量 </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">load_config</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">schema</span><span class="p">):</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&quot;utf-8-sig&quot;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="n">parse_constant</span><span class="o">=</span><span class="n">_reject_const</span><span class="p">)</span>
    <span class="k">except</span> <span class="ne">ValueError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">ConfigError</span><span class="p">([(</span><span class="s2">&quot;$&quot;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;JSON 解析失败：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)])</span> <span class="kn">from</span> <span class="nn">e</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">apply_defaults</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">schema</span><span class="p">)</span>
    <span class="n">errors</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">check</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">schema</span><span class="p">,</span> <span class="s2">&quot;$&quot;</span><span class="p">,</span> <span class="n">errors</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">errors</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">ConfigError</span><span class="p">(</span><span class="n">errors</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">data</span>
</code></pre></div></div>

<p>两个细节：<code>utf-8-sig</code> 兼容 Windows 记事本保存的 BOM，否则 <code>json.load</code> 会报 <code>Unexpected UTF-8 BOM</code>；<code>parse_constant</code> 用来拒绝 <code>NaN</code>、<code>Infinity</code>——标准 JSON 不允许这些值，但 Python 的 <code>json</code> 默认接受，写进配置后传到其他语言就会炸。</p>
<p>顺序上先补默认值、再校验。这样「有默认值的必填字段」不会误报缺失，而调用方拿到的对象一定是补全过的。代价是默认值本身写错（比如默认端口是 70000）不会被发现自己——所以在测试里应该对 schema 本身也做一次校验，或者干脆把默认值写进一组固定的测试配置里。</p>
<h3 id="_4">用一份坏配置跑一遍<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h3>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="n">SCHEMA</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;object&quot;</span><span class="p">,</span>
    <span class="s2">&quot;additionalProperties&quot;</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span>
    <span class="s2">&quot;required&quot;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&quot;server&quot;</span><span class="p">],</span>
    <span class="s2">&quot;properties&quot;</span><span class="p">:</span> <span class="p">{</span>
        <span class="s2">&quot;server&quot;</span><span class="p">:</span> <span class="p">{</span>
            <span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;object&quot;</span><span class="p">,</span>
            <span class="s2">&quot;required&quot;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&quot;port&quot;</span><span class="p">],</span>
            <span class="s2">&quot;properties&quot;</span><span class="p">:</span> <span class="p">{</span>
                <span class="s2">&quot;host&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;string&quot;</span><span class="p">,</span> <span class="s2">&quot;default&quot;</span><span class="p">:</span> <span class="s2">&quot;127.0.0.1&quot;</span><span class="p">},</span>
                <span class="s2">&quot;port&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;integer&quot;</span><span class="p">,</span> <span class="s2">&quot;minimum&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s2">&quot;maximum&quot;</span><span class="p">:</span> <span class="mi">65535</span><span class="p">},</span>
                <span class="s2">&quot;tls&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;boolean&quot;</span><span class="p">,</span> <span class="s2">&quot;default&quot;</span><span class="p">:</span> <span class="kc">False</span><span class="p">},</span>
            <span class="p">},</span>
        <span class="p">},</span>
        <span class="s2">&quot;workers&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;integer&quot;</span><span class="p">,</span> <span class="s2">&quot;minimum&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s2">&quot;maximum&quot;</span><span class="p">:</span> <span class="mi">64</span><span class="p">,</span> <span class="s2">&quot;default&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">},</span>
        <span class="s2">&quot;models&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;array&quot;</span><span class="p">,</span> <span class="s2">&quot;items&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;string&quot;</span><span class="p">,</span> <span class="s2">&quot;minLength&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">}},</span>
    <span class="p">},</span>
<span class="p">}</span>

<span class="k">try</span><span class="p">:</span>
    <span class="n">cfg</span> <span class="o">=</span> <span class="n">load_config</span><span class="p">(</span><span class="s2">&quot;config.json&quot;</span><span class="p">,</span> <span class="n">SCHEMA</span><span class="p">)</span>
<span class="k">except</span> <span class="n">ConfigError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">path</span><span class="p">,</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">e</span><span class="o">.</span><span class="n">errors</span><span class="p">:</span>
        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="k">raise</span> <span class="ne">SystemExit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>

<p>配上一份故意写错的 <code>config.json</code>（<code>port</code> 越过 65535、<code>workers</code> 写成 0、多了一个 <code>extra</code>），输出是三行：</p>
<ul>
<li><code>$.server.port: 不能大于 65535</code></li>
<li><code>$.workers: 不能小于 1</code></li>
<li><code>$.extra: 未知字段</code></li>
</ul>
<p>改对之后，返回的 <code>cfg</code> 里已经补上了 <code>server.host</code>、<code>server.tls</code> 和 <code>workers</code>，业务代码不用再写 <code>get("workers", 4)</code>。</p>
<h2 id="_5">边界与取舍<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li><strong>数字精度</strong>：Python 的 <code>int</code> 是任意精度，<code>float</code> 是双精度。配置里放大整数（如 ID）时，别用浮点表示；跨语言传递时更要注意 JS 只有 53 位安全整数。</li>
<li><strong>文件大小</strong>：<code>json.load</code> 会一次读进内存。配置文件不该超过几十 KB，如果真到了 MB 级，说明它更适合放进数据库。</li>
<li><strong>热加载</strong>：监听文件变化时，先读到完整内容再解析，校验通过后再原子替换配置对象；否则可能读到半个文件。</li>
<li><strong>错误信息脱敏</strong>：配置里常有密码和 token，报错时只打印路径和类型，不要打印值。这一点比校验本身更容易被忽略。</li>
<li><strong>未知字段的新旧兼容</strong>：上线顺序通常是「先发能读新字段的版本，再改配置」。这段时间里旧版本遇到新字段不能崩，所以 <code>additionalProperties</code> 最好只在测试环境设成 <code>false</code>。</li>
<li><strong>不要校验环境变量注入的值</strong>：容器里很多配置来自 <code>env</code>，字符串 <code>"8080"</code> 是常态。要么在入口处显式转换，要么给这些字段放宽为字符串再解析，别让校验器替你做隐式转换。</li>
</ul>
<div class="callout callout-warning"><div class="callout-label">注意</div>
<p>校验通过不等于配置正确。端口范围合法但和另一个服务冲突、路径存在但没有写权限，这些只能靠启动后的健康检查发现。校验负责「形状」，不负责「语义」。</p>
</div>
<h2 id="_6">结论<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<ul>
<li>两百行标准库足够覆盖配置校验的常见错误，不必为此引入依赖。</li>
<li><code>bool</code> 是 <code>int</code> 的子类，类型判断必须显式排除。</li>
<li>一次收集全部错误并带上 <code>$.a.b</code> 路径，比快速失败更好用。</li>
<li>默认值要用 <code>copy.deepcopy</code>，否则可变默认值会在多次加载间共享。</li>
<li>用 <code>utf-8-sig</code> 读文件、用 <code>parse_constant</code> 拒绝 <code>NaN</code>，这两个坑都来自标准库的宽松默认。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>用 Git Hook 自动拦截密钥泄露</title>
<link>https://blog.xtang.online/posts/git-hook-secret-scan/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/git-hook-secret-scan/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>AK、token、私钥被提交进仓库，往往是在推送之后才被发现。用一段 pre-commit 脚本扫描暂存区新增行，把泄露挡在本地，并给出误报处理和泄露后的补救顺序。</description>
<content:encoded><![CDATA[<p>测试环境的 AK 被写进了 <code>config.py</code>，提交、推送、合并，三天后才在代码评审里被发现。删掉那一行没有用——它还在历史里，远端缓存里，以及所有克隆过仓库的人的硬盘上。</p>
<p>唯一可靠的结论是：<strong>只要密钥进过仓库，就当作已泄露</strong>。所以真正有价值的动作发生在提交之前，而不是事后清理。</p>
<h2 id="pre-commit">为什么放在 pre-commit<a class="h-anchor" href="#pre-commit" aria-label="锚点">#</a></h2>
<p>Git 有好几个 hook 可用，位置决定了成本和效果：</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Hook</th>
<th>触发时机</th>
<th>优点</th>
<th>局限</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>pre-commit</code></td>
<td>本地提交前</td>
<td>最快拦截，开发者立刻知道</td>
<td>可被 <code>--no-verify</code> 绕过</td>
</tr>
<tr>
<td><code>pre-push</code></td>
<td>推送前</td>
<td>覆盖一次提交里的多个 commit</td>
<td>仍在本机，可绕过</td>
</tr>
<tr>
<td><code>pre-receive</code></td>
<td>服务端接收前</td>
<td>无法绕过</td>
<td>需要仓库管理权限</td>
</tr>
<tr>
<td>CI 检查</td>
<td>合并请求时</td>
<td>覆盖所有来源</td>
<td>密钥已进远端历史</td>
</tr>
</tbody>
</table></div>
<p>本地 hook 的价值是反馈快：写完代码一提交就被拦住，比等 CI 跑三分钟友好得多。但它只是第一道网，CI 里应该跑同一个脚本兜底——两者共用一份规则，避免「本地过了、CI 又报」的割裂。</p>
<p>推行时最怕两件事：一是误报太多，二是不知道被拦了之后该做什么。前者靠规则收紧解决，后者靠提示语解决——报错信息里必须直接写「这行是测试数据就加 <code># allowlist-secret</code>，否则删掉并轮换密钥」，而不是只打印「检测到敏感信息」。</p>
<h2 id="_1">规则：宁可少报，不要乱报<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>正则写宽了，每天几十条误报，团队会集体加 <code>--no-verify</code>，等于没做。写窄了会漏，所以按「高置信度前缀」优先：</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>规则</th>
<th>匹配特征</th>
<th>误报率</th>
</tr>
</thead>
<tbody>
<tr>
<td>AWS Access Key</td>
<td><code>AKIA</code> + 16 位大写字母数字</td>
<td>极低</td>
</tr>
<tr>
<td>GitHub Token</td>
<td><code>ghp_</code>、<code>gho_</code>、<code>ghs_</code> 等前缀</td>
<td>极低</td>
</tr>
<tr>
<td>Slack Token</td>
<td><code>xoxb-</code>、<code>xoxp-</code> 等前缀</td>
<td>极低</td>
</tr>
<tr>
<td>私钥文件头</td>
<td><code>-----BEGIN ... PRIVATE KEY-----</code></td>
<td>极低</td>
</tr>
<tr>
<td>通用赋值</td>
<td><code>api_key = "..."</code> 且值长度 ≥ 16</td>
<td>中等，需白名单</td>
</tr>
</tbody>
</table></div>
<p>通用规则是误报的主要来源。缓解办法有三层：要求值被引号包住且长度够、跳过文档和锁文件、允许行尾写 <code># allowlist-secret</code> 显式放行。</p>
<p>误报最典型的三种来源：README 里的示例值（<code>api_key = "sk-example-1234567890"</code>）、测试夹具里的假 token、以及打包产物里的哈希。前两种可以用白名单词兜住，第三种只能靠扩展名跳过——压缩过的 JS 和 sourcemap 里全是长随机串，扫它纯属自找麻烦。</p>
<h2 id="_2">扫描器<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/usr/bin/env python3</span>
<span class="sd">&quot;&quot;&quot;扫描暂存区新增行里的疑似密钥；命中时退出码为 1。&quot;&quot;&quot;</span>
<span class="kn">import</span> <span class="nn">re</span>
<span class="kn">import</span> <span class="nn">subprocess</span>
<span class="kn">import</span> <span class="nn">sys</span>

<span class="n">RULES</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="s2">&quot;aws-access-key&quot;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&quot;\bAKIA[0-9A-Z]</span><span class="si">{16}</span><span class="s2">\b&quot;</span><span class="p">)),</span>
    <span class="p">(</span><span class="s2">&quot;github-token&quot;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&quot;\bgh[pousr]_[A-Za-z0-9]</span><span class="si">{36}</span><span class="s2">\b&quot;</span><span class="p">)),</span>
    <span class="p">(</span><span class="s2">&quot;slack-token&quot;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&quot;\bxox[baprs]-[A-Za-z0-9-]{10,}&quot;</span><span class="p">)),</span>
    <span class="p">(</span><span class="s2">&quot;private-key&quot;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span>
        <span class="sa">r</span><span class="s2">&quot;-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----&quot;</span><span class="p">)),</span>
    <span class="p">(</span><span class="s2">&quot;generic-secret&quot;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span>
<span class="w">        </span><span class="sa">r</span><span class="sd">&quot;&quot;&quot;(?ix)\b(?:api[_-]?key|secret|token|passwd|password|access[_-]?key)\b</span>
<span class="sd">            [&#39;&quot;]?\s*[:=]\s*[&#39;&quot;]([^&#39;&quot;\s]{16,})[&#39;&quot;]&quot;&quot;&quot;</span><span class="p">)),</span>
<span class="p">]</span>
<span class="n">ALLOW</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&quot;allowlist-secret&quot;</span><span class="p">)</span>
<span class="n">PLACEHOLDER</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&quot;(?i:example|changeme|placeholder|dummy|your[_-]?key)&quot;</span><span class="p">)</span>
<span class="n">SKIP_EXT</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&quot;.md&quot;</span><span class="p">,</span> <span class="s2">&quot;.lock&quot;</span><span class="p">,</span> <span class="s2">&quot;.min.js&quot;</span><span class="p">,</span> <span class="s2">&quot;.map&quot;</span><span class="p">,</span> <span class="s2">&quot;.snap&quot;</span><span class="p">,</span> <span class="s2">&quot;.svg&quot;</span><span class="p">,</span> <span class="s2">&quot;.ipynb&quot;</span><span class="p">)</span>
</code></pre></div></div>

<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">staged_diff</span><span class="p">():</span>
    <span class="k">return</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
        <span class="p">[</span><span class="s2">&quot;git&quot;</span><span class="p">,</span> <span class="s2">&quot;diff&quot;</span><span class="p">,</span> <span class="s2">&quot;--cached&quot;</span><span class="p">,</span> <span class="s2">&quot;--unified=0&quot;</span><span class="p">,</span>
         <span class="s2">&quot;--diff-filter=ACM&quot;</span><span class="p">,</span> <span class="s2">&quot;--no-color&quot;</span><span class="p">],</span>
        <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">stdout</span>


<span class="k">def</span> <span class="nf">scan</span><span class="p">(</span><span class="n">diff_text</span><span class="p">):</span>
    <span class="n">findings</span><span class="p">,</span> <span class="n">path</span><span class="p">,</span> <span class="n">lineno</span> <span class="o">=</span> <span class="p">[],</span> <span class="kc">None</span><span class="p">,</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">raw</span> <span class="ow">in</span> <span class="n">diff_text</span><span class="o">.</span><span class="n">splitlines</span><span class="p">():</span>
        <span class="k">if</span> <span class="n">raw</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&quot;+++ b/&quot;</span><span class="p">):</span>
            <span class="n">path</span><span class="p">,</span> <span class="n">lineno</span> <span class="o">=</span> <span class="n">raw</span><span class="p">[</span><span class="mi">6</span><span class="p">:],</span> <span class="mi">0</span>
            <span class="k">continue</span>
        <span class="k">if</span> <span class="n">raw</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&quot;@@&quot;</span><span class="p">):</span>
            <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s2">&quot;\+(\d+)&quot;</span><span class="p">,</span> <span class="n">raw</span><span class="p">)</span>
            <span class="n">lineno</span> <span class="o">=</span> <span class="p">(</span><span class="nb">int</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span> <span class="k">if</span> <span class="n">m</span> <span class="k">else</span> <span class="mi">1</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span>   <span class="c1"># 首条新增行的行号</span>
            <span class="k">continue</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">raw</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&quot;+&quot;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">raw</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&quot;+++&quot;</span><span class="p">):</span>
            <span class="k">continue</span>
        <span class="n">lineno</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="n">content</span> <span class="o">=</span> <span class="n">raw</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">path</span> <span class="ow">or</span> <span class="n">path</span><span class="o">.</span><span class="n">endswith</span><span class="p">(</span><span class="n">SKIP_EXT</span><span class="p">)</span> <span class="ow">or</span> <span class="n">ALLOW</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">content</span><span class="p">):</span>
            <span class="k">continue</span>
        <span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">rx</span> <span class="ow">in</span> <span class="n">RULES</span><span class="p">:</span>
            <span class="n">m</span> <span class="o">=</span> <span class="n">rx</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span> <span class="ow">or</span> <span class="n">PLACEHOLDER</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">0</span><span class="p">)):</span>
                <span class="k">continue</span>        <span class="c1"># 占位符不算泄露，例如文档里的 example key</span>
            <span class="n">findings</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">content</span><span class="o">.</span><span class="n">strip</span><span class="p">()[:</span><span class="mi">120</span><span class="p">]))</span>
            <span class="k">break</span>               <span class="c1"># 一行只报一次，避免重复刷屏</span>
    <span class="k">return</span> <span class="n">findings</span>
</code></pre></div></div>

<p><code>--unified=0</code> 让 diff 只输出变更行，扫描成本与提交大小成正比，而不是和仓库大小成正比。<code>--diff-filter=ACM</code> 排除删除和重命名，只看新增、复制、修改。</p>
<p>只扫新增行是刻意的：历史里已有的问题不应该在每次提交时反复报，那会让 hook 从「守门」变成「噪声源」。存量密钥应该单独跑一次全量扫描，列清单、排期处理，和提交时的增量检查分开。</p>
<p>二进制文件也不用担心。<code>git diff</code> 对它只输出一行 <code>Binary files ... differ</code>，不会带出内容，正则自然匹配不到。</p>
<p>解析 hunk 头是为了给出准确行号。<code>@@ -12,3 +12,5 @@</code> 里的 <code>+12</code> 是新文件的起始行，从这个数开始给每个 <code>+</code> 行递增，报错才能精确到行。没有这一步，提示只能写「某个文件里有个密钥」，等于让人自己找。</p>
<h3 id="_3">输出与退出码<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h3>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">findings</span> <span class="o">=</span> <span class="n">scan</span><span class="p">(</span><span class="n">staged_diff</span><span class="p">())</span>
    <span class="k">except</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">CalledProcessError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;git diff 执行失败，跳过扫描：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
        <span class="k">return</span> <span class="mi">0</span>                       <span class="c1"># 不让工具故障阻断提交</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">findings</span><span class="p">:</span>
        <span class="k">return</span> <span class="mi">0</span>
    <span class="nb">print</span><span class="p">(</span><span class="s2">&quot;检测到疑似密钥，已阻止提交：&quot;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">path</span><span class="p">,</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">snippet</span> <span class="ow">in</span> <span class="n">findings</span><span class="p">:</span>
        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">lineno</span><span class="si">}</span><span class="s2">  [</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">]  </span><span class="si">{</span><span class="n">snippet</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
    <span class="nb">print</span><span class="p">(</span><span class="s2">&quot;确认是测试数据请在该行加 # allowlist-secret；否则删除并轮换密钥。&quot;</span><span class="p">,</span>
          <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
    <span class="k">return</span> <span class="mi">1</span>


<span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&quot;__main__&quot;</span><span class="p">:</span>
    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>
</code></pre></div></div>

<p>判断脚本坏了还是抓到密钥，靠退出码：<code>0</code> 放行，<code>1</code> 拦截。<code>git diff</code> 本身失败时返回 0 是刻意的——hook 不应该因为环境异常把所有人卡住，这类错误交给 CI 报。</p>
<h2 id="hook">接上 Hook<a class="h-anchor" href="#hook" aria-label="锚点">#</a></h2>
<p>把脚本放进仓库的 <code>scripts/</code> 目录，hook 只做转发：</p>
<div class="code-card" data-lang="sh"><div class="code-head"><span class="code-lang">shell</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/bin/sh</span>
<span class="c1"># .githooks/pre-commit —— 用 git config core.hooksPath .githooks 启用</span>
<span class="nb">exec</span><span class="w"> </span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">PYTHON</span><span class="k">:-</span><span class="nv">python3</span><span class="si">}</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="k">$(</span>git<span class="w"> </span>rev-parse<span class="w"> </span>--show-toplevel<span class="k">)</span><span class="s2">/scripts/secret_scan.py&quot;</span>
</code></pre></div></div>

<p><code>.git/hooks</code> 不进版本库，所以用 <code>core.hooksPath</code> 指向仓库内的目录。启用只需两行：<code>chmod +x .githooks/pre-commit scripts/secret_scan.py</code>，然后 <code>git config core.hooksPath .githooks</code>。团队里可以再加一条 <code>make setup</code> 把这步写进去，避免有人克隆后忘了配。</p>
<p><code>git rev-parse --show-toplevel</code> 保证在子目录里提交也能找到脚本。<code>PYTHON</code> 变量是给 Windows 用户留的口子——Git for Windows 里通常只有 <code>python</code>，没有 <code>python3</code>。</p>
<p>验证一下：新建一个文件写入 <code>AKIAIOSFODNN7EXAMPLE</code>，<code>git add</code> 后提交，应该看到拦截信息，<code>git log</code> 里没有新 commit。把这一行改成 <code>AKIA... # allowlist-secret</code> 再提交，应当通过。</p>
<h2 id="_4">绕过、漏网与善后<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ul>
<li><strong><code>--no-verify</code></strong>：任何本地 hook 都能被绕过。CI 里对 <code>git diff origin/main...HEAD</code> 跑同一个脚本，能覆盖这种情况。</li>
<li><strong>新克隆的仓库</strong>：<code>core.hooksPath</code> 是本地配置，不会被克隆。要么写进仓库的初始化脚本，要么直接依赖 CI。</li>
<li><strong>已经泄露</strong>：先轮换密钥（在服务端吊销），再清理历史（<code>git filter-repo</code> 或 BFG），最后通知用过这份代码的人。顺序不能反——先清历史再轮换，等于给攻击者留了时间窗。</li>
<li><strong>历史清理要谨慎</strong>：重写历史会让所有协作者的分支失效，操作前备份仓库，先在镜像上演练一遍。</li>
</ul>
<p>还有一个常被提议的方案：用信息熵检测「像不像随机字符串」。它在理论上能抓到没有固定前缀的密钥，实际用起来误报率极高——UUID、哈希、base64 编码的图片路径都会命中。除非有专人维护阈值，否则不要加，它会把团队推向 <code>--no-verify</code>。</p>
<div class="callout callout-warning"><div class="callout-label">注意</div>
<p>不要试图用 <code>.gitignore</code> 防密钥。它只影响未跟踪文件，一旦 <code>git add -f</code> 或者文件早已被跟踪，照样提交。也不要把密钥写进环境变量文件再提交，那只是换了个文件名。</p>
</div>
<h2 id="_5">结论<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<ul>
<li>密钥进过仓库就视为泄露，拦截要发生在提交前，而不是推送后。</li>
<li>用高置信度前缀规则做主力，通用赋值规则配白名单，避免误报逼出 <code>--no-verify</code>。</li>
<li><code>git diff --cached -U0</code> 只扫增量，解析 hunk 头拿到准确行号。</li>
<li>本地 hook 用 <code>core.hooksPath</code> 共享，CI 跑同一份脚本兜底。</li>
<li>泄露后的顺序是：轮换密钥 → 清理历史 → 通知相关方，别跳步。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>从零实现一个简易 LRU 缓存（Python / TypeScript 双版本）</title>
<link>https://blog.xtang.online/posts/lru-cache-python-ts/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/lru-cache-python-ts/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>哈希表加双向链表，get 和 put 都是 O(1)。文章给出 Python 与 TypeScript 两版完整实现，并说清并发、空值、缓存击穿这些容易被忽略的边界。</description>
<content:encoded><![CDATA[<p>本地缓存的需求通常长这样：热点配置只有几百条，读多写少，希望固定内存上限，最久没被访问的先淘汰。Redis 能解决，但为几百条数据多一次网络往返不划算；<code>functools.lru_cache</code> 又只适合「函数入参到返回值」的纯函数场景。</p>
<p>自己写一个 LRU，核心只有两件事：<strong>哈希表负责 O(1) 查找，双向链表负责 O(1) 调整顺序</strong>。数组做不到，因为把中间元素移到头部需要搬移；单链表也不行，删除一个节点需要找前驱。</p>
<h2 id="_1">结构：两个哨兵省掉一半边界判断<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<p>链表头是最近访问，尾部是最久未访问。插入、删除都围绕头尾进行，所以放两个哨兵节点 <code>head</code> 和 <code>tail</code>，让空链表、只有一个节点、删除尾节点这几种情况共用一套指针操作。</p>
<div class="code-card" data-lang="text"><div class="code-head"><span class="code-lang">text</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>head &lt;-&gt; [最近] &lt;-&gt; [ ... ] &lt;-&gt; [最久] &lt;-&gt; tail
          get/put 命中时移到 head 前
          容量满时删除 tail.prev
</code></pre></div></div>

<p>每个节点同时存 key 和 value。只存 value 不够，因为淘汰尾节点时还要用 key 去哈希表里删索引。</p>
<h2 id="python">Python 版：完整可运行<a class="h-anchor" href="#python" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
<span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Dict</span><span class="p">,</span> <span class="n">Generic</span><span class="p">,</span> <span class="n">Optional</span><span class="p">,</span> <span class="n">TypeVar</span>

<span class="n">K</span> <span class="o">=</span> <span class="n">TypeVar</span><span class="p">(</span><span class="s2">&quot;K&quot;</span><span class="p">)</span>
<span class="n">V</span> <span class="o">=</span> <span class="n">TypeVar</span><span class="p">(</span><span class="s2">&quot;V&quot;</span><span class="p">)</span>


<span class="k">class</span> <span class="nc">_Node</span><span class="p">(</span><span class="n">Generic</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]):</span>
    <span class="vm">__slots__</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&quot;key&quot;</span><span class="p">,</span> <span class="s2">&quot;value&quot;</span><span class="p">,</span> <span class="s2">&quot;prev&quot;</span><span class="p">,</span> <span class="s2">&quot;next&quot;</span><span class="p">)</span>

    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">K</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">V</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">key</span> <span class="o">=</span> <span class="n">key</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">value</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">prev</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]]</span> <span class="o">=</span> <span class="kc">None</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">next</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]]</span> <span class="o">=</span> <span class="kc">None</span>


<span class="k">class</span> <span class="nc">LRUCache</span><span class="p">(</span><span class="n">Generic</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]):</span>
    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">capacity</span><span class="p">:</span> <span class="nb">int</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">capacity</span> <span class="o">&lt;</span> <span class="mi">1</span><span class="p">:</span>
            <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&quot;capacity must be &gt;= 1&quot;</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">capacity</span> <span class="o">=</span> <span class="n">capacity</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="p">:</span> <span class="n">Dict</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]]</span> <span class="o">=</span> <span class="p">{}</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_head</span><span class="p">:</span> <span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]</span> <span class="o">=</span> <span class="n">_Node</span><span class="p">()</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_tail</span><span class="p">:</span> <span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]</span> <span class="o">=</span> <span class="n">_Node</span><span class="p">()</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_head</span><span class="o">.</span><span class="n">next</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_tail</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_tail</span><span class="o">.</span><span class="n">prev</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_head</span>

    <span class="k">def</span> <span class="nf">_unlink</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">node</span><span class="p">:</span> <span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
        <span class="k">assert</span> <span class="n">node</span><span class="o">.</span><span class="n">prev</span> <span class="ow">and</span> <span class="n">node</span><span class="o">.</span><span class="n">next</span>
        <span class="n">node</span><span class="o">.</span><span class="n">prev</span><span class="o">.</span><span class="n">next</span> <span class="o">=</span> <span class="n">node</span><span class="o">.</span><span class="n">next</span>
        <span class="n">node</span><span class="o">.</span><span class="n">next</span><span class="o">.</span><span class="n">prev</span> <span class="o">=</span> <span class="n">node</span><span class="o">.</span><span class="n">prev</span>

    <span class="k">def</span> <span class="nf">_push_front</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">node</span><span class="p">:</span> <span class="n">_Node</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
        <span class="n">node</span><span class="o">.</span><span class="n">next</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_head</span><span class="o">.</span><span class="n">next</span>
        <span class="n">node</span><span class="o">.</span><span class="n">prev</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_head</span>
        <span class="k">assert</span> <span class="bp">self</span><span class="o">.</span><span class="n">_head</span><span class="o">.</span><span class="n">next</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_head</span><span class="o">.</span><span class="n">next</span><span class="o">.</span><span class="n">prev</span> <span class="o">=</span> <span class="n">node</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_head</span><span class="o">.</span><span class="n">next</span> <span class="o">=</span> <span class="n">node</span>

    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="n">K</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Optional</span><span class="p">[</span><span class="n">V</span><span class="p">]:</span>
        <span class="n">node</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">node</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
            <span class="k">return</span> <span class="kc">None</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_unlink</span><span class="p">(</span><span class="n">node</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_push_front</span><span class="p">(</span><span class="n">node</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">node</span><span class="o">.</span><span class="n">value</span>

    <span class="k">def</span> <span class="nf">put</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="n">K</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="n">V</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
        <span class="n">node</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">node</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
            <span class="n">node</span><span class="o">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">value</span>
            <span class="bp">self</span><span class="o">.</span><span class="n">_unlink</span><span class="p">(</span><span class="n">node</span><span class="p">)</span>
            <span class="bp">self</span><span class="o">.</span><span class="n">_push_front</span><span class="p">(</span><span class="n">node</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="bp">self</span><span class="o">.</span><span class="n">capacity</span><span class="p">:</span>
            <span class="n">victim</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_tail</span><span class="o">.</span><span class="n">prev</span>
            <span class="k">assert</span> <span class="n">victim</span> <span class="ow">and</span> <span class="n">victim</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">self</span><span class="o">.</span><span class="n">_head</span> <span class="ow">and</span> <span class="n">victim</span><span class="o">.</span><span class="n">key</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span>
            <span class="bp">self</span><span class="o">.</span><span class="n">_unlink</span><span class="p">(</span><span class="n">victim</span><span class="p">)</span>
            <span class="k">del</span> <span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="p">[</span><span class="n">victim</span><span class="o">.</span><span class="n">key</span><span class="p">]</span>
        <span class="n">node</span> <span class="o">=</span> <span class="n">_Node</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">node</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_push_front</span><span class="p">(</span><span class="n">node</span><span class="p">)</span>

    <span class="k">def</span> <span class="fm">__len__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
        <span class="k">return</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_map</span><span class="p">)</span>
</code></pre></div></div>

<p>用法和边界：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="n">cache</span> <span class="o">=</span> <span class="n">LRUCache</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">](</span><span class="mi">2</span><span class="p">)</span>
<span class="n">cache</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="s2">&quot;a&quot;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
<span class="n">cache</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="s2">&quot;b&quot;</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;a&quot;</span><span class="p">)</span>      <span class="c1"># 命中，a 变成最近使用</span>
<span class="n">cache</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="s2">&quot;c&quot;</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>   <span class="c1"># 淘汰 b</span>
<span class="k">assert</span> <span class="n">cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;b&quot;</span><span class="p">)</span> <span class="ow">is</span> <span class="kc">None</span>
<span class="k">assert</span> <span class="n">cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;a&quot;</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span>
</code></pre></div></div>

<p>这里有一个设计取舍：<code>get</code> 用 <code>None</code> 表示未命中，因此缓存里不能存 <code>None</code>。如果业务需要存空值，把返回值改成 <code>tuple[bool, V | None]</code>，或者定义一个模块级 <code>MISSING = object()</code> 作为默认值。不要用 <code>key in map</code> 再 <code>get</code>，那是两次查找。</p>
<h2 id="typescript-map">TypeScript 版：利用 Map 的插入顺序<a class="h-anchor" href="#typescript-map" aria-label="锚点">#</a></h2>
<p>JavaScript 的 <code>Map</code> 会保留键的插入顺序，<code>delete</code> 后再 <code>set</code> 会把键移到末尾。把「末尾」当作最近使用，代码可以短很多：</p>
<div class="code-card" data-lang="ts"><div class="code-head"><span class="code-lang">typescript</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="k">export</span><span class="w"> </span><span class="kd">class</span><span class="w"> </span><span class="nx">LRUCache</span><span class="o">&lt;</span><span class="nx">K</span><span class="p">,</span><span class="w"> </span><span class="nx">V</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">private</span><span class="w"> </span><span class="k">readonly</span><span class="w"> </span><span class="nx">map</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nb">Map</span><span class="o">&lt;</span><span class="nx">K</span><span class="p">,</span><span class="w"> </span><span class="nx">V</span><span class="o">&gt;</span><span class="p">();</span>

<span class="w">  </span><span class="kr">constructor</span><span class="p">(</span><span class="k">private</span><span class="w"> </span><span class="k">readonly</span><span class="w"> </span><span class="nx">capacity</span><span class="o">:</span><span class="w"> </span><span class="kt">number</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="nb">Number</span><span class="p">.</span><span class="nx">isInteger</span><span class="p">(</span><span class="nx">capacity</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nx">capacity</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mf">1</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="k">throw</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="ne">Error</span><span class="p">(</span><span class="s2">&quot;capacity must be a positive integer&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>

<span class="w">  </span><span class="nx">get</span><span class="p">(</span><span class="nx">key</span><span class="o">:</span><span class="w"> </span><span class="kt">K</span><span class="p">)</span><span class="o">:</span><span class="w"> </span><span class="nx">V</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="kc">undefined</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">key</span><span class="p">))</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="kc">undefined</span><span class="p">;</span>
<span class="w">    </span><span class="kd">const</span><span class="w"> </span><span class="nx">value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span><span class="w"> </span><span class="kr">as</span><span class="w"> </span><span class="nx">V</span><span class="p">;</span>
<span class="w">    </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="ow">delete</span><span class="p">(</span><span class="nx">key</span><span class="p">);</span>
<span class="w">    </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">key</span><span class="p">,</span><span class="w"> </span><span class="nx">value</span><span class="p">);</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nx">value</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>

<span class="w">  </span><span class="nx">put</span><span class="p">(</span><span class="nx">key</span><span class="o">:</span><span class="w"> </span><span class="kt">K</span><span class="p">,</span><span class="w"> </span><span class="nx">value</span><span class="o">:</span><span class="w"> </span><span class="kt">V</span><span class="p">)</span><span class="o">:</span><span class="w"> </span><span class="ow">void</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">key</span><span class="p">))</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="ow">delete</span><span class="p">(</span><span class="nx">key</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">size</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">capacity</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="kd">const</span><span class="w"> </span><span class="nx">oldest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">keys</span><span class="p">().</span><span class="nx">next</span><span class="p">().</span><span class="nx">value</span><span class="w"> </span><span class="kr">as</span><span class="w"> </span><span class="nx">K</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="kc">undefined</span><span class="p">;</span>
<span class="w">      </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">oldest</span><span class="w"> </span><span class="o">!==</span><span class="w"> </span><span class="kc">undefined</span><span class="p">)</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="ow">delete</span><span class="p">(</span><span class="nx">oldest</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">key</span><span class="p">,</span><span class="w"> </span><span class="nx">value</span><span class="p">);</span>
<span class="w">  </span><span class="p">}</span>

<span class="w">  </span><span class="nx">get</span><span class="w"> </span><span class="nx">size</span><span class="p">()</span><span class="o">:</span><span class="w"> </span><span class="kt">number</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">map</span><span class="p">.</span><span class="nx">size</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>TypeScript 版本同样有「不能缓存 <code>undefined</code>」的限制。另一个坑是 <code>oldest !== undefined</code>：如果业务真的用 <code>undefined</code> 作为 key，这里会误判。生产代码要么禁止 <code>undefined</code> key，要么改用显式的双向链表版本。</p>
<h2 id="o1">并发：O(1) 不等于线程安全<a class="h-anchor" href="#o1" aria-label="锚点">#</a></h2>
<p>Python 有 GIL，但 <code>get</code> 里的「查表 → 摘链 → 插头」是三段操作，线程切换可能发生在任意字节码之间。多线程环境下要加锁：</p>
<div class="code-card" data-lang="python"><div class="code-head"><span class="code-lang">python</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="kn">import</span> <span class="nn">threading</span>

<span class="k">class</span> <span class="nc">ThreadSafeLRU</span><span class="p">(</span><span class="n">LRUCache</span><span class="p">[</span><span class="n">K</span><span class="p">,</span> <span class="n">V</span><span class="p">]):</span>
    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">capacity</span><span class="p">:</span> <span class="nb">int</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">(</span><span class="n">capacity</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_lock</span> <span class="o">=</span> <span class="n">threading</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="n">K</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Optional</span><span class="p">[</span><span class="n">V</span><span class="p">]:</span>
        <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">_lock</span><span class="p">:</span>
            <span class="k">return</span> <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">put</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="n">K</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="n">V</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
        <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">_lock</span><span class="p">:</span>
            <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
</code></pre></div></div>

<p>锁的粒度要覆盖整个操作，不能只锁哈希表。更好的做法是把「查缓存」和「回源计算」分开：锁内只做查/写，回源在锁外；同时用 single-flight 合并同一个 key 的并发回源，否则缓存未命中时会有 N 个请求一起打到数据库。</p>
<p>TypeScript 的单线程只保证同步代码不交错，<code>async</code> 函数在 <code>await</code> 处仍会让出。不要把「先 <code>get</code> 判断，再 <code>await</code> 回源，再 <code>put</code>」当成原子操作；同一 key 的并发请求需要 Promise 去重。</p>
<h2 id="_2">容量怎么估，不要拍脑袋<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<p>LRU 的容量不是越大越好。每条记录除了 value 本身，还有哈希表槽位、节点对象、key 的引用，Python 下一条小对象往往要几百字节。缓存十万条配置，可能先吃掉几十 MB 内存，换来的命中率却只比一万条高几个百分点。</p>
<p>先用 <code>tracemalloc</code> 或 <code>sys.getsizeof</code> 做一次粗估，再按「内存预算 ÷ 单条平均开销」定上限；上线后观察命中率和淘汰速率，命中率不再明显上升时就该停止加容量。TypeScript 的 <code>Map</code> 同理，V8 的对象开销和字符串驻留都会影响实际占用。</p>
<h2 id="_3">什么时候别自己写<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>场景</th>
<th>建议</th>
</tr>
</thead>
<tbody>
<tr>
<td>纯函数结果缓存</td>
<td><code>functools.lru_cache(maxsize=...)</code></td>
</tr>
<tr>
<td>需要 TTL、统计、多种淘汰策略</td>
<td><code>cachetools</code> 或 Caffeine</td>
</tr>
<tr>
<td>多进程 / 多实例共享</td>
<td>Redis，别用进程内缓存</td>
</tr>
<tr>
<td>缓存对象很大、更新频繁</td>
<td>先算内存上限，再决定容量</td>
</tr>
<tr>
<td>需要持久化</td>
<td>数据库或带持久化的缓存服务</td>
</tr>
</tbody>
</table></div>
<p>自己写的价值在于可控：容量、淘汰顺序、空值语义、并发策略都能按业务调整。代价是测试要自己补，尤其是容量为 1、重复 put、命中后淘汰顺序这三组用例。</p>
<h2 id="_4">结论<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<ul>
<li>LRU 的本质是哈希表 + 双向链表，哨兵节点能把边界判断收敛成一套指针操作。</li>
<li>Python 版用 <code>__slots__</code> 降低节点开销；TypeScript 版可以用 <code>Map</code> 的插入顺序写出更短的实现。</li>
<li><code>None</code> / <code>undefined</code> 与「未命中」的语义要提前定义，不要让调用方猜。</li>
<li>并发下必须锁住完整操作；缓存未命中时的回源，用 single-flight 防止击穿。</li>
<li>进程内缓存不解决多实例一致性问题，跨进程共享请直接用 Redis。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item><item>
<title>用 FFmpeg 批量压缩视频：一条命令省下 80% 空间</title>
<link>https://blog.xtang.online/posts/ffmpeg-batch-compress/</link>
<guid isPermaLink="true">https://blog.xtang.online/posts/ffmpeg-batch-compress/</guid>
<pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
<description>手机视频、录屏和素材归档占满硬盘时，FFmpeg 的 CRF、preset 与缩放参数比“随便压一下”重要得多。给出可直接运行的批量脚本、目标体积算法和验证方法。</description>
<content:encoded><![CDATA[<p>硬盘告警时，占空间的往往不是代码，而是几十个 GB 的录屏和手机视频。这些文件多数是 4K、高码率、只在一台设备上看过几次。把它们转成 1080p 的 H.264，通常能省下 70% 到 90% 的空间，画质损失在普通显示器上很难察觉。</p>
<p>但「压了就行」是个陷阱：参数选错会把文字录屏压成一团糊，或者让文件比原来还大。下面这套流程先讲清单个文件，再给出可以后台跑的批量脚本。</p>
<h2 id="_1">先看清源文件，再决定目标<a class="h-anchor" href="#_1" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>ffprobe<span class="w"> </span>-v<span class="w"> </span>error<span class="w"> </span>-select_streams<span class="w"> </span>v:0<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-show_entries<span class="w"> </span><span class="nv">stream</span><span class="o">=</span>codec_name,width,height,r_frame_rate,bit_rate<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-show_entries<span class="w"> </span><span class="nv">format</span><span class="o">=</span>duration,size,bit_rate<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-of<span class="w"> </span><span class="nv">default</span><span class="o">=</span><span class="nv">noprint_wrappers</span><span class="o">=</span><span class="m">1</span><span class="w"> </span>input.mp4
</code></pre></div></div>

<p>重点看四个值：分辨率、帧率、视频码率、总时长。一个 4K 30fps、40 Mbps 的十分钟录屏，和一个 1080p 60fps、8 Mbps 的游戏录像，压缩策略完全不同。</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>源素材</th>
<th>建议</th>
<th>预期体积</th>
</tr>
</thead>
<tbody>
<tr>
<td>4K 手机视频，主要在大屏看</td>
<td>降到 1080p，CRF 22–24</td>
<td>常见省 70–90%</td>
</tr>
<tr>
<td>1080p 录屏，含大量文字</td>
<td>保持分辨率，CRF 20–23，避免过度降噪</td>
<td>省 30–60%</td>
</tr>
<tr>
<td>已压过的 1080p 网络视频</td>
<td>不建议二次压缩</td>
<td>可能只省 10–30%，且更糊</td>
</tr>
<tr>
<td>需要剪辑的中间素材</td>
<td>用 ProRes / 高码率 H.264，不压</td>
<td>不适用</td>
</tr>
</tbody>
</table></div>
<p>「省 80%」不是承诺，而是量级：源文件码率越高、分辨率越大，下降空间越大；已经压过的文件再压，收益会迅速变小。</p>
<h2 id="_2">单文件：一条可复用的命令<a class="h-anchor" href="#_2" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>ffmpeg<span class="w"> </span>-hide_banner<span class="w"> </span>-i<span class="w"> </span>input.mp4<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-map<span class="w"> </span><span class="m">0</span>:v:0<span class="w"> </span>-map<span class="w"> </span><span class="m">0</span>:a:0?<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-c:v<span class="w"> </span>libx264<span class="w"> </span>-crf<span class="w"> </span><span class="m">23</span><span class="w"> </span>-preset<span class="w"> </span>medium<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-vf<span class="w"> </span><span class="s2">&quot;scale=&#39;min(1920,iw)&#39;:-2&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-c:a<span class="w"> </span>aac<span class="w"> </span>-b:a<span class="w"> </span>128k<span class="w"> </span>-ac<span class="w"> </span><span class="m">2</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-movflags<span class="w"> </span>+faststart<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-map_metadata<span class="w"> </span><span class="m">0</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>output.mp4
</code></pre></div></div>

<p>逐项解释：</p>
<ul>
<li><code>-crf 23</code> 是 H.264 的恒定质量模式，18 接近视觉无损，23 是默认平衡点，28 开始明显变糊。录屏文字多，建议 20–22；普通画面 23–25。</li>
<li><code>-preset medium</code> 决定编码速度与压缩率。<code>slow</code> 能再小 5%–10%，但耗时明显增加；批量任务用 <code>medium</code> 更稳。</li>
<li><code>scale='min(1920,iw)':-2</code> 只在宽度超过 1920 时缩小，高度按比例取偶数。不要写死 <code>1920:1080</code>，那会拉伸非 16:9 的画面。</li>
<li><code>-movflags +faststart</code> 把索引移到文件头，网页播放不用先下完整个文件。</li>
<li><code>-map_metadata 0</code> 保留拍摄时间、GPS 等元数据；如果不想要，改成 <code>-map_metadata -1</code>。</li>
<li><code>-map 0:a:0?</code> 的可选标记 <code>?</code> 表示没有音频也不报错，适合批量处理。</li>
</ul>
<p>如果源文件是 HDR（HLG / PQ），直接转 SDR 会灰蒙蒙。需要先做色调映射，例如 <code>zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p</code>，再接 <code>scale</code>。这条链路计算量大，建议单独处理，不要混在普通批量脚本里。</p>
<h2 id="_3">批量脚本：跳过已完成、失败不中断<a class="h-anchor" href="#_3" aria-label="锚点">#</a></h2>
<p>把下面保存为 <code>compress.sh</code>，放在素材目录的上一级：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="ch">#!/usr/bin/env bash</span>
<span class="nb">set</span><span class="w"> </span>-uo<span class="w"> </span>pipefail

<span class="nv">src</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">1</span><span class="p">:?usage: compress.sh &lt;source-dir&gt; [output-dir]</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nv">src</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">src</span><span class="p">%/</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nv">out</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">2</span><span class="k">:-</span><span class="si">${</span><span class="nv">src</span><span class="si">}</span><span class="p">/compressed</span><span class="si">}</span><span class="s2">&quot;</span>
mkdir<span class="w"> </span>-p<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$out</span><span class="s2">&quot;</span>

find<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$src</span><span class="s2">&quot;</span><span class="w"> </span>-type<span class="w"> </span>f<span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="se">\(</span><span class="w"> </span>-iname<span class="w"> </span><span class="s1">&#39;*.mp4&#39;</span><span class="w"> </span>-o<span class="w"> </span>-iname<span class="w"> </span><span class="s1">&#39;*.mov&#39;</span><span class="w"> </span>-o<span class="w"> </span>-iname<span class="w"> </span><span class="s1">&#39;*.mkv&#39;</span><span class="w"> </span>-o<span class="w"> </span>-iname<span class="w"> </span><span class="s1">&#39;*.avi&#39;</span><span class="w"> </span><span class="se">\)</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-print0<span class="w"> </span><span class="p">|</span>
<span class="k">while</span><span class="w"> </span><span class="nv">IFS</span><span class="o">=</span><span class="w"> </span><span class="nb">read</span><span class="w"> </span>-r<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;&#39;</span><span class="w"> </span>file<span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">  </span><span class="nv">rel</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">file</span><span class="p">#</span><span class="s2">&quot;</span><span class="nv">$src</span><span class="s2">&quot;</span><span class="p">/</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="w">  </span><span class="nv">target</span><span class="o">=</span><span class="s2">&quot;</span><span class="nv">$out</span><span class="s2">/</span><span class="si">${</span><span class="nv">rel</span><span class="p">%.*</span><span class="si">}</span><span class="s2">.mp4&quot;</span>
<span class="w">  </span>mkdir<span class="w"> </span>-p<span class="w"> </span><span class="s2">&quot;</span><span class="k">$(</span>dirname<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$target</span><span class="s2">&quot;</span><span class="k">)</span><span class="s2">&quot;</span>

<span class="w">  </span><span class="k">if</span><span class="w"> </span><span class="o">[</span><span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$target</span><span class="s2">&quot;</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">    </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;skip  </span><span class="nv">$rel</span><span class="s2">&quot;</span>
<span class="w">    </span><span class="k">continue</span>
<span class="w">  </span><span class="k">fi</span>

<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;==&gt;   </span><span class="nv">$rel</span><span class="s2">&quot;</span>
<span class="w">  </span><span class="nv">tmp</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">target</span><span class="p">%.mp4</span><span class="si">}</span><span class="s2">.part.mp4&quot;</span>
<span class="w">  </span><span class="k">if</span><span class="w"> </span>ffmpeg<span class="w"> </span>-hide_banner<span class="w"> </span>-loglevel<span class="w"> </span>error<span class="w"> </span>-nostdin<span class="w"> </span>-y<span class="w"> </span>-i<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$file</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">      </span>-map<span class="w"> </span><span class="m">0</span>:v:0<span class="w"> </span>-map<span class="w"> </span><span class="m">0</span>:a:0?<span class="w"> </span><span class="se">\</span>
<span class="w">      </span>-c:v<span class="w"> </span>libx264<span class="w"> </span>-crf<span class="w"> </span><span class="m">23</span><span class="w"> </span>-preset<span class="w"> </span>medium<span class="w"> </span><span class="se">\</span>
<span class="w">      </span>-vf<span class="w"> </span><span class="s2">&quot;scale=&#39;min(1920,iw)&#39;:-2&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">      </span>-c:a<span class="w"> </span>aac<span class="w"> </span>-b:a<span class="w"> </span>128k<span class="w"> </span>-ac<span class="w"> </span><span class="m">2</span><span class="w"> </span><span class="se">\</span>
<span class="w">      </span>-movflags<span class="w"> </span>+faststart<span class="w"> </span>-map_metadata<span class="w"> </span><span class="m">0</span><span class="w"> </span><span class="se">\</span>
<span class="w">      </span><span class="s2">&quot;</span><span class="nv">$tmp</span><span class="s2">&quot;</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">    </span>mv<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$tmp</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$target</span><span class="s2">&quot;</span>
<span class="w">    </span><span class="nb">printf</span><span class="w"> </span><span class="s1">&#39;done  %s  %s -&gt; %s\n&#39;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$rel</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">      </span><span class="s2">&quot;</span><span class="k">$(</span>du<span class="w"> </span>-h<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$file</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>cut<span class="w"> </span>-f1<span class="k">)</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="k">$(</span>du<span class="w"> </span>-h<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$target</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>cut<span class="w"> </span>-f1<span class="k">)</span><span class="s2">&quot;</span>
<span class="w">  </span><span class="k">else</span>
<span class="w">    </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;FAIL  </span><span class="nv">$rel</span><span class="s2">&quot;</span>
<span class="w">    </span>rm<span class="w"> </span>-f<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$tmp</span><span class="s2">&quot;</span>
<span class="w">  </span><span class="k">fi</span>
<span class="k">done</span>
</code></pre></div></div>

<p>几个工程细节：</p>
<ul>
<li><code>set -uo pipefail</code> 不启用 <code>-e</code>，单个文件失败不会中断整批；失败时删除半成品，下次还能重跑。</li>
<li>先写 <code>.part.mp4</code> 再 <code>mv</code>，避免中断后留下一个看似完成、实际无法播放的文件。</li>
<li><code>-nostdin</code> 很重要。ffmpeg 默认会抢终端输入，放进 <code>while read</code> 循环后可能吞掉后续文件名。</li>
<li>输出目录保持相对路径，嵌套目录不会冲突。</li>
<li>已有非空目标就跳过，天然支持断点续跑。</li>
</ul>
<p>要并行，可以把 <code>find</code> 的输出交给 <code>xargs -0 -P 2 -I{}</code>，但机械硬盘或网络盘上并行往往更慢，先测再开。</p>
<h2 id="_4">想精确控制体积：两遍编码<a class="h-anchor" href="#_4" aria-label="锚点">#</a></h2>
<p>CRF 控制质量，不控制体积。如果目标是「每个文件不超过 200 MB」，先算目标码率：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="nv">dur</span><span class="o">=</span><span class="k">$(</span>ffprobe<span class="w"> </span>-v<span class="w"> </span>error<span class="w"> </span>-show_entries<span class="w"> </span><span class="nv">format</span><span class="o">=</span>duration<span class="w"> </span>-of<span class="w"> </span><span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$file</span><span class="s2">&quot;</span><span class="k">)</span>
<span class="c1"># 目标 200MB，音频 128kbps；输出 kbps</span>
<span class="nv">kbps</span><span class="o">=</span><span class="k">$(</span>awk<span class="w"> </span>-v<span class="w"> </span><span class="nv">mb</span><span class="o">=</span><span class="m">200</span><span class="w"> </span>-v<span class="w"> </span><span class="nv">d</span><span class="o">=</span><span class="s2">&quot;</span><span class="nv">$dur</span><span class="s2">&quot;</span><span class="w"> </span><span class="s1">&#39;BEGIN { printf &quot;%d&quot;, mb*8192/d - 128 }&#39;</span><span class="k">)</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;duration=</span><span class="nv">$dur</span><span class="s2"> target_video=</span><span class="si">${</span><span class="nv">kbps</span><span class="si">}</span><span class="s2">k&quot;</span>
</code></pre></div></div>

<p>然后两遍编码：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>ffmpeg<span class="w"> </span>-y<span class="w"> </span>-i<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$file</span><span class="s2">&quot;</span><span class="w"> </span>-c:v<span class="w"> </span>libx264<span class="w"> </span>-b:v<span class="w"> </span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">kbps</span><span class="si">}</span><span class="s2">k&quot;</span><span class="w"> </span>-preset<span class="w"> </span>medium<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-pass<span class="w"> </span><span class="m">1</span><span class="w"> </span>-passlogfile<span class="w"> </span>/tmp/ffpass<span class="w"> </span>-an<span class="w"> </span>-f<span class="w"> </span>null<span class="w"> </span>/dev/null
ffmpeg<span class="w"> </span>-y<span class="w"> </span>-i<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$file</span><span class="s2">&quot;</span><span class="w"> </span>-c:v<span class="w"> </span>libx264<span class="w"> </span>-b:v<span class="w"> </span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">kbps</span><span class="si">}</span><span class="s2">k&quot;</span><span class="w"> </span>-preset<span class="w"> </span>medium<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-pass<span class="w"> </span><span class="m">2</span><span class="w"> </span>-passlogfile<span class="w"> </span>/tmp/ffpass<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-c:a<span class="w"> </span>aac<span class="w"> </span>-b:a<span class="w"> </span>128k<span class="w"> </span>-movflags<span class="w"> </span>+faststart<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$target</span><span class="s2">&quot;</span>
rm<span class="w"> </span>-f<span class="w"> </span>/tmp/ffpass*
</code></pre></div></div>

<p>两遍编码耗时接近翻倍，但体积可控。码率给得太低时，两遍也救不回画质；200 MB 的十分钟 1080p 大约对应 2.5 Mbps，文字录屏会明显吃力，这种素材应该优先保分辨率、降帧率，而不是硬压码率。</p>
<h2 id="_5">硬件编码与更省的编码器<a class="h-anchor" href="#_5" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># NVIDIA GPU（先确认 ffmpeg -encoders | grep nvenc）</span>
ffmpeg<span class="w"> </span>-i<span class="w"> </span>input.mp4<span class="w"> </span>-c:v<span class="w"> </span>h264_nvenc<span class="w"> </span>-preset<span class="w"> </span>p5<span class="w"> </span>-cq<span class="w"> </span><span class="m">23</span><span class="w"> </span>-b:v<span class="w"> </span><span class="m">0</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-c:a<span class="w"> </span>copy<span class="w"> </span>output.mp4

<span class="c1"># Apple Silicon / macOS</span>
ffmpeg<span class="w"> </span>-i<span class="w"> </span>input.mp4<span class="w"> </span>-c:v<span class="w"> </span>hevc_videotoolbox<span class="w"> </span>-q:v<span class="w"> </span><span class="m">65</span><span class="w"> </span>-tag:v<span class="w"> </span>hvc1<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-c:a<span class="w"> </span>copy<span class="w"> </span>output.mp4
</code></pre></div></div>

<p>硬件编码快，但同码率下画质通常不如 <code>libx264</code>，而且不同驱动的 <code>-cq</code> 语义不完全一致。归档用途优先软件编码；批量转码、时间敏感的场景再用 GPU。</p>
<p>H.265（<code>libx265</code>）能比 H.264 再省 20%–40%，但兼容性和解码开销更差。给 Apple 设备播放要加 <code>-tag:v hvc1</code>；网页端不要默认用 H.265。AV1（<code>libsvtav1</code>）压缩率更好，但编码慢，老设备不支持。</p>
<h2 id="_6">压完必须验证<a class="h-anchor" href="#_6" aria-label="锚点">#</a></h2>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code><span class="c1"># 容器、时长、码率</span>
ffprobe<span class="w"> </span>-v<span class="w"> </span>error<span class="w"> </span>-show_entries<span class="w"> </span><span class="nv">format</span><span class="o">=</span>duration,size,bit_rate<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-of<span class="w"> </span><span class="nv">default</span><span class="o">=</span><span class="nv">nw</span><span class="o">=</span><span class="m">1</span><span class="w"> </span>output.mp4

<span class="c1"># 抽三帧看画面（0%、50%、90% 处）</span>
<span class="k">for</span><span class="w"> </span>p<span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="m">0</span>.1<span class="w"> </span><span class="m">0</span>.5<span class="w"> </span><span class="m">0</span>.9<span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">  </span>ffmpeg<span class="w"> </span>-hide_banner<span class="w"> </span>-loglevel<span class="w"> </span>error<span class="w"> </span>-ss<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$p</span><span class="s2">&quot;</span><span class="w"> </span>-i<span class="w"> </span>output.mp4<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>-frames:v<span class="w"> </span><span class="m">1</span><span class="w"> </span>-y<span class="w"> </span><span class="s2">&quot;/tmp/check-</span><span class="si">${</span><span class="nv">p</span><span class="si">}</span><span class="s2">.jpg&quot;</span>
<span class="k">done</span>
</code></pre></div></div>

<p>如果是同一分辨率的对比，可以用 SSIM 做客观参考：</p>
<div class="code-card" data-lang="bash"><div class="code-head"><span class="code-lang">bash</span><button class="code-copy" type="button" data-copy>复制</button></div><div class="highlight"><pre><span></span><code>ffmpeg<span class="w"> </span>-i<span class="w"> </span>original.mp4<span class="w"> </span>-i<span class="w"> </span>output.mp4<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>-lavfi<span class="w"> </span><span class="s2">&quot;[0:v][1:v]ssim&quot;</span><span class="w"> </span>-f<span class="w"> </span>null<span class="w"> </span>-<span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>tail<span class="w"> </span>-3
</code></pre></div></div>

<p>注意：缩放过分辨率就不能直接比 SSIM，需要先把输出缩放回原尺寸，或者改用 VMAF 并接受它的局限。指标只是参考，文字、皮肤、暗部这些关键画面还是要人眼看。</p>
<h2 id="_7">结论<a class="h-anchor" href="#_7" aria-label="锚点">#</a></h2>
<ul>
<li>先用 <code>ffprobe</code> 看清源文件，再决定分辨率、CRF 和是否值得压；已压过的文件二次压缩收益很小。</li>
<li>普通归档用 <code>libx264 -crf 23 -preset medium</code>，文字录屏把 CRF 降到 20–22，缩放用 <code>min(1920,iw):-2</code> 避免拉伸。</li>
<li>批量脚本要跳过已完成、失败删半成品、用 <code>-nostdin</code>，并且先写临时文件再原子重命名。</li>
<li>需要固定体积时用目标码率 + 两遍编码；硬件编码适合赶时间，不适合追求同码率画质。</li>
<li>压完至少抽帧检查，保留原文件直到确认无误。</li>
</ul>]]></content:encoded>
<category>代码实战</category>
</item>
</channel>
</rss>