<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Henson&apos;s Blog</title><description>无名小卒的博客记录</description><link>https://zh19990906.github.io/</link><language>zh_CN</language><item><title>PCA9685 与双轴舵机渐进验证：从 0x40 到 CH0/CH1 小范围动作</title><link>https://zh19990906.github.io/fuwari/posts/pca9685-servo-bringup/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/pca9685-servo-bringup/</guid><description>用分阶段方法验证 I²C、项目后端、独立舵机电源和单舵机窄范围运动，降低错接与撞限位风险。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文给出从“Linux 能看到 PCA9685”到“单个舵机做小范围运动”的渐进验证流程。每一阶段只增加一个新变量：先总线，再项目驱动，再外部电源，最后才是舵机。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;不要从完整双机跟踪服务开始测试。错误的通道、方向、中心或安全范围可能让舵机直接撞向机械限位。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;阶段 0：机械和电气准备&lt;/h2&gt;
&lt;p&gt;开始前：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NanoPi K2、PCA9685 和外部舵机电源全部断电；&lt;/li&gt;
&lt;li&gt;舵机插头暂时拔下；&lt;/li&gt;
&lt;li&gt;绿色端子 V+ 暂时不接电源；&lt;/li&gt;
&lt;li&gt;PCA9685 只连接 VCC、GND、SDA、SCL；&lt;/li&gt;
&lt;li&gt;K2 Pin 1/3/5/6 分别连接 VCC/SDA/SCL/GND；&lt;/li&gt;
&lt;li&gt;控制排针 V+ 和 OE 留空；&lt;/li&gt;
&lt;li&gt;准备随时切断外部舵机电源的开关或插头；&lt;/li&gt;
&lt;li&gt;云台附近没有障碍物、线缆和手指夹点。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;建议准备万用表，至少能测量 3.3V、外部 5–6V 和极性。&lt;/p&gt;
&lt;h2&gt;阶段 1：识别正确的 I²C 总线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要从设备名称猜编号。例如 &lt;code&gt;i2c_gpio.32&lt;/code&gt; 中的 &lt;code&gt;32&lt;/code&gt; 不是 &lt;code&gt;/dev/i2c-32&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;本次 NanoPi K2 修复后出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2c-0：HDMI DDC
i2c-1：40Pin 的 i2c-A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其他系统编号可能不同。本文后续用 &lt;code&gt;1&lt;/code&gt; 表示本次实机总线，执行时必须替换为当前机器的实际编号。&lt;/p&gt;
&lt;h2&gt;阶段 2：受限地址扫描&lt;/h2&gt;
&lt;p&gt;只扫描 PCA9685 默认地址：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y 1 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成功：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;40: 40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解释：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;40&lt;/code&gt;：设备应答；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--&lt;/code&gt;：当前适配器的该地址没有应答；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UU&lt;/code&gt;：地址已被内核驱动占用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;看到 &lt;code&gt;40&lt;/code&gt; 证明 I²C 逻辑链路基本可用，但不能证明舵机电源、通道方向、机械限位或项目控制参数安全。&lt;/p&gt;
&lt;p&gt;若为 &lt;code&gt;--&lt;/code&gt;，先检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PCA9685 VCC 对 GND：约 3.3V
SDA 空闲对 GND：通常约 3.3V
SCL 空闲对 GND：通常约 3.3V
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时确认没有误扫 HDMI DDC 总线。&lt;/p&gt;
&lt;h2&gt;阶段 3：安装 K2 Python 依赖&lt;/h2&gt;
&lt;p&gt;在项目虚拟环境中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /code/yolo-gimbal-tracker
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install -e &apos;.[k2]&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;项目正式要求 Python 3.11+。本次旧系统原生环境较老时，可以使用已准备好的 Python 3.11 虚拟环境，但需要确保 &lt;code&gt;smbus2&lt;/code&gt; 能访问宿主机 &lt;code&gt;/dev/i2c-*&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python - &amp;lt;&amp;lt;&apos;PY&apos;
import platform
import smbus2

print(&quot;python:&quot;, platform.python_version())
print(&quot;smbus2:&quot;, smbus2.__version__)
PY
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;阶段 4：不接舵机的项目后端初始化&lt;/h2&gt;
&lt;p&gt;保持外部 V+ 和所有舵机断开，运行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /code/yolo-gimbal-tracker
source .venv/bin/activate

python - &amp;lt;&amp;lt;&apos;PY&apos;
from apps.k2_gimbal.servo.pca9685 import Pca9685ServoBackend

backend = Pca9685ServoBackend(
    i2c_bus=1,
    address=0x40,
    frequency_hz=50,
)

print(&quot;PCA9685 初始化及寄存器读写成功&quot;)
backend.close()
PY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构造函数会：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;打开 &lt;code&gt;smbus2.SMBus(1)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;读取 MODE1；&lt;/li&gt;
&lt;li&gt;写入 50 Hz 所需的预分频；&lt;/li&gt;
&lt;li&gt;恢复运行模式；&lt;/li&gt;
&lt;li&gt;配置 MODE2。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果初始化中途失败，类会关闭自己打开的总线并重新抛出异常。&lt;/p&gt;
&lt;p&gt;成功判据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PCA9685 初始化及寄存器读写成功
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;失败时不要急着接舵机。保留完整异常堆栈，检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;i2c_bus&lt;/code&gt; 是否是实际总线；&lt;/li&gt;
&lt;li&gt;地址是否为 &lt;code&gt;0x40&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;当前用户是否有权限访问 &lt;code&gt;/dev/i2c-1&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;smbus2&lt;/code&gt; 是否安装在当前虚拟环境；&lt;/li&gt;
&lt;li&gt;设备是否在运行中掉线。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;阶段 5：配置权限&lt;/h2&gt;
&lt;p&gt;临时用 root 运行可以排除权限问题，但长期服务应使用专用用户并加入 &lt;code&gt;i2c&lt;/code&gt; 组：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;getent group i2c
usermod -aG i2c SERVICE_USER
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重新登录或重启服务后检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;id SERVICE_USER
ls -l /dev/i2c-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要为了省事把 &lt;code&gt;/dev/i2c-*&lt;/code&gt; 永久改为所有用户可写。&lt;/p&gt;
&lt;h2&gt;阶段 6：断电接入独立舵机电源&lt;/h2&gt;
&lt;p&gt;先关闭 K2：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;poweroff
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认系统完全停止并拔掉 K2 电源，然后连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;外部 5–6V 正极 -&amp;gt; PCA9685 绿色端子 V+
外部电源负极   -&amp;gt; PCA9685 绿色端子 GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重新核对：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;V+ 与 GND 没有反接；&lt;/li&gt;
&lt;li&gt;外部电源没有接到 VCC；&lt;/li&gt;
&lt;li&gt;K2 GND、PCA9685 GND 和外部电源负极共地；&lt;/li&gt;
&lt;li&gt;外部电源电流能力足够；&lt;/li&gt;
&lt;li&gt;此时仍不接舵机。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;开启外部电源后，用万用表测绿色端子 V+ 对 GND。电压应符合舵机规格且极性正确。&lt;/p&gt;
&lt;p&gt;再次关闭外部电源和 K2，准备接单个舵机。&lt;/p&gt;
&lt;h2&gt;阶段 7：只接 CH0&lt;/h2&gt;
&lt;p&gt;断电后把底座水平轴舵机接入 CH0：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;舵机信号线 -&amp;gt; S / PWM
舵机红线   -&amp;gt; V+
舵机黑/棕线 -&amp;gt; GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以板上通道排针丝印为准。常见线色不能替代核对。&lt;/p&gt;
&lt;p&gt;不要接 CH1。一次只验证一个舵机，可以快速判断故障来自哪个通道、插头或机械轴。&lt;/p&gt;
&lt;h2&gt;阶段 8：CH0 小范围脉宽测试&lt;/h2&gt;
&lt;p&gt;通电顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;启动 K2；&lt;/li&gt;
&lt;li&gt;确认系统和 I²C 正常；&lt;/li&gt;
&lt;li&gt;开启外部舵机电源；&lt;/li&gt;
&lt;li&gt;运行小范围脚本。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;cd /code/yolo-gimbal-tracker
source .venv/bin/activate

python - &amp;lt;&amp;lt;&apos;PY&apos;
import time

from apps.k2_gimbal.servo.pca9685 import Pca9685ServoBackend

backend = Pca9685ServoBackend(
    i2c_bus=1,
    address=0x40,
    frequency_hz=50,
)

try:
    for pulse_us in (1500, 1475, 1500, 1525, 1500):
        print(f&quot;CH0 -&amp;gt; {pulse_us} us&quot;)
        backend.set_pulse_us(0, pulse_us)
        time.sleep(2)
finally:
    backend.set_pulse_us(0, 1500)
    backend.close()
PY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;脚本只在中心附近变化 25 微秒。动作可能很小，目的是确认：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CH0 确实控制 Pan；&lt;/li&gt;
&lt;li&gt;舵机能接收 PWM；&lt;/li&gt;
&lt;li&gt;方向和机械结构没有明显危险；&lt;/li&gt;
&lt;li&gt;K2 与电源不会因一个舵机动作而重启。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;finally&lt;/code&gt; 会尝试写回 1500 微秒，但不能保证在断电、I²C 故障、进程强杀或舵机卡死时成功。外部电源开关仍是最终安全措施。&lt;/p&gt;
&lt;h2&gt;动作太小时如何扩大范围&lt;/h2&gt;
&lt;p&gt;先确认 1475/1525 不会卡住、发热或撞限位，再改为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1450 -&amp;gt; 1500 -&amp;gt; 1550 -&amp;gt; 1500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每次只增加小步长。不要一开始使用 1000/2000 微秒等通用大范围，因为实际云台连杆和舵机安装角度可能远小于常见行程。&lt;/p&gt;
&lt;h2&gt;阶段 9：只接 CH1&lt;/h2&gt;
&lt;p&gt;CH0 测试完成后：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;停止脚本；&lt;/li&gt;
&lt;li&gt;关闭外部舵机电源；&lt;/li&gt;
&lt;li&gt;关闭 K2 并拔电；&lt;/li&gt;
&lt;li&gt;拔下 CH0；&lt;/li&gt;
&lt;li&gt;把俯仰舵机接到 CH1；&lt;/li&gt;
&lt;li&gt;把测试脚本中的通道 &lt;code&gt;0&lt;/code&gt; 改为 &lt;code&gt;1&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;重复 1500/1475/1525 测试。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;单独测试 CH1 可以避免两个舵机同时启动造成瞬时电流过大，也便于观察 Tilt 的机械限位。&lt;/p&gt;
&lt;h2&gt;阶段 10：两个舵机同时连接&lt;/h2&gt;
&lt;p&gt;两个轴分别通过后，断电连接 CH0 和 CH1。此时仍不应直接全范围扫动，而是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先写两个中心值；&lt;/li&gt;
&lt;li&gt;分别对 Pan、Tilt 做很小偏移；&lt;/li&gt;
&lt;li&gt;检查一个轴动作是否拉扯另一轴线缆；&lt;/li&gt;
&lt;li&gt;观察外部电源电压和 K2 稳定性；&lt;/li&gt;
&lt;li&gt;逐轴标定最小、中心和最大安全脉宽。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;记录示例：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;轴&lt;/th&gt;
&lt;th&gt;通道&lt;/th&gt;
&lt;th&gt;中心&lt;/th&gt;
&lt;th&gt;已验证最小&lt;/th&gt;
&lt;th&gt;已验证最大&lt;/th&gt;
&lt;th&gt;invert&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pan&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;待验证&lt;/td&gt;
&lt;td&gt;待验证&lt;/td&gt;
&lt;td&gt;待验证&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tilt&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;待验证&lt;/td&gt;
&lt;td&gt;待验证&lt;/td&gt;
&lt;td&gt;待验证&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;没有实测之前，不要把示例配置的 1000～2000 微秒当作安全范围。&lt;/p&gt;
&lt;h2&gt;阶段 11：切换完整 K2 服务&lt;/h2&gt;
&lt;p&gt;先用模拟后端确认网络和状态机：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: simulated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后把实际总线、地址和已标定脉宽写入本地配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: pca9685
  frequency_hz: 50
  i2c_bus: 1
  i2c_address: 0x40
  pan:
    channel: 0
    center_us: 1500
    min_safe_us: PAN_MIN_VERIFIED
    max_safe_us: PAN_MAX_VERIFIED
    invert: false
  tilt:
    channel: 1
    center_us: 1500
    min_safe_us: TILT_MIN_VERIFIED
    max_safe_us: TILT_MAX_VERIFIED
    invert: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;占位值必须换成实测整数后再启动。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;k2-gimbal --config configs/k2.local.yaml --check-config
k2-gimbal --config configs/k2.local.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务启动时会写中心脉宽，所以启动前机械结构必须允许该中心位置。&lt;/p&gt;
&lt;h2&gt;立即断电条件&lt;/h2&gt;
&lt;p&gt;出现以下任何情况，立即关闭外部舵机电源：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;舵机猛烈撞击限位；&lt;/li&gt;
&lt;li&gt;持续嗡鸣但轴不动；&lt;/li&gt;
&lt;li&gt;舵机、导线、PCA9685 或端子明显发热；&lt;/li&gt;
&lt;li&gt;有焦味、烟雾、火花；&lt;/li&gt;
&lt;li&gt;K2 重启、掉线或系统日志出现欠压相关异常；&lt;/li&gt;
&lt;li&gt;外部电源进入保护；&lt;/li&gt;
&lt;li&gt;运动方向明显会继续拉断线缆；&lt;/li&gt;
&lt;li&gt;I²C 在动作时持续报错。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;切断舵机电源后，再停止 Python 程序和 K2。不要在舵机持续堵转时花时间查看日志。&lt;/p&gt;
&lt;h2&gt;常见失败&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;Permission denied: /dev/i2c-1&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;当前用户没有访问权限。检查 &lt;code&gt;i2c&lt;/code&gt; 组和服务用户，不要修改为全局可写。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;OSError: Remote I/O error&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;常见原因包括错误总线、掉线、VCC/GND 接触不良、SDA/SCL 受干扰或 PCA9685 地址不符。先回到 &lt;code&gt;i2cdetect -y BUS 0x40 0x40&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;PCA9685 初始化成功但舵机不动&lt;/h3&gt;
&lt;p&gt;检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;绿色端子 V+ 是否有正确外部电压；&lt;/li&gt;
&lt;li&gt;舵机插头方向；&lt;/li&gt;
&lt;li&gt;是否插在脚本使用的 CH0/CH1；&lt;/li&gt;
&lt;li&gt;OE 是否被拉高；&lt;/li&gt;
&lt;li&gt;舵机是否损坏；&lt;/li&gt;
&lt;li&gt;小范围变化是否小到肉眼不明显。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;接舵机后 K2 重启&lt;/h3&gt;
&lt;p&gt;优先怀疑外部电源能力不足、错误从 K2 取舵机电源、共地或短路。不要通过提高软件重试次数解决供电问题。&lt;/p&gt;
&lt;h3&gt;动作方向相反&lt;/h3&gt;
&lt;p&gt;电气连接正确时，修改轴配置的 &lt;code&gt;invert&lt;/code&gt;，或重新定义 Pan/Tilt 正方向。不要反接舵机电源线。&lt;/p&gt;
&lt;h2&gt;本次验证状态&lt;/h2&gt;
&lt;p&gt;已完成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;i2cdetect -l&lt;/code&gt; 识别排针总线；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;i2cdetect -y 1 0x40 0x40&lt;/code&gt; 返回 &lt;code&gt;40&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在写本文时仍应视为待验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;项目 &lt;code&gt;Pca9685ServoBackend&lt;/code&gt; 的实机初始化输出；&lt;/li&gt;
&lt;li&gt;CH0 的 1500/1475/1525 动作；&lt;/li&gt;
&lt;li&gt;CH1 的同类动作；&lt;/li&gt;
&lt;li&gt;两轴安全范围标定；&lt;/li&gt;
&lt;li&gt;完整跟踪和断网回中。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续完成每一步后，应更新本文的验证日期、硬件型号和结果，而不是只在聊天记录中保留。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目 PCA9685 后端：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/apps/k2_gimbal/servo/pca9685.py&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/apps/k2_gimbal/servo/pca9685.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;项目 K2 配置：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NXP PCA9685 数据手册：&lt;a href=&quot;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&quot;&gt;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Linux I²C 用户空间接口：&lt;a href=&quot;https://docs.kernel.org/i2c/dev-interface.html&quot;&gt;https://docs.kernel.org/i2c/dev-interface.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;smbus2&lt;/code&gt; 项目：&lt;a href=&quot;https://github.com/kplindegaard/smbus2&quot;&gt;https://github.com/kplindegaard/smbus2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Ubuntu 16.04 / Linux 3.14 的 NanoPi K2：启用 40Pin 排针 I²C</title><link>https://zh19990906.github.io/fuwari/posts/nanopi-k2-enable-header-i2c/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/nanopi-k2-enable-header-i2c/</guid><description>记录从误扫 HDMI DDC、识别 disabled 设备树节点，到启用 i2c-A 并检测 PCA9685 0x40 的实机过程。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文记录一台 NanoPi K2 上真实完成的修复过程。目标是在 40Pin 物理 Pin 3/Pin 5 上启用 I²C，并让 PCA9685 在地址 &lt;code&gt;0x40&lt;/code&gt; 被 Linux 检测到。&lt;/p&gt;
&lt;p&gt;本次环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Ubuntu 16.04.7 LTS (Xenial Xerus)
Linux 3.14.29 厂商内核
固定启动文件 /boot/nanopi-k2.dtb
无 Armbian overlay、extlinux.conf 或 *Env.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;设备树节点地址、pinctrl、启动文件和最终总线编号都与镜像和内核有关。本文不能直接套用到其他 NanoPi K2 镜像、其他 Amlogic 板卡或主线内核。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;最初现象&lt;/h2&gt;
&lt;p&gt;逻辑接线为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;K2 Pin 1 -&amp;gt; PCA9685 VCC
K2 Pin 3 -&amp;gt; PCA9685 SDA
K2 Pin 5 -&amp;gt; PCA9685 SCL
K2 Pin 6 -&amp;gt; PCA9685 GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;舵机和外部 V+ 电源均未连接。&lt;/p&gt;
&lt;p&gt;系统只显示一个适配器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;i2c-0   i2c   i2c_gpio.32   I2C adapter
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设备节点也只有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/dev/i2c-0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;扫描 &lt;code&gt;0x40&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y 0 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;40: --
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--&lt;/code&gt; 只表示当前被扫描的适配器上没有设备应答，不能直接推断 PCA9685 损坏或 SDA/SCL 接反。&lt;/p&gt;
&lt;h2&gt;容易犯的错误：把 i2c_gpio.32 当成总线 32&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;i2c_gpio.32&lt;/code&gt; 是 Linux 平台设备名称，不是用户空间总线编号。下面命令是错误的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y 32 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为系统不存在 &lt;code&gt;/dev/i2c-32&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;可访问的编号始终以以下输出为准：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当时唯一可访问的是 &lt;code&gt;i2c-0&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;检查 i2c-0 使用的 GPIO&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cat /sys/class/i2c-adapter/i2c-0/name
readlink -f /sys/class/i2c-adapter/i2c-0/device

mount -t debugfs debugfs /sys/kernel/debug 2&amp;gt;/dev/null || true
cat /sys/kernel/debug/gpio | grep -i -B3 -A3 -E &apos;i2c|sda|scl&apos;
dmesg | grep -iE &apos;i2c|gpio&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2c_gpio.32
gpio-153 (sda) in hi
gpio-154 (scl) in hi
i2c-gpio i2c_gpio.32: using pins 153 (SDA) and 154 (SCL)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到 SDA/SCL 为 &lt;code&gt;hi&lt;/code&gt; 说明总线没有明显被某个设备持续拉低，但仍不能证明该适配器连接 40Pin。&lt;/p&gt;
&lt;h2&gt;完整扫描暴露了错误总线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;50: UU -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;UU&lt;/code&gt; 表示该地址已被内核驱动占用。结合内核日志：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hdmitx: system: unmux DDC for gpio read edid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以判断 &lt;code&gt;i2c-0&lt;/code&gt; 实际服务于 HDMI DDC/EDID，而不是 40Pin 排针。显示设备的 EDID 常见于 I²C 地址 &lt;code&gt;0x50&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这一步纠正了两个错误认识：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;看到 &lt;code&gt;i2c-0&lt;/code&gt; 不代表它就是排针 I²C；&lt;/li&gt;
&lt;li&gt;PCA9685 电源灯亮，不代表当前扫描的是它所在的总线。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要在 HDMI DDC 总线上反复运行全地址扫描。&lt;code&gt;i2cdetect&lt;/code&gt; 会向地址发送探测事务，应该只在明确的适配器和必要地址范围内使用。&lt;/p&gt;
&lt;h2&gt;确认系统版本和启动方式&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cat /etc/os-release
uname -a
cat /proc/cmdline
ls -la /boot
find /boot -maxdepth 3 -type f \( \
  -name &apos;*.dtb&apos; -o \
  -name &apos;*.dtbo&apos; -o \
  -name &apos;*Env.txt&apos; -o \
  -name &apos;extlinux.conf&apos; \
\) -print
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机只发现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/boot/Image
/boot/nanopi-k2.dtb
/boot/ramdisk.img
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有 overlay 配置入口，说明需要检查固定 DTB，而不是照抄 Armbian 的 overlay 指令。&lt;/p&gt;
&lt;h2&gt;检查正在运行的设备树&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;echo &apos;===== DT I2C nodes =====&apos;
find /proc/device-tree -type d | grep -Ei &apos;i2c|iic&apos; | while read -r d; do
  echo &quot;[$d]&quot;
  if [ -f &quot;$d/compatible&quot; ]; then
    printf &apos;  compatible: &apos;
    tr &apos;\0&apos; &apos; &apos; &amp;lt; &quot;$d/compatible&quot;
    echo
  fi
  if [ -f &quot;$d/status&quot; ]; then
    printf &apos;  status: &apos;
    tr -d &apos;\0&apos; &amp;lt; &quot;$d/status&quot;
    echo
  else
    echo &apos;  status: &amp;lt;not present&amp;gt;&apos;
  fi
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机发现四个硬件控制器全部禁用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/proc/device-tree/i2c@c1108d20  status: disabled
/proc/device-tree/i2c@c11087e0  status: disabled
/proc/device-tree/i2c@c11087c0  status: disabled
/proc/device-tree/i2c@c1108500  status: disabled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时存在 pinmux 分组：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/proc/device-tree/pinmux/a_i2c
/proc/device-tree/pinmux/b_i2c
/proc/device-tree/pinmux/c_i2c
/proc/device-tree/pinmux/d_i2c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FriendlyELEC 官方 40Pin 定义中，物理 Pin 3/Pin 5 是 I2C_SDA_A / I2C_SCK_A。因此本机目标是 &lt;code&gt;i2c-A&lt;/code&gt;，对应节点 &lt;code&gt;/i2c@c1108500&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;安装设备树工具&lt;/h2&gt;
&lt;p&gt;先检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;command -v fdtget
command -v fdtput
command -v dtc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;缺失时安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt-get update
apt-get install -y device-tree-compiler
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ubuntu 16.04 已停止常规支持，软件源可能失效。若安装失败，应先修复软件源或在另一台兼容 Linux 主机上处理 DTB，不要在工具不完整时直接覆盖启动文件。&lt;/p&gt;
&lt;h2&gt;修改前确认目标节点&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;tr -d &apos;\0&apos; &amp;lt; /proc/device-tree/i2c@c1108500/dev_name
echo
tr -d &apos;\0&apos; &amp;lt; /proc/device-tree/i2c@c1108500/status
echo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机预期：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2c-A
disabled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若 &lt;code&gt;dev_name&lt;/code&gt; 不是 &lt;code&gt;i2c-A&lt;/code&gt;，或节点不存在，应停止并重新分析当前 DTB。&lt;/p&gt;
&lt;h2&gt;备份 DTB&lt;/h2&gt;
&lt;p&gt;必须先确保有恢复手段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能把 SD 卡或存储介质连接到另一台 Linux 主机；&lt;/li&gt;
&lt;li&gt;知道 &lt;code&gt;/boot&lt;/code&gt; 分区位置；&lt;/li&gt;
&lt;li&gt;已保存原始 &lt;code&gt;nanopi-k2.dtb&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;最好使用备用介质测试。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 K2 上执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /boot

BACKUP=&quot;nanopi-k2.dtb.bak-$(date +%Y%m%d-%H%M%S)&quot;
cp -a nanopi-k2.dtb &quot;$BACKUP&quot;
cp -a nanopi-k2.dtb nanopi-k2-i2c.dtb

echo &quot;backup: /boot/$BACKUP&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有修改先作用于副本 &lt;code&gt;nanopi-k2-i2c.dtb&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;使用 fdtput 启用 i2c-A&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;fdtput -t s nanopi-k2-i2c.dtb /i2c@c1108500 status okay
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读取验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fdtget -t s nanopi-k2-i2c.dtb /i2c@c1108500 dev_name
fdtget -t s nanopi-k2-i2c.dtb /i2c@c1108500 status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2c-A
okay
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里仅修改 &lt;code&gt;status&lt;/code&gt;。不要删除或重建 &lt;code&gt;reg&lt;/code&gt;、&lt;code&gt;clocks&lt;/code&gt;、&lt;code&gt;resets&lt;/code&gt;、&lt;code&gt;pinctrl-0&lt;/code&gt; 等原有属性。&lt;/p&gt;
&lt;h2&gt;反编译验证&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;dtc -I dtb -O dts \
  -o /tmp/nanopi-k2-i2c.dts \
  nanopi-k2-i2c.dtb

grep -A15 &apos;i2c@c1108500&apos; /tmp/nanopi-k2-i2c.dts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机节点包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2c@c1108500 {
    compatible = &quot;amlogic, meson-i2c&quot;;
    dev_name = &quot;i2c-A&quot;;
    status = &quot;okay&quot;;
    reg = &amp;lt;0x0 0xc1108500 0x0 0x20&amp;gt;;
    device_id = &amp;lt;0x1&amp;gt;;
    pinctrl-names = &quot;default&quot;;
    pinctrl-0 = &amp;lt;0x11&amp;gt;;
    #address-cells = &amp;lt;0x1&amp;gt;;
    #size-cells = &amp;lt;0x0&amp;gt;;
    use_pio = &amp;lt;0x0&amp;gt;;
    master_i2c_speed = &amp;lt;0x493e0&amp;gt;;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;进一步确认 &lt;code&gt;pinctrl-0&lt;/code&gt; 引用仍对应 &lt;code&gt;a_i2c&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep -n -B15 -A20 &apos;phandle = &amp;lt;0x11&amp;gt;&apos; /tmp/nanopi-k2-i2c.dts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;反编译时 phandle 数值可能随编译工具变化，不能把 &lt;code&gt;0x11&lt;/code&gt; 当作跨系统固定值。关键是目标节点仍引用正确的 A 组 I²C pinctrl。&lt;/p&gt;
&lt;h2&gt;替换启动 DTB&lt;/h2&gt;
&lt;p&gt;只有在确认有离线恢复方式后执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /boot
cp -a nanopi-k2.dtb nanopi-k2.dtb.backup-before-i2c
cp -a nanopi-k2-i2c.dtb nanopi-k2.dtb
sync
reboot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改错误可能导致系统无法启动。远程无人值守设备不应在没有串口、备用介质或现场恢复人员时执行。&lt;/p&gt;
&lt;h2&gt;重启后的结果&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
dmesg | grep -iE &apos;i2c-A|meson-i2c|aml_i2c&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机出现新的 &lt;code&gt;/dev/i2c-1&lt;/code&gt;。原来的 &lt;code&gt;i2c-0&lt;/code&gt; 仍是 HDMI DDC，不应拿来控制 PCA9685。&lt;/p&gt;
&lt;p&gt;受限扫描：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y 1 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成功输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;40: 40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这证明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;排针 I²C 控制器已注册；&lt;/li&gt;
&lt;li&gt;Pin 3/Pin 5 的 SDA/SCL 工作；&lt;/li&gt;
&lt;li&gt;PCA9685 逻辑供电和地址可应答；&lt;/li&gt;
&lt;li&gt;项目配置应使用本机实际总线 &lt;code&gt;i2c_bus: 1&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;i2c-1&lt;/code&gt; 是本次镜像的结果，不是 NanoPi K2 的永久规则。升级内核、修改设备树或增加其他适配器后编号可能变化。&lt;/p&gt;
&lt;h2&gt;项目配置&lt;/h2&gt;
&lt;p&gt;本机当前配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: pca9685
  frequency_hz: 50
  i2c_bus: 1
  i2c_address: 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;仍应先运行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再决定配置中的 &lt;code&gt;i2c_bus&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;启动失败恢复&lt;/h2&gt;
&lt;p&gt;若修改后无法启动：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;彻底断电；&lt;/li&gt;
&lt;li&gt;取下存储介质；&lt;/li&gt;
&lt;li&gt;连接到另一台 Linux 主机；&lt;/li&gt;
&lt;li&gt;挂载其 &lt;code&gt;/boot&lt;/code&gt; 分区；&lt;/li&gt;
&lt;li&gt;删除或改名错误的 &lt;code&gt;nanopi-k2.dtb&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;nanopi-k2.dtb.backup-before-i2c&lt;/code&gt; 或时间戳备份复制回 &lt;code&gt;nanopi-k2.dtb&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;执行 &lt;code&gt;sync&lt;/code&gt; 后安全卸载；&lt;/li&gt;
&lt;li&gt;放回 K2 重试启动。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要删除所有备份。验证新 DTB 稳定启动多次后，仍建议保留一份已知可用版本。&lt;/p&gt;
&lt;h2&gt;排查决策树&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;i2cdetect -l&lt;/code&gt; 只有 i2c_gpio.32&lt;/h3&gt;
&lt;p&gt;先全扫描一次确认是否出现 &lt;code&gt;0x50 UU&lt;/code&gt;，再结合 &lt;code&gt;hdmitx&lt;/code&gt;/DDC 日志判断是否为 HDMI 总线。检查 &lt;code&gt;/proc/device-tree&lt;/code&gt; 中硬件 I²C 节点状态。&lt;/p&gt;
&lt;h3&gt;新总线没有出现&lt;/h3&gt;
&lt;p&gt;检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tr -d &apos;\0&apos; &amp;lt; /proc/device-tree/i2c@c1108500/status
dmesg | grep -iE &apos;i2c|pinctrl|clock|reset&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认启动程序实际加载的是被替换的 &lt;code&gt;/boot/nanopi-k2.dtb&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;新总线存在但 &lt;code&gt;0x40&lt;/code&gt; 为 &lt;code&gt;--&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;回到电气排查：VCC 是否约 3.3V、GND 是否共地、SDA/SCL 是否接对、地址焊桥是否仍为默认 &lt;code&gt;0x40&lt;/code&gt;、杜邦线是否接触可靠。&lt;/p&gt;
&lt;h3&gt;地址显示 &lt;code&gt;UU&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;表示地址被内核驱动占用，不等于故障。先确认该地址属于什么设备和驱动，不要强行使用用户空间程序同时访问。&lt;/p&gt;
&lt;h2&gt;安全清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 明确当前镜像、内核和启动方式；&lt;/li&gt;
&lt;li&gt;[ ] 确认目标是 Pin 3/Pin 5 的 i2c-A；&lt;/li&gt;
&lt;li&gt;[ ] 不把 &lt;code&gt;i2c_gpio.32&lt;/code&gt; 当作 &lt;code&gt;/dev/i2c-32&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;[ ] 不在 HDMI DDC 上反复扫描；&lt;/li&gt;
&lt;li&gt;[ ] 修改前备份原始 DTB；&lt;/li&gt;
&lt;li&gt;[ ] 有离线恢复介质和步骤；&lt;/li&gt;
&lt;li&gt;[ ] 只修改目标节点 &lt;code&gt;status&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;[ ] 用 &lt;code&gt;fdtget&lt;/code&gt; 和 &lt;code&gt;dtc&lt;/code&gt; 验证副本；&lt;/li&gt;
&lt;li&gt;[ ] 重启后以实际 &lt;code&gt;i2cdetect -l&lt;/code&gt; 决定总线编号；&lt;/li&gt;
&lt;li&gt;[ ] PCA9685 先只接逻辑线，不接舵机电源。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;FriendlyELEC NanoPi K2：&lt;a href=&quot;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&quot;&gt;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Linux I²C 用户空间接口：&lt;a href=&quot;https://docs.kernel.org/i2c/dev-interface.html&quot;&gt;https://docs.kernel.org/i2c/dev-interface.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Debian &lt;code&gt;i2cdetect&lt;/code&gt; 手册：&lt;a href=&quot;https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html&quot;&gt;https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Devicetree 规范：&lt;a href=&quot;https://devicetree-specification.readthedocs.io/&quot;&gt;https://devicetree-specification.readthedocs.io/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Device Tree Compiler：&lt;a href=&quot;https://git.kernel.org/pub/scm/utils/dtc/dtc.git/&quot;&gt;https://git.kernel.org/pub/scm/utils/dtc/dtc.git/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>YOLO 云台部署运维：配置、启动顺序、监控、回退与恢复</title><link>https://zh19990906.github.io/fuwari/posts/yolo-gimbal-deployment-operations/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-gimbal-deployment-operations/</guid><description>组织 Windows/N100 与 NanoPi K2 的部署、模拟后端联调、PCA9685 配置、启停顺序、日志和 DTB 恢复。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文把视觉主机、NanoPi K2、PCA9685 和双轴舵机组织为一个可重复部署、可回退、可恢复的系统。上线顺序始终是：先软件与模拟后端，后 I²C 与单舵机，最后完整跟踪。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;本次实机只完成了 NanoPi K2 排针 I²C 启用和 PCA9685 &lt;code&gt;0x40&lt;/code&gt; 探测。双舵机标定、断网回中和长时间联调仍需按本文验收。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;部署边界&lt;/h2&gt;
&lt;p&gt;系统包含两台主机：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;设备&lt;/th&gt;
&lt;th&gt;主要职责&lt;/th&gt;
&lt;th&gt;不负责&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Windows/N100 视觉主机&lt;/td&gt;
&lt;td&gt;OpenCV、YOLO、ByteTrack、目标选择、UDP 发送、Web 监控&lt;/td&gt;
&lt;td&gt;直接产生 PWM、执行失联回中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NanoPi K2&lt;/td&gt;
&lt;td&gt;UDP 校验、安全状态机、PCA9685、状态回传&lt;/td&gt;
&lt;td&gt;摄像头和模型推理&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;网络默认端口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;视觉主机 -&amp;gt; K2：UDP 6000
K2 -&amp;gt; 视觉主机：UDP 6001
浏览器 -&amp;gt; 视觉主机：TCP 8000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Web 页面无登录和 TLS，只应在可信局域网使用，不要映射到公网。&lt;/p&gt;
&lt;h2&gt;版本和环境&lt;/h2&gt;
&lt;p&gt;项目主分支要求 Python 3.11+。&lt;/p&gt;
&lt;p&gt;视觉主机安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install &apos;.[n100]&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Windows PowerShell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install &quot;.[n100]&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;K2 安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install &apos;.[k2]&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本次 K2 系统是 Ubuntu 16.04.7 / Linux 3.14.29。该系统自带 Python 较旧，项目运行依赖单独准备的 Python 3.11 环境。部署前确认：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python --version
python -c &apos;import smbus2; print(smbus2.__version__)&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要因为 WebSocket 对 Python 3.10 的 &lt;code&gt;asyncio.TimeoutError&lt;/code&gt; 兼容修复，就把 3.10 当作正式支持环境。&lt;/p&gt;
&lt;h2&gt;目录与本地配置&lt;/h2&gt;
&lt;p&gt;推荐把仓库部署到固定目录，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/code/yolo-gimbal-tracker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型、真实地址和实机参数放在不提交的本地文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;configs/n100.local.yaml
configs/k2.local.yaml
models/target.pt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;公开示例只使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VISION_HOST_IP
K2_HOST_IP
REPLACE_WITH_CAMERA_ID
PAN_MIN_VERIFIED
PAN_MAX_VERIFIED
TILT_MIN_VERIFIED
TILT_MAX_VERIFIED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要把真实内网地址、凭据、模型授权文件或设备唯一信息提交到博客与项目仓库。&lt;/p&gt;
&lt;h2&gt;视觉端配置&lt;/h2&gt;
&lt;p&gt;Linux/N100：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;camera:
  device: /dev/v4l/by-id/REPLACE_WITH_CAMERA_ID
  width: 1280
  height: 720
  fps: 30
  pixel_format: MJPG
  buffer_size: 1
vision:
  model_path: /code/yolo-gimbal-tracker/models/target.pt
  target_classes: [target_class]
control:
  host: K2_HOST_IP
  port: 6000
  send_rate_hz: 20
k2_status:
  bind_host: 0.0.0.0
  bind_port: 6001
  allowed_k2_ip: K2_HOST_IP
web:
  bind_host: 0.0.0.0
  port: 8000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Windows 相机源：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;camera:
  device: &quot;0&quot;
  width: 640
  height: 480
  fps: 30
  pixel_format: MJPG
  buffer_size: 1
vision:
  model_path: C:/code/yolo-gimbal-tracker/models/target.pt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo-vision --config configs/n100.local.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或 Windows：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo-vision --config configs\n100.windows.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;K2 网络与安全配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;network:
  bind_host: 0.0.0.0
  control_port: 6000
  allowed_n100_ip: VISION_HOST_IP
  n100_status_host: VISION_HOST_IP
  status_port: 6001
  status_rate_hz: 5
safety:
  max_message_age_ms: 300
  hold_timeout_ms: 500
  return_center_timeout_ms: 2000
  future_tolerance_ms: 1000
control:
  loop_rate_hz: 20
  dead_zone: 0.05
  smoothing_alpha: 0.3
  pan_gain_us: 300
  tilt_gain_us: 300
  max_step_us: 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两台机器应有稳定地址，并保持系统时间大致同步。超时调度使用单调时钟，但协议仍会拒绝明显过旧或来自未来的消息。&lt;/p&gt;
&lt;h2&gt;第一阶段：模拟后端联调&lt;/h2&gt;
&lt;p&gt;首次部署必须使用模拟后端：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: simulated
  frequency_hz: 50
  i2c_bus: 1
  i2c_address: 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 I²C 字段不会驱动硬件，但保留正确格式便于后续切换。&lt;/p&gt;
&lt;p&gt;检查 K2：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;k2-gimbal --config configs/k2.local.yaml --check-config
k2-gimbal --config configs/k2.local.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再启动视觉端：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo-vision --config configs/n100.local.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模拟阶段验收：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;K2 以 5 Hz 回传状态；&lt;/li&gt;
&lt;li&gt;视觉端页面显示 K2 在线；&lt;/li&gt;
&lt;li&gt;有目标时进入 tracking；&lt;/li&gt;
&lt;li&gt;无目标时进入 holding；&lt;/li&gt;
&lt;li&gt;停止视觉端后先 holding，再 returning_center/IDLE；&lt;/li&gt;
&lt;li&gt;Pan/Tilt 计算脉宽始终在配置范围；&lt;/li&gt;
&lt;li&gt;浏览器断开不影响 UDP 控制；&lt;/li&gt;
&lt;li&gt;错误来源地址和非法报文被拒绝。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;模拟后端验收未通过，不要接舵机。&lt;/p&gt;
&lt;h2&gt;第二阶段：PCA9685 逻辑验证&lt;/h2&gt;
&lt;p&gt;只连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;K2 Pin 1 -&amp;gt; VCC
K2 Pin 3 -&amp;gt; SDA
K2 Pin 5 -&amp;gt; SCL
K2 Pin 6 -&amp;gt; GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不接绿色端子 V+ 和舵机。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
i2cdetect -y ACTUAL_BUS 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本次实机结果是 &lt;code&gt;ACTUAL_BUS=1&lt;/code&gt;，但部署脚本和文档不得假设永远是 1。&lt;/p&gt;
&lt;p&gt;项目后端初始化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python - &amp;lt;&amp;lt;&apos;PY&apos;
from apps.k2_gimbal.servo.pca9685 import Pca9685ServoBackend

backend = Pca9685ServoBackend(
    i2c_bus=1,
    address=0x40,
    frequency_hz=50,
)
print(&quot;PCA9685 backend ready&quot;)
backend.close()
PY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该步骤尚需在目标实机完成后更新验证记录。&lt;/p&gt;
&lt;h2&gt;第三阶段：单舵机验收&lt;/h2&gt;
&lt;p&gt;断电接独立 5–6V 到绿色端子 V+/GND，只接 CH0，执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1500 -&amp;gt; 1475 -&amp;gt; 1500 -&amp;gt; 1525 -&amp;gt; 1500 us
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认电源、机械和方向后，再断电测试 CH1。两个轴分别完成后，记录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Pan：channel、center_us、min_safe_us、max_safe_us、invert
Tilt：channel、center_us、min_safe_us、max_safe_us、invert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例配置的 1000～2000 微秒不能直接作为已验证范围。&lt;/p&gt;
&lt;h2&gt;第四阶段：真实后端配置&lt;/h2&gt;
&lt;p&gt;完成实际标定后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: pca9685
  frequency_hz: 50
  i2c_bus: 1
  i2c_address: 0x40
  pan:
    channel: 0
    center_us: 1500
    min_safe_us: PAN_MIN_VERIFIED
    max_safe_us: PAN_MAX_VERIFIED
    invert: false
  tilt:
    channel: 1
    center_us: 1500
    min_safe_us: TILT_MIN_VERIFIED
    max_safe_us: TILT_MAX_VERIFIED
    invert: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;必须把占位值替换为实测整数。核心字段是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;backend: pca9685
i2c_bus: 1
i2c_address: 0x40
frequency_hz: 50
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;i2c_bus: 1&lt;/code&gt; 仅是本机当前结果。每次迁移系统或修改设备树后重新执行 &lt;code&gt;i2cdetect -l&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;推荐启动顺序&lt;/h2&gt;
&lt;h3&gt;日常启动&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;机械检查：云台没有卡住，线缆有余量；&lt;/li&gt;
&lt;li&gt;检查外部舵机电源关闭；&lt;/li&gt;
&lt;li&gt;启动 K2；&lt;/li&gt;
&lt;li&gt;确认 &lt;code&gt;/dev/i2c-*&lt;/code&gt;、&lt;code&gt;0x40&lt;/code&gt; 和系统日志正常；&lt;/li&gt;
&lt;li&gt;启动 K2 服务，确认中心脉宽安全；&lt;/li&gt;
&lt;li&gt;开启外部舵机电源；&lt;/li&gt;
&lt;li&gt;观察 K2 心跳、模式和实际脉宽；&lt;/li&gt;
&lt;li&gt;启动视觉服务；&lt;/li&gt;
&lt;li&gt;先在小范围场景测试目标；&lt;/li&gt;
&lt;li&gt;打开只读监控页面。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;K2 服务启动时会写中心，因此外部舵机电源已经开启的情况下，中心值必须经过验证。&lt;/p&gt;
&lt;h3&gt;初次硬件启动&lt;/h3&gt;
&lt;p&gt;初次测试不要运行完整 K2 服务。使用专用单通道小范围脚本，完成标定后再进入日常启动顺序。&lt;/p&gt;
&lt;h2&gt;推荐停止顺序&lt;/h2&gt;
&lt;p&gt;当前 K2 &lt;code&gt;close()&lt;/code&gt; 不显式回中，因此推荐：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;停止视觉服务或阻止继续发送目标；&lt;/li&gt;
&lt;li&gt;等待 K2 进入 holding；&lt;/li&gt;
&lt;li&gt;等待超过回中阈值，观察 returning_center；&lt;/li&gt;
&lt;li&gt;确认 Pan/Tilt 回到中心；&lt;/li&gt;
&lt;li&gt;关闭外部舵机电源；&lt;/li&gt;
&lt;li&gt;停止 K2 服务；&lt;/li&gt;
&lt;li&gt;最后关闭 K2。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;紧急情况下优先切断外部舵机电源，不要等待软件回中。&lt;/p&gt;
&lt;h2&gt;systemd 部署&lt;/h2&gt;
&lt;p&gt;Linux 视觉主机：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo deploy/install/install-n100.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;K2：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo deploy/install/install-k2.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装后编辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/yolo-gimbal-tracker/n100.yaml
/etc/yolo-gimbal-tracker/k2.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动和日志：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl start yolo-vision.service
sudo systemctl start k2-gimbal.service

journalctl -u yolo-vision.service -f
journalctl -u k2-gimbal.service -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;旧版 Ubuntu 16.04 上部署前应检查 unit 使用的 Python 路径是否指向项目的 Python 3.11 虚拟环境，而不是系统旧 Python。&lt;/p&gt;
&lt;p&gt;不要在未完成硬件标定时设置 K2 服务自动开机并直接驱动真实舵机。&lt;/p&gt;
&lt;h2&gt;Windows 自启动&lt;/h2&gt;
&lt;p&gt;Windows 可先使用手动 PowerShell 启动，完成稳定性验证后再选择任务计划程序或专用服务包装。自启动必须确保：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工作目录正确；&lt;/li&gt;
&lt;li&gt;虚拟环境解释器路径固定；&lt;/li&gt;
&lt;li&gt;配置文件和模型路径存在；&lt;/li&gt;
&lt;li&gt;防火墙规则已经配置；&lt;/li&gt;
&lt;li&gt;失败时日志可见；&lt;/li&gt;
&lt;li&gt;不会重复启动两个视觉进程争用摄像头。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;监控与健康判据&lt;/h2&gt;
&lt;h3&gt;视觉端&lt;/h3&gt;
&lt;p&gt;关注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;摄像头连接和重连次数；&lt;/li&gt;
&lt;li&gt;实际分辨率、采集 FPS；&lt;/li&gt;
&lt;li&gt;推理 FPS；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vision perf&lt;/code&gt; 的预处理、推理、后处理和总调用耗时；&lt;/li&gt;
&lt;li&gt;输入帧年龄；&lt;/li&gt;
&lt;li&gt;目标类别、ID 和可见状态；&lt;/li&gt;
&lt;li&gt;UDP 发送错误；&lt;/li&gt;
&lt;li&gt;Web &lt;code&gt;/healthz&lt;/code&gt; 与 &lt;code&gt;/readyz&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;K2&lt;/h3&gt;
&lt;p&gt;关注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;最近合法控制消息年龄；&lt;/li&gt;
&lt;li&gt;tracking、holding、returning_center、idle、fault；&lt;/li&gt;
&lt;li&gt;Pan/Tilt 实际脉宽；&lt;/li&gt;
&lt;li&gt;最近实例 ID 和序号；&lt;/li&gt;
&lt;li&gt;被拒绝报文计数；&lt;/li&gt;
&lt;li&gt;PCA9685 写入异常；&lt;/li&gt;
&lt;li&gt;K2 重启、掉压和 I²C 错误。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;电气与机械&lt;/h3&gt;
&lt;p&gt;关注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;外部 V+ 电压；&lt;/li&gt;
&lt;li&gt;舵机、端子和导线温度；&lt;/li&gt;
&lt;li&gt;嗡鸣、抖动、堵转和撞限位；&lt;/li&gt;
&lt;li&gt;两轴运动时线缆拉扯；&lt;/li&gt;
&lt;li&gt;K2 网络是否随舵机动作中断。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;备份内容&lt;/h2&gt;
&lt;p&gt;至少备份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;configs/n100.local.yaml
configs/k2.local.yaml
模型文件校验值与来源记录
/boot/nanopi-k2.dtb
/boot/nanopi-k2.dtb.backup-before-i2c
已验证的轴标定表
系统版本和 i2cdetect -l 输出
服务 unit 与 Python 路径
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置备份中可能包含真实内网信息，应存放在私有位置，不提交公开博客。&lt;/p&gt;
&lt;p&gt;模型建议记录哈希：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sha256sum models/target.pt
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;DTB 恢复&lt;/h2&gt;
&lt;p&gt;更新 K2 系统或替换 DTB 前：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cp -a /boot/nanopi-k2.dtb \
  &quot;/boot/nanopi-k2.dtb.backup-$(date +%Y%m%d-%H%M%S)&quot;
sync
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动失败时使用另一台 Linux 主机挂载存储介质，把已知可用备份恢复为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/boot/nanopi-k2.dtb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恢复后重新确认设备树节点和总线编号，不假定旧的 &lt;code&gt;i2c_bus&lt;/code&gt; 仍有效。&lt;/p&gt;
&lt;h2&gt;模拟后端回退&lt;/h2&gt;
&lt;p&gt;出现 I²C、PCA9685、电源或机械异常时，把配置切回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: simulated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模拟后端允许继续验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;视觉端相机和模型；&lt;/li&gt;
&lt;li&gt;UDP 协议；&lt;/li&gt;
&lt;li&gt;K2 状态机；&lt;/li&gt;
&lt;li&gt;Web 监控；&lt;/li&gt;
&lt;li&gt;网络和时间同步。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它不是隐藏硬件故障的长期方案。故障记录应保留原始日志、接线状态、电源测量和复现步骤。&lt;/p&gt;
&lt;h2&gt;变更管理&lt;/h2&gt;
&lt;p&gt;每次只修改一类参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型或输入分辨率；&lt;/li&gt;
&lt;li&gt;目标选择参数；&lt;/li&gt;
&lt;li&gt;K2 安全超时；&lt;/li&gt;
&lt;li&gt;控制增益和平滑；&lt;/li&gt;
&lt;li&gt;轴方向；&lt;/li&gt;
&lt;li&gt;安全脉宽；&lt;/li&gt;
&lt;li&gt;系统、内核或 DTB。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;记录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;日期
Git 提交
视觉配置版本
K2 配置版本
模型哈希
系统/内核版本
I²C 总线与地址
硬件改线
测试结果
回滚方式
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要同时修改模型、控制参数和机械结构后仅凭“看起来更好”发布。&lt;/p&gt;
&lt;h2&gt;上线前验收&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 两端 &lt;code&gt;--check-config&lt;/code&gt; 成功；&lt;/li&gt;
&lt;li&gt;[ ] 模拟后端完成 UDP、状态机和断网逻辑测试；&lt;/li&gt;
&lt;li&gt;[ ] 正确总线检测到 &lt;code&gt;0x40&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;[ ] 项目 PCA9685 后端初始化成功；&lt;/li&gt;
&lt;li&gt;[ ] CH0/CH1 单独小范围测试成功；&lt;/li&gt;
&lt;li&gt;[ ] 两轴安全范围已实测并留余量；&lt;/li&gt;
&lt;li&gt;[ ] 外部电源在双轴动作时稳定；&lt;/li&gt;
&lt;li&gt;[ ] 断网后实机先保持再平滑回中；&lt;/li&gt;
&lt;li&gt;[ ] K2 停止后视觉端及时显示离线；&lt;/li&gt;
&lt;li&gt;[ ] 视觉端性能日志没有持续帧积压；&lt;/li&gt;
&lt;li&gt;[ ] 两个浏览器持续观看不影响控制；&lt;/li&gt;
&lt;li&gt;[ ] DTB 和配置均有可用备份；&lt;/li&gt;
&lt;li&gt;[ ] 紧急断电方式清晰可达。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;本次状态&lt;/h2&gt;
&lt;p&gt;已验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;旧版 NanoPi K2 的 &lt;code&gt;i2c-A&lt;/code&gt; 设备树启用；&lt;/li&gt;
&lt;li&gt;新总线上 PCA9685 地址 &lt;code&gt;0x40&lt;/code&gt; 应答。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;待验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;项目后端真实寄存器初始化；&lt;/li&gt;
&lt;li&gt;CH0/CH1 舵机动作；&lt;/li&gt;
&lt;li&gt;安全脉宽和方向标定；&lt;/li&gt;
&lt;li&gt;完整双机跟踪；&lt;/li&gt;
&lt;li&gt;断网自动回中；&lt;/li&gt;
&lt;li&gt;30 分钟以上稳定性和多浏览器测试。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目 README：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;项目 K2 示例配置：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;项目视觉端示例配置：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/n100.example.yaml&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/n100.example.yaml&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;FriendlyELEC NanoPi K2：&lt;a href=&quot;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&quot;&gt;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Linux systemd 文档：&lt;a href=&quot;https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html&quot;&gt;https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NXP PCA9685 数据手册：&lt;a href=&quot;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&quot;&gt;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>NanoPi K2、PCA9685 与双轴舵机：接线、供电和首次通电安全</title><link>https://zh19990906.github.io/fuwari/posts/yolo-gimbal-hardware-wiring/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-gimbal-hardware-wiring/</guid><description>记录 NanoPi K2 40Pin、PCA9685 逻辑与舵机电源、CH0/CH1 接线，以及错接和机械堵转风险。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文记录本项目使用的 NanoPi K2、PCA9685 和普通三线舵机接线。所有插拔和改线必须在 NanoPi K2 与外部舵机电源均断电时完成。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;板上丝印和器件数据手册优先于网络图片、杜邦线颜色和“第几根针”的记忆。PCA9685 克隆板的接口方向可能不同。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;系统供电分成两部分&lt;/h2&gt;
&lt;p&gt;PCA9685 板上存在两套供电：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;逻辑电源 VCC&lt;/strong&gt;：给 PCA9685 芯片和 I²C 逻辑供电，本项目接 K2 的 3.3V；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;舵机电源 V+&lt;/strong&gt;：给 CH0～CH15 排针上的舵机供电，本项目使用独立 5–6V 电源。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不能把 VCC 和 V+ 当成同一个端子。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NanoPi K2 3.3V ─────&amp;gt; PCA9685 VCC（逻辑）
外部 5–6V 正极 ─────&amp;gt; PCA9685 V+（舵机）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;外部舵机电源不得接到 K2 的 3.3V、SDA 或 SCL。&lt;/p&gt;
&lt;h2&gt;NanoPi K2 到 PCA9685 的四根逻辑线&lt;/h2&gt;
&lt;p&gt;本项目使用 K2 40Pin 排针的物理针脚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NanoPi K2 Pin 1  (3.3V) -&amp;gt; PCA9685 VCC
NanoPi K2 Pin 3  (SDA)  -&amp;gt; PCA9685 SDA
NanoPi K2 Pin 5  (SCL)  -&amp;gt; PCA9685 SCL
NanoPi K2 Pin 6  (GND)  -&amp;gt; PCA9685 GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只连接这四根线时，PCA9685 芯片已经能够参与 I²C 通信，不需要舵机外部电源，也不需要插舵机。&lt;/p&gt;
&lt;p&gt;不要连接 K2 的 5V Pin 2 或 Pin 4 到 PCA9685 VCC。K2 信号是 3.3V 逻辑，错误的 5V 连接可能损坏处理器引脚。&lt;/p&gt;
&lt;h2&gt;K2 排针方向&lt;/h2&gt;
&lt;p&gt;按本次实物摆放：USB 和网络接口在下方、40Pin 排针位于右侧时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;靠散热器的内侧列是奇数针；&lt;/li&gt;
&lt;li&gt;靠机壳边缘的外侧列是偶数针；&lt;/li&gt;
&lt;li&gt;排针最上方、远离 USB 的一端是 Pin 1/Pin 2；&lt;/li&gt;
&lt;li&gt;第二行内侧是 Pin 3；&lt;/li&gt;
&lt;li&gt;第三行内侧是 Pin 5，第三行外侧是 Pin 6。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不同机壳和观察方向容易造成镜像误判。插线前应对照 FriendlyELEC 的 NanoPi K2 官方 40Pin 图，并用万用表确认 3.3V 与 GND。&lt;/p&gt;
&lt;h2&gt;PCA9685 控制排针&lt;/h2&gt;
&lt;p&gt;常见 6 针控制排针包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;V+  VCC  SDA  SCL  OE  GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但排列方向必须看板上白色丝印。K2 只接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VCC  SDA  SCL  GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本项目中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;V+&lt;/code&gt;：控制排针上的该针留空，舵机电源从绿色螺丝端子接入；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OE&lt;/code&gt;：留空；PCA9685 板上的上拉通常使输出保持启用；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;VCC&lt;/code&gt;：接 K2 3.3V；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GND&lt;/code&gt;：接 K2 GND；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SDA/SCL&lt;/code&gt;：分别接 K2 Pin 3/Pin 5。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不能因为四根线“看起来连续”就判断正确。&lt;code&gt;SCL&lt;/code&gt; 与 &lt;code&gt;GND&lt;/code&gt; 之间往往隔着 &lt;code&gt;OE&lt;/code&gt;，实际插法可能不是连续四针。&lt;/p&gt;
&lt;h2&gt;外部舵机电源&lt;/h2&gt;
&lt;p&gt;舵机电源接 PCA9685 绿色螺丝端子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;外部 5–6V 正极 -&amp;gt; PCA9685 绿色端子 V+
外部电源负极   -&amp;gt; PCA9685 绿色端子 GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先看绿色端子旁边的 &lt;code&gt;V+&lt;/code&gt; 和 &lt;code&gt;GND&lt;/code&gt; 丝印，再拧紧导线。极性反接可能损坏 PCA9685、舵机和电源。&lt;/p&gt;
&lt;p&gt;电源选择应满足：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输出电压符合舵机规格；&lt;/li&gt;
&lt;li&gt;持续电流能覆盖两个舵机的正常负载；&lt;/li&gt;
&lt;li&gt;瞬时电流能承受启动、加速和堵转冲击；&lt;/li&gt;
&lt;li&gt;导线和端子额定电流足够；&lt;/li&gt;
&lt;li&gt;最好具有限流、短路和过温保护。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要用 K2 的 5V 排针直接为两个舵机供电。舵机启动电流和堵转电流可能使 K2 掉压重启，也可能烧坏细导线或连接器。&lt;/p&gt;
&lt;h2&gt;为什么必须共地&lt;/h2&gt;
&lt;p&gt;K2 通过 SDA/SCL 向 PCA9685 发送逻辑电平，PCA9685 又使用外部电源驱动舵机。两个电源系统必须共享参考地：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;K2 GND
  │
  ├── PCA9685 逻辑 GND
  │
  └── PCA9685 绿色端子 GND ── 外部电源负极
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PCA9685 板上的逻辑 GND 与舵机 GND 通常已经在 PCB 上相连；K2 GND 接控制排针 GND，外部负极接绿色端子 GND，即形成共地。&lt;/p&gt;
&lt;p&gt;无共地可能表现为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I²C 偶发失败；&lt;/li&gt;
&lt;li&gt;舵机抖动或随机动作；&lt;/li&gt;
&lt;li&gt;接入外部电源后系统行为变化；&lt;/li&gt;
&lt;li&gt;SDA/SCL 电压参考不稳定。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;舵机三线插头&lt;/h2&gt;
&lt;p&gt;常见舵机线色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;黄 / 橙 / 白：信号
红：V+
黑 / 棕：GND
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;颜色只是常见约定，不是绝对标准。以舵机说明书和 PCA9685 通道排针丝印为准。&lt;/p&gt;
&lt;p&gt;PCA9685 通道排针通常分为三排：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;S / PWM：信号
V+：舵机电源正极
GND：地
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;插反三针插头可能造成短路或损坏舵机。通电前从插头线色一路追到板上 &lt;code&gt;S/V+/GND&lt;/code&gt; 丝印，不要只看其他通道的方向。&lt;/p&gt;
&lt;h2&gt;CH0 与 CH1&lt;/h2&gt;
&lt;p&gt;项目初始约定：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CH0 -&amp;gt; 下层/底座舵机 -&amp;gt; Pan 水平轴
CH1 -&amp;gt; 上层舵机      -&amp;gt; Tilt 俯仰轴
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  pan:
    channel: 0
  tilt:
    channel: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若实物动作轴相反，可以断电后交换插头，或修改配置中的通道编号。不要一边通电一边拔插舵机。&lt;/p&gt;
&lt;h2&gt;分阶段接线&lt;/h2&gt;
&lt;h3&gt;阶段 1：只验证逻辑与 I²C&lt;/h3&gt;
&lt;p&gt;连接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;K2 Pin 1 -&amp;gt; VCC；&lt;/li&gt;
&lt;li&gt;K2 Pin 3 -&amp;gt; SDA；&lt;/li&gt;
&lt;li&gt;K2 Pin 5 -&amp;gt; SCL；&lt;/li&gt;
&lt;li&gt;K2 Pin 6 -&amp;gt; GND。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;保持以下内容断开：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;绿色端子外部电源；&lt;/li&gt;
&lt;li&gt;CH0/CH1 舵机；&lt;/li&gt;
&lt;li&gt;控制排针 V+；&lt;/li&gt;
&lt;li&gt;OE。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;此时运行 &lt;code&gt;i2cdetect&lt;/code&gt;，目标是确认正确总线上的 &lt;code&gt;0x40&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;阶段 2：验证项目后端初始化&lt;/h3&gt;
&lt;p&gt;仍不接舵机，使用 &lt;code&gt;Pca9685ServoBackend&lt;/code&gt; 打开总线、读取和写入配置寄存器。确认没有 I/O 异常后关闭后端。&lt;/p&gt;
&lt;h3&gt;阶段 3：只接一个舵机&lt;/h3&gt;
&lt;p&gt;断电后：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;接外部 5–6V；&lt;/li&gt;
&lt;li&gt;只把水平舵机接 CH0；&lt;/li&gt;
&lt;li&gt;清理云台周围障碍；&lt;/li&gt;
&lt;li&gt;扶稳底座，但手指远离运动夹点；&lt;/li&gt;
&lt;li&gt;从 1500 微秒中位开始；&lt;/li&gt;
&lt;li&gt;只测试 1475/1525 微秒等小范围；&lt;/li&gt;
&lt;li&gt;观察发热、噪声、限位和供电稳定性。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CH0 成功后再断电，单独测试 CH1。&lt;/p&gt;
&lt;h2&gt;推荐通电顺序&lt;/h2&gt;
&lt;p&gt;首次联调：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;检查所有接线和极性；&lt;/li&gt;
&lt;li&gt;保持舵机外部电源关闭；&lt;/li&gt;
&lt;li&gt;启动 K2，确认 I²C 与项目后端；&lt;/li&gt;
&lt;li&gt;K2 服务先使用 simulated 后端；&lt;/li&gt;
&lt;li&gt;停止服务并断电；&lt;/li&gt;
&lt;li&gt;接一个舵机；&lt;/li&gt;
&lt;li&gt;开启 K2 和 PCA9685 逻辑；&lt;/li&gt;
&lt;li&gt;开启外部舵机电源；&lt;/li&gt;
&lt;li&gt;运行专用小范围测试脚本；&lt;/li&gt;
&lt;li&gt;测试完成后先停输出，再关舵机电源。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要在未知中心值和大范围默认值下直接启动完整跟踪服务。&lt;/p&gt;
&lt;h2&gt;立即断电条件&lt;/h2&gt;
&lt;p&gt;出现以下任何情况，先切断外部舵机电源，再排查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;舵机突然猛撞机械限位；&lt;/li&gt;
&lt;li&gt;持续大声嗡鸣或无法转动；&lt;/li&gt;
&lt;li&gt;舵机、导线、端子或电源明显发热；&lt;/li&gt;
&lt;li&gt;有焦味、烟雾或火花；&lt;/li&gt;
&lt;li&gt;K2 重启、断网或反复掉电；&lt;/li&gt;
&lt;li&gt;PCA9685 或电源指示异常；&lt;/li&gt;
&lt;li&gt;电源进入保护或输出电压明显下降；&lt;/li&gt;
&lt;li&gt;杜邦线松脱或短路。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;机械堵转时，即使软件脉宽仍在“常见范围”，舵机也可能持续吸收高电流。软件限位必须按实际云台结构标定。&lt;/p&gt;
&lt;h2&gt;常见危险接法&lt;/h2&gt;
&lt;h3&gt;把外部 5–6V 接到 VCC&lt;/h3&gt;
&lt;p&gt;会把高于 3.3V 的电压送入 PCA9685 逻辑和可能的 K2 I²C 上拉，存在损坏 K2 的风险。&lt;/p&gt;
&lt;h3&gt;把 K2 5V 接 VCC&lt;/h3&gt;
&lt;p&gt;同样可能让 SDA/SCL 被上拉到 5V。逻辑 VCC 应使用 K2 Pin 1 的 3.3V。&lt;/p&gt;
&lt;h3&gt;外部电源正负反接&lt;/h3&gt;
&lt;p&gt;可能立即损坏板和舵机。绿色端子极性必须以丝印为准。&lt;/p&gt;
&lt;h3&gt;不共地&lt;/h3&gt;
&lt;p&gt;I²C 和 PWM 缺少共同参考，可能出现随机动作和通信失败。&lt;/p&gt;
&lt;h3&gt;舵机插头反向&lt;/h3&gt;
&lt;p&gt;可能使 V+ 和 GND 对调，或把电源接到信号脚。通电前逐线核对。&lt;/p&gt;
&lt;h3&gt;带电插拔舵机&lt;/h3&gt;
&lt;p&gt;瞬态电流和接触抖动可能导致复位、错误脉冲和端子烧蚀。&lt;/p&gt;
&lt;h2&gt;万用表检查&lt;/h2&gt;
&lt;p&gt;只接逻辑线时，以 PCA9685 GND 为参考测量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VCC：约 3.3V
SDA 空闲：通常接近 3.3V
SCL 空闲：通常接近 3.3V
V+：未接外部电源时不应作为逻辑供电来源
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接外部舵机电源后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;绿色端子 V+ 对 GND：应符合舵机电源额定值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测量时避免表笔滑动短接相邻针脚。没有万用表时，不应通过反复试插供电线来猜测接口。&lt;/p&gt;
&lt;h2&gt;首次通电检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] K2、PCA9685 和外部电源全部断电后完成接线；&lt;/li&gt;
&lt;li&gt;[ ] Pin 1/3/5/6 与 VCC/SDA/SCL/GND 一一对应；&lt;/li&gt;
&lt;li&gt;[ ] 控制排针 V+ 和 OE 未接；&lt;/li&gt;
&lt;li&gt;[ ] 外部电源只接绿色端子 V+/GND；&lt;/li&gt;
&lt;li&gt;[ ] 已确认共地；&lt;/li&gt;
&lt;li&gt;[ ] 舵机插头信号、V+、GND 方向正确；&lt;/li&gt;
&lt;li&gt;[ ] 首次只接 CH0 一个舵机；&lt;/li&gt;
&lt;li&gt;[ ] 云台周围无障碍和夹点；&lt;/li&gt;
&lt;li&gt;[ ] 测试脉宽从 1500 开始，动作范围很小；&lt;/li&gt;
&lt;li&gt;[ ] 随时可以快速切断外部舵机电源。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;FriendlyELEC NanoPi K2：&lt;a href=&quot;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&quot;&gt;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NXP PCA9685 数据手册：&lt;a href=&quot;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&quot;&gt;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Adafruit PCA9685 引脚说明：&lt;a href=&quot;https://learn.adafruit.com/16-channel-pwm-servo-driver/pinouts&quot;&gt;https://learn.adafruit.com/16-channel-pwm-servo-driver/pinouts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;项目 K2 示例配置：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>NanoPi K2 控制端：消息校验、状态机与舵机安全限制</title><link>https://zh19990906.github.io/fuwari/posts/yolo-gimbal-k2-control-service/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-gimbal-k2-control-service/</guid><description>说明 K2 的 UDP 接收、20 Hz 安全控制、holding/return-center/fault 状态、脉宽限制和 PCA9685 后端。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;NanoPi K2 是云台的最后安全边界。它不处理摄像头或 YOLO，而是把视觉主机发送的归一化误差转换为受限舵机脉宽，并在消息停止、网络中断和硬件写入失败时执行明确状态。&lt;/p&gt;
&lt;p&gt;本文以 &lt;code&gt;yolo-gimbal-tracker&lt;/code&gt; 当前主分支代码为准，并特别区分“代码逻辑存在”和“实机已经验收”。&lt;/p&gt;
&lt;h2&gt;服务组装&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;apps/k2_gimbal/main.py&lt;/code&gt; 的 &lt;code&gt;build_service()&lt;/code&gt; 组装以下组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;K2 配置
  ├─ UdpControlReceiver
  ├─ ControlSequenceValidator
  ├─ SimulatedServoBackend 或 Pca9685ServoBackend
  ├─ GimbalController
  ├─ SafetyStateMachine
  ├─ StatusSender
  └─ K2Service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命令入口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;k2-gimbal --config configs/k2.local.yaml --check-config
k2-gimbal --config configs/k2.local.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先使用 &lt;code&gt;--check-config&lt;/code&gt;，避免服务打开 I²C 后才发现字段类型或范围错误。&lt;/p&gt;
&lt;h2&gt;网络和消息边界&lt;/h2&gt;
&lt;p&gt;示例配置包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;network:
  bind_host: 0.0.0.0
  control_port: 6000
  allowed_n100_ip: VISION_HOST_IP
  n100_status_host: VISION_HOST_IP
  status_port: 6001
  status_rate_hz: 5
safety:
  max_message_age_ms: 300
  hold_timeout_ms: 500
  return_center_timeout_ms: 2000
  future_tolerance_ms: 1000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;K2 只接受配置中的视觉主机地址。协议校验还覆盖：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;消息类型和协议版本；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;instance_id&lt;/code&gt; 是否为规范 UUID；&lt;/li&gt;
&lt;li&gt;同一实例中的序号是否递增；&lt;/li&gt;
&lt;li&gt;Unix 时间是否明显过旧或来自未来；&lt;/li&gt;
&lt;li&gt;字段是否齐全、类型和范围是否正确；&lt;/li&gt;
&lt;li&gt;是否存在未知字段；&lt;/li&gt;
&lt;li&gt;数据报是否超过大小限制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;非法网络包会被拒绝和计数，不会仅因为格式错误就进入 fault。硬件写入错误才属于需要锁存的执行器故障。&lt;/p&gt;
&lt;h2&gt;固定频率循环&lt;/h2&gt;
&lt;p&gt;示例配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;control:
  loop_rate_hz: 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;K2 控制循环以固定 20 Hz 调用状态机，状态回传以 5 Hz 发送。视觉端推理可能不是 20 FPS，但控制器的安全计时和舵机步进不直接跟随每次推理完成时间。&lt;/p&gt;
&lt;p&gt;所有超时判断使用本机单调时钟。跨设备时间戳用于报文新鲜度边界，不承担本地调度计时。&lt;/p&gt;
&lt;h2&gt;启动状态&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;SafetyStateMachine.start()&lt;/code&gt; 首先调用 &lt;code&gt;controller.write_center()&lt;/code&gt;，成功后进入 &lt;code&gt;IDLE&lt;/code&gt;。这意味着服务启动时会立即向后端写入 Pan/Tilt 中心脉宽。&lt;/p&gt;
&lt;p&gt;因此切换到真实 PCA9685 前必须完成以下检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;云台安装在中心位置附近；&lt;/li&gt;
&lt;li&gt;中心脉宽不会撞机械限位；&lt;/li&gt;
&lt;li&gt;外部舵机电源电压和极性正确；&lt;/li&gt;
&lt;li&gt;Pan/Tilt 通道没有插反；&lt;/li&gt;
&lt;li&gt;首次实机测试使用窄脉宽范围。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若启动时写中心失败，状态机会设置 &lt;code&gt;FAULT&lt;/code&gt; 和 &lt;code&gt;SERVO_WRITE_FAILED&lt;/code&gt;，并重新抛出异常。&lt;/p&gt;
&lt;h2&gt;tracking、holding、return-center 与 fault&lt;/h2&gt;
&lt;p&gt;代码中的枚举状态还包括 &lt;code&gt;STARTING&lt;/code&gt; 和 &lt;code&gt;IDLE&lt;/code&gt;。运行中最重要的四个概念如下。&lt;/p&gt;
&lt;h3&gt;tracking&lt;/h3&gt;
&lt;p&gt;最近控制消息年龄不超过 &lt;code&gt;hold_timeout_ms&lt;/code&gt;，且 &lt;code&gt;target_visible&lt;/code&gt; 为真时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error_x / error_y
  -&amp;gt; 方向反转（可选）
  -&amp;gt; 死区
  -&amp;gt; EMA 平滑
  -&amp;gt; 增益换算
  -&amp;gt; 最大单步限制
  -&amp;gt; 安全脉宽裁剪
  -&amp;gt; 后端写入
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成功后状态为 &lt;code&gt;TRACKING&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;holding&lt;/h3&gt;
&lt;p&gt;出现以下任一情况时保持当前输出：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;控制消息仍然足够新，但 &lt;code&gt;target_visible&lt;/code&gt; 为假；&lt;/li&gt;
&lt;li&gt;最后消息年龄超过保持阈值，但尚未超过回中阈值。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;holding 不会继续使用陈旧误差追踪，也不会立即跳回中心。&lt;/p&gt;
&lt;h3&gt;return-center&lt;/h3&gt;
&lt;p&gt;最后合法消息年龄超过 &lt;code&gt;return_center_timeout_ms&lt;/code&gt; 后，代码状态为 &lt;code&gt;RETURNING_CENTER&lt;/code&gt;。每个控制周期调用 &lt;code&gt;step_toward_center()&lt;/code&gt;，Pan/Tilt 每次最多移动 &lt;code&gt;max_step_us&lt;/code&gt;，到达中心后进入 &lt;code&gt;IDLE&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;本文用 &lt;code&gt;return-center&lt;/code&gt; 表示这一概念；网页或协议中可能显示 &lt;code&gt;returning_center&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;fault&lt;/h3&gt;
&lt;p&gt;后端写中心、跟踪或回中时抛出异常，状态机会：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;设置模式为 &lt;code&gt;FAULT&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;保存 &lt;code&gt;FaultInfo(&quot;SERVO_WRITE_FAILED&quot;, ...)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;停止后续控制更新；&lt;/li&gt;
&lt;li&gt;将异常继续上抛，由服务日志记录。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当前版本没有自动清除 fault。恢复前应先排查电源、I²C、PCA9685、接线和机械堵转，再人工重启服务。&lt;/p&gt;
&lt;h2&gt;死区、EMA 和最大步进&lt;/h2&gt;
&lt;p&gt;示例配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;control:
  dead_zone: 0.05
  smoothing_alpha: 0.3
  pan_gain_us: 300
  tilt_gain_us: 300
  max_step_us: 20
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;死区&lt;/h3&gt;
&lt;p&gt;当误差绝对值不超过 &lt;code&gt;dead_zone&lt;/code&gt; 时，控制器把该轴过滤值设为零并重置 EMA，避免目标在中心附近因检测噪声持续微动。&lt;/p&gt;
&lt;h3&gt;EMA 平滑&lt;/h3&gt;
&lt;p&gt;误差超出死区后使用指数移动平均：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;filtered = alpha * current + (1 - alpha) * previous
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;较大的 &lt;code&gt;smoothing_alpha&lt;/code&gt; 更快响应当前误差，较小值更平滑但延迟更大。&lt;/p&gt;
&lt;h3&gt;最大单步&lt;/h3&gt;
&lt;p&gt;控制器先计算目标脉宽，再通过 &lt;code&gt;max_step_us&lt;/code&gt; 限制一次循环最多改变多少微秒。20 Hz 下，&lt;code&gt;max_step_us: 20&lt;/code&gt; 理论上限制每秒最多累计约 400 微秒，但实际还受误差、增益和安全边界影响。&lt;/p&gt;
&lt;p&gt;最大步进不是机械速度标定的替代品。不同舵机在相同脉宽变化下的速度、负载和惯性不同。&lt;/p&gt;
&lt;h2&gt;Pan/Tilt 轴配置&lt;/h2&gt;
&lt;p&gt;示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  pan:
    channel: 0
    center_us: 1500
    min_safe_us: 1000
    max_safe_us: 2000
    invert: false
  tilt:
    channel: 1
    center_us: 1500
    min_safe_us: 1000
    max_safe_us: 2000
    invert: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初始约定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CH0：Pan，底座水平旋转；&lt;/li&gt;
&lt;li&gt;CH1：Tilt，上层俯仰。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这只是配置约定，不是硬件强制规则。若实际通道相反，可以交换舵机插头或修改配置，但一次只改变一个因素。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;invert&lt;/code&gt; 只反转控制误差方向，不会改变舵机插头、电源极性或 PWM 引脚。&lt;/p&gt;
&lt;h2&gt;simulated 后端&lt;/h2&gt;
&lt;p&gt;首次部署保持：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: simulated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模拟后端用于验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;配置能加载；&lt;/li&gt;
&lt;li&gt;UDP 消息可以接收；&lt;/li&gt;
&lt;li&gt;状态转换符合预期；&lt;/li&gt;
&lt;li&gt;Pan/Tilt 脉宽始终在安全范围；&lt;/li&gt;
&lt;li&gt;视觉端能收到 K2 真实心跳；&lt;/li&gt;
&lt;li&gt;停止视觉端后出现 holding 和 returning_center。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;模拟后端通过不代表实物供电、I²C 和机械结构安全，但可以提前排除大部分软件与网络问题。&lt;/p&gt;
&lt;h2&gt;Pca9685ServoBackend&lt;/h2&gt;
&lt;p&gt;真实后端配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: pca9685
  frequency_hz: 50
  i2c_bus: 1
  i2c_address: 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际总线编号必须以 &lt;code&gt;i2cdetect -l&lt;/code&gt; 和 &lt;code&gt;/dev/i2c-*&lt;/code&gt; 为准。本文实机启用后是 &lt;code&gt;i2c-1&lt;/code&gt;，其他镜像可能不同。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Pca9685ServoBackend&lt;/code&gt; 初始化时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;打开 &lt;code&gt;smbus2.SMBus(i2c_bus)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;读取 MODE1；&lt;/li&gt;
&lt;li&gt;进入睡眠并写入预分频；&lt;/li&gt;
&lt;li&gt;恢复 MODE1；&lt;/li&gt;
&lt;li&gt;设置自动递增和 MODE2 输出方式。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;set_pulse_us(channel, pulse_us)&lt;/code&gt; 把微秒换算为 12 位 PWM 计数，并写入对应通道的四个寄存器。类本身只验证脉宽位于当前 PWM 周期内；真正的舵机安全最小值和最大值由 &lt;code&gt;GimbalController&lt;/code&gt; 的轴配置裁剪。&lt;/p&gt;
&lt;h2&gt;状态回传&lt;/h2&gt;
&lt;p&gt;K2 状态发送器回传：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前后端；&lt;/li&gt;
&lt;li&gt;当前模式；&lt;/li&gt;
&lt;li&gt;Pan/Tilt 脉宽；&lt;/li&gt;
&lt;li&gt;fault 信息；&lt;/li&gt;
&lt;li&gt;最近控制实例和序号；&lt;/li&gt;
&lt;li&gt;进程运行时间等状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;视觉端网页中的“K2 在线”来自合法状态包的新鲜度，而不是视觉端本机 UDP 发送成功。&lt;/p&gt;
&lt;h2&gt;停止服务的当前行为&lt;/h2&gt;
&lt;p&gt;命令行捕获 &lt;code&gt;KeyboardInterrupt&lt;/code&gt; 后调用 &lt;code&gt;service.close()&lt;/code&gt;，最终关闭控制器和后端。当前 &lt;code&gt;close()&lt;/code&gt; 路径没有显式先执行 &lt;code&gt;write_center()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;因此不要把“正常停止进程”写成“必然自动回中”。更安全的运维方式是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;停止视觉端发送并观察 K2 进入 return-center；&lt;/li&gt;
&lt;li&gt;确认脉宽回到中心；&lt;/li&gt;
&lt;li&gt;再停止 K2 服务；&lt;/li&gt;
&lt;li&gt;最后切断舵机外部电源。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;后续若修改代码增加关停回中，也应考虑 I²C 已故障、机械卡住和进程被强制终止时无法保证执行。&lt;/p&gt;
&lt;h2&gt;配置与启动检查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cp configs/k2.example.yaml configs/k2.local.yaml
k2-gimbal --config configs/k2.local.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;首次网络联调：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;servo:
  backend: simulated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实硬件前检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
i2cdetect -y 1 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到地址 &lt;code&gt;40&lt;/code&gt; 后，也只说明 I²C 设备应答。仍需完成项目后端初始化和单舵机窄范围测试。&lt;/p&gt;
&lt;h2&gt;验收清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;--check-config&lt;/code&gt; 成功；&lt;/li&gt;
&lt;li&gt;[ ] simulated 后端能持续回传状态；&lt;/li&gt;
&lt;li&gt;[ ] 视觉端停止后先 holding，再 returning_center；&lt;/li&gt;
&lt;li&gt;[ ] 所有脉宽始终处于轴安全范围；&lt;/li&gt;
&lt;li&gt;[ ] I²C 正确总线上能检测 &lt;code&gt;0x40&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;[ ] 单个 CH0 舵机从 1500 微秒做小范围测试；&lt;/li&gt;
&lt;li&gt;[ ] CH1 单独测试；&lt;/li&gt;
&lt;li&gt;[ ] 方向、中心和机械限位逐轴标定；&lt;/li&gt;
&lt;li&gt;[ ] 断网回中实机验收；&lt;/li&gt;
&lt;li&gt;[ ] 后端写入故障时 fault 锁存且不继续偏转。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目 K2 代码：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/tree/main/apps/k2_gimbal&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/tree/main/apps/k2_gimbal&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;项目 K2 示例配置：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker/blob/main/configs/k2.example.yaml&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Linux I²C 用户空间接口：&lt;a href=&quot;https://docs.kernel.org/i2c/dev-interface.html&quot;&gt;https://docs.kernel.org/i2c/dev-interface.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NXP PCA9685 数据手册：&lt;a href=&quot;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&quot;&gt;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>双机 YOLO 云台跟踪系统：架构、数据流与安全边界</title><link>https://zh19990906.github.io/fuwari/posts/yolo-gimbal-system-architecture/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-gimbal-system-architecture/</guid><description>说明 Windows/N100 视觉主机与 NanoPi K2 控制主机的职责、UDP 数据流、状态机和失联安全行为。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文记录 &lt;code&gt;zh19990906/yolo-gimbal-tracker&lt;/code&gt; 当前主分支的系统结构。项目把视频推理和舵机执行拆到两台机器：Windows/N100 视觉主机负责摄像头、YOLO、ByteTrack 和目标选择，NanoPi K2 只负责接收控制量、执行安全状态机并驱动 PCA9685。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;本文描述代码当前具备的能力。PCA9685 的 I²C 探测已经在一台 NanoPi K2 上完成；双舵机全范围标定、长时间稳定性和完整断网回中仍属于待验证项目。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;系统目标与适用范围&lt;/h2&gt;
&lt;p&gt;系统面向局域网内的双轴视觉云台：摄像头观察目标，视觉端计算目标相对画面中心的误差，K2 把误差转换为 Pan/Tilt 舵机脉宽。设计重点不是“让舵机尽快动”，而是在推理变慢、网络丢包、进程退出或硬件写入失败时仍保持可预测行为。&lt;/p&gt;
&lt;p&gt;适合以下场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;视觉主机有比 K2 更强的 CPU 或 GPU；&lt;/li&gt;
&lt;li&gt;摄像头直接连接 Windows 或 N100；&lt;/li&gt;
&lt;li&gt;K2 靠近云台和 PCA9685；&lt;/li&gt;
&lt;li&gt;两台机器位于可信局域网；&lt;/li&gt;
&lt;li&gt;控制链路要求失联后保持并回中。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Web 页面当前只读，没有认证、TLS、手动转动或在线修改控制参数，不应暴露到公网。&lt;/p&gt;
&lt;h2&gt;双机职责边界&lt;/h2&gt;
&lt;h3&gt;Windows/N100 视觉主机&lt;/h3&gt;
&lt;p&gt;视觉主机负责：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用 OpenCV 打开 USB 摄像头；&lt;/li&gt;
&lt;li&gt;使用 Ultralytics YOLO 检测目标；&lt;/li&gt;
&lt;li&gt;使用 ByteTrack 维护目标 ID；&lt;/li&gt;
&lt;li&gt;按配置过滤类别并选择主目标；&lt;/li&gt;
&lt;li&gt;计算归一化水平、垂直误差；&lt;/li&gt;
&lt;li&gt;以固定 20 Hz 通过 JSON/UDP 发送最新控制消息；&lt;/li&gt;
&lt;li&gt;接收 K2 以 5 Hz 回传的状态；&lt;/li&gt;
&lt;li&gt;提供 FastAPI、MJPEG、WebSocket、健康检查和只读监控页面。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;视觉主机不直接产生舵机 PWM，也不能仅凭一次 &lt;code&gt;sendto()&lt;/code&gt; 成功断言云台已经执行。实际状态必须以 K2 心跳、模式和脉宽为准。&lt;/p&gt;
&lt;h3&gt;NanoPi K2 控制主机&lt;/h3&gt;
&lt;p&gt;NanoPi K2 不接触视频。它负责：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;只接受配置允许的视觉主机地址；&lt;/li&gt;
&lt;li&gt;校验协议版本、消息类型、实例 UUID、序号、时间戳和字段范围；&lt;/li&gt;
&lt;li&gt;以固定 20 Hz 运行控制循环；&lt;/li&gt;
&lt;li&gt;应用死区、平滑、最大步进和安全脉宽限制；&lt;/li&gt;
&lt;li&gt;根据消息新鲜度进入 tracking、holding、return-center 或 fault；&lt;/li&gt;
&lt;li&gt;通过 simulated 或 PCA9685 后端输出；&lt;/li&gt;
&lt;li&gt;以 5 Hz 回传在线状态、模式、实际脉宽和故障。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;把安全控制放在 K2 上意味着视觉端崩溃时，舵机不依赖 Windows 继续发送“回中”命令。&lt;/p&gt;
&lt;h2&gt;从摄像头到舵机的数据流&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;USB 摄像头
    │
    ▼
OpenCV 采集线程
    │ 最新帧
    ▼
Ultralytics YOLO + ByteTrack
    │ 检测框、类别、track_id
    ▼
类别过滤与目标选择
    │ 目标中心点
    ▼
归一化误差 error_x / error_y
    │ 固定 20 Hz JSON/UDP
    ▼
NanoPi K2 消息校验
    │
    ▼
状态机 + 死区 + 平滑 + 最大步进 + 脉宽限位
    │
    ▼
Pca9685ServoBackend
    │ CH0 / CH1 PWM
    ▼
Pan / Tilt 舵机
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;归一化误差通常以画面中心为零点。视觉端只表达“目标偏离中心多少”，不直接指定绝对角度。K2 根据增益、方向和安全范围计算下一次脉宽，这样电气与机械限制不会散落在视觉代码中。&lt;/p&gt;
&lt;h2&gt;UDP 控制与状态回传&lt;/h2&gt;
&lt;p&gt;控制与状态使用严格的版本化 JSON/UDP：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;视觉主机到 K2：默认 UDP 6000，固定 20 Hz；&lt;/li&gt;
&lt;li&gt;K2 到视觉主机：默认 UDP 6001，固定 5 Hz；&lt;/li&gt;
&lt;li&gt;每次进程启动生成新的 &lt;code&gt;instance_id&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;序号只在同一个实例内单调递增；&lt;/li&gt;
&lt;li&gt;数据报最大 4096 字节；&lt;/li&gt;
&lt;li&gt;未知字段、错误类型、过旧或明显来自未来的消息会被拒绝。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UDP 不提供连接或自动重传，因此系统采用“持续发送最新状态”，而不是补发历史控制消息。旧的目标误差没有控制价值，晚到的数据不应覆盖更新的数据。&lt;/p&gt;
&lt;p&gt;跨设备 Unix 时间只用于拒绝明显异常的报文。本地超时和调度使用单调时钟，避免系统时间校准导致保持或回中计时跳变。&lt;/p&gt;
&lt;h2&gt;四种安全状态&lt;/h2&gt;
&lt;h3&gt;tracking&lt;/h3&gt;
&lt;p&gt;收到合法且足够新的目标控制消息时，根据误差更新 Pan/Tilt。输出仍受到死区、平滑、最大步进和安全脉宽限制。&lt;/p&gt;
&lt;h3&gt;holding&lt;/h3&gt;
&lt;p&gt;超过保持阈值没有收到新控制消息时，维持最近的安全输出，不继续追随陈旧目标。示例配置的保持阈值是 500 ms。&lt;/p&gt;
&lt;h3&gt;return-center&lt;/h3&gt;
&lt;p&gt;失联持续超过回中阈值后，输出以受限步进逐渐靠近中心脉宽，而不是瞬间跳回中位。示例配置阈值为 2000 ms。代码状态有时显示为 &lt;code&gt;returning_center&lt;/code&gt;，本文统一用概念名称 &lt;code&gt;return-center&lt;/code&gt; 描述该阶段。&lt;/p&gt;
&lt;h3&gt;fault&lt;/h3&gt;
&lt;p&gt;舵机后端写入失败等硬件异常会锁存 fault，控制器不继续发送新的偏转。当前版本需要人工排障或重启服务恢复，不能用无限重试掩盖电源、总线或机械故障。&lt;/p&gt;
&lt;p&gt;非法远端数据包只会被拒绝和计数，不会因为网络噪声直接把云台锁进 fault。&lt;/p&gt;
&lt;h2&gt;为什么推理与控制分离&lt;/h2&gt;
&lt;p&gt;YOLO 推理 FPS 会随模型、输入分辨率和硬件负载变化。如果把舵机更新直接绑定到每次推理完成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;推理抖动会转化为不均匀舵机运动；&lt;/li&gt;
&lt;li&gt;模型阻塞会让安全超时逻辑一起阻塞；&lt;/li&gt;
&lt;li&gt;Windows 进程崩溃后没有本地执行器负责回中；&lt;/li&gt;
&lt;li&gt;摄像头和 I²C 故障难以分别定位。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当前架构让推理线程产生“最新结果”，发送循环固定 20 Hz 读取最新结果；K2 再以固定 20 Hz 执行安全控制。监控网页不在实时闭环中，浏览器断开或 Web 服务失败不应停止 UDP 控制核心。&lt;/p&gt;
&lt;h2&gt;失联与退出行为&lt;/h2&gt;
&lt;p&gt;典型故障及预期行为：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;故障&lt;/th&gt;
&lt;th&gt;预期行为&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;摄像头短暂断开&lt;/td&gt;
&lt;td&gt;视觉端退避重连，不发送无限期陈旧目标&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO 推理变慢&lt;/td&gt;
&lt;td&gt;发送循环仍按固定频率工作，并报告帧年龄和性能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;浏览器断开&lt;/td&gt;
&lt;td&gt;不影响视觉控制与 K2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单次 UDP 发送失败&lt;/td&gt;
&lt;td&gt;记录错误，后续周期继续发送最新状态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;视觉端停止或网络中断&lt;/td&gt;
&lt;td&gt;K2 先 holding，随后 return-center&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K2 停止&lt;/td&gt;
&lt;td&gt;视觉端约在状态离线阈值后显示 K2 离线&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCA9685 写入异常&lt;/td&gt;
&lt;td&gt;K2 进入 fault，停止继续偏转&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;“代码包含回中状态机”不等于实机已经完成断网回中验收。机械安装、舵机电源和安全脉宽确定后，仍需按硬件验收清单逐项验证。&lt;/p&gt;
&lt;h2&gt;项目目录与配置入口&lt;/h2&gt;
&lt;p&gt;主要入口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apps/n100_vision/       视觉主机应用
apps/k2_gimbal/         K2 控制应用
packages/protocol/      双向 UDP 协议
configs/n100.example.yaml
configs/k2.example.yaml
deploy/install/         Linux 安装脚本
deploy/systemd/         systemd 单元
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命令行入口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo-vision --config configs/n100.local.yaml --check-config
k2-gimbal --config configs/k2.local.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;首次联调应保持 &lt;code&gt;servo.backend: simulated&lt;/code&gt;。网络、状态机和配置稳定后，再在断电状态接入 PCA9685 和单个舵机。&lt;/p&gt;
&lt;h2&gt;已验证与待验证&lt;/h2&gt;
&lt;h3&gt;已从代码或合并记录确认&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Linux/Windows 相机后端选择；&lt;/li&gt;
&lt;li&gt;YOLO、ByteTrack、类别过滤和目标切换滞回；&lt;/li&gt;
&lt;li&gt;固定 20 Hz 控制消息与 5 Hz K2 状态回传；&lt;/li&gt;
&lt;li&gt;simulated/PCA9685 后端；&lt;/li&gt;
&lt;li&gt;tracking、holding、return-center、fault 安全逻辑；&lt;/li&gt;
&lt;li&gt;YOLO26 性能聚合日志；&lt;/li&gt;
&lt;li&gt;Python 3.10 &lt;code&gt;asyncio.TimeoutError&lt;/code&gt; 兼容修复。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本次实机已验证&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;在旧版 NanoPi K2 系统启用排针 I²C；&lt;/li&gt;
&lt;li&gt;新总线能在地址 &lt;code&gt;0x40&lt;/code&gt; 检测到 PCA9685。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;仍待实机验证&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;项目 Python 后端对实物 PCA9685 的初始化；&lt;/li&gt;
&lt;li&gt;CH0/CH1 单舵机小范围运动；&lt;/li&gt;
&lt;li&gt;两轴安全最小值、中心值和最大值标定；&lt;/li&gt;
&lt;li&gt;断网自动回中；&lt;/li&gt;
&lt;li&gt;双机持续运行和多浏览器耐久测试。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目仓库：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Ultralytics 跟踪模式：&lt;a href=&quot;https://docs.ultralytics.com/modes/track/&quot;&gt;https://docs.ultralytics.com/modes/track/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenCV VideoCapture：&lt;a href=&quot;https://docs.opencv.org/4.x/d8/dfe/classcv_1_1VideoCapture.html&quot;&gt;https://docs.opencv.org/4.x/d8/dfe/classcv_1_1VideoCapture.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Linux I²C 用户空间接口：&lt;a href=&quot;https://docs.kernel.org/i2c/dev-interface.html&quot;&gt;https://docs.kernel.org/i2c/dev-interface.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NXP PCA9685 数据手册：&lt;a href=&quot;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&quot;&gt;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>YOLO 云台故障排查手册：I²C、PCA9685、舵机、网络与视觉性能</title><link>https://zh19990906.github.io/fuwari/posts/yolo-gimbal-troubleshooting/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-gimbal-troubleshooting/</guid><description>按现象排查 i2cdetect --/UU、HDMI DDC、VCC/V+、舵机抖动、K2 重启、UDP 与 YOLO 性能问题。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文按“现象 → 原因 → 无破坏性检查 → 修复 → 成功判据 → 停止条件”组织。排查原则是一次只改变一个变量，先断开舵机和外部电源，再解决逻辑与通信问题。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;看到电源灯亮不等于 I²C 正常；看到 &lt;code&gt;/dev/i2c-0&lt;/code&gt; 不等于它连接 40Pin；看到 UDP 发送成功不等于 K2 已经执行。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;&lt;code&gt;i2cdetect&lt;/code&gt; 在 0x40 显示 &lt;code&gt;--&lt;/code&gt;&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y BUS 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;40: --
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;扫描了错误的 I²C 适配器；&lt;/li&gt;
&lt;li&gt;PCA9685 VCC/GND 没有逻辑供电；&lt;/li&gt;
&lt;li&gt;SDA/SCL 接错、断路或接触不良；&lt;/li&gt;
&lt;li&gt;地址焊桥改变了默认地址；&lt;/li&gt;
&lt;li&gt;排针 I²C 控制器没有在设备树中启用；&lt;/li&gt;
&lt;li&gt;PCA9685 板损坏。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;先断开舵机和绿色端子 V+，只保留四根逻辑线：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
dmesg | grep -iE &apos;i2c|gpio|pinctrl&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;万用表检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VCC 对 GND：约 3.3V
SDA 空闲：通常接近 3.3V
SCL 空闲：通常接近 3.3V
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查板上丝印，确认 K2 Pin 1/3/5/6 分别接 VCC/SDA/SCL/GND。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;以 &lt;code&gt;i2cdetect -l&lt;/code&gt; 确认实际总线；&lt;/li&gt;
&lt;li&gt;确认不是 HDMI DDC；&lt;/li&gt;
&lt;li&gt;断电后重新压紧杜邦线；&lt;/li&gt;
&lt;li&gt;以板上丝印核对 SDA/SCL；&lt;/li&gt;
&lt;li&gt;检查地址焊桥是否全部保持默认；&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;/proc/device-tree&lt;/code&gt; 中目标 I²C 节点是否为 &lt;code&gt;okay&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;40: 40
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;VCC 高于预期、导线发热、出现焦味或怀疑 5V 接入信号线时立即断电。&lt;/p&gt;
&lt;h2&gt;地址显示 &lt;code&gt;UU&lt;/code&gt;&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;完整扫描中某地址显示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;50: UU
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;UU&lt;/code&gt; 表示地址已经被内核驱动占用，不是“设备烧坏”。本次 NanoPi K2 的 &lt;code&gt;0x50 UU&lt;/code&gt; 属于 HDMI DDC/EDID 总线，是识别错误总线的重要线索。&lt;/p&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cat /sys/class/i2c-adapter/i2c-BUS/name
dmesg | grep -iE &apos;i2c|ddc|edid|hdmi&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看 &lt;code&gt;/sys/bus/i2c/devices/&lt;/code&gt; 中对应设备和驱动绑定。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;不要使用用户空间程序强行与已绑定驱动争用地址。先确认该适配器服务的硬件，再选择排针对应的新总线。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;PCA9685 所在总线的 &lt;code&gt;0x40&lt;/code&gt; 显示 &lt;code&gt;40&lt;/code&gt;；HDMI 总线保留 &lt;code&gt;0x50 UU&lt;/code&gt; 并不影响云台。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;不要为了消除 &lt;code&gt;UU&lt;/code&gt; 随意卸载显示驱动或删除设备树节点。&lt;/p&gt;
&lt;h2&gt;只有 &lt;code&gt;i2c_gpio.32&lt;/code&gt;，没有排针总线&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;i2c-0   i2c_gpio.32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有其他 &lt;code&gt;/dev/i2c-*&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;p&gt;旧版厂商镜像只注册了 HDMI 软件 I²C，40Pin 的硬件 I²C 节点仍为 &lt;code&gt;disabled&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;find /proc/device-tree -type d | grep -Ei &apos;i2c|iic&apos;

for d in /proc/device-tree/i2c@*; do
  [ -d &quot;$d&quot; ] || continue
  printf &apos;%s: &apos; &quot;$d&quot;
  [ -f &quot;$d/status&quot; ] &amp;amp;&amp;amp; tr -d &apos;\0&apos; &amp;lt; &quot;$d/status&quot;
  echo
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认系统版本、内核和 &lt;code&gt;/boot&lt;/code&gt; 启动方式。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;本次 Ubuntu 16.04.7 / Linux 3.14.29 环境中，Pin 3/5 对应 &lt;code&gt;i2c-A&lt;/code&gt;，目标节点是 &lt;code&gt;/i2c@c1108500&lt;/code&gt;。先备份 DTB，再用 &lt;code&gt;fdtput&lt;/code&gt; 把副本的 &lt;code&gt;status&lt;/code&gt; 改为 &lt;code&gt;okay&lt;/code&gt;，经 &lt;code&gt;fdtget&lt;/code&gt; 和 &lt;code&gt;dtc&lt;/code&gt; 验证后替换。&lt;/p&gt;
&lt;p&gt;其他镜像不能直接套用该节点地址。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;重启后 &lt;code&gt;i2cdetect -l&lt;/code&gt; 出现新的硬件适配器，并生成对应 &lt;code&gt;/dev/i2c-N&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;无法离线恢复启动介质、目标节点身份不明确或 DTB 无法反编译时停止修改。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;/dev/i2c-1&lt;/code&gt; 不存在&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Error: Could not open file /dev/i2c-1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;当前系统总线编号不是 1；&lt;/li&gt;
&lt;li&gt;设备树修改没有生效；&lt;/li&gt;
&lt;li&gt;启动程序加载了另一个 DTB；&lt;/li&gt;
&lt;li&gt;控制器注册失败。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -l
ls -l /dev/i2c-*
tr -d &apos;\0&apos; &amp;lt; /proc/device-tree/i2c@c1108500/status
dmesg | grep -iE &apos;i2c|pinctrl|clock|reset&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;项目配置中的 &lt;code&gt;i2c_bus&lt;/code&gt; 改为当前机器实际编号。若节点仍是 &lt;code&gt;disabled&lt;/code&gt;，检查启动 DTB；若是 &lt;code&gt;okay&lt;/code&gt; 但没有适配器，检查驱动、pinctrl、时钟和复位错误。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;i2cdetect -l&lt;/code&gt; 与 &lt;code&gt;/dev/i2c-*&lt;/code&gt; 同时出现目标适配器。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;不要通过手工创建 &lt;code&gt;/dev/i2c-1&lt;/code&gt; 文件解决；设备节点必须由内核驱动注册。&lt;/p&gt;
&lt;h2&gt;PCA9685 指示灯亮但没有应答&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;板上红灯亮，&lt;code&gt;0x40&lt;/code&gt; 仍为 &lt;code&gt;--&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;灯连接在 V+ 而不是 VCC；&lt;/li&gt;
&lt;li&gt;VCC 有电但 SDA/SCL 不通；&lt;/li&gt;
&lt;li&gt;GND 未共地；&lt;/li&gt;
&lt;li&gt;扫描错误总线；&lt;/li&gt;
&lt;li&gt;地址或芯片故障。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;测量 VCC 对控制排针 GND，而不是只看指示灯。检查 SDA/SCL 空闲电平和 K2 端物理 Pin 3/5。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;保持舵机和外部电源断开，逐根按丝印重接逻辑线。换一组已知良好的杜邦线。确认正确总线后只扫 &lt;code&gt;0x40&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;逻辑供电约 3.3V，SDA/SCL 空闲为高，受限扫描显示 &lt;code&gt;40&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;发现 VCC 被接到 5–6V、芯片发热或有异味时立即断电。&lt;/p&gt;
&lt;h2&gt;SDA 或 SCL 空闲电平异常&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;总线空闲时 SDA/SCL 长期接近 0V，或电平明显高于 K2 3.3V。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;信号线短路到 GND；&lt;/li&gt;
&lt;li&gt;外设持续拉低；&lt;/li&gt;
&lt;li&gt;VCC/V+ 混接使上拉电压错误；&lt;/li&gt;
&lt;li&gt;杜邦线插错；&lt;/li&gt;
&lt;li&gt;PCA9685 或 K2 引脚损坏。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;断开 PCA9685 后测 K2 引脚，再只接 VCC/GND，最后逐根接 SDA/SCL，定位在哪一步电平异常。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;全程断电插拔。更换线材，检查焊锡、金属碎屑和相邻针脚短路。确认 PCA9685 VCC 只接 3.3V。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;空闲电平接近 3.3V，通信时示波器或逻辑分析仪能看到开漏波形。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;信号线上出现 5V 或更高电压时立即断电，避免损坏 K2。&lt;/p&gt;
&lt;h2&gt;VCC 与 V+ 混淆&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;I²C 不工作但舵机供电灯亮；&lt;/li&gt;
&lt;li&gt;接外部电源后 K2 异常；&lt;/li&gt;
&lt;li&gt;PCA9685 芯片或 K2 发热。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;外部 5–6V 接到了 VCC；&lt;/li&gt;
&lt;li&gt;K2 3.3V 接到了 V+；&lt;/li&gt;
&lt;li&gt;控制排针 V+ 被误当作逻辑电源；&lt;/li&gt;
&lt;li&gt;绿色端子极性反接。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;断开所有电源，按板上丝印追线：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;K2 3.3V -&amp;gt; VCC
外部 5–6V -&amp;gt; 绿色端子 V+
所有负极 -&amp;gt; GND 共地
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;重新接线前用万用表确认电源极性。外部电源先限流或使用带保护的电源。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;只接逻辑电源时能检测 &lt;code&gt;0x40&lt;/code&gt;；接外部电源后 K2 稳定，绿色端子电压符合舵机规格。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;任何发热、焦味、冒烟或异常高电流都应立即断电，不要继续软件测试。&lt;/p&gt;
&lt;h2&gt;舵机完全不动&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;项目后端初始化成功，舵机没有动作。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;外部 V+ 未供电；&lt;/li&gt;
&lt;li&gt;三线插头方向错误；&lt;/li&gt;
&lt;li&gt;脚本通道与实际插槽不一致；&lt;/li&gt;
&lt;li&gt;OE 被拉高禁用输出；&lt;/li&gt;
&lt;li&gt;1475/1525 的变化肉眼不明显；&lt;/li&gt;
&lt;li&gt;舵机损坏或机械卡住。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;i2cdetect -y BUS 0x40 0x40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测绿色端子 V+，确认插头信号/V+/GND。只接 CH0，一个舵机，从 1500 到 1475/1525。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;确认供电后逐步扩大到 1450/1550，不要直接全范围。换一个已知正常的舵机或通道进行交叉测试。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;舵机在中心附近做可重复小动作，K2 不重启，电源电压稳定。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;舵机持续嗡鸣、发热或无法转动时立即断电。&lt;/p&gt;
&lt;h2&gt;舵机反向&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;目标在右侧，Pan 却向相反方向运动；或 Tilt 上下相反。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;p&gt;舵机安装方向与逻辑正方向相反，或 Pan/Tilt 通道定义互换。&lt;/p&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;用单轴小范围脚本确认 CH0/CH1 分别控制哪个轴，以及脉宽增大时实际方向。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;修改对应轴：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;invert: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或在断电后交换 CH0/CH1 插头与配置。一次只做一种修改。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;目标误差方向与云台纠偏方向一致。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;不要通过反接红黑电源线“改变方向”。&lt;/p&gt;
&lt;h2&gt;舵机抖动或持续嗡鸣&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;中心附近高频微动，或到某位置持续嗡鸣。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dead_zone&lt;/code&gt; 太小；&lt;/li&gt;
&lt;li&gt;检测框噪声；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;smoothing_alpha&lt;/code&gt;、增益或 &lt;code&gt;max_step_us&lt;/code&gt; 不合适；&lt;/li&gt;
&lt;li&gt;机械结构在限位或负载过大；&lt;/li&gt;
&lt;li&gt;电源压降或地线不稳；&lt;/li&gt;
&lt;li&gt;PWM 频率错误。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;先停止视觉端，用固定 1500 微秒观察。若固定脉宽仍嗡鸣，优先排查机械、供电和中心值；若只在跟踪时抖动，再看误差日志和控制参数。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;扩大死区；&lt;/li&gt;
&lt;li&gt;降低增益；&lt;/li&gt;
&lt;li&gt;逐步调整平滑；&lt;/li&gt;
&lt;li&gt;重新标定中心和安全范围；&lt;/li&gt;
&lt;li&gt;改善供电和共地；&lt;/li&gt;
&lt;li&gt;确认 &lt;code&gt;frequency_hz: 50&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;固定中心时安静稳定，跟踪时动作连续且不在中心附近来回切换。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;嗡鸣伴随发热、堵转或大电流时立即断电。&lt;/p&gt;
&lt;h2&gt;舵机撞限位&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;启动或跟踪时机械结构猛烈撞击。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;中心值不适合当前安装角；&lt;/li&gt;
&lt;li&gt;示例 1000～2000 微秒范围远大于实际机械范围；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;invert&lt;/code&gt; 或轴映射错误；&lt;/li&gt;
&lt;li&gt;完整服务在标定前启动。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;断电后手动检查机械可动范围，不强扭舵机。拆下连杆或减小负载后，从 1500 附近小步测试。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;逐轴测量已验证最小、中心和最大脉宽，并写入本地配置。保留额外安全余量。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;所有自动输出均远离机械硬限位，回中过程不碰撞。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;一旦碰撞、卡住或发出齿轮异响，立即断电。&lt;/p&gt;
&lt;h2&gt;接入舵机后 K2 重启或网络断开&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;舵机动作瞬间 K2 重启、SSH 断开或 USB/网络异常。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;舵机从 K2 供电；&lt;/li&gt;
&lt;li&gt;外部电源电流不足；&lt;/li&gt;
&lt;li&gt;地线或端子压降过大；&lt;/li&gt;
&lt;li&gt;瞬时短路；&lt;/li&gt;
&lt;li&gt;舵机堵转电流过大；&lt;/li&gt;
&lt;li&gt;电源与 K2 之间存在错误回灌。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;只接一个舵机，监测外部 V+ 电压。检查 K2 系统日志和重启原因，但不要在堵转时继续采集日志。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;使用独立、足额、带保护的 5–6V 电源，缩短并加粗舵机供电线，确认共地和极性。分别测试两个舵机的空载和负载行为。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;单轴和双轴小范围动作时 K2 不重启，网络和 I²C 稳定，电压无明显跌落。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;线缆、端子或电源发热时立即断电。&lt;/p&gt;
&lt;h2&gt;Pan/Tilt 通道互换&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;水平误差驱动俯仰轴，垂直误差驱动水平轴。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;p&gt;CH0/CH1 插头与配置不一致。&lt;/p&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;使用固定脉宽脚本分别写通道 0 和 1，观察实际轴。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;断电后交换插头，或修改：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pan: {channel: 0}
tilt: {channel: 1}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;CH0/Pan 纠正 &lt;code&gt;error_x&lt;/code&gt;，CH1/Tilt 纠正 &lt;code&gt;error_y&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;通道互换本身通常不会损坏硬件，但错误方向导致撞限位时必须立即断电。&lt;/p&gt;
&lt;h2&gt;UDP 有发送日志但云台不跟踪&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;视觉端显示 UDP 发送成功，K2 不进入 tracking。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;allowed_n100_ip&lt;/code&gt; 不匹配视觉主机；&lt;/li&gt;
&lt;li&gt;端口或防火墙错误；&lt;/li&gt;
&lt;li&gt;消息被序号、实例、时间戳或字段校验拒绝；&lt;/li&gt;
&lt;li&gt;K2 使用 simulated 后端；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;target_visible&lt;/code&gt; 为假；&lt;/li&gt;
&lt;li&gt;K2 已进入 fault；&lt;/li&gt;
&lt;li&gt;K2 状态回传路径不通。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;k2-gimbal --config configs/k2.local.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看两端日志、K2 心跳、模式、最近序号和 fault。确认系统时钟大致同步。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;先保持 simulated 后端，修复地址、端口和防火墙。确认 K2 能收到合法控制并回传状态后，再切真实后端。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;K2 心跳在线，目标可见时模式为 tracking，目标丢失时按配置进入 holding/return-center。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;不要为了“让包通过”关闭协议校验或允许任意来源地址。&lt;/p&gt;
&lt;h2&gt;视觉端帧率低&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;摄像头约 30 FPS，但 YOLO/ByteTrack 只有较低 FPS。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;模型或输入分辨率超过 CPU 能力；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;inference_ms&lt;/code&gt; 是主要瓶颈；&lt;/li&gt;
&lt;li&gt;输入帧排队导致 &lt;code&gt;input_age_ms_avg&lt;/code&gt; 高；&lt;/li&gt;
&lt;li&gt;后处理候选过多；&lt;/li&gt;
&lt;li&gt;ByteTrack 或 Python 其他开销较大；&lt;/li&gt;
&lt;li&gt;浏览器流与推理速率被混淆。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;查看 5 秒聚合的 &lt;code&gt;vision perf&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;track_call_ms_avg
preprocess_ms
inference_ms
postprocess_ms
other_ms
input_age_ms_avg
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;一次只改变一个变量：先减小输入分辨率，再换更小模型，最后考虑硬件加速。每次保留同一测试场景和日志。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;推理 FPS 提高，输入帧年龄不持续累积，跟踪 ID 和控制行为仍稳定。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;设备温度异常、系统频繁降频或进程内存持续增长时停止耐久测试。&lt;/p&gt;
&lt;h2&gt;跟踪 ID 频繁跳变&lt;/h2&gt;
&lt;h3&gt;现象&lt;/h3&gt;
&lt;p&gt;相同目标的 &lt;code&gt;track_id&lt;/code&gt; 经常改变，或主目标在多个对象间跳转。&lt;/p&gt;
&lt;h3&gt;最可能原因&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;检测间断；&lt;/li&gt;
&lt;li&gt;遮挡时间超过 &lt;code&gt;lost_timeout_ms&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;新目标切换阈值太低；&lt;/li&gt;
&lt;li&gt;置信度阈值或模型类别不合适；&lt;/li&gt;
&lt;li&gt;目标外观相似、速度过快。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;无破坏性检查&lt;/h3&gt;
&lt;p&gt;分别记录检测框是否连续、原始 track_id、目标评分和切换原因。不要只看最终被选中的 ID。&lt;/p&gt;
&lt;h3&gt;安全修复&lt;/h3&gt;
&lt;p&gt;逐项调整：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lost_timeout_ms: 500
switch_improvement_ratio: 1.2
switch_confirmation_frames: 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先保证检测稳定，再调整跟踪和选择滞回。&lt;/p&gt;
&lt;h3&gt;成功判据&lt;/h3&gt;
&lt;p&gt;短暂遮挡不立即切换，明显更优目标经过确认后才切换。&lt;/p&gt;
&lt;h3&gt;立即停止&lt;/h3&gt;
&lt;p&gt;ID 跳变通常不是电气危险，但云台因目标切换快速摆动时应切回 simulated 后端。&lt;/p&gt;
&lt;h2&gt;通用排查顺序&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;关闭完整视觉服务；&lt;/li&gt;
&lt;li&gt;关闭外部舵机电源；&lt;/li&gt;
&lt;li&gt;只验证 K2 系统和设备树；&lt;/li&gt;
&lt;li&gt;只验证 PCA9685 I²C &lt;code&gt;0x40&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;验证项目后端初始化；&lt;/li&gt;
&lt;li&gt;只接 CH0 小范围测试；&lt;/li&gt;
&lt;li&gt;只接 CH1 小范围测试；&lt;/li&gt;
&lt;li&gt;使用 simulated 后端验证 UDP 和状态机；&lt;/li&gt;
&lt;li&gt;写入实测安全脉宽；&lt;/li&gt;
&lt;li&gt;最后启用双机完整跟踪。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;跳过中间层会让网络、软件、电气和机械问题互相掩盖。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目仓库：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;FriendlyELEC NanoPi K2：&lt;a href=&quot;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&quot;&gt;https://wiki.friendlyelec.com/wiki/index.php/NanoPi_K2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NXP PCA9685 数据手册：&lt;a href=&quot;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&quot;&gt;https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Linux I²C 用户空间接口：&lt;a href=&quot;https://docs.kernel.org/i2c/dev-interface.html&quot;&gt;https://docs.kernel.org/i2c/dev-interface.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Debian &lt;code&gt;i2cdetect&lt;/code&gt; 手册：&lt;a href=&quot;https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html&quot;&gt;https://manpages.debian.org/i2c-tools/i2cdetect.8.en.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Ultralytics Track 模式：&lt;a href=&quot;https://docs.ultralytics.com/modes/track/&quot;&gt;https://docs.ultralytics.com/modes/track/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Windows/N100 视觉端：相机、YOLO、ByteTrack 与性能诊断</title><link>https://zh19990906.github.io/fuwari/posts/yolo-gimbal-windows-vision-host/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-gimbal-windows-vision-host/</guid><description>配置跨平台摄像头、Ultralytics YOLO、ByteTrack、目标选择、固定频率 UDP 和 vision perf 性能日志。</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;视觉主机完成从摄像头到归一化目标误差的全部工作。它可以运行在 Windows 电脑，也可以运行在 Linux N100 上。两种平台共用同一套 YAML 结构和控制协议，只在 OpenCV 相机源解析与采集后端上有所不同。&lt;/p&gt;
&lt;p&gt;项目正式支持 Python 3.11+。合并后的 WebSocket 修复兼容 Python 3.10 的 &lt;code&gt;asyncio.TimeoutError&lt;/code&gt; 语义，但这不代表 Python 3.10 已成为安装或 CI 支持版本。&lt;/p&gt;
&lt;h2&gt;数据处理链路&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;OpenCV 摄像头
  -&amp;gt; 最新帧缓存
  -&amp;gt; Ultralytics YOLO model.track()
  -&amp;gt; ByteTrack track_id
  -&amp;gt; 配置类别过滤
  -&amp;gt; 主目标选择与切换滞回
  -&amp;gt; 归一化 error_x / error_y
  -&amp;gt; 固定 20 Hz JSON/UDP
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;采集、推理、发送和 Web 展示不是同一个频率：摄像头可能采集 30 FPS，YOLO 只完成 8 FPS，UDP 仍以 20 Hz 发送最新有效结果，MJPEG 又可能限制为 15 FPS。排查性能时必须区分这些速率。&lt;/p&gt;
&lt;h2&gt;安装与配置检查&lt;/h2&gt;
&lt;p&gt;Windows PowerShell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install &quot;.[n100]&quot;
yolo-vision --config configs\n100.windows.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Linux/N100：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install &apos;.[n100]&apos;
cp configs/n100.example.yaml configs/n100.local.yaml
yolo-vision --config configs/n100.local.yaml --check-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--check-config&lt;/code&gt; 只验证配置，不打开摄像头和模型。先通过静态检查，再启动完整服务，可以把路径、类型和字段错误与运行时硬件问题分开。&lt;/p&gt;
&lt;h2&gt;Windows 相机源与 DirectShow&lt;/h2&gt;
&lt;p&gt;Windows 配置中的数字摄像头索引应写成字符串：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;camera:
  device: &quot;0&quot;
  width: 640
  height: 480
  fps: 30
  pixel_format: MJPG
  buffer_size: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;项目会把全十进制字符串转换为整数索引并选择 OpenCV &lt;code&gt;CAP_DSHOW&lt;/code&gt;。例如 &lt;code&gt;&quot;0&quot;&lt;/code&gt; 和 &lt;code&gt;&quot;1&quot;&lt;/code&gt; 会被视为本机摄像头编号；非数字字符串保持原样。&lt;/p&gt;
&lt;p&gt;推荐使用原生 Windows Python，而不是依赖 Docker Desktop 或 WSL2 透传 USB 摄像头。首次启动前先关闭可能占用设备的会议软件、浏览器相机页面或厂商预览程序。&lt;/p&gt;
&lt;p&gt;Windows 防火墙至少要允许：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New-NetFirewallRule -DisplayName &quot;YOLO Gimbal Web&quot; `
  -Direction Inbound -Protocol TCP -LocalPort 8000 -Action Allow

New-NetFirewallRule -DisplayName &quot;YOLO Gimbal K2 Status&quot; `
  -Direction Inbound -Protocol UDP -LocalPort 6001 -Action Allow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;视觉主机使用固定局域网地址或 DHCP 静态租约。配置示例应使用 &lt;code&gt;VISION_HOST_IP&lt;/code&gt;、&lt;code&gt;K2_HOST_IP&lt;/code&gt; 之类的占位符，不把真实地址提交到公开仓库。&lt;/p&gt;
&lt;h2&gt;Linux/N100 相机源与 V4L2&lt;/h2&gt;
&lt;p&gt;Linux 的 &lt;code&gt;/dev/video*&lt;/code&gt; 和 &lt;code&gt;/dev/v4l/by-id/*&lt;/code&gt; 源会选择 OpenCV &lt;code&gt;CAP_V4L2&lt;/code&gt;。长期运行推荐使用稳定的 by-id 路径，而不是可能随插拔变化的 &lt;code&gt;/dev/video0&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;camera:
  device: /dev/v4l/by-id/REPLACE_WITH_CAMERA_ID
  width: 1280
  height: 720
  fps: 30
  pixel_format: MJPG
  buffer_size: 1
  reconnect_initial_ms: 250
  reconnect_max_ms: 5000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可先检查设备：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;v4l2-ctl --list-devices
v4l2-ctl --device /dev/video0 --list-formats-ext
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求的分辨率、FPS 和 MJPG 不一定被摄像头完全接受。运行后应观察实际分辨率和采集速率，而不是只看 YAML 期望值。&lt;/p&gt;
&lt;h2&gt;Ultralytics YOLO 与 YOLO26 版本边界&lt;/h2&gt;
&lt;p&gt;项目依赖约束为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ultralytics&amp;gt;=8.4.102,&amp;lt;8.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该约束来自项目对 YOLO26 模型使用的验证版本线。模型文件不提交到 Git，配置指向本机路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vision:
  model_path: C:/code/yolo-gimbal-tracker/models/target.pt
  target_classes: [target_class]
  confidence_threshold: 0.5
  tracker: bytetrack.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动前确认：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型路径存在且当前用户可读；&lt;/li&gt;
&lt;li&gt;模型类别名称与 &lt;code&gt;target_classes&lt;/code&gt; 完全一致；&lt;/li&gt;
&lt;li&gt;CPU 环境不要默认采用过大的模型或分辨率；&lt;/li&gt;
&lt;li&gt;安装后的 Ultralytics 版本位于约束范围；&lt;/li&gt;
&lt;li&gt;不把模型推理 FPS 与摄像头采集 FPS 混为一谈。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可检查环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python - &amp;lt;&amp;lt;&apos;PY&apos;
import platform
import ultralytics

print(&quot;python:&quot;, platform.python_version())
print(&quot;ultralytics:&quot;, ultralytics.__version__)
PY
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;ByteTrack 与目标 ID&lt;/h2&gt;
&lt;p&gt;视觉端调用 Ultralytics 跟踪模式，并使用 ByteTrack 维护 &lt;code&gt;track_id&lt;/code&gt;。跟踪 ID 用于减少多目标之间的频繁切换，但不是永久身份：遮挡、检测中断或场景变化仍可能导致 ID 重建。&lt;/p&gt;
&lt;p&gt;目标选择包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只保留 YAML 配置类别；&lt;/li&gt;
&lt;li&gt;按目标面积和画面下方位置计算评分；&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;switch_improvement_ratio&lt;/code&gt; 要求新目标明显更优；&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;switch_confirmation_frames&lt;/code&gt; 要求连续多帧确认；&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;lost_timeout_ms&lt;/code&gt; 在短暂遮挡时保留当前目标。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;示例参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vision:
  lost_timeout_ms: 500
  switch_improvement_ratio: 1.2
  switch_confirmation_frames: 5
  area_weight: 0.8
  bottom_weight: 0.2
  result_ttl_ms: 300
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;滞回可以减少跳转，但设置过强会让系统过久坚持错误目标；设置过弱则会在相邻对象之间抖动。调整前先录制日志和视频证据。&lt;/p&gt;
&lt;h2&gt;归一化误差&lt;/h2&gt;
&lt;p&gt;视觉端以画面中心为零点，将目标中心偏移转换为归一化误差：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error_x &amp;lt; 0：目标在画面左侧
error_x &amp;gt; 0：目标在画面右侧
error_y &amp;lt; 0：目标在画面上方
error_y &amp;gt; 0：目标在画面下方
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;视觉端不直接输出舵机角度或脉宽。方向反转、增益、中心值和安全范围属于 K2 配置。这样更换相机分辨率时，控制接口仍然保持统一。&lt;/p&gt;
&lt;h2&gt;固定 20 Hz UDP 发送&lt;/h2&gt;
&lt;p&gt;推理线程完成一帧后更新“最新结果”，独立发送循环以配置频率读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;control:
  host: K2_HOST_IP
  port: 6000
  send_rate_hz: 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;固定发送频率有三个作用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;避免推理耗时抖动直接变成舵机更新时间抖动；&lt;/li&gt;
&lt;li&gt;K2 可以用稳定节拍判断消息新鲜度；&lt;/li&gt;
&lt;li&gt;性能降低时仍能发送最近一次有效状态或明确的无目标状态。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;result_ttl_ms&lt;/code&gt; 用于限制结果寿命。超过期限的检测不能无限期作为当前目标继续发送。视觉端异常退出后，K2 应依靠本地 holding 和 return-center 处理失联。&lt;/p&gt;
&lt;h2&gt;vision perf 聚合日志&lt;/h2&gt;
&lt;p&gt;项目每 5 秒输出一行低频性能摘要，而不是逐帧刷日志。典型形式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vision perf fps=8.17 track_call_ms_avg=121.6 track_call_ms_max=137.2 input_age_ms_avg=18.4 preprocess_ms=3.1 inference_ms=104.7 postprocess_ms=7.8 other_ms=6.0 detections_avg=1.20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;字段含义：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;实际完成推理/跟踪的速率&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;track_call_ms_avg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;model.track()&lt;/code&gt; 平均总耗时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;track_call_ms_max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;统计周期内最大总耗时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;input_age_ms_avg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;推理开始时输入帧已经等待的平均时间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preprocess_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ultralytics 预处理耗时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inference_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;模型推理耗时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postprocess_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;后处理耗时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;other_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;总调用时间中未计入前三阶段的部分&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detections_avg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每次结果的平均检测数量&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;诊断思路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;inference_ms&lt;/code&gt; 高：优先检查模型大小、输入分辨率、设备选择和硬件能力；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preprocess_ms&lt;/code&gt; 高：检查图像尺寸转换、像素格式和 CPU；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;postprocess_ms&lt;/code&gt; 高：检查候选框数量、置信度阈值和类别；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;other_ms&lt;/code&gt; 高：可能来自 ByteTrack、Python 调度、数据转换或 Ultralytics 其他开销；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;input_age_ms_avg&lt;/code&gt; 高：采集速度快于消费速度，处理的是陈旧帧；&lt;/li&gt;
&lt;li&gt;浏览器流畅但 &lt;code&gt;fps&lt;/code&gt; 低：MJPEG 与推理是不同链路，不能用网页观感代替推理测量。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;先记录同一模型和分辨率下的基线，再逐项修改。不要同时改变模型、输入尺寸、线程数和摄像头格式，否则无法确定收益来源。&lt;/p&gt;
&lt;h2&gt;Python 3.10 WebSocket 超时问题&lt;/h2&gt;
&lt;p&gt;状态 WebSocket 使用 &lt;code&gt;asyncio.wait_for()&lt;/code&gt; 的超时作为轮询节拍。Python 3.10 抛出 &lt;code&gt;asyncio.TimeoutError&lt;/code&gt;，较新 Python 版本的异常语义发生变化。原实现只捕获内置 &lt;code&gt;TimeoutError&lt;/code&gt;，导致 Python 3.10 上正常的轮询超时逃逸为 ASGI 错误。&lt;/p&gt;
&lt;p&gt;合并后的修复显式捕获：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;except asyncio.TimeoutError:
    pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个问题与 YOLO 推理慢没有直接因果关系，但大量 WebSocket 异常会干扰性能排查。正式环境仍应升级到 Python 3.11+，而不是以兼容修复为理由长期停留在 3.10。&lt;/p&gt;
&lt;h2&gt;启动与观察&lt;/h2&gt;
&lt;p&gt;Windows：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo-vision --config configs\n100.windows.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Linux：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo-vision --config configs/n100.local.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浏览器访问：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://VISION_HOST_IP:8000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;摄像头是否持续采集；&lt;/li&gt;
&lt;li&gt;实际分辨率和采集 FPS 是否合理；&lt;/li&gt;
&lt;li&gt;模型是否正确加载；&lt;/li&gt;
&lt;li&gt;检测类别和目标 ID 是否符合预期；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vision perf&lt;/code&gt; 的耗时主要位于哪一阶段；&lt;/li&gt;
&lt;li&gt;UDP 发送是否成功；&lt;/li&gt;
&lt;li&gt;K2 心跳是否在线；&lt;/li&gt;
&lt;li&gt;网页显示的 K2 模式和脉宽是否更新。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;常见性能决策&lt;/h2&gt;
&lt;h3&gt;CPU 推理不足&lt;/h3&gt;
&lt;p&gt;按风险从低到高尝试：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;减小输入分辨率；&lt;/li&gt;
&lt;li&gt;使用更小的模型；&lt;/li&gt;
&lt;li&gt;降低不必要的网页流帧率；&lt;/li&gt;
&lt;li&gt;检查是否使用 MJPG，避免高成本原始格式传输；&lt;/li&gt;
&lt;li&gt;使用有支持的 GPU/加速后端；&lt;/li&gt;
&lt;li&gt;针对目标硬件做专门导出和部署。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;每一步都用相同测试场景比较 &lt;code&gt;inference_ms&lt;/code&gt;、&lt;code&gt;track_call_ms_avg&lt;/code&gt; 和 &lt;code&gt;input_age_ms_avg&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;ID 跳变&lt;/h3&gt;
&lt;p&gt;先检查检测是否连续，再调整 &lt;code&gt;lost_timeout_ms&lt;/code&gt;、切换改进比例和确认帧数。目标长期消失后重建 ID 属于正常现象，不能把所有 ID 变化都归因于 ByteTrack 故障。&lt;/p&gt;
&lt;h3&gt;延迟不断累积&lt;/h3&gt;
&lt;p&gt;项目采用最新帧和小缓冲设计。若 &lt;code&gt;input_age_ms_avg&lt;/code&gt; 持续增长，检查实际采集实现是否仍只保留最新帧、摄像头驱动是否忽略缓冲配置，以及是否有额外队列保存历史帧。&lt;/p&gt;
&lt;h2&gt;已验证与待验证&lt;/h2&gt;
&lt;p&gt;已从主分支或合并记录确认：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Windows 数字索引使用 &lt;code&gt;CAP_DSHOW&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;Linux 设备路径使用 &lt;code&gt;CAP_V4L2&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;Ultralytics YOLO + ByteTrack；&lt;/li&gt;
&lt;li&gt;固定 20 Hz UDP 发送；&lt;/li&gt;
&lt;li&gt;每 5 秒 &lt;code&gt;vision perf&lt;/code&gt; 聚合日志；&lt;/li&gt;
&lt;li&gt;YOLO26 对应的 Ultralytics 版本约束；&lt;/li&gt;
&lt;li&gt;Python 3.10 &lt;code&gt;asyncio.TimeoutError&lt;/code&gt; 修复。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;仍需在目标机器验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;实际相机可用格式与稳定编号；&lt;/li&gt;
&lt;li&gt;目标模型在 Windows/N100 上的长期 FPS 和温度；&lt;/li&gt;
&lt;li&gt;多目标切换参数；&lt;/li&gt;
&lt;li&gt;与 K2、舵机连接后的端到端延迟和稳定性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目仓库：&lt;a href=&quot;https://github.com/zh19990906/yolo-gimbal-tracker&quot;&gt;https://github.com/zh19990906/yolo-gimbal-tracker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Ultralytics Track 模式：&lt;a href=&quot;https://docs.ultralytics.com/modes/track/&quot;&gt;https://docs.ultralytics.com/modes/track/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Ultralytics Predict 模式：&lt;a href=&quot;https://docs.ultralytics.com/modes/predict/&quot;&gt;https://docs.ultralytics.com/modes/predict/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenCV VideoCapture：&lt;a href=&quot;https://docs.opencv.org/4.x/d8/dfe/classcv_1_1VideoCapture.html&quot;&gt;https://docs.opencv.org/4.x/d8/dfe/classcv_1_1VideoCapture.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Python &lt;code&gt;asyncio.wait_for&lt;/code&gt;：&lt;a href=&quot;https://docs.python.org/3/library/asyncio-task.html#asyncio.wait_for&quot;&gt;https://docs.python.org/3/library/asyncio-task.html#asyncio.wait_for&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>FastAPI 认证授权实战：OAuth2、JWT、角色与资源权限</title><link>https://zh19990906.github.io/fuwari/posts/fastapi-authentication-authorization/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/fastapi-authentication-authorization/</guid><description>使用 FastAPI 构建可审计的认证与授权边界，覆盖密码哈希、访问令牌、刷新令牌、角色权限和资源所有者检查。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;认证回答“你是谁”，授权回答“你能做什么”。很多接口只验证 JWT 是否能解码，却没有继续检查账号状态、权限范围和资源归属，这会把身份凭据误当成完整的访问控制系统。&lt;/p&gt;
&lt;p&gt;本文以 FastAPI 自带的 &lt;code&gt;OAuth2PasswordBearer&lt;/code&gt; 为入口，给出一套可拆分、可测试的认证授权结构。示例只演示边界，不包含完整用户中心、第三方登录或企业级身份提供商。&lt;/p&gt;
&lt;h2&gt;数据模型先区分身份与权限&lt;/h2&gt;
&lt;p&gt;最小用户模型通常至少包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;稳定的用户 ID；&lt;/li&gt;
&lt;li&gt;唯一登录标识；&lt;/li&gt;
&lt;li&gt;密码哈希而不是明文密码；&lt;/li&gt;
&lt;li&gt;是否禁用；&lt;/li&gt;
&lt;li&gt;角色或权限集合；&lt;/li&gt;
&lt;li&gt;凭据版本，用于让旧令牌整体失效；&lt;/li&gt;
&lt;li&gt;创建时间和最近安全事件时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;角色适合表达岗位，例如 &lt;code&gt;admin&lt;/code&gt;、&lt;code&gt;editor&lt;/code&gt;、&lt;code&gt;viewer&lt;/code&gt;；权限适合表达动作，例如 &lt;code&gt;article:write&lt;/code&gt;。资源级操作还要检查资源所有者，而不是只看角色。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass


@dataclass(frozen=True)
class Principal:
    user_id: str
    roles: frozenset[str]
    permissions: frozenset[str]
    disabled: bool = False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要把邮箱、手机号、权限列表等频繁变化的信息全部塞进长期 JWT。令牌中的声明越多，信息过期和泄露后的影响越大。&lt;/p&gt;
&lt;h2&gt;密码使用 Argon2 哈希&lt;/h2&gt;
&lt;p&gt;密码必须使用专门的密码哈希算法。FastAPI 当前安全教程推荐通过 &lt;code&gt;pwdlib&lt;/code&gt; 使用 Argon2。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install &quot;pwdlib[argon2]&quot; pyjwt
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()


def hash_password(raw_password: str) -&amp;gt; str:
    return password_hash.hash(raw_password)


def verify_password(raw_password: str, stored_hash: str) -&amp;gt; bool:
    return password_hash.verify(raw_password, stored_hash)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;密码哈希和普通 SHA-256 用途不同。不要自行拼盐，也不要把密码加密后保存为可逆密文。登录失败时返回统一错误，避免泄露“用户存在但密码错误”之类的账号枚举信息。&lt;/p&gt;
&lt;h2&gt;Access Token 只承担短期访问&lt;/h2&gt;
&lt;p&gt;访问令牌应当短期有效，并包含明确的签发者、受众、主题和过期时间。签名密钥从运行环境或密钥管理系统读取。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
from datetime import datetime, timedelta, timezone

import jwt

JWT_SIGNING_KEY = os.environ[&quot;JWT_SIGNING_KEY&quot;]
JWT_ISSUER = &quot;https://auth.example.com&quot;
JWT_AUDIENCE = &quot;blog-api&quot;


def create_access_token(user_id: str, credential_version: int) -&amp;gt; str:
    now = datetime.now(timezone.utc)
    payload = {
        &quot;sub&quot;: user_id,
        &quot;iss&quot;: JWT_ISSUER,
        &quot;aud&quot;: JWT_AUDIENCE,
        &quot;iat&quot;: now,
        &quot;exp&quot;: now + timedelta(minutes=15),
        &quot;ver&quot;: credential_version,
        &quot;typ&quot;: &quot;access&quot;,
    }
    return jwt.encode(payload, JWT_SIGNING_KEY, algorithm=&quot;HS256&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 是签名载荷，不是加密容器。客户端和日志系统都可能看到载荷内容，因此不要写入密码、身份证号、内部备注或其他敏感信息。&lt;/p&gt;
&lt;p&gt;验证时固定算法、签发者和受众，不要根据令牌头部动态接受任意算法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from jwt.exceptions import InvalidTokenError


def decode_access_token(token: str) -&amp;gt; dict[str, object]:
    try:
        payload = jwt.decode(
            token,
            JWT_SIGNING_KEY,
            algorithms=[&quot;HS256&quot;],
            issuer=JWT_ISSUER,
            audience=JWT_AUDIENCE,
        )
    except InvalidTokenError as exc:
        raise ValueError(&quot;invalid access token&quot;) from exc

    if payload.get(&quot;typ&quot;) != &quot;access&quot;:
        raise ValueError(&quot;unexpected token type&quot;)
    return payload
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;在 FastAPI 中解析当前用户&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl=&quot;/auth/token&quot;)


async def get_current_principal(
    token: Annotated[str, Depends(oauth2_scheme)],
) -&amp;gt; Principal:
    try:
        payload = decode_access_token(token)
        user_id = str(payload[&quot;sub&quot;])
    except (KeyError, ValueError):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=&quot;invalid credentials&quot;,
            headers={&quot;WWW-Authenticate&quot;: &quot;Bearer&quot;},
        )

    user = await load_user_and_permissions(user_id)
    if user is None or user.disabled:
        raise HTTPException(status_code=401, detail=&quot;invalid credentials&quot;)
    if int(payload.get(&quot;ver&quot;, -1)) != user.credential_version:
        raise HTTPException(status_code=401, detail=&quot;credentials expired&quot;)

    return Principal(
        user_id=user.id,
        roles=frozenset(user.roles),
        permissions=frozenset(user.permissions),
        disabled=user.disabled,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据库查询应使用用户 ID，而不是信任令牌里携带的完整权限快照。对性能敏感时可以缓存权限，但要设计明确的失效策略。&lt;/p&gt;
&lt;h2&gt;Refresh Token 要可撤销和轮换&lt;/h2&gt;
&lt;p&gt;Refresh Token 生命周期更长，不应与 Access Token 使用完全相同的处理方式。推荐只给客户端一个高熵随机值，服务端保存其哈希、用户 ID、过期时间、设备信息和撤销状态。&lt;/p&gt;
&lt;p&gt;一次刷新流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端提交 Refresh Token；&lt;/li&gt;
&lt;li&gt;服务端哈希后查询记录；&lt;/li&gt;
&lt;li&gt;检查是否过期、撤销或已被使用；&lt;/li&gt;
&lt;li&gt;撤销旧记录并生成新记录；&lt;/li&gt;
&lt;li&gt;返回新的 Access Token 与 Refresh Token；&lt;/li&gt;
&lt;li&gt;如果检测到已轮换令牌被再次使用，撤销该令牌家族。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种 Refresh Token 轮换比“永远有效的第二个 JWT”更容易撤销和审计。登出、修改密码、账号冻结时必须让相关刷新凭据失效。&lt;/p&gt;
&lt;h2&gt;权限依赖只检查一个动作&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections.abc import Callable


def require_permission(permission: str) -&amp;gt; Callable:
    async def dependency(
        principal: Annotated[Principal, Depends(get_current_principal)],
    ) -&amp;gt; Principal:
        if permission not in principal.permissions:
            raise HTTPException(status_code=403, detail=&quot;forbidden&quot;)
        return principal

    return dependency
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;@app.post(&quot;/articles&quot;)
async def create_article(
    principal: Annotated[
        Principal,
        Depends(require_permission(&quot;article:write&quot;)),
    ],
):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;认证失败使用 &lt;code&gt;401&lt;/code&gt;；已经认证但没有权限使用 &lt;code&gt;403&lt;/code&gt;。不要通过不同错误内容向外暴露内部角色和权限结构。&lt;/p&gt;
&lt;h2&gt;资源所有者检查不可省略&lt;/h2&gt;
&lt;p&gt;用户拥有 &lt;code&gt;article:write&lt;/code&gt; 不代表可以修改所有文章。资源加载和授权检查应处于同一个清晰流程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def require_article_editor(
    article_id: str,
    principal: Annotated[Principal, Depends(get_current_principal)],
):
    article = await load_article(article_id)
    if article is None:
        raise HTTPException(status_code=404, detail=&quot;article not found&quot;)

    is_owner = article.owner_id == principal.user_id
    is_admin = &quot;article:admin&quot; in principal.permissions
    if not (is_owner or is_admin):
        raise HTTPException(status_code=403, detail=&quot;forbidden&quot;)
    return article
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多租户系统还必须先限定租户，再判断资源所有者。任何只根据前端传来的 &lt;code&gt;tenant_id&lt;/code&gt; 或 &lt;code&gt;owner_id&lt;/code&gt; 做授权的实现都不可靠。&lt;/p&gt;
&lt;h2&gt;浏览器客户端的保存位置&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HttpOnly&lt;/code&gt;、&lt;code&gt;Secure&lt;/code&gt;、合理 &lt;code&gt;SameSite&lt;/code&gt; 的 Cookie 可以降低脚本直接读取令牌的风险，但需要处理 CSRF；&lt;/li&gt;
&lt;li&gt;浏览器本地存储容易受到 XSS 后的令牌读取影响；&lt;/li&gt;
&lt;li&gt;移动端应使用平台安全存储；&lt;/li&gt;
&lt;li&gt;无论使用哪种位置，都要缩短 Access Token 生命周期并避免把令牌写入 URL。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要在访问日志、错误追踪、分析事件和前端调试输出中记录 Authorization Header。&lt;/p&gt;
&lt;h2&gt;常见失败模式&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;只验证签名，不验证 &lt;code&gt;iss&lt;/code&gt;、&lt;code&gt;aud&lt;/code&gt;、&lt;code&gt;exp&lt;/code&gt; 和令牌类型；&lt;/li&gt;
&lt;li&gt;永久有效 Access Token；&lt;/li&gt;
&lt;li&gt;修改密码后旧令牌仍然长期有效；&lt;/li&gt;
&lt;li&gt;Refresh Token 不轮换、不可撤销；&lt;/li&gt;
&lt;li&gt;管理接口只检查“已登录”；&lt;/li&gt;
&lt;li&gt;只做角色判断，不检查资源所有者；&lt;/li&gt;
&lt;li&gt;在 JWT 中放入敏感数据；&lt;/li&gt;
&lt;li&gt;把认证成功当作业务操作幂等和审计完成。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 密码只保存 Argon2 等专用密码哈希；&lt;/li&gt;
&lt;li&gt;[ ] Access Token 设置短过期时间并验证签发者和受众；&lt;/li&gt;
&lt;li&gt;[ ] Refresh Token 可撤销、可轮换并记录使用状态；&lt;/li&gt;
&lt;li&gt;[ ] 密码修改、账号冻结和安全事件能够让旧凭据失效；&lt;/li&gt;
&lt;li&gt;[ ] 每个写接口都有明确权限和资源所有者检查；&lt;/li&gt;
&lt;li&gt;[ ] 多租户查询始终包含租户边界；&lt;/li&gt;
&lt;li&gt;[ ] 日志和错误追踪不记录令牌、密码或认证头；&lt;/li&gt;
&lt;li&gt;[ ] 认证、越权、撤销和令牌重放均有自动化测试；&lt;/li&gt;
&lt;li&gt;[ ] 高风险操作有审计事件和二次确认策略。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/tutorial/security/&quot;&gt;FastAPI Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/&quot;&gt;FastAPI OAuth2 with Password and JWT&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据现有 FastAPI 工程文档体系补充，并以官方安全教程为主要依据进行整理。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>生产 Dockerfile：多阶段构建、缓存、非 root 用户与供应链安全</title><link>https://zh19990906.github.io/fuwari/posts/dockerfile-production-security/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/dockerfile-production-security/</guid><description>构建体积更小、权限更低且可验证的生产镜像，覆盖 multi-stage、缓存、基础镜像固定、BuildKit Secret、SBOM 和扫描。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;生产镜像不仅要“能启动”，还要可重复构建、最小化运行权限、减少不必要软件和提供供应链证据。把编译器、包管理缓存、测试工具和源码全部留在最终镜像中，会增加体积和攻击面。&lt;/p&gt;
&lt;p&gt;本文以 Python Web 服务为例，原则同样适用于 Node.js、Go 和其他应用。&lt;/p&gt;
&lt;h2&gt;使用 multi-stage 构建&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# syntax=docker/dockerfile:1

FROM python:3.13-slim@sha256:REPLACE_WITH_VERIFIED_DIGEST AS builder

WORKDIR /build
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1

COPY requirements.txt .
RUN python -m venv /opt/venv \
    &amp;amp;&amp;amp; /opt/venv/bin/pip install --require-hashes -r requirements.txt

FROM python:3.13-slim@sha256:REPLACE_WITH_VERIFIED_DIGEST AS runtime

ENV PATH=&quot;/opt/venv/bin:$PATH&quot; \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

RUN groupadd --system --gid 10001 app \
    &amp;amp;&amp;amp; useradd --system --uid 10001 --gid app --home-dir /app app

WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY --chown=app:app app ./app

USER app
EXPOSE 8000

CMD [&quot;python&quot;, &quot;-m&quot;, &quot;uvicorn&quot;, &quot;app.main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;builder 阶段包含安装工具，runtime 阶段只保留运行所需文件。构建阶段仍然不应注入会被写进镜像层的真实密钥。&lt;/p&gt;
&lt;h2&gt;选择并固定基础镜像&lt;/h2&gt;
&lt;p&gt;优先使用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;官方或组织维护的可信镜像；&lt;/li&gt;
&lt;li&gt;与应用需求匹配的最小变体；&lt;/li&gt;
&lt;li&gt;明确的语言和系统版本；&lt;/li&gt;
&lt;li&gt;定期更新并重新构建；&lt;/li&gt;
&lt;li&gt;生产发布使用经过验证的 digest。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;标签可能被重新指向，digest 是不可变内容标识。固定 digest 会让更新变成显式动作，因此需要 Dependabot 或其他自动化及时提出更新 PR。&lt;/p&gt;
&lt;p&gt;不要因为追求最小体积就盲目切换基础发行版。原生依赖、证书、时区、调试能力和漏洞修复渠道同样重要。&lt;/p&gt;
&lt;h2&gt;.dockerignore 控制构建上下文&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;.git
.github
.venv
__pycache__
.pytest_cache
.mypy_cache
.env
secrets/
tests/
docs/
dist/
*.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.dockerignore&lt;/code&gt; 可以减少发送给 BuildKit 的内容，并避免意外把密钥、Git 历史和本地缓存复制进构建上下文。即使文件没有 &lt;code&gt;COPY&lt;/code&gt;，也不应把敏感目录发送给不受控的远程 Builder。&lt;/p&gt;
&lt;h2&gt;按变化频率排列 COPY&lt;/h2&gt;
&lt;p&gt;错误顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;COPY . .
RUN pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;任何源码变化都会让依赖安装缓存失效。&lt;/p&gt;
&lt;p&gt;更好的顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;COPY requirements.txt .
RUN pip install --require-hashes -r requirements.txt
COPY app ./app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先复制变化较少的依赖清单，再复制源码。依赖文件变化时才重新安装。&lt;/p&gt;
&lt;h2&gt;依赖锁定和哈希&lt;/h2&gt;
&lt;p&gt;生产构建应使用锁文件或哈希校验，避免同一个版本范围在不同日期解析为不同依赖集合。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi==X.Y.Z \
    --hash=sha256:REPLACE_WITH_PACKAGE_HASH
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例中的版本和哈希必须由项目实际锁定工具生成，不能复制虚构值。更新依赖后运行测试并重新扫描镜像。&lt;/p&gt;
&lt;h2&gt;不安装无关软件&lt;/h2&gt;
&lt;p&gt;Debian/Ubuntu 基础镜像安装系统包时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RUN apt-get update \
    &amp;amp;&amp;amp; apt-get install -y --no-install-recommends ca-certificates \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 &lt;code&gt;apt-get update&lt;/code&gt; 和安装放在同一个 &lt;code&gt;RUN&lt;/code&gt; 中，避免缓存旧索引。不要在运行镜像中保留编译器、SSH 服务、编辑器和网络诊断全集；必要排障工具可以通过临时调试容器提供。&lt;/p&gt;
&lt;h2&gt;使用非 root USER&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;RUN groupadd --system --gid 10001 app \
    &amp;amp;&amp;amp; useradd --system --uid 10001 --gid app --home-dir /app app
USER app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;容器内 root 不等于宿主机 root，但运行时逃逸或错误挂载会放大高权限影响。应用不需要特权时必须切换到非 root 用户。&lt;/p&gt;
&lt;p&gt;固定 UID/GID 有助于卷权限可预测。不要使用 &lt;code&gt;chmod -R 777&lt;/code&gt; 解决权限问题，应明确目录所有者和最小读写权限。&lt;/p&gt;
&lt;h2&gt;只读文件系统和临时目录&lt;/h2&gt;
&lt;p&gt;Dockerfile 声明运行用户后，在 Compose 中进一步限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  web:
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用需要写入的持久数据放到明确卷或外部存储。只读根文件系统可以发现应用把状态错误地写入容器层的问题。&lt;/p&gt;
&lt;h2&gt;BuildKit Secret&lt;/h2&gt;
&lt;p&gt;构建需要访问私有依赖时，不要使用 &lt;code&gt;ARG&lt;/code&gt; 或 &lt;code&gt;ENV&lt;/code&gt; 传入凭据，因为它们可能出现在镜像历史或构建元数据中。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RUN --mount=type=secret,id=pip_config,target=/etc/pip.conf \
    pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker build \
  --secret id=pip_config,src=&quot;$HOME/.config/pip/pip.conf&quot; \
  -t blog-api:build .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Secret 只在该构建步骤临时挂载。仍需确认安装工具不会把认证信息复制到缓存和最终产物。&lt;/p&gt;
&lt;h2&gt;不把运行时 Secret 烘焙进镜像&lt;/h2&gt;
&lt;p&gt;错误做法包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;COPY .env /app/.env&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;在 Dockerfile 中设置数据库密码；&lt;/li&gt;
&lt;li&gt;把云凭据写进配置文件后构建；&lt;/li&gt;
&lt;li&gt;将私钥保留在 builder 产物目录。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;运行时通过平台 Secret、受限文件或外部密钥管理系统注入，并限制进程和日志访问。&lt;/p&gt;
&lt;h2&gt;ENTRYPOINT 与信号&lt;/h2&gt;
&lt;p&gt;使用 exec JSON 形式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CMD [&quot;python&quot;, &quot;-m&quot;, &quot;uvicorn&quot;, &quot;app.main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Shell 形式可能让应用不是 PID 1 的直接子进程，影响 SIGTERM 传播。应用要处理优雅关闭：停止接收请求、完成有界任务、关闭连接池并在超时前退出。&lt;/p&gt;
&lt;h2&gt;HEALTHCHECK 放在哪里&lt;/h2&gt;
&lt;p&gt;健康检查可以放在 Dockerfile 或 Compose。通用镜像可提供基础检查，部署环境可以覆盖具体参数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD [&quot;python&quot;, &quot;-m&quot;, &quot;app.healthcheck&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;镜像检查命令必须真的存在于 runtime 阶段。不要为了 &lt;code&gt;curl&lt;/code&gt; healthcheck 在最小镜像中额外安装大量工具。&lt;/p&gt;
&lt;h2&gt;构建检查和测试&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker build --check .
docker build --pull --tag blog-api:test .
docker run --rm blog-api:test python -m pytest -q
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更常见的是在 builder 测试阶段运行测试，再只复制运行产物。无论哪种方式，CI 必须验证最终镜像能够以非 root 用户启动并通过健康检查。&lt;/p&gt;
&lt;h2&gt;SBOM、来源证明与漏洞扫描&lt;/h2&gt;
&lt;p&gt;SBOM 记录镜像包含的软件组件，便于漏洞响应和许可证审查。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker sbom blog-api:test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;扫描示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker scout cves blog-api:test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;扫描结果需要风险分级：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;漏洞是否存在于最终 runtime 阶段；&lt;/li&gt;
&lt;li&gt;受影响代码路径是否可达；&lt;/li&gt;
&lt;li&gt;是否有已修复基础镜像；&lt;/li&gt;
&lt;li&gt;是否需要立即阻止发布；&lt;/li&gt;
&lt;li&gt;接受风险是否有负责人和到期时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要只扫描依赖清单而忽略基础系统包。CI 还可以生成构建来源证明并签名镜像，部署端验证来源。&lt;/p&gt;
&lt;h2&gt;Rootless Docker 的边界&lt;/h2&gt;
&lt;p&gt;Docker Rootless 模式让守护进程和容器以非 root 用户运行，降低守护进程和运行时漏洞的宿主机影响。它与 Dockerfile 中的 &lt;code&gt;USER&lt;/code&gt; 是两层不同控制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rootless 控制 Docker 守护进程和宿主机映射；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;USER&lt;/code&gt; 控制容器内应用进程身份。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rootless 需要 subordinate UID/GID，并可能在端口、cgroup 和网络行为上有差异。部署前在目标系统验证资源限制和运维工具兼容性。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Dockerfile 使用 multi-stage，只复制运行所需产物；&lt;/li&gt;
&lt;li&gt;[ ] 基础镜像来自可信来源，并固定版本或 digest；&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;.dockerignore&lt;/code&gt; 排除 Git、缓存、测试产物和 Secret；&lt;/li&gt;
&lt;li&gt;[ ] 依赖锁定并经过哈希或锁文件验证；&lt;/li&gt;
&lt;li&gt;[ ] 最终镜像不包含编译器和无关工具；&lt;/li&gt;
&lt;li&gt;[ ] 使用固定非 root &lt;code&gt;USER&lt;/code&gt;，不依赖 &lt;code&gt;777&lt;/code&gt; 权限；&lt;/li&gt;
&lt;li&gt;[ ] 运行时采用只读根文件系统、最小 capability 和 no-new-privileges；&lt;/li&gt;
&lt;li&gt;[ ] 构建 Secret 使用 BuildKit 挂载，不使用 &lt;code&gt;ARG&lt;/code&gt; 保存凭据；&lt;/li&gt;
&lt;li&gt;[ ] CI 验证最终镜像启动、健康检查和优雅退出；&lt;/li&gt;
&lt;li&gt;[ ] 发布生成 SBOM，并执行系统包与应用依赖扫描；&lt;/li&gt;
&lt;li&gt;[ ] 高风险漏洞处理有明确阻断或例外流程。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/building/best-practices/&quot;&gt;Docker Build best practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/building/multi-stage/&quot;&gt;Docker multi-stage builds&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/reference/dockerfile/&quot;&gt;Dockerfile reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/engine/security/rootless/&quot;&gt;Docker Rootless mode&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文补充 Docker 镜像从“能构建”到“可验证、低权限、可持续更新”的生产要求。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Docker Compose 生产实践：健康检查、资源限制、配置与回滚</title><link>https://zh19990906.github.io/fuwari/posts/docker-compose-production/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-compose-production/</guid><description>将开发环境 Compose 调整为可维护的单机生产部署，覆盖覆盖文件、健康检查、启动顺序、资源限制、日志、备份和回滚。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Docker Compose 可以用于单机或小规模受控部署，但生产要求与本地开发不同：源码热挂载、调试端口、自动重载和无上限日志都不应直接带到服务器。&lt;/p&gt;
&lt;p&gt;本文以 &lt;code&gt;compose.yaml&lt;/code&gt; 加 &lt;code&gt;compose.production.yaml&lt;/code&gt; 的覆盖方式组织配置。Compose 不是 Kubernetes 的替代品；当需要多节点调度、自动跨主机故障转移、复杂滚动发布和平台级网络策略时，应评估更合适的编排系统。&lt;/p&gt;
&lt;h2&gt;基础文件与生产覆盖文件&lt;/h2&gt;
&lt;p&gt;开发和生产共享服务拓扑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# compose.yaml
services:
  web:
    build:
      context: .
    environment:
      APP_ENV: development
    ports:
      - &quot;8000:8000&quot;
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog_app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db-data:/var/lib/postgresql/data

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  db-data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产覆盖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# compose.production.yaml
services:
  web:
    image: registry.example.com/blog-api@sha256:REPLACE_WITH_VERIFIED_DIGEST
    build: null
    environment:
      APP_ENV: production
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    logging:
      driver: json-file
      options:
        max-size: &quot;20m&quot;
        max-file: &quot;5&quot;

  db:
    restart: unless-stopped
    ports: []
    logging:
      driver: json-file
      options:
        max-size: &quot;20m&quot;
        max-file: &quot;5&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose \
  -f compose.yaml \
  -f compose.production.yaml \
  up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先查看合并后的最终配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose \
  -f compose.yaml \
  -f compose.production.yaml \
  config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产覆盖文件中不要保留源码目录挂载、调试器和自动重载命令。&lt;/p&gt;
&lt;h2&gt;healthcheck 检查服务是否可用&lt;/h2&gt;
&lt;p&gt;容器进程存在不代表服务已经可以处理请求。数据库可能仍在恢复，Web 服务可能尚未加载迁移或模型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  db:
    image: postgres:18
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U blog_app -d blog&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  web:
    healthcheck:
      test: [&quot;CMD&quot;, &quot;python&quot;, &quot;-m&quot;, &quot;app.healthcheck&quot;]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;健康检查应该：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;快速完成；&lt;/li&gt;
&lt;li&gt;有明确超时；&lt;/li&gt;
&lt;li&gt;不写业务数据；&lt;/li&gt;
&lt;li&gt;不依赖不必要的远程服务；&lt;/li&gt;
&lt;li&gt;区分进程存活和依赖可用性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果健康端点每次执行昂贵数据库查询或调用所有第三方 API，检查本身可能成为故障放大器。&lt;/p&gt;
&lt;h2&gt;depends_on 与 service_healthy&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;services:
  web:
    depends_on:
      db:
        condition: service_healthy
        restart: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;service_healthy&lt;/code&gt; 让 Compose 等待依赖健康后再创建服务。它只解决启动顺序，不保证数据库以后永远可用。应用仍然需要连接超时、有限重试和断线恢复。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;restart: true&lt;/code&gt; 在显式 Compose 操作重启依赖时，可以让依赖方重启并重新建立连接；它不等同于任意崩溃时的自动级联恢复。&lt;/p&gt;
&lt;h2&gt;restart policy&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;no&lt;/code&gt;：默认，不自动重启；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;on-failure&lt;/code&gt;：非零退出时重启；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;always&lt;/code&gt;：持续重启，包括守护进程重启后；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unless-stopped&lt;/code&gt;：除非人工停止，否则重启。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;反复崩溃的容器不应只靠重启掩盖。监控重启次数，并查看退出码、OOM 和应用日志。&lt;/p&gt;
&lt;h2&gt;资源限制&lt;/h2&gt;
&lt;p&gt;Compose 服务可以设置 CPU、内存、进程和文件描述符边界。具体字段支持取决于 Compose 和运行模式，部署前必须用目标版本验证。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  web:
    mem_limit: 1g
    cpus: 1.5
    pids_limit: 256
    ulimits:
      nofile:
        soft: 4096
        hard: 8192
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;限制过小会造成 OOM 或请求失败，完全不限制则可能让单个服务拖垮宿主机。先压测，再按实际峰值保留余量。&lt;/p&gt;
&lt;p&gt;查看资源：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker stats
docker inspect blog-web-1 --format &apos;{{json .HostConfig.Memory}}&apos;
docker inspect blog-web-1 --format &apos;{{json .State}}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置与敏感信息&lt;/h2&gt;
&lt;p&gt;普通非敏感配置可以使用环境变量；敏感值使用 Docker Secrets、宿主机权限受限文件或外部密钥系统。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  web:
    environment:
      APP_ENV: production
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; 不是密钥保险箱。它适合本地变量组合，但不应提交真实生产凭据。还要理解 shell、&lt;code&gt;.env&lt;/code&gt;、Compose 和 Dockerfile 中环境变量的优先级。&lt;/p&gt;
&lt;h2&gt;网络最小暴露&lt;/h2&gt;
&lt;p&gt;数据库和内部服务通常不需要发布宿主机端口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  db:
    expose:
      - &quot;5432&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有入口代理或 Web 服务使用 &lt;code&gt;ports&lt;/code&gt;。Compose 网络中的服务名可用于内部解析，不要把数据库端口开放给公网。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;networks:
  frontend: {}
  backend:
    internal: true

services:
  proxy:
    networks: [frontend]
  web:
    networks: [frontend, backend]
  db:
    networks: [backend]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;internal: true&lt;/code&gt; 不是完整防火墙策略，仍需结合宿主机防火墙、云安全组和应用认证。&lt;/p&gt;
&lt;h2&gt;日志与轮转&lt;/h2&gt;
&lt;p&gt;默认 &lt;code&gt;json-file&lt;/code&gt; 日志如果不限制大小，会持续占用磁盘。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;logging:
  driver: json-file
  options:
    max-size: &quot;20m&quot;
    max-file: &quot;5&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用日志写到标准输出，避免容器内未挂载的日志目录。需要集中查询时由日志代理采集，不要让应用直接承担复杂日志传输重试。&lt;/p&gt;
&lt;h2&gt;更新单个服务&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose \
  -f compose.yaml \
  -f compose.production.yaml \
  pull web

docker compose \
  -f compose.yaml \
  -f compose.production.yaml \
  up -d --no-deps web
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;单机 Compose 更新通常会有短暂重建窗口。要求零停机时可以在反向代理后运行蓝绿两套项目名，验证新版本健康后切换流量。&lt;/p&gt;
&lt;p&gt;不要使用可漂移的 &lt;code&gt;latest&lt;/code&gt; 作为唯一发布标识。镜像应使用不可变版本或经过验证的 digest。&lt;/p&gt;
&lt;h2&gt;数据备份&lt;/h2&gt;
&lt;p&gt;Compose 卷不是备份。数据库备份应使用数据库原生工具并验证恢复。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec -T db \
  pg_dump -U blog_app -d blog -Fc \
  &amp;gt; backups/blog-$(date +%F).dump
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恢复演练要在隔离环境执行，并验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;备份文件可读；&lt;/li&gt;
&lt;li&gt;Schema 和数据可恢复；&lt;/li&gt;
&lt;li&gt;应用能够连接；&lt;/li&gt;
&lt;li&gt;恢复时间满足目标；&lt;/li&gt;
&lt;li&gt;加密和保留策略符合要求。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要在数据库持续高写入时直接复制其数据目录作为一致备份。&lt;/p&gt;
&lt;h2&gt;回滚&lt;/h2&gt;
&lt;p&gt;回滚前记录当前镜像 digest 和数据库迁移版本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose images
docker image inspect registry.example.com/blog-api@sha256:REPLACE_WITH_VERIFIED_DIGEST
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;回滚流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;停止继续扩大故障影响；&lt;/li&gt;
&lt;li&gt;判断是否包含不可逆数据库迁移；&lt;/li&gt;
&lt;li&gt;把覆盖文件中的镜像恢复为上一已验证 digest；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker compose up -d --no-deps web&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;等待 healthcheck；&lt;/li&gt;
&lt;li&gt;执行关键接口冒烟测试；&lt;/li&gt;
&lt;li&gt;检查错误率和数据一致性。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;应用回滚不等于数据库自动回滚。Schema 变更应采用向前兼容的 expand/contract，避免旧应用无法读取新结构。&lt;/p&gt;
&lt;h2&gt;常用排查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose ps
docker compose logs --tail 200 web
docker compose config
docker inspect blog-web-1 --format &apos;{{json .State.Health}}&apos;
docker events --since 30m
df -h
docker system df
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先确认最终配置、容器状态、健康检查和宿主机资源，再决定是否重建。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 开发和生产使用独立覆盖文件；&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;docker compose config&lt;/code&gt; 输出经过审查；&lt;/li&gt;
&lt;li&gt;[ ] 关键服务有快速且无副作用的 healthcheck；&lt;/li&gt;
&lt;li&gt;[ ] 启动依赖使用 &lt;code&gt;service_healthy&lt;/code&gt;，应用仍有超时和重试；&lt;/li&gt;
&lt;li&gt;[ ] restart policy 与告警策略配套；&lt;/li&gt;
&lt;li&gt;[ ] CPU、内存、PID 和日志大小有边界；&lt;/li&gt;
&lt;li&gt;[ ] 数据库等内部服务不发布公网端口；&lt;/li&gt;
&lt;li&gt;[ ] 生产凭据不提交到 &lt;code&gt;.env&lt;/code&gt; 或仓库；&lt;/li&gt;
&lt;li&gt;[ ] 镜像使用不可变版本或 digest；&lt;/li&gt;
&lt;li&gt;[ ] 数据库备份经过定期恢复演练；&lt;/li&gt;
&lt;li&gt;[ ] 发布前记录回滚镜像和迁移兼容性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/compose/how-tos/production/&quot;&gt;Use Compose in production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/compose/how-tos/startup-order/&quot;&gt;Control startup and shutdown order&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/reference/compose-file/services/#healthcheck&quot;&gt;Compose service healthcheck reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/&quot;&gt;Merge Compose files&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文补充 Docker 从多容器开发到受控生产运行的实践边界。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>FastAPI 测试体系：pytest、TestClient、依赖覆盖与数据库隔离</title><link>https://zh19990906.github.io/fuwari/posts/fastapi-testing-pytest/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/fastapi-testing-pytest/</guid><description>为 FastAPI 建立可重复的单元与集成测试，覆盖 TestClient、异步接口、依赖覆盖、数据库事务回滚和外部服务替身。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;接口能在浏览器中返回一次正确结果，不等于它在权限失败、数据库异常、并发修改和外部服务超时时仍然可靠。FastAPI 测试应把路由、依赖、数据库和外部服务边界拆开，让失败能够快速定位。&lt;/p&gt;
&lt;p&gt;本文使用 &lt;code&gt;pytest&lt;/code&gt;、FastAPI &lt;code&gt;TestClient&lt;/code&gt; 和 HTTPX。测试目标不是追求一个漂亮的覆盖率数字，而是为关键业务规则和失败路径建立可重复证据。&lt;/p&gt;
&lt;h2&gt;测试层次&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层次&lt;/th&gt;
&lt;th&gt;主要验证&lt;/th&gt;
&lt;th&gt;是否访问真实外部依赖&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;纯函数单元测试&lt;/td&gt;
&lt;td&gt;校验、权限、转换、计算&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;路由测试&lt;/td&gt;
&lt;td&gt;状态码、响应体、依赖组合&lt;/td&gt;
&lt;td&gt;通常否&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据库集成测试&lt;/td&gt;
&lt;td&gt;SQL、事务、约束、迁移&lt;/td&gt;
&lt;td&gt;使用隔离测试库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;契约测试&lt;/td&gt;
&lt;td&gt;与外部 API 的请求响应格式&lt;/td&gt;
&lt;td&gt;使用沙箱或录制契约&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;端到端测试&lt;/td&gt;
&lt;td&gt;部署后的关键用户路径&lt;/td&gt;
&lt;td&gt;是，但数量应少&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;不要把所有测试都写成完整 HTTP + 真数据库 + 真第三方服务。测试越慢、越不稳定，开发者越容易跳过它。&lt;/p&gt;
&lt;h2&gt;最小 TestClient 测试&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install pytest httpx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get(&quot;/health&quot;)
def health() -&amp;gt; dict[str, str]:
    return {&quot;status&quot;: &quot;ok&quot;}


client = TestClient(app)


def test_health_returns_ok() -&amp;gt; None:
    response = client.get(&quot;/health&quot;)

    assert response.status_code == 200
    assert response.json() == {&quot;status&quot;: &quot;ok&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试应断言业务需要的字段，而不是把整个大型响应做脆弱快照。时间、随机 ID 和排序不稳定的集合需要先规范化。&lt;/p&gt;
&lt;h2&gt;使用 fixture 统一创建客户端&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import pytest
from fastapi.testclient import TestClient

from app.main import create_app


@pytest.fixture
def app():
    return create_app(testing=True)


@pytest.fixture
def client(app):
    with TestClient(app) as test_client:
        yield test_client
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用上下文管理器可以让应用的 lifespan 启动和关闭逻辑在测试中执行。不要在模块导入时连接数据库、Redis 或外部 API，否则测试还没开始就产生不可控副作用。&lt;/p&gt;
&lt;h2&gt;dependency_overrides 替换外部依赖&lt;/h2&gt;
&lt;p&gt;FastAPI 可以通过 &lt;code&gt;app.dependency_overrides&lt;/code&gt; 在测试中替换认证、数据库或第三方客户端依赖。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dataclasses import dataclass


@dataclass(frozen=True)
class TestPrincipal:
    user_id: str
    permissions: frozenset[str]


async def override_current_user() -&amp;gt; TestPrincipal:
    return TestPrincipal(
        user_id=&quot;user-test-1&quot;,
        permissions=frozenset({&quot;article:read&quot;}),
    )


def test_list_articles_as_user(app, client):
    app.dependency_overrides[get_current_principal] = override_current_user
    try:
        response = client.get(&quot;/articles&quot;)
        assert response.status_code == 200
    finally:
        app.dependency_overrides.clear()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;必须在测试后清理覆盖，否则后续测试会继承错误身份。可以通过 fixture 自动清理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@pytest.fixture(autouse=True)
def clear_overrides(app):
    yield
    app.dependency_overrides.clear()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;身份和权限要分别测试&lt;/h2&gt;
&lt;p&gt;至少覆盖：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;无 Authorization Header 返回 &lt;code&gt;401&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;无效或过期令牌返回 &lt;code&gt;401&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;已登录但权限不足返回 &lt;code&gt;403&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;资源所有者可以修改自己的资源；&lt;/li&gt;
&lt;li&gt;普通用户不能修改他人资源；&lt;/li&gt;
&lt;li&gt;管理权限是否按设计覆盖资源所有者规则；&lt;/li&gt;
&lt;li&gt;禁用账号不能继续访问。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要只测试管理员成功路径。越权通常发生在“普通账号访问别人的资源”这一类横向权限场景。&lt;/p&gt;
&lt;h2&gt;测试数据库使用事务回滚&lt;/h2&gt;
&lt;p&gt;测试库应和生产库使用相同数据库引擎，尤其是依赖 PostgreSQL 锁、JSON、数组、约束或事务隔离时。SQLite 适合纯 SQLAlchemy 基础测试，但不能代表 PostgreSQL 行为。&lt;/p&gt;
&lt;p&gt;常用策略是每个测试开启外层事务，测试结束后统一回滚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import pytest
from sqlalchemy.orm import Session


@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection, expire_on_commit=False)

    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再覆盖应用数据库依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@pytest.fixture
def client_with_db(app, client, db_session):
    def override_db():
        yield db_session

    app.dependency_overrides[get_db] = override_db
    yield client
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果应用代码会调用 &lt;code&gt;commit()&lt;/code&gt;，需要使用 SAVEPOINT 或在测试 Session 中重新绑定事务事件。选择何种模式必须通过实际提交、异常回滚和唯一约束测试验证，而不是只看简单查询通过。&lt;/p&gt;
&lt;h2&gt;迁移必须在测试库执行&lt;/h2&gt;
&lt;p&gt;测试启动前使用 Alembic 升级到目标版本，可以捕获“ORM 模型存在但迁移缺失”的问题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;alembic upgrade head
pytest -q
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CI 不应直接对长期共享测试库反复迁移。更稳定的方式是为每个 Job 创建临时数据库或启动独立 PostgreSQL Service Container。&lt;/p&gt;
&lt;h2&gt;异步测试&lt;/h2&gt;
&lt;p&gt;异步业务函数可以直接使用 pytest 异步插件和 HTTPX &lt;code&gt;AsyncClient&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import pytest
from httpx import ASGITransport, AsyncClient


@pytest.mark.anyio
async def test_async_health(app):
    transport = ASGITransport(app=app)
    async with AsyncClient(
        transport=transport,
        base_url=&quot;http://testserver&quot;,
    ) as client:
        response = await client.get(&quot;/health&quot;)

    assert response.status_code == 200
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在正在运行的事件循环里调用 &lt;code&gt;asyncio.run()&lt;/code&gt;。同步与异步 fixture 的生命周期要与客户端和数据库连接保持一致。&lt;/p&gt;
&lt;h2&gt;外部 HTTP 服务使用明确替身&lt;/h2&gt;
&lt;p&gt;测试不应依赖真实短信、邮件、支付或模型 API。优先把外部调用封装为客户端接口，再替换实现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class FakeNotifier:
    def __init__(self) -&amp;gt; None:
        self.messages: list[tuple[str, str]] = []

    async def send(self, recipient: str, message: str) -&amp;gt; None:
        self.messages.append((recipient, message))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;断言最终业务行为，而不是过度断言内部调用次数。对于外部协议格式，单独增加契约测试，确认请求字段、签名和错误映射。&lt;/p&gt;
&lt;h2&gt;测试异常和边界输入&lt;/h2&gt;
&lt;p&gt;每个写接口至少考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缺失字段和格式错误；&lt;/li&gt;
&lt;li&gt;超长字符串、空列表和重复项；&lt;/li&gt;
&lt;li&gt;唯一约束冲突；&lt;/li&gt;
&lt;li&gt;资源不存在；&lt;/li&gt;
&lt;li&gt;下游超时；&lt;/li&gt;
&lt;li&gt;数据库异常后的事务状态；&lt;/li&gt;
&lt;li&gt;重复请求和幂等键；&lt;/li&gt;
&lt;li&gt;并发更新产生的版本冲突。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;错误响应也应有稳定契约，例如错误代码、可读消息和关联 ID，而不是把数据库异常文本直接返回客户端。&lt;/p&gt;
&lt;h2&gt;覆盖率的正确使用&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pytest --cov=app --cov-report=term-missing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;覆盖率只能提示“哪些代码没有执行”，不能证明断言正确。优先覆盖认证授权、金额状态、事务边界、重试和错误映射；自动生成模型和简单属性不需要为了数字编写低价值测试。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 测试可以在全新环境中独立运行；&lt;/li&gt;
&lt;li&gt;[ ] 路由测试不调用收费或不稳定的真实第三方服务；&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;dependency_overrides&lt;/code&gt; 在每个测试后清理；&lt;/li&gt;
&lt;li&gt;[ ] PostgreSQL 特性使用真实 PostgreSQL 测试；&lt;/li&gt;
&lt;li&gt;[ ] 每个测试拥有隔离数据并通过事务回滚或临时数据库清理；&lt;/li&gt;
&lt;li&gt;[ ] Alembic 迁移在 CI 测试库中执行；&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;401&lt;/code&gt;、&lt;code&gt;403&lt;/code&gt;、资源所有者和禁用账号路径均有覆盖；&lt;/li&gt;
&lt;li&gt;[ ] 下游超时、数据库异常和重复请求有测试；&lt;/li&gt;
&lt;li&gt;[ ] 异步测试不嵌套事件循环；&lt;/li&gt;
&lt;li&gt;[ ] 失败输出不泄露密码、令牌或完整数据库连接串。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/tutorial/testing/&quot;&gt;FastAPI Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/advanced/testing-dependencies/&quot;&gt;FastAPI Testing Dependencies with Overrides&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytest.org/&quot;&gt;pytest Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文补充现有 FastAPI Web 工程系列，重点建立可重复的测试和隔离边界。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>FastAPI 数据库工程：SQLAlchemy 2.x、事务、连接池与 Alembic</title><link>https://zh19990906.github.io/fuwari/posts/fastapi-sqlalchemy-alembic/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/fastapi-sqlalchemy-alembic/</guid><description>使用 SQLAlchemy 2.x 和 Alembic 管理 FastAPI 数据访问，覆盖 Session 生命周期、事务、连接池、N+1 查询、迁移和发布顺序。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;FastAPI 负责请求生命周期，SQLAlchemy &lt;code&gt;Session&lt;/code&gt; 负责一个数据库工作单元。最常见的问题不是 ORM 语法，而是 Session 被跨请求共享、异常后没有回滚、连接池容量被多 Worker 放大，以及数据库 Schema 与代码版本不同步。&lt;/p&gt;
&lt;p&gt;本文使用 SQLAlchemy 2.x 风格。同步和异步方案都可以正确工作，关键是从入口到驱动保持一致，不在异步路由中偷偷执行阻塞数据库调用。&lt;/p&gt;
&lt;h2&gt;推荐目录&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;app/
├── main.py
├── db.py
├── models.py
├── schemas.py
├── repositories/
├── services/
└── api/
alembic/
alembic.ini
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;db.py&lt;/code&gt; 只负责 Engine、连接池和 Session 工厂；业务事务由 service 层控制；路由负责 HTTP 输入输出，不把 ORM 对象生命周期暴露给外部。&lt;/p&gt;
&lt;h2&gt;创建 Engine 和 sessionmaker&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import os

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = os.environ[&quot;DATABASE_URL&quot;]

engine = create_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=5,
    pool_timeout=5,
    pool_pre_ping=True,
)

SessionFactory = sessionmaker(
    bind=engine,
    autoflush=False,
    expire_on_commit=False,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pool_pre_ping&lt;/code&gt; 可以在取连接时发现部分失效连接，但不能替代数据库高可用、请求超时和应用重试。不要把 &lt;code&gt;max_connections&lt;/code&gt; 全部分给一个服务。&lt;/p&gt;
&lt;h2&gt;每个请求一个 Session&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections.abc import Generator
from sqlalchemy.orm import Session


def get_db() -&amp;gt; Generator[Session, None, None]:
    with SessionFactory() as session:
        yield session
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;from typing import Annotated
from fastapi import Depends

DbSession = Annotated[Session, Depends(get_db)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Session&lt;/code&gt; 是有状态事务对象，不应作为全局单例，也不能在并发线程或 asyncio Task 之间共享。请求结束必须关闭，未提交事务会随连接归还而回滚。&lt;/p&gt;
&lt;h2&gt;SQLAlchemy 2 查询方式&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from sqlalchemy import select


def get_user_by_email(session: Session, email: str) -&amp;gt; User | None:
    statement = select(User).where(User.email == email)
    return session.scalar(statement)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要继续围绕旧 &lt;code&gt;Query&lt;/code&gt; API 设计新代码。复杂查询应返回明确结构，避免把整个 ORM 实体直接作为 API 响应。&lt;/p&gt;
&lt;h2&gt;事务由业务动作控制&lt;/h2&gt;
&lt;p&gt;创建订单、扣减库存和写审计日志属于一个业务事务时，应由 service 层统一提交。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sqlalchemy.exc import IntegrityError


def create_article(session: Session, command: CreateArticle) -&amp;gt; Article:
    article = Article(
        title=command.title,
        owner_id=command.owner_id,
    )
    session.add(article)

    try:
        session.flush()
        session.add(
            AuditEvent(
                actor_id=command.owner_id,
                action=&quot;article.created&quot;,
                resource_id=article.id,
            )
        )
        session.commit()
    except IntegrityError:
        session.rollback()
        raise ArticleConflictError()

    return article
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;异常后必须先 &lt;code&gt;rollback()&lt;/code&gt; 才能继续使用 Session。不要在 repository 的每个小函数里自动 &lt;code&gt;commit()&lt;/code&gt;，否则上层无法把多个写操作组成一个原子事务。&lt;/p&gt;
&lt;p&gt;更紧凑的写法是事务上下文：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with SessionFactory.begin() as session:
    session.add(article)
    session.add(audit_event)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上下文正常退出时提交，异常时回滚并关闭。&lt;/p&gt;
&lt;h2&gt;不要把 Session 传给后台任务&lt;/h2&gt;
&lt;p&gt;FastAPI 请求依赖提供的 Session 会在响应生命周期结束后关闭。后台任务只接收不可变 ID，并自行建立资源生命周期。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def refresh_search_index(article_id: str) -&amp;gt; None:
    with SessionFactory() as session:
        article = session.get(Article, article_id)
        if article is None:
            return
        update_index(article)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果任务不可丢失，应在请求事务中写入 Outbox 或任务表，再由独立 Worker 消费，而不是依赖进程内后台任务。&lt;/p&gt;
&lt;h2&gt;N+1 查询&lt;/h2&gt;
&lt;p&gt;加载文章列表后逐条访问作者关系，可能产生一次列表查询加 N 次作者查询。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sqlalchemy.orm import selectinload

statement = (
    select(Article)
    .options(selectinload(Article.author))
    .order_by(Article.created_at.desc())
    .limit(50)
)
articles = session.scalars(statement).all()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;selectinload&lt;/code&gt;、&lt;code&gt;joinedload&lt;/code&gt; 和显式 JOIN 的选择取决于数据量和返回形状。开启 SQL 日志或查询统计验证实际 SQL，不要凭 ORM 代码外观看性能。&lt;/p&gt;
&lt;h2&gt;分页避免无限 offset&lt;/h2&gt;
&lt;p&gt;简单后台页面可以使用 &lt;code&gt;LIMIT/OFFSET&lt;/code&gt;。大表连续翻页更适合基于稳定排序键的游标分页。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;statement = (
    select(Article)
    .where(Article.id &amp;gt; after_id)
    .order_by(Article.id)
    .limit(page_size)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;排序字段必须稳定且有索引。客户端传入的页大小要设置上限。&lt;/p&gt;
&lt;h2&gt;异步数据库访问&lt;/h2&gt;
&lt;p&gt;使用异步路由时需要异步驱动和 &lt;code&gt;AsyncSession&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

async_engine = create_async_engine(
    os.environ[&quot;ASYNC_DATABASE_URL&quot;],
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
)

AsyncSessionFactory = async_sessionmaker(
    async_engine,
    expire_on_commit=False,
)


async def get_async_db():
    async with AsyncSessionFactory() as session:
        yield session
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个并发 Task 使用独立 &lt;code&gt;AsyncSession&lt;/code&gt;。不要用一个 Session 同时执行多个协程，也不要在异步 Session 中触发隐式懒加载网络 I/O。&lt;/p&gt;
&lt;h2&gt;Worker 与连接池容量&lt;/h2&gt;
&lt;p&gt;连接上界至少要按部署副本计算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;连接上界 ≈ replicas × workers × (pool_size + max_overflow)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还要预留迁移、后台任务、监控、备份、管理连接和滚动发布中新旧副本重叠。&lt;code&gt;pool_timeout&lt;/code&gt; 应小于请求总超时，连接耗尽时快速失败比无限等待更容易恢复。&lt;/p&gt;
&lt;h2&gt;Alembic 初始化&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install alembic
alembic init alembic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;alembic/env.py&lt;/code&gt; 中导入模型元数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from app.models import Base

target_metadata = Base.metadata
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生成迁移：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;alembic revision --autogenerate -m &quot;add article status&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;自动生成只是草稿，必须人工检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是否误删列或索引；&lt;/li&gt;
&lt;li&gt;新增非空列是否有数据回填；&lt;/li&gt;
&lt;li&gt;枚举和约束是否可回滚；&lt;/li&gt;
&lt;li&gt;大表操作是否长时间持锁；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;downgrade()&lt;/code&gt; 是否真实可执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;安全的发布顺序&lt;/h2&gt;
&lt;p&gt;不兼容 Schema 变更使用 expand/contract：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先新增兼容列或表；&lt;/li&gt;
&lt;li&gt;发布同时兼容新旧结构的应用；&lt;/li&gt;
&lt;li&gt;后台回填历史数据；&lt;/li&gt;
&lt;li&gt;切换读写到新结构；&lt;/li&gt;
&lt;li&gt;确认旧版本已退出；&lt;/li&gt;
&lt;li&gt;最后删除旧列和约束。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要让多个 Web 实例同时自动执行迁移。使用单独发布 Job，在应用扩容前完成经过审查的迁移。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 每个请求或任务使用独立 Session；&lt;/li&gt;
&lt;li&gt;[ ] Session 在所有路径都能关闭，异常后明确回滚；&lt;/li&gt;
&lt;li&gt;[ ] 事务边界位于业务 service 层，而不是分散在 repository；&lt;/li&gt;
&lt;li&gt;[ ] 连接池容量按副本和 Worker 总数计算；&lt;/li&gt;
&lt;li&gt;[ ] 查询、连接和请求均设置超时；&lt;/li&gt;
&lt;li&gt;[ ] 列表接口限制页大小，并检查 N+1 查询；&lt;/li&gt;
&lt;li&gt;[ ] 异步路由只使用异步驱动和独立 AsyncSession；&lt;/li&gt;
&lt;li&gt;[ ] Alembic 自动生成结果经过人工审查；&lt;/li&gt;
&lt;li&gt;[ ] 迁移在独立 Job 中运行，并有备份和回滚计划；&lt;/li&gt;
&lt;li&gt;[ ] CI 使用空数据库执行 &lt;code&gt;alembic upgrade head&lt;/code&gt; 和应用测试。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/en/20/orm/session_basics.html&quot;&gt;SQLAlchemy 2.0 Session Basics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html&quot;&gt;SQLAlchemy asyncio Extension&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://alembic.sqlalchemy.org/en/latest/tutorial.html&quot;&gt;Alembic Tutorial&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文补充 FastAPI 数据库工程主线，示例以 SQLAlchemy 2.x 和 Alembic 官方文档为准。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>GitHub Actions 实战：从 Pull Request 检查到安全部署</title><link>https://zh19990906.github.io/fuwari/posts/github-actions-secure-cicd/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/github-actions-secure-cicd/</guid><description>使用 GitHub Actions 建立最小权限的 CI/CD，覆盖 PR 检查、缓存、并发、环境审批、OIDC、不可变 Action、构建产物和回滚。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;CI/CD 的目标不是“每次 push 跑一串命令”，而是让代码在进入主分支和生产环境前经过可重复检查，并让发布身份、权限和产物来源可以审计。&lt;/p&gt;
&lt;p&gt;本文以 GitHub Actions 为例，覆盖 Pull Request 检查、最小权限、依赖缓存、并发取消、Environment 审批、OIDC 和回滚。示例使用公共占位符，不包含真实云账号、密钥或内部地址。&lt;/p&gt;
&lt;h2&gt;Workflow、Job 与 Step&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Workflow
├── Job: lint-and-test
│   ├── Step: checkout
│   ├── Step: install
│   └── Step: test
└── Job: build
    ├── Step: checkout
    └── Step: build artifact
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Workflow 由事件触发；&lt;/li&gt;
&lt;li&gt;Job 默认并行运行，使用 &lt;code&gt;needs&lt;/code&gt; 建立依赖；&lt;/li&gt;
&lt;li&gt;Step 在同一 Runner 中顺序执行；&lt;/li&gt;
&lt;li&gt;每个 Job 都有独立文件系统和 &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;Job 间共享文件需要显式上传 Artifact。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要依赖另一个 Job 的临时目录仍然存在。&lt;/p&gt;
&lt;h2&gt;Pull Request 检查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;name: Build and Check

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

      - name: Setup Node.js
        uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e
        with:
          node-version: 22
          cache: pnpm

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Test
        run: pnpm test

      - name: Build
        run: pnpm build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PR 检查应至少验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锁文件未漂移；&lt;/li&gt;
&lt;li&gt;单元和集成测试；&lt;/li&gt;
&lt;li&gt;类型检查和格式检查；&lt;/li&gt;
&lt;li&gt;生产构建；&lt;/li&gt;
&lt;li&gt;文档或 Schema 校验；&lt;/li&gt;
&lt;li&gt;依赖和安全策略。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;分支保护规则应要求关键检查通过后才能合并。工作流文件本身的修改也必须经过评审，因为它可以改变代码执行和凭据访问方式。&lt;/p&gt;
&lt;h2&gt;GITHUB_TOKEN 最小权限&lt;/h2&gt;
&lt;p&gt;GitHub 为每个 Job 生成短期 &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;。默认权限不应依赖仓库历史设置，而要在 Workflow 或 Job 中显式声明。&lt;/p&gt;
&lt;p&gt;只读检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions:
  contents: read
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发布 GitHub Pages：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions:
  contents: read
  pages: write
  id-token: write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;id-token: write&lt;/code&gt; 只允许请求 OIDC Token，不自动授予仓库写权限。不要为了方便使用 &lt;code&gt;write-all&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果只有一个 Job 需要写权限，把权限放在该 Job，而不是整个 Workflow。&lt;/p&gt;
&lt;h2&gt;固定 Action 到 full-length commit SHA&lt;/h2&gt;
&lt;p&gt;标签和分支可以被移动。GitHub 安全指南建议把第三方 Action 固定到 full-length commit SHA，获得不可变引用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更新流程应包含：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;验证 SHA 属于官方仓库；&lt;/li&gt;
&lt;li&gt;对应可信 Release 或 Tag；&lt;/li&gt;
&lt;li&gt;阅读变更说明；&lt;/li&gt;
&lt;li&gt;由 Dependabot 或维护 PR 更新；&lt;/li&gt;
&lt;li&gt;通过 CI 后合并。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;仅固定版本 Tag 比完全不固定好，但不提供相同的不可变保证。&lt;/p&gt;
&lt;h2&gt;不信任 Pull Request 输入&lt;/h2&gt;
&lt;p&gt;分支名、Issue 标题、PR Body、Commit Message 和用户输入都可能包含恶意内容。不要直接拼接到 Shell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Unsafe example
  run: echo &quot;${{ github.event.pull_request.title }}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表达式在 Shell 执行前展开，特殊字符可能改变命令。更安全的方式是先写入环境变量并正确引用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Print title safely
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: printf &apos;%s\n&apos; &quot;$PR_TITLE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更复杂输入应由脚本语言参数解析，不要生成 Shell 代码。&lt;/p&gt;
&lt;h2&gt;pull_request 与 pull_request_target&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pull_request&lt;/code&gt; 默认在合并上下文运行，来自 Fork 的代码通常无法访问仓库 Secret。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pull_request_target&lt;/code&gt; 在目标仓库上下文运行，可能访问更高权限，因此不能同时 Checkout 并执行不受信任 Fork 代码。&lt;/p&gt;
&lt;p&gt;高风险反模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on: pull_request_target

steps:
  - uses: actions/checkout@...
    with:
      ref: ${{ github.event.pull_request.head.sha }}
  - run: ./scripts/from-untrusted-pr.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要给外部 PR 打标签或留言时，让高权限 Workflow 只处理元数据，不执行 PR 内容。&lt;/p&gt;
&lt;h2&gt;concurrency 取消过时运行&lt;/h2&gt;
&lt;p&gt;同一分支连续 Push 时，旧检查已经没有价值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署通常不应直接取消正在修改生产状态的 Job。可以使用固定环境组并设置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;concurrency:
  group: production
  cancel-in-progress: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;具体选择取决于部署是否原子、是否支持回滚，以及中途取消会不会留下半完成状态。&lt;/p&gt;
&lt;h2&gt;Matrix 测试&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;strategy:
  fail-fast: false
  matrix:
    node: [22, 23]

steps:
  - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e
    with:
      node-version: ${{ matrix.node }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Matrix 适合验证多个受支持运行时、操作系统或依赖版本。不要为了展示覆盖面测试已经不支持的组合，这会增加时间和维护成本。&lt;/p&gt;
&lt;h2&gt;缓存不是构建产物&lt;/h2&gt;
&lt;p&gt;缓存用于加速可重新生成的内容，例如包管理器下载目录。Artifact 用于在 Job 之间传递构建结果和保留审计证据。&lt;/p&gt;
&lt;p&gt;缓存键必须包含锁文件哈希：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with:
  cache: pnpm
  cache-dependency-path: pnpm-lock.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要缓存包含凭据的目录，也不要把缓存命中当作依赖完整性证明。安装仍应使用锁文件严格模式。&lt;/p&gt;
&lt;h2&gt;构建一次，部署同一产物&lt;/h2&gt;
&lt;p&gt;理想流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Commit
  ↓
Test
  ↓
Build immutable artifact
  ↓
Security checks
  ↓
Approval
  ↓
Deploy exact artifact
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在生产部署 Job 中重新从浮动依赖构建。测试通过的产物和部署产物必须具有相同摘要。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Upload artifact
  uses: actions/upload-artifact@REPLACE_WITH_VERIFIED_FULL_SHA
  with:
    name: site-dist
    path: dist/
    if-no-files-found: error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下载后可校验 manifest 或 SHA-256，再执行部署。&lt;/p&gt;
&lt;h2&gt;Environment 与审批&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;jobs:
  deploy:
    environment:
      name: production
      url: https://example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Environment 可以配置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;必需审批者；&lt;/li&gt;
&lt;li&gt;等待时间；&lt;/li&gt;
&lt;li&gt;允许部署的分支或 Tag；&lt;/li&gt;
&lt;li&gt;Environment Secret；&lt;/li&gt;
&lt;li&gt;部署历史。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;生产凭据只放在 production Environment，不要让普通 PR 检查 Job 获得。&lt;/p&gt;
&lt;h2&gt;使用 OIDC 代替长期云密钥&lt;/h2&gt;
&lt;p&gt;GitHub Actions 可以通过 OIDC 请求短期身份。Workflow 只需要：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions:
  contents: read
  id-token: write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;云平台信任策略应限制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub Organization 和 Repository；&lt;/li&gt;
&lt;li&gt;目标分支、Tag 或 Environment；&lt;/li&gt;
&lt;li&gt;允许的 reusable workflow；&lt;/li&gt;
&lt;li&gt;Audience；&lt;/li&gt;
&lt;li&gt;最小云角色权限。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OIDC 减少仓库中长期云密钥，但如果信任条件过宽，攻击者仍可能从其他分支或 Workflow 获取短期凭据。必须同时限制 GitHub 端权限和云端 Subject 条件。&lt;/p&gt;
&lt;h2&gt;Reusable Workflow&lt;/h2&gt;
&lt;p&gt;组织中多个仓库重复相同发布流程时，可以抽取 reusable workflow：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on:
  workflow_call:
    inputs:
      artifact-name:
        required: true
        type: string
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调用方显式传入输入和 Secret。被调用 Workflow 不应假设拥有比调用方更多权限，权限会受到调用链限制。&lt;/p&gt;
&lt;p&gt;共享 Workflow 要版本化并审查破坏性变更，避免一次修改影响所有仓库。&lt;/p&gt;
&lt;h2&gt;第三方依赖和脚本&lt;/h2&gt;
&lt;p&gt;Workflow 会执行：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Action 仓库代码；&lt;/li&gt;
&lt;li&gt;包管理器生命周期脚本；&lt;/li&gt;
&lt;li&gt;项目构建脚本；&lt;/li&gt;
&lt;li&gt;容器镜像入口；&lt;/li&gt;
&lt;li&gt;下载的二进制工具。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此需要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;固定 Action SHA；&lt;/li&gt;
&lt;li&gt;锁定依赖；&lt;/li&gt;
&lt;li&gt;Dependabot 和依赖审查；&lt;/li&gt;
&lt;li&gt;校验下载文件摘要；&lt;/li&gt;
&lt;li&gt;限制可使用的 Action 来源；&lt;/li&gt;
&lt;li&gt;定期删除不再使用的 Secret 和 Workflow。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Self-hosted Runner 风险&lt;/h2&gt;
&lt;p&gt;Self-hosted Runner 可能保留磁盘、网络和凭据状态。不应让不受信任的 Fork PR 在可访问内部网络和生产凭据的 Runner 上运行。&lt;/p&gt;
&lt;p&gt;最低要求：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用一次性或任务后销毁的 Runner；&lt;/li&gt;
&lt;li&gt;隔离网络；&lt;/li&gt;
&lt;li&gt;不共享 Docker Socket；&lt;/li&gt;
&lt;li&gt;限制标签和可调用仓库；&lt;/li&gt;
&lt;li&gt;监控异常进程和出站流量；&lt;/li&gt;
&lt;li&gt;及时更新 Runner 软件。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;发布和回滚&lt;/h2&gt;
&lt;p&gt;发布记录至少包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Commit SHA；&lt;/li&gt;
&lt;li&gt;Artifact 或镜像 digest；&lt;/li&gt;
&lt;li&gt;Workflow Run；&lt;/li&gt;
&lt;li&gt;迁移版本；&lt;/li&gt;
&lt;li&gt;审批人；&lt;/li&gt;
&lt;li&gt;环境；&lt;/li&gt;
&lt;li&gt;时间；&lt;/li&gt;
&lt;li&gt;冒烟测试结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;回滚不是重新运行旧源码构建，而是部署上一已验证的不可变产物。数据库变更必须向前兼容，否则应用回滚可能失败。&lt;/p&gt;
&lt;h2&gt;当前博客仓库的可借鉴点&lt;/h2&gt;
&lt;p&gt;本仓库已经使用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PR 和 &lt;code&gt;main&lt;/code&gt; Push 触发；&lt;/li&gt;
&lt;li&gt;Node.js Matrix；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;文档、UI、个性化和构建检查；&lt;/li&gt;
&lt;li&gt;Workflow &lt;code&gt;concurrency&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;GitHub Pages 的 &lt;code&gt;pages: write&lt;/code&gt; 和 &lt;code&gt;id-token: write&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;Action 固定到完整 SHA。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续新增 Workflow 时应继续保持这些约束，而不是复制一个拥有更高权限的通用模板。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] PR 必须通过测试、类型检查和生产构建；&lt;/li&gt;
&lt;li&gt;[ ] Workflow 和 Job 显式设置最小 &lt;code&gt;permissions&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;[ ] 第三方 Action 固定到 full-length commit SHA；&lt;/li&gt;
&lt;li&gt;[ ] 不受信任输入不直接拼接到 Shell；&lt;/li&gt;
&lt;li&gt;[ ] 不使用高权限 &lt;code&gt;pull_request_target&lt;/code&gt; 执行 Fork 代码；&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;concurrency&lt;/code&gt; 策略与检查和部署的取消语义匹配；&lt;/li&gt;
&lt;li&gt;[ ] 依赖按锁文件安装，缓存键包含锁文件哈希；&lt;/li&gt;
&lt;li&gt;[ ] 测试、扫描和部署使用同一个不可变产物；&lt;/li&gt;
&lt;li&gt;[ ] 生产部署使用 Environment、审批和分支限制；&lt;/li&gt;
&lt;li&gt;[ ] 云认证优先使用 OIDC，并限制 Repository、Ref 和 Environment；&lt;/li&gt;
&lt;li&gt;[ ] Self-hosted Runner 不运行不受信任代码；&lt;/li&gt;
&lt;li&gt;[ ] 回滚使用上一已验证产物，并确认数据库兼容性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/reference/security/secure-use&quot;&gt;GitHub Actions Secure Use Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/concepts/security/github_token&quot;&gt;GitHub GITHUB_TOKEN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/reference/security/oidc&quot;&gt;GitHub OpenID Connect Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax&quot;&gt;GitHub Workflow Syntax&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文以当前仓库 CI 和 Pages 发布方式为案例，补充从检查到安全部署的 DevOps 主线。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>PostgreSQL 事务与并发：隔离级别、锁、死锁和重试</title><link>https://zh19990906.github.io/fuwari/posts/postgresql-transactions-locks/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/postgresql-transactions-locks/</guid><description>理解 PostgreSQL 事务隔离、行锁、死锁和序列化失败，并为库存扣减、任务领取和并发更新设计可重试边界。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;数据库并发问题往往不是“SQL 写错了”，而是两个都正确的请求在不同时间观察和修改同一份数据。事务用于把一组操作组成原子边界，隔离级别决定并发事务可以看到什么，锁则控制谁可以先修改资源。&lt;/p&gt;
&lt;p&gt;本文聚焦 PostgreSQL 的 &lt;code&gt;Read Committed&lt;/code&gt;、&lt;code&gt;Repeatable Read&lt;/code&gt;、&lt;code&gt;Serializable&lt;/code&gt;、显式行锁和死锁处理。事务不能替代业务幂等，也不能让外部 HTTP、消息系统和数据库天然处于同一个原子操作中。&lt;/p&gt;
&lt;h2&gt;事务边界&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 101
  AND balance &amp;gt;= 100;

INSERT INTO ledger(account_id, amount, event_type)
VALUES (101, -100, &apos;purchase&apos;);

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用必须检查第一条 &lt;code&gt;UPDATE&lt;/code&gt; 实际影响的行数。如果余额不足导致更新零行，却仍然插入流水，事务语法虽然成功，业务仍然错误。&lt;/p&gt;
&lt;p&gt;事务应尽量短：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不在事务中等待用户输入；&lt;/li&gt;
&lt;li&gt;不在持锁期间调用慢外部 API；&lt;/li&gt;
&lt;li&gt;不把大批文件处理放入事务；&lt;/li&gt;
&lt;li&gt;先完成必要计算，再打开事务执行数据库读写；&lt;/li&gt;
&lt;li&gt;为语句和锁等待设置合理超时。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;默认隔离级别 Read Committed&lt;/h2&gt;
&lt;p&gt;PostgreSQL 默认使用 &lt;code&gt;Read Committed&lt;/code&gt;。每条语句看到该语句开始前已经提交的数据，同一事务内两次查询可能看到其他事务新提交的结果。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT status FROM orders WHERE id = 501;
-- 其他事务提交修改
SELECT status FROM orders WHERE id = 501;
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它适合多数短事务，但“先查询、在应用里判断、再更新”的读改写流程可能丢失并发变化。&lt;/p&gt;
&lt;p&gt;不安全模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT stock FROM products WHERE id = 9;
-- 应用计算 stock - 1
UPDATE products SET stock = 19 WHERE id = 9;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更安全的单语句条件更新：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE products
SET stock = stock - 1
WHERE id = 9
  AND stock &amp;gt; 0
RETURNING stock;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;条件和修改由同一条 SQL 完成，可以减少竞争窗口。&lt;/p&gt;
&lt;h2&gt;Repeatable Read&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Repeatable Read&lt;/code&gt; 让事务中的查询基于稳定快照。它适合需要一致读取多个相关查询的场景，但并发写入可能在提交时产生序列化失败。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM monthly_summary WHERE month = DATE &apos;2026-08-01&apos;;
SELECT * FROM monthly_items WHERE month = DATE &apos;2026-08-01&apos;;
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;稳定快照不代表“不会冲突”。应用仍然要捕获数据库返回的失败并重试整个事务，而不是只重试最后一条语句。&lt;/p&gt;
&lt;h2&gt;Serializable&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Serializable&lt;/code&gt; 尝试让并发事务的结果等价于某个串行执行顺序。PostgreSQL 通过可序列化快照隔离检测危险依赖，无法安全排序时会终止其中一个事务。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 读取和写入业务数据
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;适合复杂一致性规则，但必须满足：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;事务较短；&lt;/li&gt;
&lt;li&gt;重试逻辑明确；&lt;/li&gt;
&lt;li&gt;外部副作用不在可重试事务内部直接执行；&lt;/li&gt;
&lt;li&gt;重试次数有上限并带随机退避；&lt;/li&gt;
&lt;li&gt;记录冲突率，用数据判断隔离级别是否合适。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;使用 FOR UPDATE 锁定目标行&lt;/h2&gt;
&lt;p&gt;任务领取、余额修改和状态机转换常需要显式行锁。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN;

SELECT id, status
FROM jobs
WHERE id = 7001
FOR UPDATE;

UPDATE jobs
SET status = &apos;running&apos;, started_at = now()
WHERE id = 7001
  AND status = &apos;queued&apos;;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; 阻止其他事务同时修改或锁定该行，直到当前事务结束。必须按稳定顺序锁定多行，降低死锁概率。&lt;/p&gt;
&lt;p&gt;批量 Worker 领取任务可以使用 &lt;code&gt;SKIP LOCKED&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH picked AS (
    SELECT id
    FROM jobs
    WHERE status = &apos;queued&apos;
    ORDER BY id
    FOR UPDATE SKIP LOCKED
    LIMIT 10
)
UPDATE jobs
SET status = &apos;running&apos;, started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; 适合队列式领取，不适合需要完整一致视图的普通查询，因为它会跳过当前被锁定的数据。&lt;/p&gt;
&lt;h2&gt;乐观并发控制&lt;/h2&gt;
&lt;p&gt;频繁读取、偶尔冲突的业务可以增加版本号：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE articles
SET title = $1,
    version = version + 1,
    updated_at = now()
WHERE id = $2
  AND version = $3
RETURNING version;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;影响零行表示版本已经变化，API 可以返回冲突，让客户端重新读取。不要静默覆盖他人的修改。&lt;/p&gt;
&lt;h2&gt;死锁如何形成&lt;/h2&gt;
&lt;p&gt;两个事务以不同顺序锁定资源：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;事务 A：锁定账户 1 → 等待账户 2
事务 B：锁定账户 2 → 等待账户 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL 会检测死锁并终止一个事务。数据库自动解除死锁，不代表应用可以忽略错误；被终止事务必须回滚并决定是否重试。&lt;/p&gt;
&lt;p&gt;降低死锁：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有代码按相同顺序锁定资源；&lt;/li&gt;
&lt;li&gt;缩短事务；&lt;/li&gt;
&lt;li&gt;不在事务中等待网络调用；&lt;/li&gt;
&lt;li&gt;为批量更新设置稳定排序；&lt;/li&gt;
&lt;li&gt;避免无索引条件导致大范围扫描和锁等待；&lt;/li&gt;
&lt;li&gt;不用无限重试掩盖设计问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;查看锁与等待关系&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT
    a.pid,
    a.usename,
    a.state,
    a.wait_event_type,
    a.wait_event,
    now() - a.xact_start AS transaction_age,
    a.query
FROM pg_stat_activity AS a
WHERE a.datname = current_database()
ORDER BY a.xact_start NULLS LAST;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看 &lt;code&gt;pg_locks&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
    l.pid,
    l.locktype,
    l.mode,
    l.granted,
    l.relation::regclass AS relation,
    a.query
FROM pg_locks AS l
LEFT JOIN pg_stat_activity AS a ON a.pid = l.pid
ORDER BY l.granted, l.pid;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用 &lt;code&gt;pg_blocking_pids()&lt;/code&gt; 定位阻塞者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
    pid,
    pg_blocking_pids(pid) AS blocking_pids,
    now() - query_start AS query_age,
    query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) &amp;gt; 0;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在不了解事务内容时直接终止生产连接。先确认阻塞者、事务年龄、业务影响和是否存在回滚成本。&lt;/p&gt;
&lt;h2&gt;超时设置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SET LOCAL lock_timeout = &apos;2s&apos;;
SET LOCAL statement_timeout = &apos;10s&apos;;
SET LOCAL idle_in_transaction_session_timeout = &apos;30s&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;lock_timeout&lt;/code&gt; 限制等待锁时间；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;statement_timeout&lt;/code&gt; 限制语句总执行时间；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; 限制事务打开后长时间空闲。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;设置必须结合业务延迟目标。超时过短会放大瞬时抖动，过长则让连接和锁长期占用。&lt;/p&gt;
&lt;h2&gt;应用重试整个事务&lt;/h2&gt;
&lt;p&gt;伪代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import random
import time


def run_transaction_with_retry(operation, attempts: int = 3):
    for attempt in range(attempts):
        try:
            return operation()
        except SerializationFailure:
            if attempt == attempts - 1:
                raise
            time.sleep((2**attempt) * 0.05 + random.random() * 0.05)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重试函数内部的操作必须可以安全重新执行。邮件、扣款和外部请求等副作用应通过 Outbox、幂等键或事务提交后的可靠任务处理。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 每个业务动作有明确事务边界；&lt;/li&gt;
&lt;li&gt;[ ] 事务内不等待用户输入或慢外部 API；&lt;/li&gt;
&lt;li&gt;[ ] 库存、余额等竞争写入使用条件更新、版本号或 &lt;code&gt;FOR UPDATE&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;[ ] 多资源锁定采用一致顺序；&lt;/li&gt;
&lt;li&gt;[ ] Serializable 和 Repeatable Read 失败会重试整个事务；&lt;/li&gt;
&lt;li&gt;[ ] 重试操作具备幂等性，外部副作用不被重复执行；&lt;/li&gt;
&lt;li&gt;[ ] 设置锁等待、语句和空闲事务超时；&lt;/li&gt;
&lt;li&gt;[ ] 监控长事务、阻塞链和死锁日志；&lt;/li&gt;
&lt;li&gt;[ ] 运维脚本在终止连接前确认业务影响；&lt;/li&gt;
&lt;li&gt;[ ] 并发测试覆盖竞争更新和重试路径。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/transaction-iso.html&quot;&gt;PostgreSQL Transaction Isolation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/explicit-locking.html&quot;&gt;PostgreSQL Explicit Locking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html&quot;&gt;PostgreSQL Serialization Failure Handling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/monitoring-locks.html&quot;&gt;PostgreSQL Lock Monitoring&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文补充现有 PostgreSQL 查询优化系列，重点覆盖并发写入和事务失败后的恢复边界。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python 服务可观测性：结构化日志、指标与 OpenTelemetry 链路追踪</title><link>https://zh19990906.github.io/fuwari/posts/python-opentelemetry-observability/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-opentelemetry-observability/</guid><description>为 Python 和 FastAPI 服务建立日志、指标、Trace 和 OTLP 导出链路，覆盖上下文传播、高基数风险、敏感数据和 Collector 部署边界。</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;监控回答“系统是否异常”，可观测性帮助回答“为什么异常”。单独增加更多日志并不能自动解决问题：没有请求关联 ID、服务间上下文和稳定字段时，日志量越大，定位反而越慢。&lt;/p&gt;
&lt;p&gt;OpenTelemetry 提供统一的 Trace、Metrics、Logs API、SDK 和协议。当前 Python 实现中 Traces 与 Metrics 为稳定状态，Logs 仍处于 Development；生产方案应按各信号成熟度分别评估，而不是假设它们完全等价。&lt;/p&gt;
&lt;h2&gt;三类信号的职责&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;信号&lt;/th&gt;
&lt;th&gt;适合回答&lt;/th&gt;
&lt;th&gt;常见错误&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;日志&lt;/td&gt;
&lt;td&gt;某个事件发生了什么&lt;/td&gt;
&lt;td&gt;记录敏感数据、字段不一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;指标&lt;/td&gt;
&lt;td&gt;系统整体是否偏离正常范围&lt;/td&gt;
&lt;td&gt;标签高基数、只看平均值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace&lt;/td&gt;
&lt;td&gt;一个请求跨服务经历了什么&lt;/td&gt;
&lt;td&gt;全量采样成本过高、缺少传播&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;可观测性不能替代业务审计。审计事件关注“谁在何时执行了什么受控操作”，保留周期、完整性和访问权限通常不同于普通应用日志。&lt;/p&gt;
&lt;h2&gt;结构化日志&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import json
import logging
from datetime import datetime, timezone


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -&amp;gt; str:
        payload = {
            &quot;timestamp&quot;: datetime.now(timezone.utc).isoformat(),
            &quot;level&quot;: record.levelname,
            &quot;logger&quot;: record.name,
            &quot;message&quot;: record.getMessage(),
            &quot;trace_id&quot;: getattr(record, &quot;trace_id&quot;, None),
            &quot;request_id&quot;: getattr(record, &quot;request_id&quot;, None),
        }
        return json.dumps(payload, ensure_ascii=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;字段名要稳定。不要把整个请求体、Authorization Header、Cookie、数据库连接串或模型提示词默认写入日志。&lt;/p&gt;
&lt;p&gt;日志级别建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DEBUG&lt;/code&gt;：只在受控调试环境开启；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;INFO&lt;/code&gt;：关键生命周期与业务里程碑；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WARNING&lt;/code&gt;：系统能够继续但需要关注；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ERROR&lt;/code&gt;：当前操作失败；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CRITICAL&lt;/code&gt;：服务级不可用或数据安全风险。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Request ID 与 Trace ID&lt;/h2&gt;
&lt;p&gt;Request ID 适合单个入口请求的用户支持和日志搜索；Trace ID 由链路追踪系统生成并跨服务传播。两者可以同时存在。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from uuid import uuid4

from fastapi import Request


@app.middleware(&quot;http&quot;)
async def attach_request_id(request: Request, call_next):
    request_id = request.headers.get(&quot;X-Request-ID&quot;) or str(uuid4())
    response = await call_next(request)
    response.headers[&quot;X-Request-ID&quot;] = request_id
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只信任外部 Request ID 用于关联，不要把它直接作为权限、数据库主键或文件路径。&lt;/p&gt;
&lt;h2&gt;安装 OpenTelemetry&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install \
  opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-exporter-otlp \
  opentelemetry-instrumentation-fastapi \
  opentelemetry-instrumentation-httpx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用使用 SDK 初始化 Provider；可复用库通常只依赖 API，让宿主应用决定采样和导出。&lt;/p&gt;
&lt;h2&gt;自动插桩&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;opentelemetry-instrument \
  --traces_exporter otlp \
  --metrics_exporter otlp \
  uvicorn app.main:app --host 0.0.0.0 --port 8000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常用环境变量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export OTEL_SERVICE_NAME=blog-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production,service.version=2026.08.04
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OTLP Endpoint 应指向受控 Collector 或后端。不要把遥测出口直接暴露在公网，也不要在环境变量示例中提交真实认证信息。&lt;/p&gt;
&lt;h2&gt;手动创建 Span&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer(__name__)


def rebuild_article_index(article_id: str) -&amp;gt; None:
    with tracer.start_as_current_span(&quot;article.index.rebuild&quot;) as span:
        span.set_attribute(&quot;article.id&quot;, article_id)
        try:
            rebuild(article_id)
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR))
            raise
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Span 名称表达稳定操作，不要把用户 ID、完整 URL 或随机值放进名称。动态信息使用属性，并限制敏感性和基数。&lt;/p&gt;
&lt;p&gt;读取当前 Trace ID 写入日志：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from opentelemetry import trace


def current_trace_id() -&amp;gt; str | None:
    context = trace.get_current_span().get_span_context()
    if not context.is_valid:
        return None
    return format(context.trace_id, &quot;032x&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;指标设计&lt;/h2&gt;
&lt;p&gt;服务最基本的 RED 指标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rate：请求速率；&lt;/li&gt;
&lt;li&gt;Errors：错误数量或比例；&lt;/li&gt;
&lt;li&gt;Duration：延迟分布。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from opentelemetry import metrics

meter = metrics.get_meter(__name__)
article_counter = meter.create_counter(
    &quot;article.publish.count&quot;,
    description=&quot;Number of completed article publications&quot;,
)


def record_publish(result: str) -&amp;gt; None:
    article_counter.add(1, {&quot;result&quot;: result})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;标签值应是有限集合，例如 &lt;code&gt;result=success|conflict|error&lt;/code&gt;。不要使用用户 ID、订单 ID、完整错误文本或 URL 作为指标标签，这会造成高基数并显著增加存储和查询成本。&lt;/p&gt;
&lt;p&gt;延迟应使用 Histogram，而不是只记录平均值。平均值可能掩盖少量极慢请求。&lt;/p&gt;
&lt;h2&gt;上下文传播&lt;/h2&gt;
&lt;p&gt;OpenTelemetry Python 默认使用 W3C Trace Context 和 Baggage。HTTP 客户端和服务端插桩会传播 &lt;code&gt;traceparent&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;不要把访问令牌、邮箱等敏感信息放进 Baggage。Baggage 会跨服务传播，且可能进入遥测后端。&lt;/p&gt;
&lt;p&gt;异步任务和消息系统需要显式传播上下文：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTTP 使用标准 Header；&lt;/li&gt;
&lt;li&gt;Kafka 等消息在消息 Header 中注入上下文；&lt;/li&gt;
&lt;li&gt;后台任务如果与请求存在因果关系，可继续父上下文或使用 Span Link；&lt;/li&gt;
&lt;li&gt;长时间队列任务不应伪装为一直打开的同步请求 Span。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Collector 的作用&lt;/h2&gt;
&lt;p&gt;推荐链路：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;应用
  ↓ OTLP
OpenTelemetry Collector
  ├─ 批处理
  ├─ 重试与队列
  ├─ 属性清洗
  ├─ 采样
  └─ 导出到遥测后端
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Collector 可以减少应用直接绑定某个厂商后端。它不是无限缓冲区；后端长时间不可用时仍然可能丢数据或占满磁盘，需要限制队列和监控自身健康。&lt;/p&gt;
&lt;p&gt;简化配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;receivers:
  otlp:
    protocols:
      grpc: {}
      http: {}

processors:
  batch: {}
  memory_limiter:
    check_interval: 1s
    limit_mib: 512

exporters:
  debug: {}

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产环境把 &lt;code&gt;debug&lt;/code&gt; 换成经过认证的后端 Exporter，并限制 Collector 管理端口的网络访问。&lt;/p&gt;
&lt;h2&gt;采样&lt;/h2&gt;
&lt;p&gt;全量 Trace 在高流量系统中成本很高。常见策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;开发环境全采样；&lt;/li&gt;
&lt;li&gt;生产使用父级一致的概率采样；&lt;/li&gt;
&lt;li&gt;对错误和高延迟请求使用尾部采样；&lt;/li&gt;
&lt;li&gt;关键交易单独设置更高采样率；&lt;/li&gt;
&lt;li&gt;采样策略变更后监控成本与可定位性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;采样会影响 Trace，不应影响错误计数等核心指标。&lt;/p&gt;
&lt;h2&gt;敏感数据与属性清洗&lt;/h2&gt;
&lt;p&gt;禁止默认采集：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Authorization Header 和 Cookie；&lt;/li&gt;
&lt;li&gt;密码、访问令牌和刷新令牌；&lt;/li&gt;
&lt;li&gt;完整 SQL 参数；&lt;/li&gt;
&lt;li&gt;上传文件内容；&lt;/li&gt;
&lt;li&gt;模型 Prompt 中的个人信息；&lt;/li&gt;
&lt;li&gt;内部密钥路径和连接串。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;路由模板 &lt;code&gt;/users/{user_id}&lt;/code&gt; 适合作为低基数属性，实际路径 &lt;code&gt;/users/928374&lt;/code&gt; 不适合直接作为指标标签。&lt;/p&gt;
&lt;h2&gt;告警从用户影响出发&lt;/h2&gt;
&lt;p&gt;优先告警：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;错误率持续超过阈值；&lt;/li&gt;
&lt;li&gt;p95/p99 延迟超过 SLO；&lt;/li&gt;
&lt;li&gt;数据库连接池耗尽；&lt;/li&gt;
&lt;li&gt;队列积压和消费停滞；&lt;/li&gt;
&lt;li&gt;Collector 拒绝或丢弃数据；&lt;/li&gt;
&lt;li&gt;服务实例反复重启。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要给每一条错误日志创建告警。告警必须可行动，并包含服务、环境、时间窗口、关键指标和排障入口。&lt;/p&gt;
&lt;h2&gt;生产检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 日志使用稳定 JSON 字段，并包含 Request ID 或 Trace ID；&lt;/li&gt;
&lt;li&gt;[ ] 日志、Span 和指标不采集密码、令牌和认证 Header；&lt;/li&gt;
&lt;li&gt;[ ] Trace、Metrics 和 Logs 的成熟度分别评估；&lt;/li&gt;
&lt;li&gt;[ ] OTLP 流量只发送到受控 Collector 或后端；&lt;/li&gt;
&lt;li&gt;[ ] 服务名、版本和部署环境作为 Resource 属性；&lt;/li&gt;
&lt;li&gt;[ ] 指标标签使用有限集合，避免高基数；&lt;/li&gt;
&lt;li&gt;[ ] 延迟使用 Histogram 并关注 p95/p99；&lt;/li&gt;
&lt;li&gt;[ ] 上下文跨 HTTP 和消息链路传播；&lt;/li&gt;
&lt;li&gt;[ ] Collector 的队列、内存、丢弃和出口失败被监控；&lt;/li&gt;
&lt;li&gt;[ ] 采样策略与成本、SLO 和故障定位需求匹配。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://opentelemetry.io/docs/languages/python/&quot;&gt;OpenTelemetry Python&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://opentelemetry.io/docs/languages/python/instrumentation/&quot;&gt;OpenTelemetry Python Instrumentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://opentelemetry.io/docs/languages/python/getting-started/&quot;&gt;OpenTelemetry Python Getting Started&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文补充 Python 服务从日志排查到分布式可观测性的工程链路。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>YOLO26 手势大战：从 HaGRID 清洗到双模型训练</title><link>https://zh19990906.github.io/fuwari/posts/yolo26-hand-gesture-game/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo26-hand-gesture-game/</guid><description>记录在小磁盘与 OSS 挂载盘环境中流式清洗 HaGRID 数据，并使用两张 RTX PRO 5000 分别训练 YOLO26n 与 YOLO26s 的完整过程。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这篇文章记录一个完整的小型计算机视觉项目：从 HaGRID 原始压缩包中筛选手势数据，转换为 YOLO 检测格式，训练 YOLO26n 与 YOLO26s，再根据验证结果选择适合实时摄像头游戏的模型。&lt;/p&gt;
&lt;p&gt;最终希望实现的链路是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;摄像头画面
    ↓
YOLO 手势检测
    ↓
连续帧稳定与技能冷却
    ↓
游戏状态机
    ↓
玩家通过手势完成对战
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;1. 项目目标与类别设计&lt;/h2&gt;
&lt;p&gt;第一版游戏使用四个明确手势作为操作输入：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类别编号&lt;/th&gt;
&lt;th&gt;HaGRID 类别&lt;/th&gt;
&lt;th&gt;游戏含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;握拳，可映射为石头或攻击&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;palm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;张开手掌，可映射为布或防御&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;peace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;V 字手势，可映射为剪刀或技能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;like&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;点赞，可映射为确认或特殊技能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no_gesture&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自然手部姿态，用于降低误触发&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里没有只训练四个有效动作，而是把 &lt;code&gt;no_gesture&lt;/code&gt; 也作为检测类别。原因是游戏中的输入并不总是标准手势：手刚进入画面、动作切换、摸脸、拿东西以及自然放松的手都可能被误判成技能。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
后续仍可以尝试只训练四个有效手势，并把低置信度或没有检测框视为 &lt;code&gt;no_gesture&lt;/code&gt;。第一版先显式训练负类，便于观察误触发情况。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;2. HaGRID 数据集介绍&lt;/h2&gt;
&lt;p&gt;本项目使用 &lt;a href=&quot;https://github.com/hukenovs/hagrid&quot;&gt;HaGRID（HAnd Gesture Recognition Image Dataset）&lt;/a&gt;。它既可以用于整图分类，也提供了手势检测所需的边界框标注。&lt;/p&gt;
&lt;p&gt;截至本次实验，官方仓库对 HaGRIDv2-1M 的描述包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;约 1.5 TB 数据；&lt;/li&gt;
&lt;li&gt;1,086,158 张 Full HD RGB 图片；&lt;/li&gt;
&lt;li&gt;33 个手势类别以及单独的 &lt;code&gt;no_gesture&lt;/code&gt; 类别；&lt;/li&gt;
&lt;li&gt;65,977 名参与者；&lt;/li&gt;
&lt;li&gt;按 &lt;code&gt;user_id&lt;/code&gt; 划分 train、val、test，比例约为 76%、9%、15%。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对这个项目而言，最重要的不是把完整数据集全部下载下来，而是保留官方划分，从而尽量避免同一个人同时出现在训练集和验证集里。否则模型可能记住人物、背景或拍摄环境，得到过于乐观的验证结果。&lt;/p&gt;
&lt;p&gt;我们只准备六个输入文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/root/dadaset/
├── fist.zip
├── palm.zip
├── peace.zip
├── like.zip
├── no_gesture.zip
└── annotations.zip
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;五个手势压缩包提供图片，&lt;code&gt;annotations.zip&lt;/code&gt; 提供官方 train、val、test 划分、图片 ID、类别和边界框。&lt;/p&gt;
&lt;h3&gt;2.1 抽样规模&lt;/h3&gt;
&lt;p&gt;为了避免后期重新读取几十 GB 的原始压缩包，四个主要类别一次多保留一部分数据：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类别&lt;/th&gt;
&lt;th&gt;Train&lt;/th&gt;
&lt;th&gt;Val&lt;/th&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fist&lt;/td&gt;
&lt;td&gt;8,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;palm&lt;/td&gt;
&lt;td&gt;8,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;peace&lt;/td&gt;
&lt;td&gt;8,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;like&lt;/td&gt;
&lt;td&gt;8,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no_gesture&lt;/td&gt;
&lt;td&gt;1,464&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;no_gesture&lt;/code&gt; 的独立图片数量较少，但其他手势图片中的第二只自然状态手也可能带有 &lt;code&gt;no_gesture&lt;/code&gt; 标注。因此最终验证日志中，&lt;code&gt;no_gesture&lt;/code&gt; 的图片数和实例数会高于单独压缩包的验证抽样数。&lt;/p&gt;
&lt;p&gt;清洗并合并后的数据集约为 52 GB。&lt;/p&gt;
&lt;h2&gt;3. 为什么不能完整解压后再清洗&lt;/h2&gt;
&lt;p&gt;处理阶段的机器有以下限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;本地磁盘：约 100 GB
内存：350 GB
原始数据：多个约 40 GB 的手势 ZIP
存储环境：OSS 网络挂载盘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果采用传统流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ZIP
→ 完整解压
→ 扫描几万张图片
→ 复制选中图片
→ 删除其余图片
→ 再打包
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;会出现三个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;ZIP 与完整解压目录可能同时占满本地磁盘；&lt;/li&gt;
&lt;li&gt;OSS 挂载盘处理大量小文件时，&lt;code&gt;stat&lt;/code&gt;、&lt;code&gt;open&lt;/code&gt;、&lt;code&gt;create&lt;/code&gt; 和 &lt;code&gt;close&lt;/code&gt; 的成本很高；&lt;/li&gt;
&lt;li&gt;大部分图片解压后马上又会被删除，产生大量无意义 I/O。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;因此最终采用流式方案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;annotations.zip
    ↓ 读取标注并在内存中抽样
得到需要保留的 image_id 集合
    ↓
手势 ZIP 中建立文件名索引
    ↓
只读取被选中的图片字节
    ↓
内存中读取宽高并转换边界框
    ↓
直接顺序写入一个 TAR 大文件
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个流程不会完整解压图片。内存中每次只保留当前图片字节、尺寸和标签，磁盘上只新增一个连续写入的 TAR 文件。&lt;/p&gt;
&lt;h2&gt;4. 流式清洗脚本&lt;/h2&gt;
&lt;p&gt;完整脚本保存在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/zh19990906/fuwari/blob/main/scripts/hagrid/stream_hagrid_zip_to_tar.py&quot;&gt;&lt;code&gt;scripts/hagrid/stream_hagrid_zip_to_tar.py&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;安装依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install pillow tqdm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;脚本的核心工作分为五步。&lt;/p&gt;
&lt;h3&gt;4.1 从 annotations.zip 定位类别标注&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from pathlib import PurePosixPath
import zipfile


def find_annotation_member(
    zf: zipfile.ZipFile,
    split: str,
    class_name: str,
) -&amp;gt; str:
    matches: list[str] = []

    for name in zf.namelist():
        path = PurePosixPath(name)
        if path.suffix.lower() != &quot;.json&quot;:
            continue
        if path.stem.lower() != class_name.lower():
            continue
        if split in [part.lower() for part in path.parts]:
            matches.append(name)

    if not matches:
        raise FileNotFoundError(
            f&quot;annotations.zip 中没有找到 {split}/{class_name}.json&quot;
        )

    matches.sort(key=lambda item: (len(PurePosixPath(item).parts), len(item)))
    return matches[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2 保留官方 split，并固定随机种子抽样&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;rng = random.Random(seed)
selected: dict[str, tuple[str, dict]] = {}

with zipfile.ZipFile(annotations_zip, &quot;r&quot;) as annotation_archive:
    for split in (&quot;train&quot;, &quot;val&quot;, &quot;test&quot;):
        member = find_annotation_member(
            annotation_archive,
            split,
            class_name,
        )
        data = json.loads(annotation_archive.read(member).decode(&quot;utf-8&quot;))
        entries = list(iter_entries(data))
        rng.shuffle(entries)
        entries = entries[: limits[split]]

        for image_id, entry in entries:
            selected[image_id] = (split, entry)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;固定 &lt;code&gt;seed=42&lt;/code&gt; 的意义是让第一次清洗可复现。脚本还会在每个 TAR 内写入 &lt;code&gt;manifests/&amp;lt;class&amp;gt;.json&lt;/code&gt;，记录选中图片、来源、目标路径、边界框数量和缺失样本。&lt;/p&gt;
&lt;h3&gt;4.3 边界框转换为 YOLO 格式&lt;/h3&gt;
&lt;p&gt;YOLO 标签格式为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class_id center_x center_y width height
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四个坐标都归一化到 0～1。HaGRID 常见标注是归一化 &lt;code&gt;xywh&lt;/code&gt;，转换方式为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def bbox_to_yolo(box: list[float]):
    x, y, width, height = map(float, box)
    center_x = x + width / 2.0
    center_y = y + height / 2.0
    return center_x, center_y, width, height
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际脚本还兼容像素 &lt;code&gt;xywh&lt;/code&gt; 和像素 &lt;code&gt;xyxy&lt;/code&gt;，并对坐标范围、空框和损坏图片进行检查。&lt;/p&gt;
&lt;h3&gt;4.4 图片不落地，直接写入 TAR&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import io
import tarfile


def add_bytes_to_tar(
    archive: tarfile.TarFile,
    archive_name: str,
    payload: bytes,
) -&amp;gt; None:
    info = tarfile.TarInfo(name=archive_name)
    info.size = len(payload)
    info.mtime = 0
    info.mode = 0o644
    archive.addfile(info, io.BytesIO(payload))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;图片从 ZIP 读取到内存后，直接写到以下路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;images/train/&amp;lt;class&amp;gt;_&amp;lt;image_id&amp;gt;.jpg
labels/train/&amp;lt;class&amp;gt;_&amp;lt;image_id&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;文件名增加类别前缀，避免五个压缩包中出现相同图片 ID 时互相覆盖。&lt;/p&gt;
&lt;h3&gt;4.5 生成 data.yaml 和 manifest&lt;/h3&gt;
&lt;p&gt;每个类别 TAR 都带有同一份类别配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;path: .
train: images/train
val: images/val
test: images/test

names:
  0: fist
  1: palm
  2: peace
  3: like
  4: no_gesture
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;五个 TAR 解压到同一目录时，&lt;code&gt;images&lt;/code&gt;、&lt;code&gt;labels&lt;/code&gt; 和 &lt;code&gt;manifests&lt;/code&gt; 会自然合并。&lt;/p&gt;
&lt;h2&gt;5. 执行数据清洗&lt;/h2&gt;
&lt;p&gt;先处理单个类别进行验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /root/dadaset

python stream_hagrid_zip_to_tar.py \
  --class-name like \
  --image-zip /root/dadaset/like.zip \
  --annotations-zip /root/dadaset/annotations.zip \
  --output-tar /root/dadaset/like_clean.tar \
  --train-limit 8000 \
  --val-limit 1000 \
  --test-limit 1000 \
  --overwrite
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;no_gesture&lt;/code&gt; 使用独立上限：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python stream_hagrid_zip_to_tar.py \
  --class-name no_gesture \
  --image-zip /root/dadaset/no_gesture.zip \
  --annotations-zip /root/dadaset/annotations.zip \
  --output-tar /root/dadaset/no_gesture_clean.tar \
  --no-gesture-train-limit 1464 \
  --no-gesture-val-limit 200 \
  --no-gesture-test-limit 500 \
  --overwrite
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四个主要手势可以循环处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /root/dadaset

for cls in fist palm peace like; do
  python stream_hagrid_zip_to_tar.py \
    --class-name &quot;$cls&quot; \
    --image-zip &quot;/root/dadaset/${cls}.zip&quot; \
    --annotations-zip /root/dadaset/annotations.zip \
    --output-tar &quot;/root/dadaset/${cls}_clean.tar&quot; \
    --train-limit 8000 \
    --val-limit 1000 \
    --test-limit 1000 \
    --overwrite

  tar -tf &quot;/root/dadaset/${cls}_clean.tar&quot; &amp;gt;/dev/null
  sha256sum &quot;/root/dadaset/${cls}_clean.tar&quot; \
    &amp;gt; &quot;/root/dadaset/${cls}_clean.tar.sha256&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JPEG 已经经过压缩，因此默认使用普通 &lt;code&gt;.tar&lt;/code&gt;，不再使用 gzip。普通 TAR 生成更快，也更适合向 OSS 顺序传输。&lt;/p&gt;
&lt;h2&gt;6. 合并五个 TAR 为 YOLO 数据集&lt;/h2&gt;
&lt;p&gt;清洗后的五个 TAR 位于：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mnt/model/code/hagrid/clean/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把它们解压到同一目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /mnt/model/code/hagrid/dataset_yolo
cd /mnt/model/code/hagrid/dataset_yolo

for file in /mnt/model/code/hagrid/clean/*_clean.tar; do
  echo &quot;正在解压：$file&quot;
  tar -xf &quot;$file&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mnt/model/code/hagrid/dataset_yolo/
├── images/
│   ├── train/
│   ├── val/
│   └── test/
├── labels/
│   ├── train/
│   ├── val/
│   └── test/
├── manifests/
└── data.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 &lt;code&gt;data.yaml&lt;/code&gt; 的根路径改为绝对路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sed -i \
  &apos;s|^path:.*|path: /mnt/model/code/hagrid/dataset_yolo|&apos; \
  /mnt/model/code/hagrid/dataset_yolo/data.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查图片和标签数量是否一致：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /mnt/model/code/hagrid/dataset_yolo

for split in train val test; do
  echo &quot;===== $split =====&quot;
  echo -n &quot;images: &quot;
  find &quot;images/$split&quot; -type f | wc -l
  echo -n &quot;labels: &quot;
  find &quot;labels/$split&quot; -type f -name &apos;*.txt&apos; | wc -l
done
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;7. 训练环境&lt;/h2&gt;
&lt;p&gt;实际训练环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Ultralytics: 8.4.104
Python: 3.11.11
PyTorch: 2.9.0+cu128
GPU: 2 × NVIDIA RTX PRO 5000 72GB Blackwell
System memory: 350 GB
Dataset size: 52 GB
Image size: 640
Task: object detection
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两张显卡没有用于同一个 DDP 任务，而是分别训练两个模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GPU 0：YOLO26n，测试轻量模型的实时能力；&lt;/li&gt;
&lt;li&gt;GPU 1：YOLO26s，测试更大模型是否能明显提升精度。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样可以在同一时间完成两组实验，而不是先后等待。&lt;/p&gt;
&lt;h2&gt;8. Python 训练脚本&lt;/h2&gt;
&lt;p&gt;完整脚本保存在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/zh19990906/fuwari/blob/main/scripts/hagrid/train_yolo26.py&quot;&gt;&lt;code&gt;scripts/hagrid/train_yolo26.py&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;脚本内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

from ultralytics import YOLO

DEFAULT_DATA = &quot;/mnt/model/code/hagrid/dataset_yolo/data.yaml&quot;
DEFAULT_PROJECT = &quot;/mnt/model/code/hagrid/runs&quot;


def parse_device(value: str):
    value = value.strip()
    if value.lower() == &quot;cpu&quot;:
        return &quot;cpu&quot;

    ids = [int(item.strip()) for item in value.split(&quot;,&quot;) if item.strip()]
    return ids[0] if len(ids) == 1 else ids


def build_parser() -&amp;gt; argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=&quot;训练 YOLO26 手势检测模型&quot;)
    parser.add_argument(&quot;--data&quot;, default=DEFAULT_DATA)
    parser.add_argument(&quot;--model&quot;, default=&quot;yolo26s.pt&quot;)
    parser.add_argument(&quot;--device&quot;, type=parse_device, default=[0, 1])
    parser.add_argument(&quot;--epochs&quot;, type=int, default=120)
    parser.add_argument(&quot;--imgsz&quot;, type=int, default=640)
    parser.add_argument(&quot;--batch&quot;, type=int, default=64)
    parser.add_argument(&quot;--workers&quot;, type=int, default=16)
    parser.add_argument(&quot;--patience&quot;, type=int, default=30)
    parser.add_argument(&quot;--seed&quot;, type=int, default=42)
    parser.add_argument(&quot;--project&quot;, default=DEFAULT_PROJECT)
    parser.add_argument(&quot;--name&quot;, default=&quot;gesture_yolo26s_dual&quot;)
    parser.add_argument(
        &quot;--cache&quot;,
        choices=[&quot;false&quot;, &quot;ram&quot;, &quot;disk&quot;],
        default=&quot;false&quot;,
    )
    parser.add_argument(&quot;--resume&quot;, default=None)
    parser.add_argument(&quot;--exist-ok&quot;, action=&quot;store_true&quot;)
    return parser


def main() -&amp;gt; None:
    args = build_parser().parse_args()
    data_path = Path(args.data)

    if not data_path.is_file():
        raise FileNotFoundError(f&quot;找不到数据配置：{data_path}&quot;)

    if args.resume:
        checkpoint = Path(args.resume)
        if not checkpoint.is_file():
            raise FileNotFoundError(f&quot;找不到断点文件：{checkpoint}&quot;)
        YOLO(str(checkpoint)).train(resume=True)
        return

    cache = False if args.cache == &quot;false&quot; else args.cache
    model = YOLO(args.model)

    model.train(
        task=&quot;detect&quot;,
        data=str(data_path),
        epochs=args.epochs,
        imgsz=args.imgsz,
        batch=args.batch,
        device=args.device,
        workers=args.workers,
        project=args.project,
        name=args.name,
        exist_ok=args.exist_ok,
        pretrained=True,
        optimizer=&quot;auto&quot;,
        patience=args.patience,
        seed=args.seed,
        deterministic=True,
        amp=True,
        cos_lr=True,
        close_mosaic=10,
        plots=True,
        save=True,
        val=True,
        cache=cache,
    )


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装训练依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U ultralytics
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;9. 启动两个训练任务&lt;/h2&gt;
&lt;p&gt;YOLO26n 更小，因此使用更大的 batch；YOLO26s 更大，单张图片和中间特征占用更多显存。&lt;/p&gt;
&lt;h3&gt;9.1 GPU 0：YOLO26n&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /mnt/model/code/hagrid

nohup python train_yolo26.py \
  --model yolo26n.pt \
  --device 0 \
  --batch 256 \
  --epochs 120 \
  --imgsz 640 \
  --workers 16 \
  --patience 25 \
  --cache false \
  --name gesture_yolo26n \
  &amp;gt; gesture_yolo26n.log 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.2 GPU 1：YOLO26s&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /mnt/model/code/hagrid

nohup python train_yolo26.py \
  --model yolo26s.pt \
  --device 1 \
  --batch 192 \
  --epochs 150 \
  --imgsz 640 \
  --workers 16 \
  --patience 35 \
  --cache ram \
  --name gesture_yolo26s \
  &amp;gt; gesture_yolo26s.log 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据集位于网络或挂载存储时，RAM cache 可以减少后续 epoch 的读取等待。这里让较慢的 YOLO26s 使用 RAM cache，YOLO26n 不缓存，避免两个进程各自缓存一份数据。&lt;/p&gt;
&lt;p&gt;查看运行状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ps -ef | grep train_yolo26.py | grep -v grep
watch -n 2 nvidia-smi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看日志：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tail -f /mnt/model/code/hagrid/gesture_yolo26n.log
tail -f /mnt/model/code/hagrid/gesture_yolo26s.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;断点续训：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python train_yolo26.py \
  --resume /mnt/model/code/hagrid/runs/gesture_yolo26s/weights/last.pt
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;10. 验证结果&lt;/h2&gt;
&lt;p&gt;验证集共有 4,200 张图片、5,203 个实例。&lt;/p&gt;
&lt;h3&gt;10.1 总体指标&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模型&lt;/th&gt;
&lt;th&gt;参数量&lt;/th&gt;
&lt;th&gt;GFLOPs&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;mAP50&lt;/th&gt;
&lt;th&gt;mAP50-95&lt;/th&gt;
&lt;th&gt;推理耗时/图&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26n&lt;/td&gt;
&lt;td&gt;2,375,811&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.993&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;0.855&lt;/td&gt;
&lt;td&gt;0.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26s&lt;/td&gt;
&lt;td&gt;9,467,115&lt;/td&gt;
&lt;td&gt;20.5&lt;/td&gt;
&lt;td&gt;0.990&lt;/td&gt;
&lt;td&gt;0.990&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;0.858&lt;/td&gt;
&lt;td&gt;0.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里的推理耗时是 Ultralytics 在 RTX PRO 5000 上的验证统计，不代表摄像头采集、缩放、绘制和游戏逻辑全部完成后的端到端延迟。&lt;/p&gt;
&lt;h3&gt;10.2 各类别 mAP50-95&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类别&lt;/th&gt;
&lt;th&gt;YOLO26n&lt;/th&gt;
&lt;th&gt;YOLO26s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fist&lt;/td&gt;
&lt;td&gt;0.843&lt;/td&gt;
&lt;td&gt;0.843&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;palm&lt;/td&gt;
&lt;td&gt;0.934&lt;/td&gt;
&lt;td&gt;0.932&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;peace&lt;/td&gt;
&lt;td&gt;0.878&lt;/td&gt;
&lt;td&gt;0.880&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;like&lt;/td&gt;
&lt;td&gt;0.869&lt;/td&gt;
&lt;td&gt;0.871&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no_gesture&lt;/td&gt;
&lt;td&gt;0.752&lt;/td&gt;
&lt;td&gt;0.762&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;四个标准手势都获得了较高指标，&lt;code&gt;no_gesture&lt;/code&gt; 明显更难。原因也很直观：标准手势具有明确形状，而自然手部姿态内部差异很大，可能包含半握拳、侧手、遮挡、拿东西以及动作过渡帧。&lt;/p&gt;
&lt;h2&gt;11. 最终模型选择&lt;/h2&gt;
&lt;p&gt;YOLO26s 的 mAP50-95 比 YOLO26n 高 0.003，但代价是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数量约为 YOLO26n 的 4 倍；&lt;/li&gt;
&lt;li&gt;计算量约为 YOLO26n 的 4 倍；&lt;/li&gt;
&lt;li&gt;当前验证硬件上的推理耗时约为 2 倍；&lt;/li&gt;
&lt;li&gt;总体 Precision 和 Recall 并没有超过 YOLO26n。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此第一版实时游戏优先采用 YOLO26n：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mnt/model/code/hagrid/runs/gesture_yolo26n/weights/best.pt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;YOLO26s 保留为精度对照和后续困难场景备选。最终是否切换到 YOLO26s，不应只看验证集 mAP，而应通过真实摄像头测试暗光、侧手、远距离和动作切换表现。&lt;/p&gt;
&lt;h2&gt;12. 模型归档与改名&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /mnt/model/code/hagrid/models

cp /mnt/model/code/hagrid/runs/gesture_yolo26n/weights/best.pt \
  /mnt/model/code/hagrid/models/gesture_yolo26n_640_v1.pt

cp /mnt/model/code/hagrid/runs/gesture_yolo26s/weights/best.pt \
  /mnt/model/code/hagrid/models/gesture_yolo26s_640_v1.pt

cd /mnt/model/code/hagrid/models
sha256sum gesture_yolo26n_640_v1.pt \
          gesture_yolo26s_640_v1.pt \
  &amp;gt; SHA256SUMS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;保留训练目录中的原始 &lt;code&gt;best.pt&lt;/code&gt; 和 &lt;code&gt;last.pt&lt;/code&gt;，发布目录只保存命名明确、带版本号的模型副本。&lt;/p&gt;
&lt;h2&gt;13. 接入游戏前还需要解决什么&lt;/h2&gt;
&lt;p&gt;高验证指标不等于游戏体验稳定。推理层至少需要增加：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;连续帧投票&lt;/strong&gt;：例如最近 5 帧中至少 4 帧类别一致；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;置信度阈值&lt;/strong&gt;：有效手势可从 &lt;code&gt;0.6～0.75&lt;/code&gt; 范围开始测试；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;技能冷却&lt;/strong&gt;：同一手势触发后等待约 0.5 秒；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态切换保护&lt;/strong&gt;：从一个手势切换到另一个手势时，先经过 &lt;code&gt;no_gesture&lt;/code&gt; 或稳定窗口；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;真实负样本回收&lt;/strong&gt;：记录误触发画面，后续加入自然手势和动作过渡数据；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;双手规则&lt;/strong&gt;：明确只取置信度最高的手、面积最大的手，还是允许双手组合技能。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一个简单的稳定器可以维护最近若干帧结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections import Counter, deque

history = deque(maxlen=5)


def stable_gesture(label: str | None) -&amp;gt; str | None:
    history.append(label)
    gesture, count = Counter(history).most_common(1)[0]
    return gesture if gesture is not None and count &amp;gt;= 4 else None
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;14. 总结&lt;/h2&gt;
&lt;p&gt;这次实验最有价值的部分不只是训练出了两个高指标模型，而是完成了一条适应实际基础设施限制的数据工程链路：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;大体积 HaGRID ZIP
→ 保留官方人员隔离划分
→ 按 ID 流式读取选中图片
→ 内存中转换 YOLO 标签
→ 顺序写入 TAR
→ 在训练机合并数据集
→ 双 GPU 并行训练两个模型
→ 根据精度与计算成本选择实时版本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在小磁盘和 OSS 挂载盘环境中，避免完整解压与碎文件搬运比单纯增加 CPU 或内存更重要。模型部分则说明：对于只有五类、类别形状较明确的任务，更大的模型不一定带来足以抵消计算成本的收益。&lt;/p&gt;
</content:encoded></item><item><title>YOLO26 人头检测与人数统计：CrowdHuman 清洗、训练和对比</title><link>https://zh19990906.github.io/fuwari/posts/yolo26-head-detection-crowdhuman/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo26-head-detection-crowdhuman/</guid><description>使用 CrowdHuman 的 head box 标注清洗出 YOLO 单类别数据集，训练 YOLO26n、YOLO26s 和 YOLO26m，并用统一样本对比检测效果。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这篇文章记录一次人头检测模型训练过程：从 CrowdHuman 原始标注中只提取 &lt;code&gt;hbox&lt;/code&gt;，转换为 YOLO 单类别数据集，然后分别训练 YOLO26n、YOLO26s 和 YOLO26m，最后用同一批验证图片做画框与计数对比。&lt;/p&gt;
&lt;p&gt;项目第一阶段只关注两个目标：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;图片或视频输入
    ↓
YOLO 人头检测
    ↓
绘制 head 框
    ↓
统计 count
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里不训练人体框，也不训练可见身体框。最终模型只有一个类别：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0: head
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;1. 数据集来源&lt;/h2&gt;
&lt;p&gt;本次使用 &lt;a href=&quot;https://www.crowdhuman.org/&quot;&gt;CrowdHuman&lt;/a&gt; 数据集。CrowdHuman 面向密集人群检测，包含大量多人、遮挡、小目标和拥挤场景，适合做人头检测与人数统计的基线数据。&lt;/p&gt;
&lt;p&gt;CrowdHuman 每个 &lt;code&gt;person&lt;/code&gt; 标注里通常包含三类框：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;本项目是否使用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;head box，头部框&lt;/td&gt;
&lt;td&gt;使用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;full body box，完整人体框&lt;/td&gt;
&lt;td&gt;不使用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;visible body box，可见人体框&lt;/td&gt;
&lt;td&gt;不使用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;示例标注结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;ID&quot;: &quot;example_image_id&quot;,
  &quot;gtboxes&quot;: [
    {
      &quot;tag&quot;: &quot;person&quot;,
      &quot;hbox&quot;: [123, 129, 63, 64],
      &quot;head_attr&quot;: {
        &quot;ignore&quot;: 0,
        &quot;occ&quot;: 1,
        &quot;unsure&quot;: 0
      },
      &quot;fbox&quot;: [61, 123, 191, 453],
      &quot;vbox&quot;: [62, 126, 154, 446],
      &quot;extra&quot;: {
        &quot;box_id&quot;: 0,
        &quot;occ&quot;: 1
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为目标是人头计数，转换时只读取 &lt;code&gt;gtboxes[*].hbox&lt;/code&gt;，并把所有有效头框写成 YOLO 的 &lt;code&gt;class 0&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;2. YOLO 数据结构&lt;/h2&gt;
&lt;p&gt;转换后的数据集结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo_head/
├── images/
│   ├── train/
│   └── val/
├── labels/
│   ├── train/
│   └── val/
├── crowdhuman_head.yaml
└── README.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;YOLO 通过文件名建立图片与标签的对应关系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;images/train/example.jpg
labels/train/example.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个标签文件中一行表示一个头部框：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class_id x_center y_center width height
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;坐标是归一化后的比例值，不是像素值。对于本项目，标签行格式始终是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0 x_center y_center width height
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;crowdhuman_head.yaml&lt;/code&gt; 使用相对路径，便于移动和分享：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;path: .
train: images/train
val: images/val

names:
  0: head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果训练框架没有按 YAML 所在目录解析 &lt;code&gt;path: .&lt;/code&gt;，可以在本地训练时把 &lt;code&gt;path&lt;/code&gt; 改成数据集根目录的绝对路径。提交或分享数据集时仍建议保留相对路径版本。&lt;/p&gt;
&lt;h2&gt;3. 数据清洗与格式转换&lt;/h2&gt;
&lt;p&gt;下面脚本会读取 CrowdHuman 的 &lt;code&gt;annotation_train.odgt&lt;/code&gt; 和 &lt;code&gt;annotation_val.odgt&lt;/code&gt;，只提取有效 &lt;code&gt;hbox&lt;/code&gt;，并生成 YOLO 标签。&lt;/p&gt;
&lt;p&gt;清洗规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tag != &quot;person&quot;&lt;/code&gt; 的框不使用；&lt;/li&gt;
&lt;li&gt;缺少 &lt;code&gt;hbox&lt;/code&gt; 的框不使用；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;head_attr.ignore == 1&lt;/code&gt; 的框不使用；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;head_attr.unsure == 1&lt;/code&gt; 的框不使用；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;extra.ignore == 1&lt;/code&gt; 的框不使用；&lt;/li&gt;
&lt;li&gt;裁剪到图片范围后宽高过小的框不使用。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;import argparse
import json
import shutil
from pathlib import Path

from PIL import Image


MIN_BOX_SIZE = 2


def should_skip_gtbox(gtbox):
    if gtbox.get(&quot;tag&quot;) != &quot;person&quot;:
        return True

    if &quot;hbox&quot; not in gtbox:
        return True

    head_attr = gtbox.get(&quot;head_attr&quot;, {})
    extra = gtbox.get(&quot;extra&quot;, {})

    if head_attr.get(&quot;ignore&quot;, 0) == 1:
        return True

    if head_attr.get(&quot;unsure&quot;, 0) == 1:
        return True

    if extra.get(&quot;ignore&quot;, 0) == 1:
        return True

    return False


def hbox_to_yolo(hbox, img_w, img_h, min_box_size=MIN_BOX_SIZE):
    x, y, w, h = hbox

    x1 = max(0, x)
    y1 = max(0, y)
    x2 = min(img_w, x + w)
    y2 = min(img_h, y + h)

    box_w = x2 - x1
    box_h = y2 - y1

    if box_w &amp;lt;= min_box_size or box_h &amp;lt;= min_box_size:
        return None

    cx = (x1 + box_w / 2) / img_w
    cy = (y1 + box_h / 2) / img_h
    norm_w = box_w / img_w
    norm_h = box_h / img_h

    return cx, cy, norm_w, norm_h


def copy_image(src_path, dst_path):
    if not dst_path.exists():
        shutil.copy2(src_path, dst_path)


def convert_split(split_name, ann_path, image_dir, output_root, skip_empty):
    output_image_dir = output_root / &quot;images&quot; / split_name
    output_label_dir = output_root / &quot;labels&quot; / split_name
    output_image_dir.mkdir(parents=True, exist_ok=True)
    output_label_dir.mkdir(parents=True, exist_ok=True)

    image_count = 0
    box_count = 0
    empty_label_count = 0
    missing_image_count = 0

    with ann_path.open(&quot;r&quot;, encoding=&quot;utf-8&quot;) as ann_file:
        for line in ann_file:
            item = json.loads(line)
            image_id = item[&quot;ID&quot;]
            image_path = image_dir / f&quot;{image_id}.jpg&quot;

            if not image_path.exists():
                missing_image_count += 1
                continue

            with Image.open(image_path) as image:
                img_w, img_h = image.size

            label_lines = []
            for gtbox in item.get(&quot;gtboxes&quot;, []):
                if should_skip_gtbox(gtbox):
                    continue

                yolo_box = hbox_to_yolo(gtbox[&quot;hbox&quot;], img_w, img_h)
                if yolo_box is None:
                    continue

                cx, cy, w, h = yolo_box
                label_lines.append(f&quot;0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}&quot;)

            if skip_empty and not label_lines:
                empty_label_count += 1
                continue

            copy_image(image_path, output_image_dir / image_path.name)
            (output_label_dir / f&quot;{image_id}.txt&quot;).write_text(
                &quot;\n&quot;.join(label_lines),
                encoding=&quot;utf-8&quot;,
            )

            image_count += 1
            box_count += len(label_lines)
            if not label_lines:
                empty_label_count += 1

    return {
        &quot;split&quot;: split_name,
        &quot;images&quot;: image_count,
        &quot;boxes&quot;: box_count,
        &quot;empty_labels&quot;: empty_label_count,
        &quot;missing_images&quot;: missing_image_count,
    }


def write_yaml(output_root):
    yaml_text = &quot;&quot;&quot;path: .
train: images/train
val: images/val

names:
  0: head
&quot;&quot;&quot;
    (output_root / &quot;crowdhuman_head.yaml&quot;).write_text(yaml_text, encoding=&quot;utf-8&quot;)


def parse_args():
    parser = argparse.ArgumentParser(
        description=&quot;Convert CrowdHuman head boxes to YOLO format.&quot;
    )
    parser.add_argument(&quot;--raw-root&quot;, default=&quot;raw/CrowdHuman/crowdhuman&quot;)
    parser.add_argument(&quot;--output-root&quot;, default=&quot;yolo_head&quot;)
    parser.add_argument(&quot;--skip-empty&quot;, action=&quot;store_true&quot;)
    return parser.parse_args()


def main():
    args = parse_args()
    raw_root = Path(args.raw_root)
    output_root = Path(args.output_root)

    splits = {
        &quot;train&quot;: {
            &quot;ann&quot;: raw_root / &quot;annotation_train.odgt&quot;,
            &quot;image_dir&quot;: raw_root / &quot;train&quot; / &quot;Images&quot;,
        },
        &quot;val&quot;: {
            &quot;ann&quot;: raw_root / &quot;annotation_val.odgt&quot;,
            &quot;image_dir&quot;: raw_root / &quot;val&quot; / &quot;Images&quot;,
        },
    }

    for split_name, cfg in splits.items():
        result = convert_split(
            split_name,
            cfg[&quot;ann&quot;],
            cfg[&quot;image_dir&quot;],
            output_root,
            args.skip_empty,
        )
        print(result)

    write_yaml(output_root)
    print(&quot;done&quot;)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装依赖并执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install pillow
python convert_crowdhuman_head.py --raw-root raw/CrowdHuman/crowdhuman --output-root yolo_head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果数据在 OSS、NFS 或其他网络挂载盘上，批量复制图片会比较慢。训练时更建议把转换后的训练集放到本地盘或高速块存储，再从本地读取图片。&lt;/p&gt;
&lt;h2&gt;4. 训练脚本&lt;/h2&gt;
&lt;p&gt;训练使用 Ultralytics Python API。相比把所有参数写在命令行里，Python 脚本更容易复用，也方便把结果目录和实验名规范化。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import argparse
from pathlib import Path

from ultralytics import YOLO


def parse_args():
    parser = argparse.ArgumentParser(description=&quot;Train a YOLO head detector.&quot;)
    parser.add_argument(&quot;--data&quot;, default=&quot;yolo_head/crowdhuman_head.yaml&quot;)
    parser.add_argument(&quot;--model&quot;, default=&quot;yolo26m.pt&quot;)
    parser.add_argument(&quot;--imgsz&quot;, type=int, default=1280)
    parser.add_argument(&quot;--epochs&quot;, type=int, default=100)
    parser.add_argument(&quot;--batch&quot;, type=int, default=32)
    parser.add_argument(&quot;--workers&quot;, type=int, default=8)
    parser.add_argument(&quot;--device&quot;, default=&quot;0&quot;)
    parser.add_argument(&quot;--project&quot;, default=&quot;runs/head&quot;)
    parser.add_argument(&quot;--name&quot;, default=&quot;yolo26m_crowdhuman_head&quot;)
    parser.add_argument(&quot;--resume&quot;, action=&quot;store_true&quot;)
    return parser.parse_args()


def main():
    args = parse_args()
    data_path = Path(args.data)

    if not data_path.exists():
        raise FileNotFoundError(f&quot;Data yaml not found: {data_path}&quot;)

    model = YOLO(args.model)
    model.train(
        data=str(data_path),
        imgsz=args.imgsz,
        epochs=args.epochs,
        batch=args.batch,
        workers=args.workers,
        device=args.device,
        project=args.project,
        name=args.name,
        resume=args.resume,
    )


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;单卡训练示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python train_head.py \
  --data yolo_head/crowdhuman_head.yaml \
  --model yolo26m.pt \
  --imgsz 1280 \
  --epochs 100 \
  --batch 32 \
  --workers 8 \
  --device 0 \
  --name yolo26m_crowdhuman_head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多卡训练示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python train_head.py \
  --data yolo_head/crowdhuman_head.yaml \
  --model yolo26s.pt \
  --imgsz 1280 \
  --epochs 100 \
  --batch 96 \
  --workers 16 \
  --device 0,1,2,3,4,5,6,7 \
  --name yolo26s_crowdhuman_head_8gpu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多卡训练时，&lt;code&gt;batch&lt;/code&gt; 可以按单卡 batch 线性放大。例如四卡总 batch 为 48，八卡可以先试 96。如果数据加载或 DDP 初始化过慢，可以先降低 &lt;code&gt;workers&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;5. 训练结果对比&lt;/h2&gt;
&lt;p&gt;本次分别训练了 YOLO26n、YOLO26s 和 YOLO26m。验证集为 CrowdHuman val，共 4,370 张图片，约 97,244 个有效 head 实例。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模型&lt;/th&gt;
&lt;th&gt;参数量&lt;/th&gt;
&lt;th&gt;GFLOPs&lt;/th&gt;
&lt;th&gt;P&lt;/th&gt;
&lt;th&gt;R&lt;/th&gt;
&lt;th&gt;mAP50&lt;/th&gt;
&lt;th&gt;mAP50-95&lt;/th&gt;
&lt;th&gt;权重大小&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26n&lt;/td&gt;
&lt;td&gt;2.38M&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;0.845&lt;/td&gt;
&lt;td&gt;0.732&lt;/td&gt;
&lt;td&gt;0.819&lt;/td&gt;
&lt;td&gt;0.532&lt;/td&gt;
&lt;td&gt;5.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26s&lt;/td&gt;
&lt;td&gt;9.47M&lt;/td&gt;
&lt;td&gt;20.5&lt;/td&gt;
&lt;td&gt;0.856&lt;/td&gt;
&lt;td&gt;0.741&lt;/td&gt;
&lt;td&gt;0.834&lt;/td&gt;
&lt;td&gt;0.552&lt;/td&gt;
&lt;td&gt;20.4 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26m&lt;/td&gt;
&lt;td&gt;20.35M&lt;/td&gt;
&lt;td&gt;67.8&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;0.763&lt;/td&gt;
&lt;td&gt;0.849&lt;/td&gt;
&lt;td&gt;0.570&lt;/td&gt;
&lt;td&gt;44.1 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;从验证指标看，YOLO26m 的召回率、mAP50 和 mAP50-95 都最高，更适合作为主模型。YOLO26s 的精度与体积折中更好，适合作为轻量部署候选。YOLO26n 体积最小，适合快速验证流程，但漏检风险更高。&lt;/p&gt;
&lt;p&gt;对人数统计任务而言，召回率非常关键。漏检会直接导致人数少算，因此不能只看 precision 或推理速度。&lt;/p&gt;
&lt;h2&gt;6. 测试效果脚本&lt;/h2&gt;
&lt;p&gt;训练完成后，先把不同模型的 &lt;code&gt;best.pt&lt;/code&gt; 统一放到一个模型目录中，并用同一批图片做横向对比。下面脚本会对每个模型分别生成画框图片，并写出 &lt;code&gt;summary.csv&lt;/code&gt;，记录每张图的检测数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import argparse
import csv
from pathlib import Path

import cv2
from ultralytics import YOLO


IMAGE_SUFFIXES = {&quot;.jpg&quot;, &quot;.jpeg&quot;, &quot;.png&quot;, &quot;.bmp&quot;, &quot;.webp&quot;}


def model_name_from_path(model_path):
    return Path(model_path).stem


def parse_model_args(model_args):
    parsed = []
    for item in model_args:
        if &quot;=&quot; in item:
            name, path = item.split(&quot;=&quot;, 1)
        else:
            path = item
            name = model_name_from_path(path)
        parsed.append((name, path))
    return parsed


def list_images(source_dir):
    source_path = Path(source_dir)
    return sorted(
        path
        for path in source_path.rglob(&quot;*&quot;)
        if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
    )


def draw_count_label(image, count, model_name):
    text = f&quot;{model_name} count={count}&quot;
    cv2.rectangle(image, (8, 8), (8 + 18 * len(text), 42), (0, 0, 0), -1)
    cv2.putText(
        image,
        text,
        (16, 33),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.8,
        (255, 255, 255),
        2,
        cv2.LINE_AA,
    )


def run_model(model_name, model_path, image_paths, output_root, imgsz, conf, iou, device):
    model = YOLO(model_path)
    output_dir = Path(output_root) / model_name
    output_dir.mkdir(parents=True, exist_ok=True)

    summary_rows = []

    for image_path in image_paths:
        results = model.predict(
            source=str(image_path),
            imgsz=imgsz,
            conf=conf,
            iou=iou,
            device=device,
            verbose=False,
        )
        result = results[0]
        count = 0 if result.boxes is None else len(result.boxes)
        plotted = result.plot()
        draw_count_label(plotted, count, model_name)

        output_path = output_dir / image_path.name
        cv2.imwrite(str(output_path), plotted)

        summary_rows.append(
            {
                &quot;model&quot;: model_name,
                &quot;image&quot;: image_path.name,
                &quot;count&quot;: count,
                &quot;output&quot;: str(output_path),
            }
        )

    return summary_rows


def write_summary(output_root, rows):
    summary_path = Path(output_root) / &quot;summary.csv&quot;
    with summary_path.open(&quot;w&quot;, newline=&quot;&quot;, encoding=&quot;utf-8&quot;) as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=[&quot;model&quot;, &quot;image&quot;, &quot;count&quot;, &quot;output&quot;])
        writer.writeheader()
        writer.writerows(rows)
    return summary_path


def parse_args():
    parser = argparse.ArgumentParser(
        description=&quot;Compare multiple YOLO head models on the same images.&quot;
    )
    parser.add_argument(&quot;--source&quot;, required=True)
    parser.add_argument(&quot;--output&quot;, default=&quot;predict_compare&quot;)
    parser.add_argument(&quot;--models&quot;, nargs=&quot;+&quot;, required=True)
    parser.add_argument(&quot;--imgsz&quot;, type=int, default=1280)
    parser.add_argument(&quot;--conf&quot;, type=float, default=0.25)
    parser.add_argument(&quot;--iou&quot;, type=float, default=0.7)
    parser.add_argument(&quot;--device&quot;, default=&quot;0&quot;)
    return parser.parse_args()


def main():
    args = parse_args()
    image_paths = list_images(args.source)

    if not image_paths:
        raise SystemExit(f&quot;No images found in {args.source}&quot;)

    Path(args.output).mkdir(parents=True, exist_ok=True)

    all_rows = []
    for model_name, model_path in parse_model_args(args.models):
        print(f&quot;Running {model_name} on {len(image_paths)} images&quot;)
        rows = run_model(
            model_name=model_name,
            model_path=model_path,
            image_paths=image_paths,
            output_root=args.output,
            imgsz=args.imgsz,
            conf=args.conf,
            iou=args.iou,
            device=args.device,
        )
        all_rows.extend(rows)

    summary_path = write_summary(args.output, all_rows)
    print(f&quot;Done. Summary: {summary_path}&quot;)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python compare_head_models.py \
  --source test_samples/crowdhuman_val_100 \
  --output predict_compare/crowdhuman_val_100_conf025 \
  --models \
    m=models/head_crowdhuman/yolo26m_head_crowdhuman_best.pt \
    s=models/head_crowdhuman/yolo26s_head_crowdhuman_best.pt \
    n=models/head_crowdhuman/yolo26n_head_crowdhuman_best.pt \
  --imgsz 1280 \
  --conf 0.25 \
  --iou 0.7 \
  --device 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;predict_compare/
└── crowdhuman_val_100_conf025/
    ├── m/
    ├── s/
    ├── n/
    └── summary.csv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;校验时优先观察：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;远处小头是否漏检；&lt;/li&gt;
&lt;li&gt;密集人群是否出现重复框；&lt;/li&gt;
&lt;li&gt;遮挡头部是否还能检出；&lt;/li&gt;
&lt;li&gt;是否把灯、海报、圆形物体误检为头；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;count&lt;/code&gt; 与肉眼估计是否接近。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还可以用不同置信度重复测试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python compare_head_models.py \
  --source test_samples/crowdhuman_val_100 \
  --output predict_compare/crowdhuman_val_100_conf020 \
  --models m=models/head_crowdhuman/yolo26m_head_crowdhuman_best.pt \
  --imgsz 1280 \
  --conf 0.20 \
  --iou 0.7 \
  --device 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;conf&lt;/code&gt; 越低，召回通常越高，但误检会增加；&lt;code&gt;conf&lt;/code&gt; 越高，误检减少，但更容易漏检。人头计数场景一般先从 &lt;code&gt;0.20&lt;/code&gt; 到 &lt;code&gt;0.30&lt;/code&gt; 之间找平衡点。&lt;/p&gt;
&lt;h2&gt;7. 本地视频检测&lt;/h2&gt;
&lt;p&gt;图片测试通过后，可以对本地视频逐帧检测并输出带框视频。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import argparse
from pathlib import Path

import cv2
from ultralytics import YOLO


def parse_args():
    parser = argparse.ArgumentParser(description=&quot;Detect heads in a local video.&quot;)
    parser.add_argument(&quot;--model&quot;, required=True)
    parser.add_argument(&quot;--source&quot;, required=True)
    parser.add_argument(&quot;--output&quot;, default=&quot;output_head_detect.mp4&quot;)
    parser.add_argument(&quot;--imgsz&quot;, type=int, default=1280)
    parser.add_argument(&quot;--conf&quot;, type=float, default=0.25)
    parser.add_argument(&quot;--iou&quot;, type=float, default=0.7)
    parser.add_argument(&quot;--device&quot;, default=&quot;0&quot;)
    return parser.parse_args()


def draw_count(frame, count):
    text = f&quot;count={count}&quot;
    cv2.rectangle(frame, (10, 10), (190, 50), (0, 0, 0), -1)
    cv2.putText(
        frame,
        text,
        (20, 40),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.9,
        (255, 255, 255),
        2,
        cv2.LINE_AA,
    )


def main():
    args = parse_args()

    model = YOLO(args.model)
    cap = cv2.VideoCapture(args.source)

    if not cap.isOpened():
        raise RuntimeError(f&quot;Failed to open video: {args.source}&quot;)

    fps = cap.get(cv2.CAP_PROP_FPS) or 25
    width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

    output_path = Path(args.output)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    writer = cv2.VideoWriter(
        str(output_path),
        cv2.VideoWriter_fourcc(*&quot;mp4v&quot;),
        fps,
        (width, height),
    )

    frame_id = 0

    while True:
        ok, frame = cap.read()
        if not ok:
            break

        results = model.predict(
            source=frame,
            imgsz=args.imgsz,
            conf=args.conf,
            iou=args.iou,
            device=args.device,
            verbose=False,
        )

        result = results[0]
        count = 0 if result.boxes is None else len(result.boxes)
        plotted = result.plot()
        draw_count(plotted, count)

        writer.write(plotted)
        frame_id += 1

        if frame_id % 30 == 0:
            print(f&quot;processed frames={frame_id}, current_count={count}&quot;)

    cap.release()
    writer.release()
    print(f&quot;Done. Output saved to: {output_path}&quot;)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python detect_video_head_count.py \
  --model models/head_crowdhuman/yolo26m_head_crowdhuman_best.pt \
  --source videos/input.mp4 \
  --output video_results/input_head_count.mp4 \
  --imgsz 1280 \
  --conf 0.25 \
  --iou 0.7 \
  --device 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;视频统计最好不要只看单帧 &lt;code&gt;count&lt;/code&gt;。真实视频中会有遮挡、运动模糊和短时漏检，后续可以加入跟踪器或滑动窗口平滑，让人数统计更稳定。&lt;/p&gt;
&lt;h2&gt;8. 小结&lt;/h2&gt;
&lt;p&gt;这次实验的关键不是简单地把 CrowdHuman 转成 YOLO，而是明确任务边界：只训练 &lt;code&gt;head&lt;/code&gt; 类，避免人体框和头部框混在一起。训练结果表明，YOLO26m 在召回率和整体 mAP 上更适合作为主模型；YOLO26s 可以作为轻量部署模型；YOLO26n 更适合快速验证。&lt;/p&gt;
&lt;p&gt;下一步应当使用真实业务场景图片继续测试。如果 CrowdHuman 上指标不错，但实际图片漏检明显，就需要收集少量目标场景图片进行二阶段微调。&lt;/p&gt;
</content:encoded></item><item><title>LangChain 1.x Agent Runtime 与 LangGraph 分工</title><link>https://zh19990906.github.io/fuwari/posts/langchain-agent-runtime/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/langchain-agent-runtime/</guid><description>从 create_agent 的执行循环、状态与检查点理解 LangChain 和 LangGraph 的职责边界，并判断什么时候不该使用 Agent。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;LangChain 1.x 把 Agent 开发入口集中到了 &lt;code&gt;create_agent&lt;/code&gt;。它负责把模型、工具、系统提示词和中间件组合成一个可调用的 Agent；真正承载状态流转、持久化、暂停恢复和长流程执行的底层运行时则是 LangGraph。&lt;/p&gt;
&lt;p&gt;理解这两个层次，可以避免两种常见问题：一是把所有业务流程都写成不可预测的 Agent；二是在只需要增加一条审批规则时，过早手写完整状态图。&lt;/p&gt;
&lt;h2&gt;一次 Agent 调用发生了什么&lt;/h2&gt;
&lt;p&gt;一个标准工具调用 Agent 通常重复以下循环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户消息
  ↓
模型判断下一步
  ├─ 不调用工具 → 返回最终答案
  └─ 生成工具调用
          ↓
      校验并执行工具
          ↓
      把工具结果加入消息
          └────────────→ 再次调用模型
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;create_agent&lt;/code&gt; 提供这个循环的标准实现。模型可以连续调用多个工具，也可以根据工具结果修正计划，直到不再产生工具调用。&lt;/p&gt;
&lt;p&gt;这不意味着业务系统应该把控制权完全交给模型。模型只适合承担需要语言理解、模糊判断或动态选择工具的步骤；权限检查、金额计算、状态迁移和数据约束仍应由确定性代码控制。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;create_agent&lt;/code&gt; 负责什么&lt;/h2&gt;
&lt;p&gt;LangChain 层主要负责统一和组装：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型调用接口；&lt;/li&gt;
&lt;li&gt;消息和内容块；&lt;/li&gt;
&lt;li&gt;工具定义与参数 Schema；&lt;/li&gt;
&lt;li&gt;系统提示词；&lt;/li&gt;
&lt;li&gt;结构化输出；&lt;/li&gt;
&lt;li&gt;Middleware；&lt;/li&gt;
&lt;li&gt;标准 Agent 循环。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LangChain 1.x 推荐从下面的导入路径创建 Agent：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from langchain.agents import create_agent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;旧项目可能仍在使用 &lt;code&gt;langgraph.prebuilt.create_react_agent&lt;/code&gt; 或早期 &lt;code&gt;AgentExecutor&lt;/code&gt;。迁移时不应只替换函数名，还要检查动态提示词、自定义状态、模型路由和前后置 Hook 是否已经改为 Middleware 形式。&lt;/p&gt;
&lt;h2&gt;LangGraph 负责什么&lt;/h2&gt;
&lt;p&gt;LangGraph 把一次运行表示为带状态的图。核心概念包括：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;概念&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;在节点之间传递的结构化状态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node&lt;/td&gt;
&lt;td&gt;执行模型、工具或普通 Python 逻辑的步骤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge&lt;/td&gt;
&lt;td&gt;定义节点之间的流向&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conditional edge&lt;/td&gt;
&lt;td&gt;根据状态选择下一步&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkpoint&lt;/td&gt;
&lt;td&gt;保存某一步执行后的状态快照&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thread&lt;/td&gt;
&lt;td&gt;用 &lt;code&gt;thread_id&lt;/code&gt; 标识的一条持续会话或任务线&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;create_agent&lt;/code&gt; 返回的对象本身运行在 LangGraph 上，因此可以自然获得流式输出、检查点、人在环审批和故障恢复能力。只有当外围拓扑超出标准 Agent 循环时，才需要直接编排 &lt;code&gt;StateGraph&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;例如这些场景更适合直接使用 LangGraph：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先做固定的输入分类，再路由到不同 Agent；&lt;/li&gt;
&lt;li&gt;Agent 前后必须执行不可跳过的确定性步骤；&lt;/li&gt;
&lt;li&gt;多条分支需要并行执行后汇总；&lt;/li&gt;
&lt;li&gt;需要明确的重试节点、补偿节点和人工处理节点；&lt;/li&gt;
&lt;li&gt;要求从历史 Checkpoint 回放或分叉执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;最小工具调用 Agent&lt;/h2&gt;
&lt;p&gt;下面的示例只提供一个无外部副作用的加法工具。模型名称通过环境变量提供，模型供应商凭据由对应集成按照其官方环境变量读取。&lt;/p&gt;
&lt;p&gt;安装依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U langchain langgraph langchain-openai
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver


@tool
def add(left: int, right: int) -&amp;gt; int:
    &quot;&quot;&quot;返回两个整数的和。&quot;&quot;&quot;
    return left + right


model_name = os.environ[&quot;LANGCHAIN_MODEL&quot;]

agent = create_agent(
    model=model_name,
    tools=[add],
    system_prompt=(
        &quot;你是一个谨慎的计算助手。需要计算时调用工具，&quot;
        &quot;不要自行猜测工具结果。&quot;
    ),
    checkpointer=InMemorySaver(),
)

config = {
    &quot;configurable&quot;: {
        &quot;thread_id&quot;: &quot;calculator-demo&quot;,
    }
}

result = agent.invoke(
    {
        &quot;messages&quot;: [
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: &quot;请计算 37 加 58，并说明结果。&quot;,
            }
        ]
    },
    config=config,
)

print(result[&quot;messages&quot;][-1].content)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行前设置模型名称和对应供应商要求的凭据。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export LANGCHAIN_MODEL=&quot;&amp;lt;provider&amp;gt;:&amp;lt;model&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例没有把凭据作为函数参数或源代码常量保存。生产服务还应通过密钥管理系统注入环境变量，而不是把 &lt;code&gt;.env&lt;/code&gt; 文件提交到仓库。&lt;/p&gt;
&lt;h2&gt;Checkpointer 与 &lt;code&gt;thread_id&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;只传入 &lt;code&gt;checkpointer&lt;/code&gt; 还不够。调用时还需要提供稳定的 &lt;code&gt;thread_id&lt;/code&gt;，运行时才能找到同一条任务线的历史状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = {
    &quot;configurable&quot;: {
        &quot;thread_id&quot;: conversation_id,
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同一个 &lt;code&gt;thread_id&lt;/code&gt; 会继续同一条状态线；&lt;/li&gt;
&lt;li&gt;新的 &lt;code&gt;thread_id&lt;/code&gt; 会创建新的状态线；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;InMemorySaver&lt;/code&gt; 只适合本地演示和单进程测试；&lt;/li&gt;
&lt;li&gt;多进程、容器重启或跨实例恢复需要持久化 Checkpointer；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;thread_id&lt;/code&gt; 不是权限凭据，读取状态前仍要校验当前用户是否有权访问该线程。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Checkpoint 让系统能从已保存状态恢复，但不会自动让外部副作用具备事务性。如果某个工具在保存 Checkpoint 前已经调用了第三方接口，重试时仍可能重复执行，因此工具本身需要幂等键或去重机制。&lt;/p&gt;
&lt;h2&gt;什么时候使用哪一层&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;需求&lt;/th&gt;
&lt;th&gt;推荐方案&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;固定顺序的字段校验和数据库写入&lt;/td&gt;
&lt;td&gt;普通 Python / 工作流代码&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;模型在少量只读工具中动态选择&lt;/td&gt;
&lt;td&gt;&lt;code&gt;create_agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;标准 Agent 加摘要、审批或模型路由&lt;/td&gt;
&lt;td&gt;&lt;code&gt;create_agent&lt;/code&gt; + Middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;明确的多阶段分支、循环和恢复节点&lt;/td&gt;
&lt;td&gt;直接使用 LangGraph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;长时间后台任务但不需要语言推理&lt;/td&gt;
&lt;td&gt;任务队列或工作流引擎&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;强事务、强一致的资金或库存状态迁移&lt;/td&gt;
&lt;td&gt;确定性领域服务，不让 Agent 直接控制&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一个实用判断方式是：如果流程能够用稳定的条件分支完整描述，并且模型不需要理解自然语言上下文，就优先写普通代码。&lt;/p&gt;
&lt;h2&gt;工具设计边界&lt;/h2&gt;
&lt;h3&gt;参数必须有明确 Schema&lt;/h3&gt;
&lt;p&gt;工具参数应尽量小而明确，不要让模型直接拼接 SQL、Shell 或任意文件路径。高风险输入应转换为受控枚举或业务对象 ID。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@tool
def lookup_order(order_id: str) -&amp;gt; dict[str, str]:
    &quot;&quot;&quot;读取一个订单的公开状态，不执行修改。&quot;&quot;&quot;
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;设置真正的底层超时&lt;/h3&gt;
&lt;p&gt;Agent 的整体超时不能替代 HTTP、数据库和对象存储客户端自身的连接、读取与写入超时。否则停止等待 Agent 时，底层阻塞请求可能仍然占用线程或连接。&lt;/p&gt;
&lt;h3&gt;限制循环与成本&lt;/h3&gt;
&lt;p&gt;模型可能因为工具结果不完整而反复调用同一个工具。生产系统需要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;最大步骤数；&lt;/li&gt;
&lt;li&gt;单次运行时间上限；&lt;/li&gt;
&lt;li&gt;Token 或费用预算；&lt;/li&gt;
&lt;li&gt;相同工具参数的重复调用检测；&lt;/li&gt;
&lt;li&gt;失败分类与可观察日志。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;区分读工具和写工具&lt;/h3&gt;
&lt;p&gt;只读查询可以在完成授权后直接执行。具有外部副作用的工具应增加：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;权限检查；&lt;/li&gt;
&lt;li&gt;参数确认；&lt;/li&gt;
&lt;li&gt;人在环审批；&lt;/li&gt;
&lt;li&gt;幂等键；&lt;/li&gt;
&lt;li&gt;审计记录；&lt;/li&gt;
&lt;li&gt;失败补偿策略。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;人在环只决定“是否允许继续”，不能替代这些工程控制。&lt;/p&gt;
&lt;h2&gt;版本与依赖管理&lt;/h2&gt;
&lt;p&gt;Agent 框架接口变化较快。项目中应固定直接依赖的兼容范围，并在升级时运行针对工具调用、结构化输出、Checkpointer 和 Middleware 的测试。&lt;/p&gt;
&lt;p&gt;推荐记录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Python 版本
langchain 版本
langgraph 版本
模型集成包版本
模型名称
工具 Schema 版本
Checkpoint 存储实现
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要根据一段历史 Notebook 输出判断当前接口是否仍然有效，应以当前官方迁移指南和 API 文档为准。&lt;/p&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 这个流程是否真的需要模型动态决策
2. 工具参数是否经过结构化校验和权限检查
3. 读工具与写工具是否采用不同的审批策略
4. 每个外部请求是否有连接和读取超时
5. 工具是否支持幂等或业务去重
6. 是否限制最大步骤、运行时间和费用
7. 生产环境是否使用持久化 checkpointer
8. thread_id 是否和租户、用户权限正确绑定
9. 是否记录模型调用、工具调用、审批和错误
10. 框架升级后是否运行端到端回归测试
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;官方参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/releases/langchain-v1&quot;&gt;LangChain v1 更新说明&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/migrate/langchain-v1&quot;&gt;LangChain v1 迁移指南&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/agents&quot;&gt;LangChain Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langgraph/persistence&quot;&gt;LangGraph Persistence&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前官方文档进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>LangChain Middleware 与人在环 Agent</title><link>https://zh19990906.github.io/fuwari/posts/langchain-middleware-hitl/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/langchain-middleware-hitl/</guid><description>使用 Middleware 管理上下文、模型路由和工具调用，并通过可恢复中断为敏感操作增加 approve、edit、reject 审批。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Middleware 是 LangChain 1.x 中控制 Agent 运行过程的主要扩展机制。它可以在不重写标准 Agent 循环的情况下，调整提示词、裁剪上下文、选择模型、限制工具、记录调用，或者在具有副作用的工具真正执行前暂停并等待人工决定。&lt;/p&gt;
&lt;p&gt;Middleware 不是另一个独立运行时。它的 Hook 运行在 &lt;code&gt;create_agent&lt;/code&gt; 返回的 LangGraph 内部，因此状态持久化、人在环中断和恢复执行仍依赖 LangGraph。&lt;/p&gt;
&lt;h2&gt;Middleware 位于哪里&lt;/h2&gt;
&lt;p&gt;一个工具调用 Agent 的简化生命周期如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;进入 Agent
  ↓
before_agent
  ↓
before_model
  ↓
wrap_model_call → 调用模型 → after_model
  ↓
模型是否请求工具？
  ├─ 否 → after_agent → 返回
  └─ 是
       ↓
     wrap_tool_call → 执行工具
       ↓
     回到 before_model
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见 Hook 的职责：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;适合做什么&lt;/th&gt;
&lt;th&gt;不适合做什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;before_agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;加载会话级上下文、输入检查&lt;/td&gt;
&lt;td&gt;每轮模型调用都重复做昂贵查询&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;before_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;动态提示词、消息裁剪、工具可见性&lt;/td&gt;
&lt;td&gt;执行不可恢复的外部写入&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wrap_model_call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;模型路由、重试、限流、埋点&lt;/td&gt;
&lt;td&gt;把权限判断只交给模型&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;after_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;输出校验、检查工具调用、触发审批&lt;/td&gt;
&lt;td&gt;假设模型输出必然符合业务规则&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wrap_tool_call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;参数校验、审计、超时、异常转换&lt;/td&gt;
&lt;td&gt;吞掉错误后伪装成成功&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;after_agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;汇总指标、清理运行级资源&lt;/td&gt;
&lt;td&gt;替代持久任务队列&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;应把 Middleware 设计成可组合的小策略。一个 Middleware 同时处理权限、摘要、计费、重试和日志，后续很难判断执行顺序和失败责任。&lt;/p&gt;
&lt;h2&gt;上下文治理&lt;/h2&gt;
&lt;p&gt;随着对话增长，把全部历史消息持续发送给模型会提高费用和延迟，也可能把已经失效的信息继续带入决策。&lt;/p&gt;
&lt;p&gt;常见治理方式包括：&lt;/p&gt;
&lt;h3&gt;裁剪&lt;/h3&gt;
&lt;p&gt;保留系统提示词、最近若干轮消息和仍然有效的工具结果。裁剪速度快，但删除的信息无法再被模型读取。&lt;/p&gt;
&lt;h3&gt;摘要&lt;/h3&gt;
&lt;p&gt;达到 Token 阈值后，让模型把较早上下文压缩为摘要，再保留最近消息。摘要本身也可能遗漏细节，因此关键业务状态应放在结构化 State 或数据库中，而不是只存在自然语言摘要里。&lt;/p&gt;
&lt;h3&gt;删除无效工具结果&lt;/h3&gt;
&lt;p&gt;重复查询、过期搜索结果和体积很大的原始响应，可以转换为结构化摘要或引用 ID。不要把完整数据库记录、日志文件或对象内容无限追加到消息历史。&lt;/p&gt;
&lt;h3&gt;敏感信息处理&lt;/h3&gt;
&lt;p&gt;在消息进入模型前识别并屏蔽不必要的个人信息、密钥和内部地址。仅做字符串替换不足以构成完整的数据防泄露方案，还要控制日志、Trace、Checkpoint 和错误报告中的内容。&lt;/p&gt;
&lt;h2&gt;动态模型路由&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;wrap_model_call&lt;/code&gt; 可以根据运行上下文选择模型，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;简短分类任务使用低成本模型；&lt;/li&gt;
&lt;li&gt;长上下文或复杂推理使用能力更强的模型；&lt;/li&gt;
&lt;li&gt;某个供应商不可用时切换到经过验证的备用模型；&lt;/li&gt;
&lt;li&gt;达到会话预算后禁止继续调用昂贵模型。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;路由规则应该由可测试的指标驱动，而不是只依赖“问题看起来很难”这样的模糊提示词。建议记录每条规则的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;触发条件
选中的模型
输入与输出 Token
延迟
工具调用次数
成功率
人工接管率
费用
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同模型对工具调用、结构化输出和内容块的支持可能不同。切换模型前必须确认工具 Schema 和输出处理逻辑仍然兼容。&lt;/p&gt;
&lt;h2&gt;人在环适合处理什么&lt;/h2&gt;
&lt;p&gt;以下操作通常需要在工具执行前增加审批：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改或删除数据；&lt;/li&gt;
&lt;li&gt;向外部系统发送消息；&lt;/li&gt;
&lt;li&gt;改变访问权限；&lt;/li&gt;
&lt;li&gt;执行费用明显的批量任务；&lt;/li&gt;
&lt;li&gt;发布内容或触发部署；&lt;/li&gt;
&lt;li&gt;调用不可逆的第三方接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只读查询也不是天然安全。读取跨租户数据、导出敏感字段或访问高成本资源，同样可能需要授权和审计。&lt;/p&gt;
&lt;h2&gt;一个无外部副作用的 HITL 示例&lt;/h2&gt;
&lt;p&gt;下面的 &lt;code&gt;stage_change&lt;/code&gt; 只会把审批后的变更写入当前 Python 进程的内存列表，用于演示暂停、审阅和恢复。它不会写文件、发邮件或修改数据库。&lt;/p&gt;
&lt;p&gt;安装依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U langchain langgraph langchain-openai
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建 Agent：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver


staged_changes: list[dict[str, str]] = []


@tool
def stage_change(resource: str, new_value: str) -&amp;gt; str:
    &quot;&quot;&quot;把演示变更写入本地内存列表，不产生外部副作用。&quot;&quot;&quot;
    staged_changes.append(
        {
            &quot;resource&quot;: resource,
            &quot;new_value&quot;: new_value,
        }
    )
    return &quot;change staged&quot;


agent = create_agent(
    model=os.environ[&quot;LANGCHAIN_MODEL&quot;],
    tools=[stage_change],
    system_prompt=(
        &quot;你是配置助手。用户要求修改配置时调用 stage_change，&quot;
        &quot;工具执行前必须等待人工审批。&quot;
    ),
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                &quot;stage_change&quot;: {
                    &quot;allowed_decisions&quot;: [&quot;approve&quot;, &quot;edit&quot;, &quot;reject&quot;],
                    &quot;description&quot;: &quot;请检查待暂存的演示配置变更&quot;,
                }
            }
        )
    ],
    checkpointer=InMemorySaver(),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发起调用时必须提供稳定的 &lt;code&gt;thread_id&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = {
    &quot;configurable&quot;: {
        &quot;thread_id&quot;: &quot;hitl-demo-001&quot;,
    }
}

result = agent.invoke(
    {
        &quot;messages&quot;: [
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: &quot;把演示环境的日志级别改为 warning。&quot;,
            }
        ]
    },
    config=config,
    version=&quot;v2&quot;,
)

for pending in result.interrupts:
    print(pending.value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时工具还没有执行，&lt;code&gt;staged_changes&lt;/code&gt; 仍为空。审核界面应展示工具名称、参数、允许的决定类型和本次运行身份，再把人工决定传回后端。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;approve&lt;/code&gt;、&lt;code&gt;edit&lt;/code&gt; 与 &lt;code&gt;reject&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;恢复时必须使用同一个 &lt;code&gt;thread_id&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;原样批准&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from langgraph.types import Command

agent.invoke(
    Command(
        resume={
            &quot;decisions&quot;: [
                {
                    &quot;type&quot;: &quot;approve&quot;,
                }
            ]
        }
    ),
    config=config,
    version=&quot;v2&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;编辑参数后执行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;agent.invoke(
    Command(
        resume={
            &quot;decisions&quot;: [
                {
                    &quot;type&quot;: &quot;edit&quot;,
                    &quot;edited_action&quot;: {
                        &quot;name&quot;: &quot;stage_change&quot;,
                        &quot;args&quot;: {
                            &quot;resource&quot;: &quot;demo/log-level&quot;,
                            &quot;new_value&quot;: &quot;info&quot;,
                        },
                    },
                }
            ]
        }
    ),
    config=config,
    version=&quot;v2&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编辑应保持工具语义不变。把原本的低风险操作改成完全不同的资源或动作时，更安全的做法是拒绝并要求 Agent 重新生成计划。&lt;/p&gt;
&lt;h3&gt;拒绝执行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;agent.invoke(
    Command(
        resume={
            &quot;decisions&quot;: [
                {
                    &quot;type&quot;: &quot;reject&quot;,
                    &quot;message&quot;: (
                        &quot;该变更未通过审核，不要再次调用同一工具。&quot;
                    ),
                }
            ]
        }
    ),
    config=config,
    version=&quot;v2&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个工具调用同时等待审批时，必须按照中断请求中的顺序，为每个 Action 提供一个决定。&lt;/p&gt;
&lt;h2&gt;Interrupt 的关键规则&lt;/h2&gt;
&lt;h3&gt;节点会从头重新执行&lt;/h3&gt;
&lt;p&gt;LangGraph 恢复中断时，会重新开始执行包含 &lt;code&gt;interrupt&lt;/code&gt; 的节点，而不是从 Python 源代码的下一行继续。因此中断之前执行过的代码可能再次运行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;进入节点
→ 执行中断前代码
→ interrupt
→ 保存状态并暂停
→ 收到恢复命令
→ 重新进入节点
→ 再次执行中断前代码
→ interrupt 返回人工决定
→ 执行后续代码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中断前不要执行不可重复的副作用。如果必须做准备工作，应让它具备幂等性，或者把真正写入放到审批之后的独立节点。&lt;/p&gt;
&lt;h3&gt;不要用宽泛异常捕获包住中断&lt;/h3&gt;
&lt;p&gt;Interrupt 通过运行时信号暂停执行。把它包在捕获所有异常的 &lt;code&gt;try/except&lt;/code&gt; 中，可能把暂停信号误判为普通错误。&lt;/p&gt;
&lt;h3&gt;Payload 必须可序列化&lt;/h3&gt;
&lt;p&gt;中断内容应使用字符串、数字、布尔值、列表和字典等 JSON 可序列化结构。不要把数据库连接、函数、客户端对象或复杂运行时实例放入 Payload。&lt;/p&gt;
&lt;h3&gt;中断顺序要稳定&lt;/h3&gt;
&lt;p&gt;同一节点中的多个 Interrupt 依赖稳定顺序恢复。不要让前一次运行与恢复运行因为随机条件而改变 Interrupt 的排列。&lt;/p&gt;
&lt;h3&gt;生产环境使用持久化 Checkpointer&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;InMemorySaver&lt;/code&gt; 只适合演示。如果进程退出，暂停状态就会消失。生产系统应使用持久化 Checkpointer，并保证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Checkpoint 数据加密和访问隔离；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;thread_id&lt;/code&gt; 与租户、用户和业务对象绑定；&lt;/li&gt;
&lt;li&gt;保存内容不包含无必要的秘密；&lt;/li&gt;
&lt;li&gt;存储故障时有明确的失败行为；&lt;/li&gt;
&lt;li&gt;运行恢复后仍能查到对应审批记录。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;审批不等于授权&lt;/h2&gt;
&lt;p&gt;HITL Middleware 解决的是“在执行前停下来等待决定”，但它不能替代：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;身份认证；&lt;/li&gt;
&lt;li&gt;RBAC 或 ABAC 权限校验；&lt;/li&gt;
&lt;li&gt;多租户隔离；&lt;/li&gt;
&lt;li&gt;参数白名单；&lt;/li&gt;
&lt;li&gt;数据库事务；&lt;/li&gt;
&lt;li&gt;业务幂等；&lt;/li&gt;
&lt;li&gt;审计日志；&lt;/li&gt;
&lt;li&gt;双人复核或职责分离。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后端在接收人工决定时，仍要重新检查审核者身份和权限。不能因为前端展示了一个“批准”按钮，就允许任何持有 &lt;code&gt;thread_id&lt;/code&gt; 的请求恢复任务。&lt;/p&gt;
&lt;h2&gt;失败与重试策略&lt;/h2&gt;
&lt;p&gt;工具执行失败后，应区分：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;错误类型&lt;/th&gt;
&lt;th&gt;推荐处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;临时网络错误&lt;/td&gt;
&lt;td&gt;有上限的指数退避重试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;参数无效&lt;/td&gt;
&lt;td&gt;返回结构化错误，让 Agent 修正&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限不足&lt;/td&gt;
&lt;td&gt;立即终止，不自动重试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;人工拒绝&lt;/td&gt;
&lt;td&gt;遵循拒绝说明，不重复同一动作&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;状态已变化&lt;/td&gt;
&lt;td&gt;重新读取状态并再次审批&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;外部写入结果未知&lt;/td&gt;
&lt;td&gt;使用幂等键查询结果，不能盲目重放&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Middleware 可以统一实现重试和错误转换，但重试次数、可重试错误和幂等策略应由工具或领域层明确声明。&lt;/p&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 每个 Middleware 是否只承担一个清晰职责
2. Middleware 顺序是否经过测试
3. 摘要和裁剪是否会丢失关键业务状态
4. 模型路由是否有延迟、费用和成功率指标
5. 哪些工具需要 approve、edit、reject 是否已明确
6. 审核者身份与权限是否在服务端重新校验
7. 中断前的代码是否无副作用或具备幂等性
8. Interrupt Payload 是否可序列化且不含秘密
9. 生产环境是否使用持久化 Checkpointer
10. thread_id 是否隔离租户并防止越权恢复
11. 每次提议、编辑、批准、拒绝和执行是否可审计
12. 工具失败和恢复执行是否有幂等与补偿策略
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;官方参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/middleware/overview&quot;&gt;LangChain Middleware Overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langchain/human-in-the-loop&quot;&gt;LangChain Human-in-the-loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langgraph/interrupts&quot;&gt;LangGraph Interrupts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.langchain.com/oss/python/langgraph/persistence&quot;&gt;LangGraph Persistence&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前官方文档进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>FastAPI 并发模型与后台任务</title><link>https://zh19990906.github.io/fuwari/posts/fastapi-concurrency-background-tasks/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/fastapi-concurrency-background-tasks/</guid><description>正确选择 async def、同步线程池和 BackgroundTasks，并评估多 Worker 对数据库连接池和应用资源的放大效应。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;FastAPI 可以同时运行同步和异步路径函数，但“写成 &lt;code&gt;async def&lt;/code&gt;”不会让任何代码自动变成非阻塞。真正决定并发行为的是调用链中的网络、数据库、文件和 CPU 操作是否能够在等待时让出执行权。&lt;/p&gt;
&lt;p&gt;后台任务也不是独立任务队列。&lt;code&gt;BackgroundTasks&lt;/code&gt; 适合响应返回后在同一应用进程中完成少量、短时间、允许丢失的工作；需要重试、状态查询和跨部署存活的任务应交给持久化队列或工作流系统。&lt;/p&gt;
&lt;h2&gt;先按工作类型选择执行方式&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;工作类型&lt;/th&gt;
&lt;th&gt;推荐方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;原生异步 HTTP / 数据库调用&lt;/td&gt;
&lt;td&gt;&lt;code&gt;async def&lt;/code&gt; + &lt;code&gt;await&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;只能使用同步阻塞客户端&lt;/td&gt;
&lt;td&gt;普通 &lt;code&gt;def&lt;/code&gt;，或在异步路径中使用 &lt;code&gt;asyncio.to_thread&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;纯 Python CPU 密集计算&lt;/td&gt;
&lt;td&gt;独立进程、任务 Worker 或原生计算库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;响应后执行的短小非关键工作&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BackgroundTasks&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;需要重试、持久状态或执行数分钟以上&lt;/td&gt;
&lt;td&gt;外部任务队列 / 工作流引擎&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;不要用“接口是否需要等待结果”来判断 &lt;code&gt;def&lt;/code&gt; 或 &lt;code&gt;async def&lt;/code&gt;。两种函数都可以返回结果，区别是等待 I/O 时如何调度其他请求。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;async def&lt;/code&gt; 适合原生异步调用链&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import httpx
from fastapi import FastAPI


app = FastAPI()


@app.get(&quot;/status/{service_name}&quot;)
async def read_status(service_name: str) -&amp;gt; dict[str, object]:
    async with httpx.AsyncClient(timeout=5.0) as client:
        response = await client.get(
            &quot;https://status.example.com/api/services&quot;,
            params={&quot;name&quot;: service_name},
        )
        response.raise_for_status()
        return response.json()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;await client.get(...)&lt;/code&gt; 等待网络时，事件循环可以处理其他请求。&lt;/p&gt;
&lt;p&gt;如果在 &lt;code&gt;async def&lt;/code&gt; 中直接调用同步阻塞函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/bad&quot;)
async def bad_endpoint():
    return blocking_sdk_call()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;blocking_sdk_call()&lt;/code&gt; 会占住事件循环线程，其他协程无法正常推进，直到它返回。&lt;/p&gt;
&lt;h2&gt;普通 &lt;code&gt;def&lt;/code&gt; 的执行方式&lt;/h2&gt;
&lt;p&gt;FastAPI 会把普通同步路径函数放入线程池执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get(&quot;/legacy/{item_id}&quot;)
def read_legacy_item(item_id: str) -&amp;gt; dict[str, str]:
    return legacy_sync_client.fetch(item_id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这适合无法替换的同步 I/O SDK，但要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程池容量有限；&lt;/li&gt;
&lt;li&gt;阻塞很久的请求会占用工作线程；&lt;/li&gt;
&lt;li&gt;同步数据库客户端仍受连接池限制；&lt;/li&gt;
&lt;li&gt;增加线程数不会让下游服务容量变大；&lt;/li&gt;
&lt;li&gt;CPU 密集 Python 代码不会因为放在线程池中就高效并行。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;普通工具函数不会被 FastAPI 自动调度&lt;/h3&gt;
&lt;p&gt;FastAPI 只会根据路径函数和依赖函数的声明选择调用方式。你在 &lt;code&gt;async def&lt;/code&gt; 内直接调用的普通 Python 函数，仍然在当前事件循环线程同步执行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def endpoint():
    # 这是普通函数调用，不会自动进入 FastAPI 线程池。
    result = blocking_function()
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;在异步路径中隔离无法替换的同步 I/O&lt;/h2&gt;
&lt;p&gt;Python 提供 &lt;code&gt;asyncio.to_thread()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio

from fastapi import FastAPI


app = FastAPI()


def load_with_legacy_sdk(item_id: str) -&amp;gt; dict[str, str]:
    return legacy_sync_client.fetch(item_id)


@app.get(&quot;/items/{item_id}&quot;)
async def read_item(item_id: str) -&amp;gt; dict[str, str]:
    return await asyncio.to_thread(load_with_legacy_sdk, item_id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它适合不可替换的阻塞 I/O。仍需给底层客户端设置真正的连接和读取超时，因为取消等待协程并不能安全强杀已经运行的线程函数。&lt;/p&gt;
&lt;p&gt;CPU 密集任务不应大量塞进默认线程池。可以使用独立任务 Worker、&lt;code&gt;ProcessPoolExecutor&lt;/code&gt;，或者 NumPy、PyTorch 等在原生层执行并释放 GIL 的库。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;BackgroundTasks&lt;/code&gt; 的适用范围&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import logging

from fastapi import BackgroundTasks, FastAPI, status


logger = logging.getLogger(__name__)
app = FastAPI()


def record_noncritical_event(item_id: str, action: str) -&amp;gt; None:
    logger.info(&quot;item=%s action=%s&quot;, item_id, action)


@app.post(&quot;/items/{item_id}/refresh&quot;, status_code=status.HTTP_202_ACCEPTED)
async def request_refresh(
    item_id: str,
    background_tasks: BackgroundTasks,
) -&amp;gt; dict[str, str]:
    background_tasks.add_task(
        record_noncritical_event,
        item_id,
        &quot;refresh-requested&quot;,
    )
    return {&quot;status&quot;: &quot;accepted&quot;, &quot;item_id&quot;: item_id}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;任务会在响应发送后由当前应用进程执行。函数可以是普通 &lt;code&gt;def&lt;/code&gt; 或 &lt;code&gt;async def&lt;/code&gt;，但这不改变它的可靠性边界。&lt;/p&gt;
&lt;p&gt;适合的任务：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;写一条非关键日志；&lt;/li&gt;
&lt;li&gt;更新可丢失的本地指标；&lt;/li&gt;
&lt;li&gt;小型缓存清理；&lt;/li&gt;
&lt;li&gt;失败后允许用户重新触发的短操作。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不适合的任务：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;必须送达的通知；&lt;/li&gt;
&lt;li&gt;需要自动重试的 Webhook；&lt;/li&gt;
&lt;li&gt;视频转码、模型推理或大文件处理；&lt;/li&gt;
&lt;li&gt;需要任务 ID 和进度查询的工作；&lt;/li&gt;
&lt;li&gt;跨服务的业务状态迁移；&lt;/li&gt;
&lt;li&gt;Worker 重启后必须继续的任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;BackgroundTasks&lt;/code&gt; 没有内置持久化、确认、重试、调度和任务状态。部署重启或 Worker 被终止时，尚未完成的任务可能丢失。&lt;/p&gt;
&lt;h2&gt;不要把请求级资源传给后台任务&lt;/h2&gt;
&lt;p&gt;依赖函数通过 &lt;code&gt;yield&lt;/code&gt; 提供的数据库 Session、事务或客户端，可能在响应生命周期结束时被关闭。不要把这些对象直接传入后台任务。&lt;/p&gt;
&lt;p&gt;不推荐：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;background_tasks.add_task(update_record, request_scoped_session, item_id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐传递不可变标识，并让任务自己创建和释放需要的资源：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def update_record_in_background(item_id: str) -&amp;gt; None:
    with create_session() as session:
        record = session.get(Item, item_id)
        if record is None:
            return
        record.refresh_requested = True
        session.commit()


background_tasks.add_task(update_record_in_background, item_id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果这个写入不可丢失，就不应该依赖 &lt;code&gt;BackgroundTasks&lt;/code&gt;，而应先在请求事务中写入任务记录或 Outbox，再由独立 Worker 执行。&lt;/p&gt;
&lt;h2&gt;使用 &lt;code&gt;lifespan&lt;/code&gt; 管理每个 Worker 的共享资源&lt;/h2&gt;
&lt;p&gt;长期客户端和连接池应在应用启动时创建，在关闭时释放：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, Request


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http_client = httpx.AsyncClient(
        timeout=httpx.Timeout(5.0, connect=2.0),
        limits=httpx.Limits(
            max_connections=50,
            max_keepalive_connections=20,
        ),
    )
    try:
        yield
    finally:
        await app.state.http_client.aclose()


app = FastAPI(lifespan=lifespan)


@app.get(&quot;/health/dependency&quot;)
async def dependency_health(request: Request):
    response = await request.app.state.http_client.get(
        &quot;https://status.example.com/health&quot;
    )
    return {&quot;upstream_status&quot;: response.status_code}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个 Worker 都是独立进程，会分别运行自己的 &lt;code&gt;lifespan&lt;/code&gt;。不要误以为四个 Worker 共享同一个 Python 客户端或同一个进程内连接池。&lt;/p&gt;
&lt;h2&gt;多 Worker 会放大数据库连接数&lt;/h2&gt;
&lt;p&gt;假设每个 Worker 的数据库池允许 &lt;code&gt;pool_size&lt;/code&gt; 条常驻连接，最简单的上界是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;total_possible_connections = workers * pool_size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果还配置了临时溢出连接和多个部署副本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;total_possible_connections
= replicas * workers * (pool_size + max_overflow)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如应用扩容、滚动发布的新旧副本重叠、后台 Worker 和管理脚本都会继续占用连接。数据库最大连接数不能全部分配给 Web 应用，还要预留：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;管理和迁移连接；&lt;/li&gt;
&lt;li&gt;监控与备份；&lt;/li&gt;
&lt;li&gt;后台任务；&lt;/li&gt;
&lt;li&gt;故障转移和滚动发布；&lt;/li&gt;
&lt;li&gt;其他服务；&lt;/li&gt;
&lt;li&gt;应急操作空间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要仅根据 CPU 核心数增加 Worker。需要结合单请求内存、外部连接、延迟目标和实际负载测试。&lt;/p&gt;
&lt;p&gt;启动多个进程的示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fastapi run --workers 4 app.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;容器编排环境通常更适合每个容器运行一个进程，再通过副本数量扩缩容；具体方式取决于平台的健康检查、资源限制和进程管理策略。&lt;/p&gt;
&lt;h2&gt;避免嵌套事件循环&lt;/h2&gt;
&lt;p&gt;历史代码常见：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;loop = asyncio.get_event_loop()
loop.run_until_complete(async_function())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 FastAPI 正在运行的事件循环中再次调用 &lt;code&gt;run_until_complete()&lt;/code&gt; 会失败，也破坏了调用链的并发模型。&lt;/p&gt;
&lt;p&gt;正确选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前函数已经是异步函数：直接 &lt;code&gt;await async_function()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;同步脚本的最外层入口：使用 &lt;code&gt;asyncio.run(main())&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;异步代码调用阻塞同步函数：使用 &lt;code&gt;await asyncio.to_thread(...)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;从其他线程向已运行事件循环提交协程：使用明确的线程安全桥接方式，并管理 Future 结果；&lt;/li&gt;
&lt;li&gt;框架管理事件循环时：不要自行创建、运行或关闭它。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;import asyncio


async def main() -&amp;gt; None:
    await async_function()


if __name__ == &quot;__main__&quot;:
    asyncio.run(main())
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;超时、取消与资源释放&lt;/h2&gt;
&lt;p&gt;API 层的超时不一定能停止底层工作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTTP 客户端要设置连接、读取、写入和连接池超时；&lt;/li&gt;
&lt;li&gt;数据库查询要有语句或事务超时；&lt;/li&gt;
&lt;li&gt;线程中的阻塞函数需要自己的超时；&lt;/li&gt;
&lt;li&gt;Background Task 要记录异常，不能静默失败；&lt;/li&gt;
&lt;li&gt;应用关闭时要停止接收新请求，并给在途请求有限完成时间；&lt;/li&gt;
&lt;li&gt;长任务要支持取消标志或持久状态机。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对外返回 &lt;code&gt;202 Accepted&lt;/code&gt; 只表示请求已接受，不代表后台业务一定会完成。需要可靠任务时，应返回持久化任务 ID，并提供状态查询接口。&lt;/p&gt;
&lt;h2&gt;常见错误&lt;/h2&gt;
&lt;h3&gt;在 &lt;code&gt;async def&lt;/code&gt; 里使用同步 HTTP 客户端&lt;/h3&gt;
&lt;p&gt;这会阻塞事件循环。改用异步客户端，或临时放入 &lt;code&gt;asyncio.to_thread()&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;为每次请求创建新的连接池&lt;/h3&gt;
&lt;p&gt;会增加握手、连接数和资源抖动。使用 &lt;code&gt;lifespan&lt;/code&gt; 创建每 Worker 的共享资源。&lt;/p&gt;
&lt;h3&gt;Worker 数量增加后数据库耗尽&lt;/h3&gt;
&lt;p&gt;检查 &lt;code&gt;workers * pool_size&lt;/code&gt;，再乘部署副本和溢出连接；数据库池不是每个服务的全局共享值。&lt;/p&gt;
&lt;h3&gt;把重要任务交给 &lt;code&gt;BackgroundTasks&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;进程退出后没有恢复能力。先持久化任务或 Outbox，再由独立 Worker 处理。&lt;/p&gt;
&lt;h3&gt;捕获异常后返回成功&lt;/h3&gt;
&lt;p&gt;无论请求内还是后台工作，都应记录结构化上下文并区分可重试、不可重试和业务拒绝。&lt;/p&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 每条 I/O 调用链是否保持一致的同步或异步模型
2. async def 中是否仍有未隔离的阻塞函数
3. 底层 HTTP、数据库和 SDK 是否设置真实超时
4. CPU 密集任务是否移出 Web 事件循环和默认线程池
5. BackgroundTasks 是否只承载短小、非关键、允许丢失的工作
6. 后台任务是否只接收 ID 或不可变数据，不复用请求级资源
7. 共享客户端是否在 lifespan 中初始化和关闭
8. 是否按 replicas * workers * pool capacity 估算数据库连接
9. 滚动发布和后台 Worker 是否已计入连接预留
10. 是否避免在运行中的事件循环调用 run_until_complete
11. 应用关闭时是否有在途请求与任务的有限排空策略
12. 是否监控事件循环延迟、线程池、连接池、任务失败和请求超时
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;官方参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/async/&quot;&gt;FastAPI 并发与 async / await&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/tutorial/background-tasks/&quot;&gt;FastAPI Background Tasks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/advanced/events/&quot;&gt;FastAPI Lifespan Events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/deployment/server-workers/&quot;&gt;FastAPI Server Workers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/deployment/concepts/&quot;&gt;FastAPI Deployment Concepts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3/library/asyncio/&quot;&gt;Python asyncio&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3/library/asyncio-eventloop.html#executing-code-in-thread-or-process-pools&quot;&gt;Python Event Loop Executor&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前官方文档进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python 可靠使用 Kafka：生产、消费与 Offset</title><link>https://zh19990906.github.io/fuwari/posts/python-kafka-reliable-messaging/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-kafka-reliable-messaging/</guid><description>使用 confluent-kafka-python 处理发送确认、手动提交 Offset、优雅退出和至少一次语义，并通过业务幂等应对重复消息。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Kafka 客户端能够成功连接 Broker，并不代表消息链路已经可靠。生产者要确认消息是否真正写入；消费者要决定处理完成前后何时提交 Offset；业务还必须接受“可能重复”这一事实，并把幂等性设计在数据写入边界。&lt;/p&gt;
&lt;p&gt;本文使用 &lt;code&gt;confluent-kafka-python&lt;/code&gt;。它基于 &lt;code&gt;librdkafka&lt;/code&gt;，提供 Producer、Consumer 和管理客户端，适合需要较高吞吐和完整 Kafka 配置能力的 Python 服务。&lt;/p&gt;
&lt;h2&gt;安装&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install -U confluent-kafka
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;连接信息通过环境变量提供：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export KAFKA_BOOTSTRAP_SERVERS=&quot;broker-a.example.com:9092,broker-b.example.com:9092&quot;
export KAFKA_TOPIC=&quot;events.demo&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要 SASL/TLS 时再由部署环境提供用户名和凭据，不要把连接密钥写入源码、Dockerfile 或提交到仓库的配置文件。&lt;/p&gt;
&lt;h2&gt;先理解四个对象&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;概念&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Producer&lt;/td&gt;
&lt;td&gt;把记录发送到 Topic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic / Partition&lt;/td&gt;
&lt;td&gt;Topic 被分成多个有序分区&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer Group&lt;/td&gt;
&lt;td&gt;同一组内的消费者分摊分区&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offset&lt;/td&gt;
&lt;td&gt;消费者在某个分区中的读取位置&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Kafka 只保证同一 Partition 内的记录顺序。需要同一业务实体有序时，应使用稳定 Key，让同一实体路由到同一 Partition；不要假设整个 Topic 全局有序。&lt;/p&gt;
&lt;h2&gt;一个可检查发送结果的 Producer&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import json
import os
import socket

from confluent_kafka import KafkaException, Producer


producer_config: dict[str, object] = {
    &quot;bootstrap.servers&quot;: os.environ[&quot;KAFKA_BOOTSTRAP_SERVERS&quot;],
    &quot;client.id&quot;: socket.gethostname(),
    &quot;acks&quot;: &quot;all&quot;,
    &quot;enable.idempotence&quot;: True,
    &quot;message.timeout.ms&quot;: 10_000,
}

if os.getenv(&quot;KAFKA_SASL_USERNAME&quot;):
    producer_config.update(
        {
            &quot;security.protocol&quot;: &quot;SASL_SSL&quot;,
            &quot;sasl.mechanism&quot;: &quot;PLAIN&quot;,
            &quot;sasl.username&quot;: os.environ[&quot;KAFKA_SASL_USERNAME&quot;],
            &quot;sasl.password&quot;: os.environ[&quot;KAFKA_SASL_PASSWORD&quot;],
        }
    )

producer = Producer(producer_config)


def delivery_report(error, message) -&amp;gt; None:
    if error is not None:
        raise KafkaException(error)
    print(
        &quot;delivered&quot;,
        message.topic(),
        message.partition(),
        message.offset(),
    )


def send_event(event: dict[str, object]) -&amp;gt; None:
    payload = json.dumps(
        event,
        ensure_ascii=False,
        separators=(&quot;,&quot;, &quot;:&quot;),
    ).encode(&quot;utf-8&quot;)

    producer.produce(
        topic=os.environ[&quot;KAFKA_TOPIC&quot;],
        key=str(event[&quot;event_id&quot;]).encode(&quot;utf-8&quot;),
        value=payload,
        on_delivery=delivery_report,
    )
    producer.poll(0)


send_event(
    {
        &quot;event_id&quot;: &quot;demo-001&quot;,
        &quot;event_type&quot;: &quot;example.created&quot;,
        &quot;payload&quot;: {&quot;value&quot;: 42},
    }
)

remaining = producer.flush(10)
if remaining:
    raise TimeoutError(f&quot;{remaining} Kafka messages were not delivered&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;code&gt;produce()&lt;/code&gt; 不是同步写入&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;producer.produce()&lt;/code&gt; 通常只是把记录放入本地队列，让客户端批量、压缩并异步发送。成功返回不等于 Broker 已经确认。&lt;/p&gt;
&lt;p&gt;发送结果通过 Delivery Callback 返回，而 Callback 需要在调用 &lt;code&gt;poll()&lt;/code&gt; 或 &lt;code&gt;flush()&lt;/code&gt; 时被派发。&lt;/p&gt;
&lt;h3&gt;不要每条消息都 &lt;code&gt;flush()&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;每条消息发送后立刻 &lt;code&gt;flush()&lt;/code&gt; 会把吞吐限制在网络往返速度。常见模式是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;循环中持续调用 &lt;code&gt;produce()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;周期性调用 &lt;code&gt;poll(0)&lt;/code&gt; 派发回调；&lt;/li&gt;
&lt;li&gt;服务关闭或批次结束时调用一次有超时的 &lt;code&gt;producer.flush()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;检查返回的未完成消息数量。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本地队列可能已满&lt;/h3&gt;
&lt;p&gt;当生产速度持续高于发送速度时，&lt;code&gt;produce()&lt;/code&gt; 可能抛出 &lt;code&gt;BufferError&lt;/code&gt;。处理方式不是无限重试，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调用 &lt;code&gt;poll()&lt;/code&gt; 让已完成回调释放队列；&lt;/li&gt;
&lt;li&gt;对生产速率施加背压；&lt;/li&gt;
&lt;li&gt;监控本地队列长度和发送延迟；&lt;/li&gt;
&lt;li&gt;设置有截止时间的重试；&lt;/li&gt;
&lt;li&gt;超过截止时间后明确失败或转入持久补偿队列。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;code&gt;acks&lt;/code&gt; 与幂等 Producer&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;acks=&quot;all&quot;&lt;/code&gt; 要求当前同步副本集合按 Broker 配置确认写入。&lt;code&gt;enable.idempotence=True&lt;/code&gt; 可以减少 Producer 重试造成的分区内重复写入，并让相关安全配置保持一致。&lt;/p&gt;
&lt;p&gt;这仍不能替代业务幂等：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送方可能在收到确认前崩溃，然后重新提交业务事件；&lt;/li&gt;
&lt;li&gt;上游事务可能重复触发同一发送逻辑；&lt;/li&gt;
&lt;li&gt;跨系统写数据库和发 Kafka 不是天然原子操作；&lt;/li&gt;
&lt;li&gt;消费者至少一次处理会再次执行同一事件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要数据库与事件一致性时，可考虑 Outbox 模式，而不是先提交数据库、再“尽力”发送 Kafka。&lt;/p&gt;
&lt;h2&gt;手动提交 Offset 的 Consumer&lt;/h2&gt;
&lt;p&gt;下面的消费者只在 &lt;code&gt;process_event()&lt;/code&gt; 成功后提交当前消息位置。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json
import os
import signal

from confluent_kafka import Consumer, KafkaError, KafkaException


running = True


def request_shutdown(signum, frame) -&amp;gt; None:
    del signum, frame
    global running
    running = False


signal.signal(signal.SIGINT, request_shutdown)
signal.signal(signal.SIGTERM, request_shutdown)

consumer_config: dict[str, object] = {
    &quot;bootstrap.servers&quot;: os.environ[&quot;KAFKA_BOOTSTRAP_SERVERS&quot;],
    &quot;group.id&quot;: os.environ.get(&quot;KAFKA_CONSUMER_GROUP&quot;, &quot;demo-worker&quot;),
    &quot;auto.offset.reset&quot;: &quot;earliest&quot;,
    &quot;enable.auto.commit&quot;: False,
}

if os.getenv(&quot;KAFKA_SASL_USERNAME&quot;):
    consumer_config.update(
        {
            &quot;security.protocol&quot;: &quot;SASL_SSL&quot;,
            &quot;sasl.mechanism&quot;: &quot;PLAIN&quot;,
            &quot;sasl.username&quot;: os.environ[&quot;KAFKA_SASL_USERNAME&quot;],
            &quot;sasl.password&quot;: os.environ[&quot;KAFKA_SASL_PASSWORD&quot;],
        }
    )

consumer = Consumer(consumer_config)


def process_event(event: dict[str, object]) -&amp;gt; None:
    event_id = str(event[&quot;event_id&quot;])
    print(&quot;processed&quot;, event_id)


consumer.subscribe([os.environ[&quot;KAFKA_TOPIC&quot;]])

try:
    while running:
        message = consumer.poll(timeout=1.0)
        if message is None:
            continue

        if message.error():
            if message.error().code() == KafkaError._PARTITION_EOF:
                continue
            raise KafkaException(message.error())

        try:
            event = json.loads(message.value().decode(&quot;utf-8&quot;))
            process_event(event)
        except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as error:
            # 生产环境应记录 Topic、Partition、Offset 和错误类型，
            # 再按策略写入隔离 Topic 或人工处理队列。
            raise ValueError(
                f&quot;invalid event at {message.topic()} &quot;
                f&quot;partition={message.partition()} &quot;
                f&quot;offset={message.offset()}&quot;
            ) from error

        consumer.commit(message=message, asynchronous=False)
finally:
    consumer.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;consumer.close()&lt;/code&gt; 会释放 Socket，并让 Consumer Group 更快完成 Rebalance。不要依赖进程被强制结束后由 Broker 超时回收。&lt;/p&gt;
&lt;h2&gt;为什么处理后再提交&lt;/h2&gt;
&lt;p&gt;假设顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;读取消息
→ 完成业务处理
→ 提交 Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果业务处理完成后、提交 Offset 前进程崩溃，重启后会再次读取这条消息，因此得到“至少一次”语义。&lt;/p&gt;
&lt;p&gt;如果先提交 Offset 再处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;读取消息
→ 提交 Offset
→ 业务处理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;进程在最后一步失败时，消息位置已经前移，可能永久漏处理。&lt;/p&gt;
&lt;p&gt;至少一次不是“完全可靠”的同义词，它把丢失风险转换成重复风险。业务写入必须能够识别重复事件。&lt;/p&gt;
&lt;h2&gt;业务幂等&lt;/h2&gt;
&lt;p&gt;每条业务事件应有稳定的 &lt;code&gt;event_id&lt;/code&gt;。消费者处理时可以在同一数据库事务中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;检查 event_id 是否已经处理
→ 未处理：执行业务写入
→ 记录 event_id
→ 提交事务
→ 提交 Kafka Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;幂等设计示例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据库唯一约束；&lt;/li&gt;
&lt;li&gt;幂等请求表；&lt;/li&gt;
&lt;li&gt;按业务版本执行条件更新；&lt;/li&gt;
&lt;li&gt;目标记录的最后处理事件 ID；&lt;/li&gt;
&lt;li&gt;外部 API 的幂等键。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要只在内存 Set 中记录已处理 ID。进程重启、扩容或流量切换后，该记录无法共享。&lt;/p&gt;
&lt;h2&gt;批量处理与提交&lt;/h2&gt;
&lt;p&gt;逐条同步提交最容易理解，但提交请求较多。批量处理可以提高吞吐：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Poll 一批消息
→ 逐条或批量执行幂等业务处理
→ 确认整批成功
→ 提交每个 Partition 已连续成功处理的最大 Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不能因为某个 Partition 的后续消息成功，就跳过同一 Partition 前面失败的消息直接提交更大 Offset。并发处理时需要按 Partition 维护连续完成位置。&lt;/p&gt;
&lt;h2&gt;Poison Message&lt;/h2&gt;
&lt;p&gt;格式错误、缺字段或业务永远无法处理的消息会反复阻塞消费。应定义明确策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;记录 Topic、Partition、Offset、Key 和错误类型；&lt;/li&gt;
&lt;li&gt;限制重试次数；&lt;/li&gt;
&lt;li&gt;原始 Payload 按权限脱敏保存；&lt;/li&gt;
&lt;li&gt;转入隔离 Topic；&lt;/li&gt;
&lt;li&gt;提供重放和人工修复工具；&lt;/li&gt;
&lt;li&gt;告警而不是静默丢弃。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;隔离消息后是否提交原 Offset，需要由业务的数据完整性要求决定。&lt;/p&gt;
&lt;h2&gt;Rebalance 与长任务&lt;/h2&gt;
&lt;p&gt;Consumer Group 成员变化会触发分区重新分配。单条处理时间过长时，要关注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;max.poll.interval.ms&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;Poll 循环是否持续运行；&lt;/li&gt;
&lt;li&gt;分区撤销前是否完成或停止任务；&lt;/li&gt;
&lt;li&gt;是否错误地让两个 Worker 同时处理同一业务对象；&lt;/li&gt;
&lt;li&gt;关闭时是否停止接收新消息并等待在途任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;长耗时任务可以把 Kafka 消息转换为有状态任务记录，再由专门 Worker 执行；不要无限延长 Poll 间隔掩盖不适合的处理模型。&lt;/p&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. Broker、Topic 和凭据是否由环境或密钥管理服务提供
2. 是否使用 TLS/SASL 和最小权限账号
3. Producer 是否设置 acks=all 与 enable.idempotence
4. 是否处理 Delivery Callback 和本地队列已满
5. 服务关闭前是否调用有超时的 producer.flush
6. Consumer 是否明确选择自动或手动提交策略
7. Offset 是否只在业务处理成功后提交
8. 消费业务是否使用持久化幂等键
9. 是否明确 Partition Key 与顺序要求
10. Poison Message 是否有重试上限和隔离流程
11. Rebalance、退出和在途任务是否有处理策略
12. 是否监控 Consumer Lag、发送失败、提交失败和处理延迟
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;官方参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.confluent.io/kafka-clients/python/current/overview.html&quot;&gt;Confluent Kafka Python Client Overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html&quot;&gt;confluent-kafka-python API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/confluentinc/confluent-kafka-python&quot;&gt;confluent-kafka-python Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kafka.apache.org/documentation/#design&quot;&gt;Apache Kafka Design&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前官方文档进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python 安全使用 MinIO 对象存储</title><link>https://zh19990906.github.io/fuwari/posts/python-minio-object-storage/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-minio-object-storage/</guid><description>使用 MinIO Python SDK 管理私有 Bucket、上传下载、流式响应、元数据和预签名 URL，并避免默认凭据与匿名公开策略。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;MinIO 提供兼容 Amazon S3 API 的对象存储能力。Python 应用通常负责上传文件、读取对象、生成临时下载地址和维护对象元数据，而不是把 Bucket 直接改成匿名公开。&lt;/p&gt;
&lt;p&gt;本文以“TLS、私有 Bucket、最小权限凭据”为默认姿态。示例不会使用默认账号，也不会把 Access Key 和 Secret Key 写入源码。&lt;/p&gt;
&lt;h2&gt;安装 SDK&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install -U minio
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署环境提供以下变量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export MINIO_ENDPOINT=&quot;storage.example.com:9000&quot;
export MINIO_ACCESS_KEY=&quot;&amp;lt;managed-access-key&amp;gt;&quot;
export MINIO_SECRET_KEY=&quot;&amp;lt;managed-secret-key&amp;gt;&quot;
export MINIO_SECURE=&quot;true&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;环境变量只用于演示配置入口。生产环境应由密钥管理服务注入，并确保进程日志、错误报告和诊断页面不会输出完整凭据。&lt;/p&gt;
&lt;h2&gt;初始化客户端&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import os

from minio import Minio


def parse_bool(value: str) -&amp;gt; bool:
    normalized = value.strip().lower()
    if normalized in {&quot;1&quot;, &quot;true&quot;, &quot;yes&quot;, &quot;on&quot;}:
        return True
    if normalized in {&quot;0&quot;, &quot;false&quot;, &quot;no&quot;, &quot;off&quot;}:
        return False
    raise ValueError(f&quot;invalid boolean value: {value!r}&quot;)


client = Minio(
    endpoint=os.environ[&quot;MINIO_ENDPOINT&quot;],
    access_key=os.environ[&quot;MINIO_ACCESS_KEY&quot;],
    secret_key=os.environ[&quot;MINIO_SECRET_KEY&quot;],
    secure=parse_bool(os.getenv(&quot;MINIO_SECURE&quot;, &quot;true&quot;)),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;endpoint&lt;/code&gt; 只包含主机和端口，不应带 &lt;code&gt;http://&lt;/code&gt; 或 &lt;code&gt;https://&lt;/code&gt;。协议由 &lt;code&gt;secure&lt;/code&gt; 决定。&lt;/p&gt;
&lt;p&gt;生产环境不要为了“先跑起来”而关闭证书校验。内部 CA 应通过系统信任链或受控 HTTP Client 配置提供，而不是长期使用明文 HTTP。&lt;/p&gt;
&lt;h2&gt;一个职责清晰的轻量封装&lt;/h2&gt;
&lt;p&gt;下面的封装只处理对象存储 API，不负责数据库事务、业务权限或文件内容安全检查。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from collections.abc import Iterator
from datetime import timedelta
from pathlib import Path

from minio import Minio
from minio.datatypes import Object
from minio.error import S3Error


class ObjectStorage:
    def __init__(self, client: Minio) -&amp;gt; None:
        self.client = client

    def ensure_bucket(self, bucket_name: str) -&amp;gt; None:
        if not self.client.bucket_exists(bucket_name):
            self.client.make_bucket(bucket_name)

    def upload_file(
        self,
        bucket_name: str,
        object_name: str,
        file_path: Path,
        *,
        content_type: str,
    ) -&amp;gt; None:
        self.client.fput_object(
            bucket_name=bucket_name,
            object_name=object_name,
            file_path=str(file_path),
            content_type=content_type,
        )

    def download_file(
        self,
        bucket_name: str,
        object_name: str,
        destination: Path,
    ) -&amp;gt; None:
        self.client.fget_object(
            bucket_name=bucket_name,
            object_name=object_name,
            file_path=str(destination),
        )

    def read_bytes(
        self,
        bucket_name: str,
        object_name: str,
    ) -&amp;gt; bytes:
        response = self.client.get_object(bucket_name, object_name)
        try:
            return response.read()
        finally:
            response.close()
            response.release_conn()

    def list_objects(
        self,
        bucket_name: str,
        *,
        prefix: str = &quot;&quot;,
    ) -&amp;gt; Iterator[Object]:
        return self.client.list_objects(
            bucket_name,
            prefix=prefix,
            recursive=True,
        )

    def stat_object(self, bucket_name: str, object_name: str):
        return self.client.stat_object(bucket_name, object_name)

    def delete_object(self, bucket_name: str, object_name: str) -&amp;gt; None:
        self.client.remove_object(bucket_name, object_name)

    def presigned_download(
        self,
        bucket_name: str,
        object_name: str,
        *,
        valid_for: timedelta = timedelta(minutes=15),
    ) -&amp;gt; str:
        return self.client.presigned_get_object(
            bucket_name,
            object_name,
            expires=valid_for,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;storage = ObjectStorage(client)
storage.ensure_bucket(&quot;private-documents&quot;)

storage.upload_file(
    &quot;private-documents&quot;,
    &quot;reports/example.txt&quot;,
    Path(&quot;example.txt&quot;),
    content_type=&quot;text/plain; charset=utf-8&quot;,
)

url = storage.presigned_download(
    &quot;private-documents&quot;,
    &quot;reports/example.txt&quot;,
)
print(url)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例 Bucket 和对象名都是通用名称。真实系统中应使用不可猜测的业务对象 ID，并在数据库中保存对象与租户、权限和状态的关系。&lt;/p&gt;
&lt;h2&gt;为什么必须释放流式响应&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;get_object()&lt;/code&gt; 返回的是流式 HTTP Response。异常通常可能在真正读取数据时才出现。&lt;/p&gt;
&lt;p&gt;正确结构是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;response = client.get_object(bucket_name, object_name)
try:
    for chunk in response.stream(32 * 1024):
        consume(chunk)
finally:
    response.close()
    response.release_conn()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果不调用 &lt;code&gt;response.close()&lt;/code&gt; 和 &lt;code&gt;response.release_conn()&lt;/code&gt;，底层连接可能不能及时回到连接池，持续流量下会逐渐耗尽可用连接。&lt;/p&gt;
&lt;p&gt;不需要自己处理流时，优先使用 &lt;code&gt;fget_object()&lt;/code&gt; 或 &lt;code&gt;download_file()&lt;/code&gt; 等更高层方法。&lt;/p&gt;
&lt;h2&gt;Bucket 创建不是请求级操作&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ensure_bucket()&lt;/code&gt; 适合部署初始化或明确的管理流程，不应在每次普通上传请求中都无条件创建 Bucket。&lt;/p&gt;
&lt;p&gt;需要考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;创建 Bucket 的凭据权限通常高于上传对象；&lt;/li&gt;
&lt;li&gt;多个实例同时创建会产生竞争；&lt;/li&gt;
&lt;li&gt;Region、对象锁定和版本控制需要在创建时确定；&lt;/li&gt;
&lt;li&gt;业务请求不应该因为管理 API 短暂失败而创建未知状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;更安全的做法是由基础设施或部署任务预先创建 Bucket，应用运行凭据只具备必要的对象读写权限。&lt;/p&gt;
&lt;h2&gt;私有 Bucket、公开策略和预签名 URL&lt;/h2&gt;
&lt;h3&gt;私有 Bucket&lt;/h3&gt;
&lt;p&gt;默认选择。只有持有有效凭据且通过策略授权的服务才能访问对象。&lt;/p&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户上传文件；&lt;/li&gt;
&lt;li&gt;报告和导出结果；&lt;/li&gt;
&lt;li&gt;模型、数据集和中间产物；&lt;/li&gt;
&lt;li&gt;内部备份；&lt;/li&gt;
&lt;li&gt;受权限控制的图片与附件。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;公开 Bucket Policy&lt;/h3&gt;
&lt;p&gt;公开策略会允许匿名访问某些对象。它适合真正公开、可永久缓存的静态资源，但发布前要确认对象中不包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户标识；&lt;/li&gt;
&lt;li&gt;私有文档；&lt;/li&gt;
&lt;li&gt;EXIF 或其他隐藏元数据；&lt;/li&gt;
&lt;li&gt;内部路径和文件名；&lt;/li&gt;
&lt;li&gt;后续不应公开的新对象。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要为了让前端“能打开链接”就把整个 Bucket 设为公开。&lt;/p&gt;
&lt;h3&gt;预签名 URL&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;presigned_get_object()&lt;/code&gt; 为私有对象生成带过期时间的临时 URL。它适合浏览器或移动端直接下载对象，服务端无需代理完整文件流。&lt;/p&gt;
&lt;p&gt;预签名 URL 本质上是临时凭证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 HTTPS 传输；&lt;/li&gt;
&lt;li&gt;有效期尽量短；&lt;/li&gt;
&lt;li&gt;不写入公开日志和分析系统；&lt;/li&gt;
&lt;li&gt;生成前重新校验用户权限；&lt;/li&gt;
&lt;li&gt;对高敏感文件考虑一次性令牌或服务端代理；&lt;/li&gt;
&lt;li&gt;不要认为删除页面上的 URL 就能撤销已经签发的链接。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;上传安全&lt;/h2&gt;
&lt;p&gt;对象存储只负责保存字节，不会自动判断文件是否安全。&lt;/p&gt;
&lt;p&gt;上传接口还应处理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件大小上限；&lt;/li&gt;
&lt;li&gt;Content-Type 白名单与实际内容检测；&lt;/li&gt;
&lt;li&gt;文件名规范化；&lt;/li&gt;
&lt;li&gt;对象路径不能由用户直接拼接；&lt;/li&gt;
&lt;li&gt;恶意文件扫描；&lt;/li&gt;
&lt;li&gt;压缩炸弹；&lt;/li&gt;
&lt;li&gt;图片和文档的元数据清理；&lt;/li&gt;
&lt;li&gt;服务端加密要求；&lt;/li&gt;
&lt;li&gt;上传完成后的数据库状态更新。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要只相信客户端提供的 &lt;code&gt;Content-Type&lt;/code&gt; 和扩展名。&lt;/p&gt;
&lt;h2&gt;大文件与内存&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;read_bytes()&lt;/code&gt; 会把整个对象读入内存，只适合明确限制大小的小对象。&lt;/p&gt;
&lt;p&gt;大文件应使用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fget_object()&lt;/code&gt; 直接写文件；&lt;/li&gt;
&lt;li&gt;分块流式处理；&lt;/li&gt;
&lt;li&gt;浏览器直传的预签名 PUT；&lt;/li&gt;
&lt;li&gt;SDK 的分片上传能力；&lt;/li&gt;
&lt;li&gt;明确的最大对象大小和超时。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Web API 代理大文件时，要避免同时在内存中保留上传请求体和对象存储响应。&lt;/p&gt;
&lt;h2&gt;对象名与覆盖&lt;/h2&gt;
&lt;p&gt;S3 兼容对象存储使用 Bucket + Object Name 定位对象。相同名称再次上传通常会覆盖当前对象或产生新版本，具体取决于版本控制配置。&lt;/p&gt;
&lt;p&gt;推荐对象名：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;tenant-id&amp;gt;/&amp;lt;resource-type&amp;gt;/&amp;lt;stable-object-id&amp;gt;/&amp;lt;revision&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要直接使用未经处理的用户文件名作为完整对象路径。文件名可以作为受控元数据保存，真实对象名使用服务端生成的 ID。&lt;/p&gt;
&lt;h2&gt;错误处理&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from minio.error import S3Error


def safe_stat(
    storage: ObjectStorage,
    bucket_name: str,
    object_name: str,
):
    try:
        return storage.stat_object(bucket_name, object_name)
    except S3Error as error:
        if error.code == &quot;NoSuchKey&quot;:
            return None
        raise
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要对所有 &lt;code&gt;S3Error&lt;/code&gt; 返回“文件不存在”。权限不足、证书错误、Bucket 不存在和服务不可用需要不同的告警与响应。&lt;/p&gt;
&lt;p&gt;重试上传前要确认操作是否可能已经成功。使用稳定对象名覆盖写入可能是幂等的，但如果每次重试都生成新对象名，就会制造孤儿对象。&lt;/p&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. Endpoint 和凭据是否由密钥管理服务注入
2. 是否默认启用 TLS 并验证证书
3. 应用账号是否只拥有必要 Bucket 和对象权限
4. Bucket 是否由部署流程预创建
5. 是否默认保持 Bucket 私有
6. 生成预签名 URL 前是否重新校验权限
7. 预签名 URL 是否使用较短有效期且避免进入日志
8. get_object 的响应是否总在 finally 中 close 和 release_conn
9. 上传是否限制大小并校验实际文件类型
10. 对象名是否由服务端生成并隔离租户
11. 大文件是否避免一次性读入内存
12. 是否监控错误率、延迟、连接池、容量和孤儿对象
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;官方参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/minio/minio-py&quot;&gt;MinIO Python SDK Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://min.io/docs/minio/linux/developers/python/API.html&quot;&gt;MinIO Python Client API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/minio/minio-py/tree/master/examples&quot;&gt;MinIO Python SDK Examples&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前官方文档进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python 生产环境使用 Redis</title><link>https://zh19990906.github.io/fuwari/posts/python-redis-production/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-redis-production/</guid><description>使用 redis-py 管理连接池、超时、TTL、Pipeline、SCAN 和并发占位，并理解单实例方案的可靠性边界。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Redis 常被用作缓存、会话存储、限流计数器、任务去重和短期状态服务。真正进入生产环境后，重点不再是会不会调用 &lt;code&gt;set()&lt;/code&gt; 和 &lt;code&gt;get()&lt;/code&gt;，而是连接如何复用、命令是否有边界、键能否过期，以及故障时应用会怎么退化。&lt;/p&gt;
&lt;p&gt;本文只讨论 Python 客户端侧的工程实践，不包含 Redis Server、Sentinel 或 Cluster 的完整部署教程。&lt;/p&gt;
&lt;h2&gt;安装 redis-py&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install -U redis
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;redis-py&lt;/code&gt; 同时提供同步与 &lt;code&gt;asyncio&lt;/code&gt; 客户端。同步脚本或同步 Web Worker 可以使用 &lt;code&gt;redis.Redis&lt;/code&gt;；已经运行在事件循环中的应用应优先使用 &lt;code&gt;redis.asyncio.Redis&lt;/code&gt;，避免同步网络调用阻塞事件循环。&lt;/p&gt;
&lt;h2&gt;使用 URL 与共享连接池&lt;/h2&gt;
&lt;p&gt;连接信息通过环境变量注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export REDIS_URL=&quot;rediss://&amp;lt;username&amp;gt;:&amp;lt;credential&amp;gt;@redis.example.com:6380/0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

import redis


pool = redis.ConnectionPool.from_url(
    os.environ[&quot;REDIS_URL&quot;],
    decode_responses=True,
    max_connections=20,
    socket_connect_timeout=2,
    socket_timeout=2,
    health_check_interval=30,
)

client = redis.Redis(connection_pool=pool)

try:
    client.ping()
    client.set(&quot;demo:greeting&quot;, &quot;hello&quot;, ex=60)
    print(client.get(&quot;demo:greeting&quot;))
finally:
    client.close()
    pool.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键参数：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;参数&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;socket_connect_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;限制建立连接等待时间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;socket_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;限制命令响应等待时间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;health_check_interval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;空闲连接复用前周期性检查&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_connections&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;限制客户端池可占用的最大连接数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;decode_responses&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自动把字节响应按编码转换为字符串&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里使用 &lt;code&gt;Redis(connection_pool=pool)&lt;/code&gt;，客户端不拥有共享池，池由应用生命周期统一关闭。&lt;code&gt;Redis.from_pool(pool)&lt;/code&gt; 适合单个客户端独占连接池的场景；它会接管池的生命周期，关闭客户端时也会关闭池，不应让多个请求级客户端同时接管同一个池。&lt;/p&gt;
&lt;p&gt;生产环境优先使用 TLS 的 &lt;code&gt;rediss://&lt;/code&gt; URL、Redis ACL 用户和密钥管理服务。不要把完整连接 URL 写入日志或异常响应，因为 URL 可能包含凭据。&lt;/p&gt;
&lt;h2&gt;数据结构怎么选&lt;/h2&gt;
&lt;h3&gt;String&lt;/h3&gt;
&lt;p&gt;适合缓存单值、计数器、序列化结果和短期令牌状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;client.set(&quot;cache:article:42&quot;, &quot;rendered-content&quot;, ex=300)
value = client.get(&quot;cache:article:42&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Hash&lt;/h3&gt;
&lt;p&gt;适合保存一个对象的少量字段，允许独立更新字段。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;client.hset(
    &quot;session:demo-user&quot;,
    mapping={
        &quot;locale&quot;: &quot;zh-CN&quot;,
        &quot;theme&quot;: &quot;dark&quot;,
    },
)
client.expire(&quot;session:demo-user&quot;, 1800)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Set&lt;/h3&gt;
&lt;p&gt;适合无重复成员、标签集合和已经处理过的业务 ID。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;added = client.sadd(&quot;job:processed&quot;, &quot;event-001&quot;)
if added == 1:
    print(&quot;first time&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set 自身不会自动过期。用于时间窗口去重时，需要在首次创建键后设置 TTL，或者使用专门的数据结构和清理策略。&lt;/p&gt;
&lt;h3&gt;List&lt;/h3&gt;
&lt;p&gt;适合简单的有序列表和有限长度日志，但不能替代具备确认、重试和消费组语义的消息队列。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with client.pipeline(transaction=False) as pipe:
    pipe.lpush(&quot;recent:events&quot;, &quot;event-003&quot;)
    pipe.ltrim(&quot;recent:events&quot;, 0, 99)
    pipe.execute()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;TTL 是缓存设计的一部分&lt;/h2&gt;
&lt;p&gt;缓存键如果没有 TTL，可能在业务数据已经变化后永久保留旧值，也可能让内存持续增长。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;client.set(&quot;cache:profile:42&quot;, &quot;...&quot;, ex=600)
remaining = client.ttl(&quot;cache:profile:42&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要明确：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TTL 从什么时候开始；&lt;/li&gt;
&lt;li&gt;写入新值是否重置 TTL；&lt;/li&gt;
&lt;li&gt;缓存未命中时如何回源；&lt;/li&gt;
&lt;li&gt;回源失败时是否允许使用旧值；&lt;/li&gt;
&lt;li&gt;大量键同时过期是否造成请求尖峰；&lt;/li&gt;
&lt;li&gt;业务删除时是否主动清理相关键。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对热点键可以加入随机抖动，避免大量缓存同一秒失效：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import random

base_ttl = 600
client.set(
    &quot;cache:item:42&quot;,
    &quot;...&quot;,
    ex=base_ttl + random.randint(0, 60),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Pipeline 减少往返&lt;/h2&gt;
&lt;p&gt;Pipeline 可以把多条命令批量发送，减少网络往返次数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with client.pipeline(transaction=False) as pipe:
    for item_id in range(1, 101):
        pipe.hgetall(f&quot;item:{item_id}&quot;)
    items = pipe.execute()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;transaction=False&lt;/code&gt; 表示只做批量传输，不自动提供 &lt;code&gt;MULTI/EXEC&lt;/code&gt; 事务语义。即使使用事务，Redis 事务也不会像关系数据库那样在命令报错时自动回滚所有业务效果。&lt;/p&gt;
&lt;p&gt;Pipeline 过大也会增加客户端内存、单次响应体积和 Redis 处理延迟。应按可控批次执行，而不是一次堆积数十万条命令。&lt;/p&gt;
&lt;h2&gt;使用 &lt;code&gt;SCAN&lt;/code&gt;，不要在线上大键空间执行 &lt;code&gt;KEYS&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;KEYS pattern&lt;/code&gt; 会遍历当前数据库的全部键。键空间很大时，单次命令可能长时间占用 Redis 主线程。&lt;/p&gt;
&lt;p&gt;redis-py 提供了迭代器封装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for key in client.scan_iter(
    match=&quot;cache:article:*&quot;,
    count=500,
):
    print(key)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SCAN&lt;/code&gt; 是增量游标遍历：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一次迭代不会返回全部键；&lt;/li&gt;
&lt;li&gt;遍历期间键空间可能变化；&lt;/li&gt;
&lt;li&gt;结果可能重复，调用方应允许幂等处理；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;count&lt;/code&gt; 是提示值，不保证每批严格数量；&lt;/li&gt;
&lt;li&gt;不应把扫描结果当成强一致快照。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果业务经常需要按属性查找键，更好的方案通常是维护显式索引或把查询放到合适的数据库中，而不是频繁扫描 Redis。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;SET NX EX&lt;/code&gt; 的并发占位&lt;/h2&gt;
&lt;p&gt;Redis 的单条 &lt;code&gt;SET&lt;/code&gt; 可以组合“键不存在才写入”和过期时间：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;acquired = client.set(
    &quot;dedupe:job:42&quot;,
    &quot;worker-a&quot;,
    nx=True,
    ex=30,
)

if acquired:
    print(&quot;this worker owns the short-lived slot&quot;)
else:
    print(&quot;another worker already owns it&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;短时间防止重复提交；&lt;/li&gt;
&lt;li&gt;简单任务占位；&lt;/li&gt;
&lt;li&gt;幂等窗口标记；&lt;/li&gt;
&lt;li&gt;防止同一个缓存键被同时大量回源。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它不是无条件可靠的分布式锁：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务可能执行超过 TTL；&lt;/li&gt;
&lt;li&gt;客户端暂停后可能在租约失效后继续写入；&lt;/li&gt;
&lt;li&gt;删除锁时必须确认值仍属于当前持有者；&lt;/li&gt;
&lt;li&gt;Redis 故障转移和网络分区会影响语义；&lt;/li&gt;
&lt;li&gt;涉及资金、库存等强一致状态时应使用领域数据库事务或专门协调机制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;释放占位时不能直接无条件 &lt;code&gt;DEL&lt;/code&gt;，应使用 Lua 脚本比较持有者值后再删除。&lt;/p&gt;
&lt;h2&gt;异步客户端&lt;/h2&gt;
&lt;p&gt;FastAPI 等异步应用中可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

from redis.asyncio import Redis


async def read_cache(key: str) -&amp;gt; str | None:
    client = Redis.from_url(
        os.environ[&quot;REDIS_URL&quot;],
        decode_responses=True,
        socket_connect_timeout=2,
        socket_timeout=2,
    )
    try:
        return await client.get(key)
    finally:
        await client.aclose()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;长期 Web 服务不要为每次请求新建客户端。应在应用 &lt;code&gt;lifespan&lt;/code&gt; 中创建共享客户端，在关闭阶段执行 &lt;code&gt;aclose()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;同步和异步客户端不能通过简单增加 &lt;code&gt;await&lt;/code&gt; 互换。所依赖的框架、连接池和调用链必须保持一致的并发模型。&lt;/p&gt;
&lt;h2&gt;错误处理与降级&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from redis.exceptions import ConnectionError, TimeoutError


def get_optional_cache(key: str) -&amp;gt; str | None:
    try:
        return client.get(key)
    except (ConnectionError, TimeoutError):
        return None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有“缓存不可用时可以安全回源”的读取适合这样降级。下面这些操作不能把错误静默当成成功：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;分布式限流；&lt;/li&gt;
&lt;li&gt;幂等占位；&lt;/li&gt;
&lt;li&gt;会话状态；&lt;/li&gt;
&lt;li&gt;任务确认；&lt;/li&gt;
&lt;li&gt;权限相关缓存；&lt;/li&gt;
&lt;li&gt;业务计数写入。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;重试需要限制次数、总时长和可重试错误。对写操作盲目重试可能产生重复效果，应先确认命令是否天然幂等。&lt;/p&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 是否通过环境变量或密钥管理服务提供 REDIS_URL
2. 生产连接是否使用 TLS 和最小权限 ACL 用户
3. 是否设置连接、读取超时和最大连接数
4. 连接池大小是否低于 Redis 与应用的容量上限
5. 所有缓存键是否有明确的 TTL 或清理策略
6. Pipeline 批次是否有上限
7. 是否避免在大键空间执行 KEYS
8. SCAN 结果是否允许重复并采用幂等处理
9. SET NX EX 是否只用于能够接受其边界的场景
10. 缓存故障时每类读写操作的降级策略是否明确
11. 是否监控连接数、超时、命中率、内存与大键
12. 应用退出时是否正确关闭客户端和连接池
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;官方参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://redis.io/docs/latest/develop/clients/redis-py/&quot;&gt;redis-py 官方指南&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://redis.io/docs/latest/develop/clients/redis-py/connect/&quot;&gt;redis-py 连接方式&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://redis.io/docs/latest/commands/scan/&quot;&gt;Redis SCAN 命令&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://redis.io/docs/latest/commands/set/&quot;&gt;Redis SET 命令&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://redis.readthedocs.io/en/stable/connections.html&quot;&gt;redis-py Connection API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前官方文档进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>FRP 内网穿透实战：Linux 服务端与 Linux/Windows 客户端</title><link>https://zh19990906.github.io/fuwari/posts/frp-cross-platform-tunnel/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/frp-cross-platform-tunnel/</guid><description>使用 FRP 将 Linux 或 Windows 内网服务安全映射到公网，覆盖 TCP、HTTP 域名代理、Systemd、计划任务、TLS 与排障。</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;FRP（Fast Reverse Proxy）可以把位于 NAT 或防火墙之后的服务，通过一台具有公网地址的服务器转发到互联网。公网服务器运行 &lt;code&gt;frps&lt;/code&gt;，内网 Linux 或 Windows 设备运行 &lt;code&gt;frpc&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;本文覆盖两种常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 TCP 远程端口访问内网 SSH 或其他 TCP 服务；&lt;/li&gt;
&lt;li&gt;使用域名访问内网 Web 服务，并由公网 Nginx 统一处理 HTTPS。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;FRP 解决的是网络可达性，不是业务授权系统，也不是 VPN 或零信任网关。映射出去的 SSH、Web 和其他服务仍然需要自己的身份验证、权限控制、更新和审计。&lt;/p&gt;
&lt;h2&gt;FRP 的数据路径&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;公网访问者
    ↓
公网 Linux 服务器：frps
    ↓ TLS 控制连接与工作连接
内网 Linux / Windows：frpc
    ↓
本地 SSH、Web 或其他 TCP 服务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先区分三个容易混淆的端口：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bindPort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;frpc&lt;/code&gt; 连接 &lt;code&gt;frps&lt;/code&gt; 的控制端口&lt;/td&gt;
&lt;td&gt;&lt;code&gt;7000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;remotePort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;公网用户访问 TCP 代理的端口&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;localPort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;内网真实服务监听的端口&lt;/td&gt;
&lt;td&gt;&lt;code&gt;22&lt;/code&gt; 或 &lt;code&gt;8080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;HTTP 代理稍有不同。公网请求先进入 &lt;code&gt;vhostHTTPPort&lt;/code&gt;，&lt;code&gt;frps&lt;/code&gt; 再根据请求的 Host 找到对应代理，不需要为每个站点分配独立公网端口。&lt;/p&gt;
&lt;h2&gt;部署前检查&lt;/h2&gt;
&lt;p&gt;开始前确认：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;有一台公网 Linux 服务器，并拥有管理员权限；&lt;/li&gt;
&lt;li&gt;内网设备能够主动连接公网服务器的 &lt;code&gt;bindPort&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;云安全组与主机防火墙可以分别配置；&lt;/li&gt;
&lt;li&gt;HTTP 域名代理使用的 DNS 记录已经指向公网服务器；&lt;/li&gt;
&lt;li&gt;如果 Nginx 已经占用 80/443，FRP 的 HTTP VHost 使用内部端口；&lt;/li&gt;
&lt;li&gt;内网服务只监听必要接口，并有自己的认证机制；&lt;/li&gt;
&lt;li&gt;不把数据库、Redis、Docker API、Kubernetes API、NAS 管理后台等高风险管理接口直接映射到公网。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;文中的域名均为示例。部署时替换为自己的域名，不要把真实 Token、密码和服务器地址提交到代码仓库。&lt;/p&gt;
&lt;h2&gt;固定版本并校验下载文件&lt;/h2&gt;
&lt;p&gt;本文以 FRP v0.69.0 为示例。发布前应重新查看官方 Release 页；命令固定版本号是为了让安装过程可复现，而不是建议永远停留在这个版本。&lt;/p&gt;
&lt;h3&gt;Linux AMD64 或 ARM64&lt;/h3&gt;
&lt;p&gt;先确认架构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uname -m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见对应关系：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x86_64&lt;/code&gt;：使用 &lt;code&gt;linux_amd64&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aarch64&lt;/code&gt;：使用 &lt;code&gt;linux_arm64&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AMD64 示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FRP_VERSION=0.69.0
FRP_ARCH=linux_amd64

curl -fLO &quot;https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_${FRP_ARCH}.tar.gz&quot;
sha256sum &quot;frp_${FRP_VERSION}_${FRP_ARCH}.tar.gz&quot;
tar -xzf &quot;frp_${FRP_VERSION}_${FRP_ARCH}.tar.gz&quot;
cd &quot;frp_${FRP_VERSION}_${FRP_ARCH}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ARM64 只需改为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FRP_ARCH=linux_arm64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把 &lt;code&gt;sha256sum&lt;/code&gt; 输出与 Release 页面对应资产的 SHA-256 对比。不要只因为压缩包能够解压就跳过完整性检查。&lt;/p&gt;
&lt;h3&gt;Windows AMD64&lt;/h3&gt;
&lt;p&gt;使用浏览器或 PowerShell 下载 &lt;code&gt;frp_0.69.0_windows_amd64.zip&lt;/code&gt;，然后校验：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-FileHash .\frp_0.69.0_windows_amd64.zip -Algorithm SHA256
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;校验值同样以官方 Release 页面为准。&lt;/p&gt;
&lt;h3&gt;版本兼容与升级顺序&lt;/h3&gt;
&lt;p&gt;从 v0.69.0 开始，FRP 公布了明确的版本兼容窗口。混合版本升级时应先升级 &lt;code&gt;frps&lt;/code&gt;，再升级 &lt;code&gt;frpc&lt;/code&gt;，让服务端先具备处理新客户端行为的能力。&lt;/p&gt;
&lt;p&gt;v0.69.0 还提供可选 Wire Protocol v2，但默认仍是 v1。基础部署不需要主动开启 v2；只有确认两端版本与变更影响后再单独升级协议。&lt;/p&gt;
&lt;h2&gt;Linux 公网服务器部署 frps&lt;/h2&gt;
&lt;h3&gt;创建系统用户和目录&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo useradd --system --no-create-home --shell /usr/sbin/nologin frp
sudo install -d -o root -g frp -m 0750 /etc/frp
sudo install -m 0755 ./frps /usr/local/bin/frps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生成强随机 Token：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo sh -c &apos;umask 027; openssl rand -hex 32 &amp;gt; /etc/frp/token&apos;
sudo chown root:frp /etc/frp/token
sudo chmod 0640 /etc/frp/token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端必须使用相同 Token。通过受保护的传输渠道复制 Token 文件，不要把它贴进聊天记录、工单或仓库。&lt;/p&gt;
&lt;p&gt;如果需要 Dashboard，再生成独立密码供 Systemd 环境文件读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo sh -c &apos;umask 027; printf &quot;FRP_DASHBOARD_PASSWORD=%s\n&quot; &quot;$(openssl rand -base64 32 | tr -d &quot;\n&quot;)&quot; &amp;gt; /etc/frp/frps.env&apos;
sudo chown root:frp /etc/frp/frps.env
sudo chmod 0640 /etc/frp/frps.env
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;编写 &lt;code&gt;/etc/frp/frps.toml&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;bindAddr = &quot;0.0.0.0&quot;
bindPort = 7000

# 只接受使用 TLS 的 frpc。
transport.tls.force = true

# Token 从权限受限的独立文件读取。
auth.method = &quot;token&quot;
auth.additionalScopes = [&quot;HeartBeats&quot;, &quot;NewWorkConns&quot;]
auth.tokenSource.type = &quot;file&quot;
auth.tokenSource.file.path = &quot;/etc/frp/token&quot;

# 限制客户端可以申请的 TCP 远程端口。
allowPorts = [
  { start = 6000, end = 6099 },
]
maxPortsPerClient = 10

# 由本机 Nginx 转发到该端口，不直接暴露给公网。
vhostHTTPPort = 8080

# Dashboard 只监听回环地址。
webServer.addr = &quot;127.0.0.1&quot;
webServer.port = 7500
webServer.user = &quot;frp-admin&quot;
webServer.password = &quot;{{ .Envs.FRP_DASHBOARD_PASSWORD }}&quot;

log.to = &quot;console&quot;
log.level = &quot;info&quot;
log.disablePrintColor = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设置权限并校验配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown root:frp /etc/frp/frps.toml
sudo chmod 0640 /etc/frp/frps.toml
sudo -u frp /usr/local/bin/frps verify -c /etc/frp/frps.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到配置语法通过后再启动服务。严格校验可以提前发现字段拼写错误和不支持的配置项。&lt;/p&gt;
&lt;h3&gt;TLS 到底保护了什么&lt;/h3&gt;
&lt;p&gt;FRP 自 v0.50.0 起，&lt;code&gt;frpc&lt;/code&gt; 的 &lt;code&gt;transport.tls.enable&lt;/code&gt; 默认值已经是 &lt;code&gt;true&lt;/code&gt;。本文仍显式写出该字段，便于审计。服务端的 &lt;code&gt;transport.tls.force = true&lt;/code&gt; 则用于拒绝没有启用 TLS 的客户端。&lt;/p&gt;
&lt;p&gt;默认 TLS 可以加密 &lt;code&gt;frpc&lt;/code&gt; 与 &lt;code&gt;frps&lt;/code&gt; 之间的连接，但如果没有配置受信 CA，客户端不能获得与公有 PKI HTTPS 相同的服务器身份保证。高风险环境应为 &lt;code&gt;frps&lt;/code&gt; 配置证书和私钥，并在 &lt;code&gt;frpc&lt;/code&gt; 配置 &lt;code&gt;transport.tls.trustedCaFile&lt;/code&gt; 与 &lt;code&gt;transport.tls.serverName&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Token 用于验证客户端是否允许连接 FRP 服务端，也不能替代证书身份校验或业务服务自己的用户权限。&lt;/p&gt;
&lt;h2&gt;使用 Systemd 管理 frps&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;/etc/systemd/system/frps.service&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=FRP server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=frp
Group=frp
EnvironmentFile=-/etc/frp/frps.env
ExecStartPre=/usr/local/bin/frps verify -c /etc/frp/frps.toml
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
Restart=on-failure
RestartSec=5s

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;加载并启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload
sudo systemctl enable --now frps
sudo systemctl status frps
sudo journalctl -u frps -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用服务管理器可以获得开机启动、失败重启、统一日志和明确的运行用户，不需要依赖一直打开的终端会话。&lt;/p&gt;
&lt;h2&gt;私有访问 Dashboard&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;webServer.addr = &quot;127.0.0.1&quot;&lt;/code&gt; 意味着 Dashboard 不接受外部网络连接。需要查看时建立 SSH 本地转发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -L 7500:127.0.0.1:7500 admin@frp.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在本机浏览器打开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://127.0.0.1:7500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在云安全组或防火墙中开放 7500。Dashboard 能看到代理、客户端和流量信息，本身也是管理面。&lt;/p&gt;
&lt;h2&gt;Linux 客户端部署 frpc&lt;/h2&gt;
&lt;h3&gt;安装文件和 Token&lt;/h3&gt;
&lt;p&gt;在内网 Linux 设备执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo useradd --system --no-create-home --shell /usr/sbin/nologin frp
sudo install -d -o root -g frp -m 0750 /etc/frp
sudo install -m 0755 ./frpc /usr/local/bin/frpc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把服务端生成的 Token 安全复制到 &lt;code&gt;/etc/frp/token&lt;/code&gt;，然后限制权限：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown root:frp /etc/frp/token
sudo chmod 0640 /etc/frp/token
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;编写公共配置&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/etc/frp/frpc.toml&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;serverAddr = &quot;frp.example.com&quot;
serverPort = 7000
clientID = &quot;linux-home-01&quot;

transport.protocol = &quot;tcp&quot;
transport.tls.enable = true

auth.method = &quot;token&quot;
auth.additionalScopes = [&quot;HeartBeats&quot;, &quot;NewWorkConns&quot;]
auth.tokenSource.type = &quot;file&quot;
auth.tokenSource.file.path = &quot;/etc/frp/token&quot;

log.to = &quot;console&quot;
log.level = &quot;info&quot;
log.disablePrintColor = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后续 TCP 与 HTTP 代理配置可以继续追加在同一个文件中。&lt;/p&gt;
&lt;h2&gt;TCP 映射内网 SSH&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;frpc.toml&lt;/code&gt; 末尾加入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[proxies]]
name = &quot;home-ssh&quot;
type = &quot;tcp&quot;
localIP = &quot;127.0.0.1&quot;
localPort = 22
remotePort = 6001
transport.useEncryption = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;校验并启动后，公网访问方式为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -p 6001 user@frp.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FRP 的端口映射不会自动保护 SSH：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用密钥认证；&lt;/li&gt;
&lt;li&gt;禁止 root 密码登录；&lt;/li&gt;
&lt;li&gt;限制允许登录的账号；&lt;/li&gt;
&lt;li&gt;在防火墙中尽可能限制来源地址；&lt;/li&gt;
&lt;li&gt;保留主机密钥校验；&lt;/li&gt;
&lt;li&gt;监控失败登录和异常扫描。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;transport.useEncryption&lt;/code&gt; 是代理数据层的附加加密选项，不能替代 SSH 自身的端到端加密和身份认证。&lt;/p&gt;
&lt;h2&gt;HTTP 域名代理&lt;/h2&gt;
&lt;p&gt;假设内网 Web 服务监听 &lt;code&gt;127.0.0.1:8080&lt;/code&gt;，DNS 中的 &lt;code&gt;app.example.com&lt;/code&gt; 已经指向公网 FRP 服务器。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;frpc.toml&lt;/code&gt; 追加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[proxies]]
name = &quot;home-web&quot;
type = &quot;http&quot;
localIP = &quot;127.0.0.1&quot;
localPort = 8080
customDomains = [&quot;app.example.com&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FRPS 会根据 Host 将请求路由到该代理。HTTP 模式适合多个域名共享同一个 VHost 端口；与 TCP 模式不同，它不是通过 &lt;code&gt;remotePort&lt;/code&gt; 区分服务。&lt;/p&gt;
&lt;h3&gt;Nginx 终止 HTTPS&lt;/h3&gt;
&lt;p&gt;公网服务器已经由 Nginx 占用 80/443 时，推荐链路：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;访问者 HTTPS :443
    ↓
Nginx 终止 TLS
    ↓ HTTP，仅限本机
FRPS vhostHTTPPort :8080
    ↓
FRPC
    ↓
内网 Web :8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nginx 示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTPS &lt;code&gt;server&lt;/code&gt; 块使用相同的 &lt;code&gt;location&lt;/code&gt; 配置，并加载证书。证书申请与自动续期可继续参考站内的 &lt;a href=&quot;../nginx-reverse-proxy/&quot;&gt;Nginx 反向代理&lt;/a&gt; 和 &lt;a href=&quot;../nginx-acme-https/&quot;&gt;acme.sh HTTPS&lt;/a&gt; 文档。&lt;/p&gt;
&lt;p&gt;这里必须保留原始 Host。FRPS 根据 &lt;code&gt;app.example.com&lt;/code&gt; 选择 HTTP 代理；如果 Nginx 把 Host 改成其他值，FRPS 会找不到匹配项。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;vhostHTTPPort = 8080&lt;/code&gt; 不需要对公网开放。云安全组和主机防火墙应阻止外部直接访问该端口，让所有 Web 流量先经过 Nginx 的认证、限流、日志和 HTTPS 配置。&lt;/p&gt;
&lt;h2&gt;使用 Systemd 管理 Linux frpc&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;/etc/systemd/system/frpc.service&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=FRP client
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=frp
Group=frp
ExecStartPre=/usr/local/bin/frpc verify -c /etc/frp/frpc.toml
ExecStart=/usr/local/bin/frpc -c /etc/frp/frpc.toml
Restart=on-failure
RestartSec=5s

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设置配置权限并启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown root:frp /etc/frp/frpc.toml
sudo chmod 0640 /etc/frp/frpc.toml
sudo -u frp /usr/local/bin/frpc verify -c /etc/frp/frpc.toml

sudo systemctl daemon-reload
sudo systemctl enable --now frpc
sudo systemctl status frpc
sudo journalctl -u frpc -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;localIP&lt;/code&gt; 指向其他本机服务，要确保 &lt;code&gt;frp&lt;/code&gt; 用户能够建立连接；如果服务只监听另一个网络命名空间或容器网络，还需要单独处理网络可达性。&lt;/p&gt;
&lt;h2&gt;Windows 客户端部署 frpc&lt;/h2&gt;
&lt;p&gt;以下示例使用固定目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;C:\frp\frpc.exe
C:\frp\frpc.toml
C:\frp\token
C:\frp\logs\
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;准备目录与配置&lt;/h3&gt;
&lt;p&gt;以管理员身份打开 PowerShell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New-Item -ItemType Directory -Path C:\frp\logs -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把 &lt;code&gt;frpc.exe&lt;/code&gt;、&lt;code&gt;frpc.toml&lt;/code&gt; 和服务端的 Token 文件放入 &lt;code&gt;C:\frp&lt;/code&gt;。配置与 Linux 保持相同逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;serverAddr = &quot;frp.example.com&quot;
serverPort = 7000
clientID = &quot;windows-home-01&quot;

transport.protocol = &quot;tcp&quot;
transport.tls.enable = true

auth.method = &quot;token&quot;
auth.additionalScopes = [&quot;HeartBeats&quot;, &quot;NewWorkConns&quot;]
auth.tokenSource.type = &quot;file&quot;
auth.tokenSource.file.path = &quot;C:/frp/token&quot;

log.to = &quot;C:/frp/logs/frpc.log&quot;
log.level = &quot;info&quot;
log.maxDays = 7

[[proxies]]
name = &quot;windows-web&quot;
type = &quot;http&quot;
localIP = &quot;127.0.0.1&quot;
localPort = 8080
customDomains = [&quot;windows-app.example.com&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;限制目录 ACL，只允许系统和管理员读取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;icacls C:\frp /inheritance:r
icacls C:\frp /grant:r &quot;SYSTEM:(OI)(CI)F&quot; &quot;Administrators:(OI)(CI)F&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;先以前台方式验证&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Set-Location C:\frp
.\frpc.exe verify -c .\frpc.toml
.\frpc.exe -c .\frpc.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认日志显示连接成功、代理注册成功，并从外部网络完成访问测试。前台调试通过后再配置开机任务。&lt;/p&gt;
&lt;h3&gt;使用 ScheduledTasks 开机运行&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;$action = New-ScheduledTaskAction `
  -Execute &apos;C:\frp\frpc.exe&apos; `
  -Argument &apos;-c C:\frp\frpc.toml&apos; `
  -WorkingDirectory &apos;C:\frp&apos;

$trigger = New-ScheduledTaskTrigger -AtStartup

$settings = New-ScheduledTaskSettingsSet `
  -RestartCount 5 `
  -RestartInterval (New-TimeSpan -Minutes 1) `
  -StartWhenAvailable

Register-ScheduledTask `
  -TaskName &apos;FRP Client&apos; `
  -Action $action `
  -Trigger $trigger `
  -Settings $settings `
  -User &apos;SYSTEM&apos; `
  -RunLevel Highest `
  -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;管理任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Start-ScheduledTask -TaskName &apos;FRP Client&apos;
Get-ScheduledTaskInfo -TaskName &apos;FRP Client&apos;
Stop-ScheduledTask -TaskName &apos;FRP Client&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;日志查看：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-Content C:\frp\logs\frpc.log -Tail 100 -Wait
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;计划任务以 &lt;code&gt;SYSTEM&lt;/code&gt; 身份运行，因此不要把配置放进某个普通用户的个人目录。修改 Token 或配置后，重新运行 &lt;code&gt;frpc.exe verify&lt;/code&gt;，再重启任务。&lt;/p&gt;
&lt;h2&gt;防火墙与最小暴露&lt;/h2&gt;
&lt;p&gt;公网服务器建议只开放实际需要的端口：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;端口&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;th&gt;公网策略&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;7000/tcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FRPC 连接 FRPS&lt;/td&gt;
&lt;td&gt;能限制客户端出口地址时尽量限制来源&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;6001/tcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;示例 SSH TCP 映射&lt;/td&gt;
&lt;td&gt;只开放实际使用的远程端口，并限制来源&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;80/443&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nginx Web 入口&lt;/td&gt;
&lt;td&gt;按公开网站策略开放&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;8080/tcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FRPS HTTP VHost&lt;/td&gt;
&lt;td&gt;不对公网开放&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;7500/tcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;不对公网开放&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;allowPorts&lt;/code&gt; 控制 FRPC 可以向 FRPS 申请哪些 TCP 远程端口，云安全组和主机防火墙控制公网能否访问这些端口。两者作用不同，需要同时配置。&lt;/p&gt;
&lt;p&gt;不要为了省事开放整个 &lt;code&gt;6000-6099&lt;/code&gt;。配置允许范围可以保留扩展空间，防火墙只开放当前真正使用的 &lt;code&gt;remotePort&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;从内到外排障&lt;/h2&gt;
&lt;p&gt;出现连接失败时，不要一开始就反复重启。按数据路径逐层检查。&lt;/p&gt;
&lt;h3&gt;1. 检查内网本地服务&lt;/h3&gt;
&lt;p&gt;Linux：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ss -lntp
curl http://127.0.0.1:8080/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Windows：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-NetTCPConnection
Test-NetConnection 127.0.0.1 -Port 8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 FRPC 所在设备自己都无法访问 &lt;code&gt;localIP:localPort&lt;/code&gt;，FRP 也无法转发。&lt;/p&gt;
&lt;h3&gt;2. 校验配置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;frps verify -c /etc/frp/frps.toml
frpc verify -c /etc/frp/frpc.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Windows：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;C:\frp\frpc.exe verify -c C:\frp\frpc.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 检查进程状态和日志&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl status frps frpc
journalctl -u frps -u frpc --since &quot;10 minutes ago&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Windows：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-ScheduledTaskInfo -TaskName &apos;FRP Client&apos;
Get-Content C:\frp\logs\frpc.log -Tail 100
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 检查控制端口可达性&lt;/h3&gt;
&lt;p&gt;Windows：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Test-NetConnection frp.example.com -Port 7000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Linux：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nc -vz frp.example.com 7000
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. 检查 Token、TLS 和版本&lt;/h3&gt;
&lt;p&gt;服务端与客户端必须读取相同 Token。修改 Token 文件后需要重启进程，因为文件 Token 在配置加载时读取，不会自动动态刷新。&lt;/p&gt;
&lt;p&gt;如果服务端强制 TLS，旧客户端或显式关闭 TLS 的配置会被拒绝。混合版本出现异常时，先对照官方兼容窗口，并确认升级顺序是否为服务端在前、客户端在后。&lt;/p&gt;
&lt;h3&gt;6. 检查远程端口限制&lt;/h3&gt;
&lt;p&gt;TCP 代理申请的 &lt;code&gt;remotePort&lt;/code&gt; 必须位于 &lt;code&gt;allowPorts&lt;/code&gt; 范围内，并且没有超过 &lt;code&gt;maxPortsPerClient&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;7. 检查安全组与防火墙&lt;/h3&gt;
&lt;p&gt;云安全组放行不代表主机防火墙已放行，反过来也一样。检查端口监听和两层规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ss -lntp
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8. 检查 HTTP Host 路由&lt;/h3&gt;
&lt;p&gt;在公网服务器本机测试 FRPS VHost：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -H &apos;Host: app.example.com&apos; http://127.0.0.1:8080/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果这个命令成功、外部 HTTPS 失败，问题通常在 DNS、Nginx 或证书；如果本机 VHost 就失败，检查 FRPC 代理名称、&lt;code&gt;customDomains&lt;/code&gt;、内网服务和 FRP 日志。&lt;/p&gt;
&lt;h3&gt;9. 检查端口冲突&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ss -lntp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认 7000、8080 和需要的 &lt;code&gt;remotePort&lt;/code&gt; 没有被其他 FRP 实例、Nginx 或系统服务占用。&lt;/p&gt;
&lt;h2&gt;安全边界&lt;/h2&gt;
&lt;p&gt;需要明确以下事实：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Token 只验证 FRPC 是否可以连接 FRPS，不是业务级用户授权；&lt;/li&gt;
&lt;li&gt;TLS 加密链路不等于应用已经安全，仍需考虑证书身份校验；&lt;/li&gt;
&lt;li&gt;Dashboard 不应直接暴露互联网；&lt;/li&gt;
&lt;li&gt;HTTP 服务仍需应用登录、权限、CSRF 防护、限流、日志和安全更新；&lt;/li&gt;
&lt;li&gt;TCP 映射后的 SSH 或远程桌面会面对公网扫描和口令攻击；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allowPorts&lt;/code&gt;、云安全组、主机防火墙和应用权限必须共同生效；&lt;/li&gt;
&lt;li&gt;FRP 不替代企业 VPN、零信任网关或细粒度访问代理；&lt;/li&gt;
&lt;li&gt;不应直接公开数据库、缓存、容器引擎和基础设施管理接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;生产环境检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. FRP 版本和资产架构是否明确，下载文件是否校验 SHA-256
2. 是否先升级 frps，再升级 frpc
3. frps 是否设置 transport.tls.force = true
4. Token 是否从权限受限文件读取，且没有进入仓库和日志
5. 是否限制 allowPorts 与 maxPortsPerClient
6. Dashboard 是否只监听 127.0.0.1
7. 7500 和 8080 是否没有对公网开放
8. TCP remotePort 是否只开放当前实际使用的端口和来源
9. SSH、Web 等被代理服务是否拥有自己的认证与权限控制
10. frps/frpc 启动前是否执行 verify
11. Linux 是否由 Systemd 管理，Windows 是否由计划任务管理
12. Nginx 是否保留 Host 并统一终止 HTTPS
13. DNS、云安全组和主机防火墙是否同时验证
14. 是否监控 FRP 日志、异常连接和公网扫描
15. 是否准备 Token 轮换、版本升级和故障回退流程
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/fatedier/frp&quot;&gt;FRP 官方仓库与基础说明&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/fatedier/frp/releases&quot;&gt;FRP Releases&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gofrp.org/en/docs/features/common/configure/&quot;&gt;FRP 配置校验&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gofrp.org/en/docs/features/common/authentication/&quot;&gt;FRP Token 认证&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gofrp.org/en/docs/examples/vhost-http/&quot;&gt;FRP HTTP 自定义域名代理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gofrp.org/en/docs/setup/systemd/&quot;&gt;FRP Systemd 部署&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskaction&quot;&gt;Microsoft New-ScheduledTaskAction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/CNFlyCat/UsefulTutorials/tree/master/Frp%E5%86%85%E7%BD%91%E7%A9%BF%E9%80%8F%E6%90%AD%E5%BB%BA%E6%95%99%E5%AD%A6&quot;&gt;CNFlyCat 的 FRP 教程&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;本文参考了 CNFlyCat 教程中的跨平台场景和章节组织，但正文、配置和安全建议均重新编写，并以 FRP 与 Microsoft 官方文档为准。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>YOLO 模型导出、基准测试与部署检查</title><link>https://zh19990906.github.io/fuwari/posts/yolo-export-and-deployment/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-export-and-deployment/</guid><description>将 YOLO 模型导出到 ONNX、TensorRT 或 OpenVINO，并验证精度、延迟和运行环境。</description><pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;模型训练完成不等于部署完成。真正的部署链路包括预处理、推理运行时、输出解析、阈值、后处理、并发、监控和回滚。&lt;/p&gt;
&lt;h2&gt;保存部署基线&lt;/h2&gt;
&lt;p&gt;导出前记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型权重 SHA256；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ultralytics&lt;/code&gt;、PyTorch、CUDA 和驱动版本；&lt;/li&gt;
&lt;li&gt;输入尺寸、颜色空间和归一化方式；&lt;/li&gt;
&lt;li&gt;类别名称和顺序；&lt;/li&gt;
&lt;li&gt;置信度与 IoU 阈值；&lt;/li&gt;
&lt;li&gt;一组固定回归图片及 PyTorch 输出；&lt;/li&gt;
&lt;li&gt;目标硬件上的基准命令。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;sha256sum best.pt
python -m pip freeze &amp;gt; deployment-requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;导出 ONNX&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo export \
  model=best.pt \
  format=onnx \
  imgsz=640 \
  dynamic=True \
  simplify=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ultralytics import YOLO

model = YOLO(&quot;best.pt&quot;)
model.export(format=&quot;onnx&quot;, imgsz=640, dynamic=True, simplify=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;动态尺寸更灵活，但可能影响运行时优化。输入尺寸固定的生产服务可以同时评估静态模型。&lt;/p&gt;
&lt;h2&gt;TensorRT&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo export model=best.pt format=engine imgsz=640 device=0 half=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TensorRT 引擎通常与 GPU 架构、CUDA、TensorRT 版本和构建环境相关。不要默认把一台机器生成的引擎复制到所有设备后都能正常运行。&lt;/p&gt;
&lt;p&gt;FP16 通常是常见折中。INT8 需要代表性校准数据，并必须重新验证精度。&lt;/p&gt;
&lt;h2&gt;OpenVINO&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo export model=best.pt format=openvino imgsz=640 half=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenVINO 适合 Intel CPU、GPU 和部分 NPU 场景。应在真实目标设备上测试线程、批量大小和异步请求配置。&lt;/p&gt;
&lt;h2&gt;导出后验证&lt;/h2&gt;
&lt;p&gt;对固定回归集运行 PyTorch 与目标后端，比较：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;预处理后输入张量；&lt;/li&gt;
&lt;li&gt;检测数量与类别；&lt;/li&gt;
&lt;li&gt;置信度差异；&lt;/li&gt;
&lt;li&gt;坐标或掩码误差；&lt;/li&gt;
&lt;li&gt;是否仍需要 NMS；&lt;/li&gt;
&lt;li&gt;冷启动和热运行延迟；&lt;/li&gt;
&lt;li&gt;峰值内存和显存。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;允许浮点误差，但业务关键结果不能出现不可解释的明显偏差。&lt;/p&gt;
&lt;h2&gt;基准测试&lt;/h2&gt;
&lt;p&gt;区分以下指标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型纯推理时间；&lt;/li&gt;
&lt;li&gt;预处理 + 推理 + 后处理端到端时间；&lt;/li&gt;
&lt;li&gt;单张延迟与批量吞吐；&lt;/li&gt;
&lt;li&gt;P50、P95、P99 延迟；&lt;/li&gt;
&lt;li&gt;冷启动时间；&lt;/li&gt;
&lt;li&gt;CPU/GPU 利用率、内存和功耗。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;简单平均值可能掩盖长尾问题。实时视频应用还要观察积压、丢帧和流关闭时的资源释放。&lt;/p&gt;
&lt;h2&gt;服务化建议&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;启动时加载一次模型，不要每次请求重新加载。&lt;/li&gt;
&lt;li&gt;限制上传文件大小、图像尺寸和并发。&lt;/li&gt;
&lt;li&gt;为队列、推理和外部存储设置超时。&lt;/li&gt;
&lt;li&gt;对 GPU 推理使用有上限的工作队列。&lt;/li&gt;
&lt;li&gt;记录模型版本、请求耗时、错误和置信度统计。&lt;/li&gt;
&lt;li&gt;健康检查区分“进程存活”和“模型可推理”。&lt;/li&gt;
&lt;li&gt;保留上一个可用模型，支持快速回滚。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;容器化&lt;/h2&gt;
&lt;p&gt;Dockerfile 中固定运行时和系统依赖版本。不要把训练数据、密钥和大量中间文件放进镜像。&lt;/p&gt;
&lt;p&gt;GPU 容器还需要宿主机驱动和 NVIDIA Container Toolkit 配合。镜像包含 CUDA 用户态库，不会替代宿主机 GPU 驱动。&lt;/p&gt;
&lt;h2&gt;上线检查清单&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;在目标硬件完成精度和性能测试。&lt;/li&gt;
&lt;li&gt;验证错误输入、超大图片和空结果。&lt;/li&gt;
&lt;li&gt;固定模型、运行时和配置版本。&lt;/li&gt;
&lt;li&gt;配置资源限制、超时、日志和指标。&lt;/li&gt;
&lt;li&gt;执行压力测试和长时间稳定性测试。&lt;/li&gt;
&lt;li&gt;准备灰度、回滚和模型文件完整性校验。&lt;/li&gt;
&lt;li&gt;确认模型、代码和依赖许可证。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/modes/export/&quot;&gt;Ultralytics 导出模式&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/modes/benchmark/&quot;&gt;Ultralytics 基准测试模式&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>PostgreSQL 配置与性能优化基础</title><link>https://zh19990906.github.io/fuwari/posts/postgresql-configuration-optimization/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/postgresql-configuration-optimization/</guid><description>从连接、内存、WAL、自动清理和慢查询入手，建立可验证的 PostgreSQL 调优流程。</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;PostgreSQL 调优应从指标和业务负载出发，而不是复制一份“万能参数”。参数之间相互影响，错误地放大连接数或单查询内存，可能让高并发时的总内存远超机器容量。&lt;/p&gt;
&lt;h2&gt;查看当前配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SHOW config_file;
SHOW data_directory;
SHOW max_connections;
SHOW shared_buffers;
SHOW work_mem;
SHOW maintenance_work_mem;
SHOW effective_cache_size;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看参数来源：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT name, setting, unit, source, pending_restart
FROM pg_settings
WHERE name IN (
  &apos;max_connections&apos;,
  &apos;shared_buffers&apos;,
  &apos;work_mem&apos;,
  &apos;maintenance_work_mem&apos;,
  &apos;effective_cache_size&apos;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;连接数&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;max_connections&lt;/code&gt; 不是吞吐量旋钮。每个连接都有资源成本，过多活跃连接会增加上下文切换和内存压力。&lt;/p&gt;
&lt;p&gt;优先考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用端设置连接池上下限；&lt;/li&gt;
&lt;li&gt;为连接和查询设置超时；&lt;/li&gt;
&lt;li&gt;使用 PgBouncer 等连接池代理处理大量短连接；&lt;/li&gt;
&lt;li&gt;查看真实活跃连接，而不是只看总连接数。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state
ORDER BY state;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;内存参数&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;shared_buffers&lt;/code&gt;：PostgreSQL 共享缓存，不应占满系统内存。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;work_mem&lt;/code&gt;：每个排序或哈希操作可能使用的内存，不是每个连接只分配一次。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt;：VACUUM、CREATE INDEX 等维护操作可用内存。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;effective_cache_size&lt;/code&gt;：规划器对系统缓存可用量的估计，不会直接分配内存。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;调整 &lt;code&gt;work_mem&lt;/code&gt; 前先查看执行计划中的磁盘排序，并按并发峰值估算总量。&lt;/p&gt;
&lt;h2&gt;慢查询日志&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;log_min_duration_statement = 500ms
log_line_prefix = &apos;%m [%p] %u@%d %r &apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更精细的统计可以使用 &lt;code&gt;pg_stat_statements&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
  calls,
  total_exec_time,
  mean_exec_time,
  rows,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总耗时高的高频 SQL 和单次耗时极高的 SQL 都值得关注。&lt;/p&gt;
&lt;h2&gt;自动清理与统计&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;自动清理长期跟不上时，应先分析写入模式、长事务、表规模和实际触发频率，再针对单表调整：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.05,
  autovacuum_analyze_scale_factor = 0.02
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要轻易关闭 autovacuum。它不仅清理死元组，也关系到事务 ID 回卷安全。&lt;/p&gt;
&lt;h2&gt;WAL 与检查点&lt;/h2&gt;
&lt;p&gt;频繁检查点可能带来 I/O 抖动。观察日志和统计：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM pg_stat_bgwriter;
SELECT * FROM pg_stat_checkpointer;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;具体可用视图取决于 PostgreSQL 版本。调整 &lt;code&gt;max_wal_size&lt;/code&gt;、&lt;code&gt;checkpoint_timeout&lt;/code&gt; 等参数前，应了解恢复时间、磁盘容量和复制需求。&lt;/p&gt;
&lt;h2&gt;长事务与锁&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT pid, usename, state, xact_start, query_start, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看阻塞关系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
  blocked.pid AS blocked_pid,
  blocking.pid AS blocking_pid,
  blocked.query AS blocked_query,
  blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;优化前先解决异常长事务、空闲事务和锁等待，它们可能让 VACUUM、DDL 和正常请求都受影响。&lt;/p&gt;
&lt;h2&gt;调优原则&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;建立 CPU、内存、磁盘、连接、延迟和错误率基线。&lt;/li&gt;
&lt;li&gt;从最耗时 SQL 和等待事件定位瓶颈。&lt;/li&gt;
&lt;li&gt;一次只改变少量参数。&lt;/li&gt;
&lt;li&gt;记录修改前后指标和执行计划。&lt;/li&gt;
&lt;li&gt;在接近生产数据规模和并发下验证。&lt;/li&gt;
&lt;li&gt;保留回滚值，并确认哪些参数需要重启。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/runtime-config.html&quot;&gt;PostgreSQL 服务端配置&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/runtime-config-query.html&quot;&gt;查询规划配置&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/pgstatstatements.html&quot;&gt;pg_stat_statements&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>YOLO 版本演进与选型时间线</title><link>https://zh19990906.github.io/fuwari/posts/yolo-version-timeline/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo-version-timeline/</guid><description>从 YOLOv5、YOLOv8、YOLO11 到 YOLO26，梳理工程定位、兼容性和选型原则。</description><pubDate>Thu, 15 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;YOLO 并不是一条由单一组织连续发布的统一产品线。工程选型时要同时确认模型来源、代码仓库、许可证、任务类型和导出目标，不能只比较版本数字。&lt;/p&gt;
&lt;p&gt;本文重点整理 Ultralytics 工程体系中仍常见的四个节点：YOLOv5、YOLOv8、YOLO11 和 YOLO26。&lt;/p&gt;
&lt;h2&gt;2020：YOLOv5&lt;/h2&gt;
&lt;p&gt;YOLOv5 以 PyTorch 工程化体验、清晰的训练脚本和丰富部署实践获得广泛使用。大量历史项目、教程和行业代码仍基于 &lt;code&gt;ultralytics/yolov5&lt;/code&gt; 仓库。&lt;/p&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;维护已有 YOLOv5 项目；&lt;/li&gt;
&lt;li&gt;复现实验或继续使用已验证的旧部署链路；&lt;/li&gt;
&lt;li&gt;依赖特定社区插件和旧格式输出的系统。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要注意：原始 YOLOv5 仓库训练的权重，不应默认认为可以直接由现代 &lt;code&gt;ultralytics&lt;/code&gt; 包加载。官方文档中的 YOLOv5u 是采用现代 Ultralytics 检测头的变体，与历史仓库模型需要区分。&lt;/p&gt;
&lt;h2&gt;2023-01-10：YOLOv8&lt;/h2&gt;
&lt;p&gt;YOLOv8 将检测、分割、姿态、分类等任务统一到 &lt;code&gt;ultralytics&lt;/code&gt; Python 包和 CLI 中，形成了更一致的训练、验证、预测和导出接口。&lt;/p&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;已有 YOLOv8 数据集和成熟部署；&lt;/li&gt;
&lt;li&gt;需要大量社区资料和稳定工具链；&lt;/li&gt;
&lt;li&gt;不急于迁移但仍需要现代多任务接口的项目。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2024-09-10：YOLO11&lt;/h2&gt;
&lt;p&gt;YOLO11 在 YOLOv8 的统一接口基础上继续优化精度、速度和参数效率。官方将其定位为适用于检测、实例分割、姿态、分类和 OBB 等任务的通用模型。&lt;/p&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;追求成熟生产稳定性；&lt;/li&gt;
&lt;li&gt;希望比 YOLOv8 使用更高效的新模型；&lt;/li&gt;
&lt;li&gt;部署环境尚不需要 YOLO26 的端到端特性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2026-01-14：YOLO26&lt;/h2&gt;
&lt;p&gt;YOLO26 是当前最新的 Ultralytics YOLO 系列。它采用原生端到端、默认无需 NMS 的推理路径，并针对边缘与低功耗环境简化检测头和导出流程。&lt;/p&gt;
&lt;p&gt;当前官方模型系列覆盖检测、实例分割、语义分割、深度估计、姿态、分类和 OBB，并提供 YOLOE-26 开放词汇扩展。&lt;/p&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新建项目并希望采用当前模型体系；&lt;/li&gt;
&lt;li&gt;CPU、边缘设备或简化部署链路是重要目标；&lt;/li&gt;
&lt;li&gt;需要语义分割、深度估计等新任务支持；&lt;/li&gt;
&lt;li&gt;能够完整验证数据、精度和导出后端兼容性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;如何选择&lt;/h2&gt;
&lt;h3&gt;维护旧系统&lt;/h3&gt;
&lt;p&gt;优先保持原版本和原仓库，先补测试、数据集版本和部署基准。模型升级不应与业务改造同时进行。&lt;/p&gt;
&lt;h3&gt;新建稳定项目&lt;/h3&gt;
&lt;p&gt;优先评估 YOLO26 与 YOLO11。官方当前同时推荐两者用于稳定生产工作负载。应以自己的数据集、硬件和导出格式测试结果决定。&lt;/p&gt;
&lt;h3&gt;社区教程项目&lt;/h3&gt;
&lt;p&gt;教程使用哪个版本，就先在隔离环境中复现对应版本。不要把 YOLOv5 仓库命令、YOLOv8 API 和 YOLO26 模型文件混用。&lt;/p&gt;
&lt;h2&gt;迁移检查清单&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;固定 Python、PyTorch、CUDA 与 &lt;code&gt;ultralytics&lt;/code&gt; 版本。&lt;/li&gt;
&lt;li&gt;保存数据集 YAML、类别顺序和标注转换脚本。&lt;/li&gt;
&lt;li&gt;在同一验证集比较 mAP、召回率、延迟和显存。&lt;/li&gt;
&lt;li&gt;对预处理、后处理、坐标格式和阈值做回归测试。&lt;/li&gt;
&lt;li&gt;重新验证 ONNX、TensorRT、OpenVINO 或其他导出后端。&lt;/li&gt;
&lt;li&gt;检查许可证是否符合项目分发方式。&lt;/li&gt;
&lt;li&gt;保留旧模型和可回滚部署包。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/&quot;&gt;Ultralytics 支持的模型&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolov5/&quot;&gt;YOLOv5&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolov8/&quot;&gt;YOLOv8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolo11/&quot;&gt;YOLO11&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolo26/&quot;&gt;YOLO26&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>YOLO26 快速入门、训练与任务选择</title><link>https://zh19990906.github.io/fuwari/posts/yolo26-practice/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo26-practice/</guid><description>使用当前 Ultralytics YOLO26 完成推理、训练、验证和多任务模型选择。</description><pubDate>Wed, 14 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;YOLO26 于 2026 年 1 月 14 日发布，是当前最新的 Ultralytics YOLO 系列。它采用原生端到端、默认无需 NMS 的推理路径，并针对 CPU、边缘设备和导出流程进行了简化。&lt;/p&gt;
&lt;h2&gt;安装与检查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade ultralytics

yolo checks
python -c &quot;import ultralytics; print(ultralytics.__version__)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;YOLO26 需要支持该模型的新版 &lt;code&gt;ultralytics&lt;/code&gt; 包。生产项目应固定测试通过的版本，不要在构建过程中自动升级到任意最新版。&lt;/p&gt;
&lt;h2&gt;模型与任务&lt;/h2&gt;
&lt;p&gt;常见模型命名：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检测：&lt;code&gt;yolo26n.pt&lt;/code&gt;、&lt;code&gt;yolo26s.pt&lt;/code&gt; 等。&lt;/li&gt;
&lt;li&gt;实例分割：&lt;code&gt;yolo26n-seg.pt&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;语义分割：&lt;code&gt;yolo26n-sem.pt&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;深度估计：&lt;code&gt;yolo26n-depth.pt&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;姿态：&lt;code&gt;yolo26n-pose.pt&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;OBB：&lt;code&gt;yolo26n-obb.pt&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;分类：&lt;code&gt;yolo26n-cls.pt&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;先根据任务选择正确模型家族，再选择 &lt;code&gt;n/s/m/l/x&lt;/code&gt; 尺寸。不同任务的标注格式、输出对象和评估指标不同。&lt;/p&gt;
&lt;h2&gt;快速推理&lt;/h2&gt;
&lt;p&gt;CLI：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo predict model=yolo26n.pt source=images/ imgsz=640 conf=0.25
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ultralytics import YOLO

model = YOLO(&quot;yolo26n.pt&quot;)
results = model.predict(
    source=&quot;images/example.jpg&quot;,
    imgsz=640,
    conf=0.25,
)

for result in results:
    print(result.boxes.xyxy)
    print(result.boxes.conf)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;YOLO26 默认端到端推理路径与旧版本后处理可能不同。迁移时不要假设历史 NMS 参数、输出张量和插件代码可以原样复用。&lt;/p&gt;
&lt;h2&gt;训练检测模型&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo detect train \
  model=yolo26n.pt \
  data=data.yaml \
  epochs=100 \
  imgsz=640 \
  batch=16 \
  device=0 \
  project=runs/yolo26 \
  name=baseline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ultralytics import YOLO

model = YOLO(&quot;yolo26n.pt&quot;)
model.train(
    data=&quot;data.yaml&quot;,
    epochs=100,
    imgsz=640,
    batch=16,
    device=0,
    project=&quot;runs/yolo26&quot;,
    name=&quot;baseline&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方预训练检查点已经包含对应训练配方信息。微调时先从默认值建立基线，再根据数据规模和错误类型调整增强、学习率或模型尺寸。&lt;/p&gt;
&lt;h2&gt;验证&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo detect val \
  model=runs/yolo26/baseline/weights/best.pt \
  data=data.yaml \
  imgsz=640 \
  plots=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除 mAP 外，还应检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每类召回率和误检；&lt;/li&gt;
&lt;li&gt;小目标、遮挡、低照度场景；&lt;/li&gt;
&lt;li&gt;置信度分布；&lt;/li&gt;
&lt;li&gt;与 YOLO11 或旧生产模型的同集对比；&lt;/li&gt;
&lt;li&gt;目标设备上的端到端延迟。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;P2 与 P6 结构&lt;/h2&gt;
&lt;p&gt;官方提供 &lt;code&gt;yolo26-p2.yaml&lt;/code&gt; 和 &lt;code&gt;yolo26-p6.yaml&lt;/code&gt; 等架构配置，用于更小目标或更大输入场景。它们是架构 YAML，并不代表每个尺寸都有现成预训练 &lt;code&gt;.pt&lt;/code&gt; 权重。使用前应确认初始化方式和训练成本。&lt;/p&gt;
&lt;h2&gt;YOLOE-26&lt;/h2&gt;
&lt;p&gt;YOLOE-26 用于开放词汇检测与分割，可通过文本或视觉提示处理训练时未固定的类别。它适合动态类别场景，但与普通闭集检测的数据、评估和部署需求不同，应单独验证。&lt;/p&gt;
&lt;h2&gt;迁移到 YOLO26&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;用同一数据集重新训练，而不是只比较官方指标。&lt;/li&gt;
&lt;li&gt;检查端到端输出和后处理接口变化。&lt;/li&gt;
&lt;li&gt;重新测试 ONNX、TensorRT、OpenVINO 等导出。&lt;/li&gt;
&lt;li&gt;保存新旧模型并进行灰度验证。&lt;/li&gt;
&lt;li&gt;对延迟、功耗、内存和精度同时做基准。&lt;/li&gt;
&lt;li&gt;阅读许可证，确认商业和分发方式符合要求。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolo26/&quot;&gt;YOLO26 官方文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/guides/yolo26-training-recipe/&quot;&gt;YOLO26 训练配方&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>注意力机制与 Transformer 架构</title><link>https://zh19990906.github.io/fuwari/posts/attention-and-transformer/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/attention-and-transformer/</guid><description>用直观流程理解 Q、K、V、自注意力、多头注意力和 Transformer 编码器/解码器。</description><pubDate>Wed, 07 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Transformer 的核心不是“完全不需要顺序”，而是通过注意力让每个 token 可以直接读取其他位置的信息，再结合位置信息、前馈网络和残差结构逐层构建上下文表示。&lt;/p&gt;
&lt;h2&gt;为什么需要注意力&lt;/h2&gt;
&lt;p&gt;循环神经网络按时间步处理序列，长距离信息需要经过许多状态传递；卷积网络依靠堆叠扩大感受野。注意力提供了更直接的交互方式：当前 token 可以为序列中不同位置分配不同权重。&lt;/p&gt;
&lt;p&gt;例如句子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;小王把书放在桌上，因为它太重了。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型理解“它”时，需要综合“书”“桌”等候选信息。注意力不是显式语法规则，但可以学习哪些位置对当前表示更重要。&lt;/p&gt;
&lt;h2&gt;Q、K、V 的直觉&lt;/h2&gt;
&lt;p&gt;每个 token 的隐藏向量通过三个线性变换得到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Query（Q）&lt;/strong&gt;：当前位置正在寻找什么；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key（K）&lt;/strong&gt;：每个位置可以用什么特征被匹配；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Value（V）&lt;/strong&gt;：匹配后真正汇总的信息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;类比检索系统：Query 是查询，Key 是索引特征，Value 是被取回的内容。这个类比帮助理解数据流，但 Q/K/V 都是训练得到的连续向量，不是人工编写的关键词。&lt;/p&gt;
&lt;h2&gt;缩放点积注意力&lt;/h2&gt;
&lt;p&gt;核心公式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Attention(Q, K, V) = softmax(QKᵀ / √dₖ)V
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;QKᵀ&lt;/code&gt; 计算每个 Query 与所有 Key 的相似分数；&lt;/li&gt;
&lt;li&gt;除以 &lt;code&gt;√dₖ&lt;/code&gt;，减小维度增大导致的数值幅度；&lt;/li&gt;
&lt;li&gt;经过 softmax 得到每行权重；&lt;/li&gt;
&lt;li&gt;权重与 V 相乘，得到加权汇总结果。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;简化伪代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;scores = query @ key.transpose(-1, -2)
scores = scores / sqrt(key_dimension)
weights = softmax(scores, axis=-1)
output = weights @ value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码只展示数据流。真实实现还要处理 batch、多头、mask、数值稳定性、低精度和高效内核。&lt;/p&gt;
&lt;h2&gt;自注意力的数据流&lt;/h2&gt;
&lt;p&gt;自注意力中，Q、K、V 都来自同一序列：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入 token
→ Embedding + 位置信息
→ 线性映射得到 Q/K/V
→ 计算注意力权重
→ 汇总其他 token 的 V
→ 得到新的上下文表示
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每层都会更新表示。浅层可能更关注局部结构，深层可以组合更抽象的语义，但具体模式取决于模型、数据和训练目标。&lt;/p&gt;
&lt;h2&gt;多头注意力&lt;/h2&gt;
&lt;p&gt;单个注意力头只有一套投影空间。多头注意力并行计算多组 Q/K/V：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Head 1：可能关注局部搭配
Head 2：可能关注实体关系
Head 3：可能关注长距离依赖
...
拼接所有 Head
→ 输出投影
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;形式上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MultiHead(Q, K, V) = Concat(head₁, ..., headₕ)Wᴼ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要把每个头解释成固定的人类概念。某些头的行为可以观察，但不同层和不同输入下会变化。&lt;/p&gt;
&lt;h2&gt;位置信息&lt;/h2&gt;
&lt;p&gt;纯注意力只看向量集合，无法自然区分 token 顺序。Transformer 需要注入位置信息，常见方式包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;固定正弦位置编码；&lt;/li&gt;
&lt;li&gt;可学习绝对位置向量；&lt;/li&gt;
&lt;li&gt;相对位置偏置；&lt;/li&gt;
&lt;li&gt;旋转位置编码（RoPE）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不同方法影响长度外推、计算方式和模型结构。阅读模型配置时，应确认最大位置、RoPE 参数和实际支持的上下文长度，而不是只看宣传值。&lt;/p&gt;
&lt;h2&gt;Mask 的作用&lt;/h2&gt;
&lt;h3&gt;Padding Mask&lt;/h3&gt;
&lt;p&gt;批量序列长度不同，需要屏蔽补齐 token，避免模型把 padding 当作有效内容。&lt;/p&gt;
&lt;h3&gt;Causal Mask&lt;/h3&gt;
&lt;p&gt;自回归生成时，第 &lt;code&gt;i&lt;/code&gt; 个位置只能查看自己和之前的位置，不能看到未来答案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;可见矩阵
1 0 0 0
1 1 0 0
1 1 1 0
1 1 1 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;训练时即使整段目标文本同时输入，也通过 causal mask 保持“预测下一个 token”的约束。&lt;/p&gt;
&lt;h3&gt;业务 Mask&lt;/h3&gt;
&lt;p&gt;部分模型还使用滑动窗口、局部注意力或稀疏注意力来降低计算量。Mask 写错会导致信息泄漏、生成质量下降或数值异常。&lt;/p&gt;
&lt;h2&gt;Transformer Block&lt;/h2&gt;
&lt;p&gt;典型 Block 包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入
→ LayerNorm
→ Attention
→ 残差相加
→ LayerNorm
→ 前馈网络（MLP/FFN）
→ 残差相加
→ 输出
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同模型可能使用 Pre-Norm、Post-Norm、RMSNorm、门控 MLP、不同激活函数或并行分支，但“信息混合 + 非线性变换 + 残差”是阅读结构图时的主线。&lt;/p&gt;
&lt;h2&gt;前馈网络&lt;/h2&gt;
&lt;p&gt;注意力负责 token 之间交换信息，FFN 通常对每个位置独立执行非线性变换：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hidden → 扩大维度 → 激活/门控 → 压回隐藏维度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大型模型中，FFN 参数和计算量经常占很大比例。混合专家模型（MoE）会让每个 token 只路由到部分专家，以增加参数容量而不按比例增加单 token 计算。&lt;/p&gt;
&lt;h2&gt;Encoder、Decoder 与 Decoder-only&lt;/h2&gt;
&lt;h3&gt;Encoder-only&lt;/h3&gt;
&lt;p&gt;每个位置通常可以双向查看上下文，适合分类、抽取和表示学习。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入文本 → 双向编码 → 分类头或向量
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Encoder-Decoder&lt;/h3&gt;
&lt;p&gt;Encoder 读取源序列，Decoder 通过交叉注意力读取 Encoder 输出，并自回归生成目标序列。经典机器翻译与摘要常使用这种结构。&lt;/p&gt;
&lt;h3&gt;Decoder-only&lt;/h3&gt;
&lt;p&gt;使用 causal self-attention，根据已有 token 预测后续 token。许多通用 LLM 采用 Decoder-only 架构，再通过指令微调和对话模板支持多任务。&lt;/p&gt;
&lt;h2&gt;Cross-Attention&lt;/h2&gt;
&lt;p&gt;Cross-Attention 的 Q 来自当前序列，K/V 来自另一个序列或模态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Decoder hidden 生成 Q
Encoder output 生成 K/V
→ Decoder 选择需要的源信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多模态模型也可能通过交叉注意力或统一 token 空间融合图像、音频和文本。&lt;/p&gt;
&lt;h2&gt;计算与上下文成本&lt;/h2&gt;
&lt;p&gt;标准全注意力需要构造长度 &lt;code&gt;n × n&lt;/code&gt; 的分数矩阵，时间和显存通常随序列长度近似平方增长。上下文翻倍可能带来远高于两倍的注意力成本。&lt;/p&gt;
&lt;p&gt;实际系统会使用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FlashAttention 等内存高效内核；&lt;/li&gt;
&lt;li&gt;KV Cache；&lt;/li&gt;
&lt;li&gt;分页缓存；&lt;/li&gt;
&lt;li&gt;滑动窗口或稀疏注意力；&lt;/li&gt;
&lt;li&gt;分块预填充；&lt;/li&gt;
&lt;li&gt;上下文压缩和检索。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;KV Cache&lt;/h2&gt;
&lt;p&gt;自回归生成时，历史 token 的 K/V 不必每一步重新计算，可以保存在 KV Cache 中。代价是缓存随层数、隐藏维度、序列长度和并发增长。&lt;/p&gt;
&lt;p&gt;因此服务容量不能只看模型权重：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;总显存 ≈ 模型权重 + KV Cache + 激活/工作区 + 框架开销
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;长上下文和高并发往往首先受 KV Cache 限制。&lt;/p&gt;
&lt;h2&gt;注意力权重能否解释模型&lt;/h2&gt;
&lt;p&gt;注意力权重可以帮助观察信息流，但不能直接等同于完整因果解释：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;后续层会继续变换表示；&lt;/li&gt;
&lt;li&gt;残差连接绕过注意力分支；&lt;/li&gt;
&lt;li&gt;多头和 MLP 共同影响结果；&lt;/li&gt;
&lt;li&gt;高权重不一定意味着对最终输出贡献最大。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;解释模型时应结合消融实验、梯度方法、对照输入和行为评估。&lt;/p&gt;
&lt;h2&gt;阅读模型结构图的顺序&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 输入如何分词或编码
2. 隐藏维度与层数
3. Attention 类型和头数
4. 位置编码与上下文限制
5. Norm、残差和 FFN 结构
6. Mask 与信息可见范围
7. 输出头和训练目标
8. 推理时 KV Cache 与并行策略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先理解单个 Block 的输入输出，再看模型如何重复堆叠，避免一开始陷入全部公式和框架实现细节。&lt;/p&gt;
&lt;h2&gt;常见误区&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;注意力就是搜索数据库：&lt;/strong&gt; 只是直觉类比，向量和权重由模型端到端学习。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;上下文越长越好：&lt;/strong&gt; 长上下文增加成本，也不保证模型能有效利用远处信息。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;所有头都有明确功能：&lt;/strong&gt; 头的行为通常分布式且输入相关。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transformer 不处理顺序：&lt;/strong&gt; 它通过位置机制表示顺序。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意力权重就是解释：&lt;/strong&gt; 它只是分析信号之一。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;学习检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 能画出 Q/K/V 到输出的数据流
2. 理解 softmax 前为什么除以 √dₖ
3. 能区分 self-attention 与 cross-attention
4. 能说明 causal mask 防止了什么
5. 能区分 Encoder、Encoder-Decoder、Decoder-only
6. 知道上下文长度对计算和 KV Cache 的影响
7. 阅读具体模型时会核对位置编码和 Attention 变体
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>NLP 与大语言模型基础概念</title><link>https://zh19990906.github.io/fuwari/posts/nlp-and-llm-foundations/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/nlp-and-llm-foundations/</guid><description>从自然语言处理任务、文本表示和预训练模型理解大语言模型所处的位置。</description><pubDate>Tue, 06 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;自然语言处理（NLP）研究如何让计算机处理、理解和生成人类语言。大语言模型（LLM）不是完全独立于 NLP 的新领域，而是建立在文本表示、语言建模、预训练和深度学习长期发展之上的一类通用模型。&lt;/p&gt;
&lt;h2&gt;NLP 解决什么问题&lt;/h2&gt;
&lt;p&gt;常见任务可以分为几类：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;文本分类&lt;/td&gt;
&lt;td&gt;情感判断、主题分类、垃圾信息识别&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;序列标注&lt;/td&gt;
&lt;td&gt;分词、词性标注、命名实体识别&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;信息抽取&lt;/td&gt;
&lt;td&gt;关系抽取、事件抽取、结构化字段提取&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;检索与排序&lt;/td&gt;
&lt;td&gt;搜索、语义召回、问答候选排序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文本转换&lt;/td&gt;
&lt;td&gt;翻译、改写、纠错、摘要&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文本生成&lt;/td&gt;
&lt;td&gt;对话、代码生成、内容草拟&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;问答与推理&lt;/td&gt;
&lt;td&gt;阅读理解、知识问答、多步骤任务&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一个真实系统通常组合多种任务。例如企业问答可能同时需要文档解析、分块、向量检索、重排、上下文拼接和生成。&lt;/p&gt;
&lt;h2&gt;文本如何变成模型输入&lt;/h2&gt;
&lt;p&gt;模型不能直接处理文字，需要把 token 转换为数值表示。&lt;/p&gt;
&lt;h3&gt;离散表示&lt;/h3&gt;
&lt;p&gt;早期方法常见：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;one-hot；&lt;/li&gt;
&lt;li&gt;Bag-of-Words；&lt;/li&gt;
&lt;li&gt;n-gram；&lt;/li&gt;
&lt;li&gt;TF-IDF。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它们易于解释，适合小数据和传统机器学习，但难以表达词序、上下文和语义相似性。&lt;/p&gt;
&lt;h3&gt;分布式词向量&lt;/h3&gt;
&lt;p&gt;Word2Vec、GloVe 等方法把词映射到稠密向量。相似词在向量空间中更接近，但一个词通常只有一个固定向量，难以区分多义词在不同句子中的含义。&lt;/p&gt;
&lt;h3&gt;上下文表示&lt;/h3&gt;
&lt;p&gt;基于 Transformer 的预训练模型会根据上下文动态计算 token 表示。同一个词在不同句子中可以获得不同向量，这为阅读理解、生成和跨任务迁移提供了更强基础。&lt;/p&gt;
&lt;h2&gt;Token 与分词&lt;/h2&gt;
&lt;p&gt;现代模型通常使用子词或字节级 token，而不是简单按“词”切分。Token 数量会影响：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;上下文窗口占用；&lt;/li&gt;
&lt;li&gt;推理费用；&lt;/li&gt;
&lt;li&gt;生成速度；&lt;/li&gt;
&lt;li&gt;截断位置；&lt;/li&gt;
&lt;li&gt;多语言表现。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不同模型的 tokenizer 不同，同一段文本的 token 数不能直接通用。上线前应使用目标模型的 tokenizer 做容量测试。&lt;/p&gt;
&lt;h2&gt;从任务模型到预训练模型&lt;/h2&gt;
&lt;p&gt;传统流程经常是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;为一个任务收集标注数据
→ 设计特征或网络
→ 训练一个专用模型
→ 部署并维护
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预训练模型先在大规模语料上学习通用语言规律，再通过微调、提示或外部检索适配任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;大规模预训练
→ 获得通用语言能力
→ 微调 / 提示 / RAG / 工具调用
→ 完成具体任务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;优势是减少每个任务从零训练的成本，但并不意味着无需数据治理、评估和领域知识。&lt;/p&gt;
&lt;h2&gt;语言模型是什么&lt;/h2&gt;
&lt;p&gt;语言模型估计 token 序列的概率。自回归模型常学习：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;P(x₁, x₂, ..., xₙ) = ∏ P(xᵢ | x₁, ..., xᵢ₋₁)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推理时，模型根据已有上下文预测下一个 token，再把新 token 加回上下文继续生成。&lt;/p&gt;
&lt;p&gt;“预测下一个 token”是训练目标，不等于模型只能做续写。通过指令数据、对话格式、工具协议和外部系统，它可以表现出问答、分类、抽取和代码生成等能力。&lt;/p&gt;
&lt;h2&gt;LLM 与传统 NLP 的关系&lt;/h2&gt;
&lt;p&gt;LLM 改变了任务实现方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个任务可以共享同一个基础模型；&lt;/li&gt;
&lt;li&gt;少量示例和自然语言指令可以定义任务；&lt;/li&gt;
&lt;li&gt;生成式接口统一了许多输出形式；&lt;/li&gt;
&lt;li&gt;工具调用使模型能够连接数据库、搜索和业务 API。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但传统 NLP 技术仍然重要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;规则和词典在高精度场景中仍有效；&lt;/li&gt;
&lt;li&gt;小模型在延迟、成本和隐私方面可能更合适；&lt;/li&gt;
&lt;li&gt;分类器、重排器和实体抽取模型常作为 LLM 系统组件；&lt;/li&gt;
&lt;li&gt;数据清洗、采样和评估方法仍然适用。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;预训练、微调、提示和 RAG&lt;/h2&gt;
&lt;h3&gt;预训练&lt;/h3&gt;
&lt;p&gt;在大规模数据上学习语言模式和知识。成本高，一般由模型提供方或大型研究团队完成。&lt;/p&gt;
&lt;h3&gt;指令微调&lt;/h3&gt;
&lt;p&gt;使用“指令—回答”数据让模型更好地遵循任务要求和对话格式。&lt;/p&gt;
&lt;h3&gt;领域微调&lt;/h3&gt;
&lt;p&gt;用于固定输出风格、分类体系或特定行为。它不一定适合注入经常变化的事实知识。&lt;/p&gt;
&lt;h3&gt;Prompt&lt;/h3&gt;
&lt;p&gt;通过系统提示、任务描述、示例和输出约束在推理时定义行为。Prompt 需要版本管理和回归测试。&lt;/p&gt;
&lt;h3&gt;RAG&lt;/h3&gt;
&lt;p&gt;检索增强生成先从外部知识库检索相关内容，再把内容提供给模型。它适合频繁更新、需要来源和权限控制的知识，但会引入分块、召回、重排和上下文污染问题。&lt;/p&gt;
&lt;h2&gt;Embedding、重排与生成&lt;/h2&gt;
&lt;p&gt;一个常见检索问答链路：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户问题
→ Embedding 召回候选片段
→ Reranker 精排
→ 拼接上下文
→ LLM 生成答案
→ 引用与安全检查
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Embedding 负责“找相似内容”，重排模型负责“在候选中判断相关性”，生成模型负责“组织回答”。它们可以使用不同模型，并应分别评估。&lt;/p&gt;
&lt;h2&gt;模型能力不等于系统可靠性&lt;/h2&gt;
&lt;p&gt;LLM 可能产生流畅但错误的内容。可靠系统需要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输入与权限校验；&lt;/li&gt;
&lt;li&gt;结构化输出约束；&lt;/li&gt;
&lt;li&gt;工具参数校验；&lt;/li&gt;
&lt;li&gt;超时和重试；&lt;/li&gt;
&lt;li&gt;来源引用；&lt;/li&gt;
&lt;li&gt;人工确认高风险操作；&lt;/li&gt;
&lt;li&gt;日志、追踪和回放；&lt;/li&gt;
&lt;li&gt;离线与在线评估。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;模型输出必须被当作不可信输入继续校验，尤其是 SQL、shell 命令、URL 和资金/权限操作。&lt;/p&gt;
&lt;h2&gt;评估不要只看单一分数&lt;/h2&gt;
&lt;p&gt;需要同时考虑：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;正确性&lt;/td&gt;
&lt;td&gt;答案是否与标准或证据一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;完整性&lt;/td&gt;
&lt;td&gt;是否覆盖关键步骤和限制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;相关性&lt;/td&gt;
&lt;td&gt;是否直接回答用户问题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全性&lt;/td&gt;
&lt;td&gt;是否泄露数据或执行危险行为&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;稳定性&lt;/td&gt;
&lt;td&gt;多次运行结果是否可接受&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;延迟&lt;/td&gt;
&lt;td&gt;首 token 与完整响应耗时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;成本&lt;/td&gt;
&lt;td&gt;输入、输出、检索和工具总成本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可观测性&lt;/td&gt;
&lt;td&gt;是否能定位失败发生在哪个环节&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;自动指标适合规模化筛选，但重要场景仍需人工评审和业务结果验证。&lt;/p&gt;
&lt;h2&gt;基础学习路线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. Python、线性代数、概率与基础机器学习
2. 文本清洗、分词、TF-IDF 与分类任务
3. Embedding 和相似度检索
4. 注意力机制与 Transformer
5. 预训练、微调和推理
6. Prompt、结构化输出和工具调用
7. RAG、重排与评估
8. 部署、监控、成本和安全
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;学习时应做小实验验证概念，而不是只记框架 API。框架变化很快，数据流、模型输入输出和评估方法更值得长期掌握。&lt;/p&gt;
&lt;h2&gt;术语速查&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;术语&lt;/th&gt;
&lt;th&gt;简要说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token&lt;/td&gt;
&lt;td&gt;模型处理的文本单位&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding&lt;/td&gt;
&lt;td&gt;文本或对象的稠密向量表示&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context Window&lt;/td&gt;
&lt;td&gt;单次推理可处理的 token 范围&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pretraining&lt;/td&gt;
&lt;td&gt;大规模通用训练阶段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-tuning&lt;/td&gt;
&lt;td&gt;使用额外数据调整模型参数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inference&lt;/td&gt;
&lt;td&gt;使用已训练模型产生输出&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;检索外部内容后再生成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hallucination&lt;/td&gt;
&lt;td&gt;生成缺乏依据或错误的内容&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Nginx 使用 acme.sh 申请证书并自动续期</title><link>https://zh19990906.github.io/fuwari/posts/nginx-acme-https/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/nginx-acme-https/</guid><description>使用 Webroot 完成证书申请、安装、Nginx HTTPS 配置和自动续期检查。</description><pubDate>Thu, 09 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;HTTPS 部署包含四个环节：域名解析、ACME 验证、证书安装和续期后的自动重载。证书“申请成功”并不代表 Nginx 已经使用了新证书，安装路径和 reload hook 同样重要。&lt;/p&gt;
&lt;h2&gt;前置条件&lt;/h2&gt;
&lt;p&gt;开始前确认：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;app.example.com&lt;/code&gt; 已解析到当前服务器；&lt;/li&gt;
&lt;li&gt;公网可以访问 TCP 80 和 443；&lt;/li&gt;
&lt;li&gt;Nginx 已能通过 HTTP 提供一个 Webroot 目录；&lt;/li&gt;
&lt;li&gt;服务器时间正确；&lt;/li&gt;
&lt;li&gt;使用具备 &lt;code&gt;sudo&lt;/code&gt; 权限的维护账号。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;getent hosts app.example.com
sudo ss -lntp | grep -E &apos;:80|:443&apos;
timedatectl status
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;安装 Nginx 与必要工具&lt;/h2&gt;
&lt;p&gt;Ubuntu/Debian：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
sudo apt install -y nginx curl socat
sudo systemctl enable --now nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t
systemctl status nginx --no-pager
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;准备 HTTP Webroot&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /var/www/app.example.com/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/app.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTP 配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name app.example.com;

    root /var/www/app.example.com;

    location ^~ /.well-known/acme-challenge/ {
        default_type text/plain;
        try_files $uri =404;
    }

    location / {
        return 200 &quot;HTTPS setup in progress\n&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从外部网络验证挑战目录可访问，而不仅是在本机执行 &lt;code&gt;curl 127.0.0.1&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;安装 acme.sh&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;curl https://get.acme.sh | sh -s email=admin@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重新加载 shell 环境，或直接使用完整路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.acme.sh/acme.sh --version
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装脚本来自网络。生产环境可以先下载、审阅，再执行；同时应记录安装版本和升级方式。&lt;/p&gt;
&lt;h2&gt;申请证书&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;~/.acme.sh/acme.sh --issue \
  -d app.example.com \
  --webroot /var/www/app.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个具体域名可以重复使用 &lt;code&gt;-d&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.acme.sh/acme.sh --issue \
  -d app.example.com \
  -d api.example.com \
  --webroot /var/www/app.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Webroot 模式要求每个域名的挑战请求都能到达同一个目录。通配符证书不能通过 HTTP Webroot 完成，通常需要 DNS API 验证。&lt;/p&gt;
&lt;h2&gt;安装证书到稳定路径&lt;/h2&gt;
&lt;p&gt;不要让 Nginx 直接引用 &lt;code&gt;~/.acme.sh/&lt;/code&gt; 内部工作目录。创建专用目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /etc/nginx/ssl/app.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装证书并配置续期后重载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.acme.sh/acme.sh --install-cert -d app.example.com \
  --key-file /etc/nginx/ssl/app.example.com/private.key \
  --fullchain-file /etc/nginx/ssl/app.example.com/fullchain.pem \
  --reloadcmd &quot;sudo systemctl reload nginx&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;私钥权限：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown root:root /etc/nginx/ssl/app.example.com/private.key
sudo chmod 600 /etc/nginx/ssl/app.example.com/private.key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;reloadcmd&lt;/code&gt; 必须在当前安装方式下能够无交互执行。使用 sudo 时应只授予重载 Nginx 所需的最小权限，不要为 acme.sh 开放任意 root 命令。&lt;/p&gt;
&lt;h2&gt;配置 HTTPS&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name app.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/app.example.com;
        default_type text/plain;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate /etc/nginx/ssl/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/app.example.com/private.key;

    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -I https://app.example.com
openssl s_client \
  -connect app.example.com:443 \
  -servername app.example.com &amp;lt;/dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;自动续期&lt;/h2&gt;
&lt;p&gt;acme.sh 安装时通常会注册计划任务。检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;crontab -l
~/.acme.sh/acme.sh --cron --home ~/.acme.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看证书列表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.acme.sh/acme.sh --list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以使用测试环境或强制续期验证完整链路，但不要频繁请求生产证书，以免触发 CA 限制。验证目标包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新证书写入稳定路径；&lt;/li&gt;
&lt;li&gt;文件权限仍正确；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nginx -t&lt;/code&gt; 通过；&lt;/li&gt;
&lt;li&gt;reload hook 成功；&lt;/li&gt;
&lt;li&gt;外部客户端看到新的到期时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;DNS 验证与通配符证书&lt;/h2&gt;
&lt;p&gt;通配符示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要创建 &lt;code&gt;_acme-challenge.example.com&lt;/code&gt; 的 TXT 记录。推荐使用受限的 DNS API 令牌，并只授权修改目标 DNS Zone；不要把全局云账号密钥放在脚本中。&lt;/p&gt;
&lt;p&gt;DNS API 变量名称与提供商有关，应查阅 acme.sh 对应 DNS 插件的说明，并通过权限最小化和密钥轮换降低风险。&lt;/p&gt;
&lt;h2&gt;常见问题&lt;/h2&gt;
&lt;h3&gt;验证请求 404&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Webroot 路径和 Nginx &lt;code&gt;root&lt;/code&gt; 不一致；&lt;/li&gt;
&lt;li&gt;其他 location 提前拦截；&lt;/li&gt;
&lt;li&gt;CDN、WAF 或反向代理没有把挑战路径转发到当前主机；&lt;/li&gt;
&lt;li&gt;域名解析仍指向旧地址。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -T | grep -n -A10 -B5 acme-challenge
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;端口 80 无法访问&lt;/h3&gt;
&lt;p&gt;检查云防火墙、系统防火墙、运营商限制和上游负载均衡。HTTP-01 验证要求 CA 能从公网访问端口 80。&lt;/p&gt;
&lt;h3&gt;续期成功但网站仍显示旧证书&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Nginx 引用的不是 &lt;code&gt;--install-cert&lt;/code&gt; 目标路径；&lt;/li&gt;
&lt;li&gt;reload hook 失败；&lt;/li&gt;
&lt;li&gt;前面还有 CDN 或负载均衡器终止 TLS；&lt;/li&gt;
&lt;li&gt;浏览器或监控检查了另一个域名。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;私钥权限过宽&lt;/h3&gt;
&lt;p&gt;私钥只应对必要的 root/Nginx 读取路径开放。不要把 &lt;code&gt;/etc/nginx/ssl&lt;/code&gt; 整体设为 &lt;code&gt;777&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;上线检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 域名解析、80 和 443 均从外网验证
2. 挑战目录只提供验证文件
3. Nginx 引用稳定证书路径
4. 私钥权限最小化
5. nginx -t 后再 reload
6. 自动续期任务存在
7. reload hook 已真实演练
8. 监控证书到期时间和续期失败日志
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>PostgreSQL 索引与 EXPLAIN 分析</title><link>https://zh19990906.github.io/fuwari/posts/postgresql-indexes-and-explain/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/postgresql-indexes-and-explain/</guid><description>使用 EXPLAIN ANALYZE 理解扫描、连接、排序和索引是否真正改善查询。</description><pubDate>Sun, 24 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;索引不是越多越好。它会占用磁盘、增加写入成本，并需要统计信息和真实查询条件配合，规划器才可能选择使用。&lt;/p&gt;
&lt;h2&gt;从 EXPLAIN 开始&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;EXPLAIN
SELECT * FROM orders WHERE user_id = 1001;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要真实执行时间时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE user_id = 1001;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 会真正执行语句。对 &lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 或昂贵查询，应在事务和安全环境中测试，并理解副作用。
:::&lt;/p&gt;
&lt;h2&gt;常见节点&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Seq Scan&lt;/code&gt;：顺序扫描整张表。小表或需要大量行时可能是正确选择。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Index Scan&lt;/code&gt;：通过索引定位后访问表数据。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Index Only Scan&lt;/code&gt;：需要的数据可从索引获取，但仍受可见性映射影响。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Bitmap Index Scan&lt;/code&gt; + &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;：适合返回中等数量离散行。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Nested Loop&lt;/code&gt;：小结果集和索引连接常见。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Hash Join&lt;/code&gt;：等值连接且输入较大时常见。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Merge Join&lt;/code&gt;：双方已排序或可高效排序时可能出现。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Sort&lt;/code&gt;：关注排序方式、内存和是否写入磁盘。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;建立索引&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY idx_orders_user_id
ON orders (user_id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产大表通常考虑 &lt;code&gt;CONCURRENTLY&lt;/code&gt; 以降低阻塞，但创建时间更长，也有额外限制。失败的并发索引可能留下无效索引，应检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE indexrelid = &apos;idx_orders_user_id&apos;::regclass;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;联合索引&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;索引列顺序应基于查询条件，不是简单把所有 &lt;code&gt;WHERE&lt;/code&gt; 字段都堆进去。通常优先考虑高频等值条件，再考虑范围与排序，但必须用实际执行计划验证。&lt;/p&gt;
&lt;h2&gt;部分索引&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = &apos;pending&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当查询长期集中在一个较小子集时，部分索引可以减小体积和维护成本。查询条件必须能让规划器证明它满足索引谓词。&lt;/p&gt;
&lt;h2&gt;表达式索引&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX idx_users_lower_email
ON users (lower(email));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应查询：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM users WHERE lower(email) = lower(&apos;Alice@example.com&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;查看索引使用情况&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT
  schemaname,
  relname,
  indexrelname,
  idx_scan,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;idx_scan = 0&lt;/code&gt; 不等于可以立即删除。统计可能刚重置，索引可能用于月度任务、约束或灾难场景。删除前观察完整业务周期并检查约束依赖。&lt;/p&gt;
&lt;h2&gt;统计信息&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ANALYZE orders;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;估算行数与实际行数差距很大时，检查自动清理与统计信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = &apos;orders&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对分布特殊的列可以提高统计目标，但会增加分析时间和统计数据体积：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;优化流程&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;保存慢 SQL、参数和当前执行计划。&lt;/li&gt;
&lt;li&gt;查看实际行数与估算行数差异。&lt;/li&gt;
&lt;li&gt;确认主要耗时节点、缓冲区读取和磁盘排序。&lt;/li&gt;
&lt;li&gt;检查过滤、连接、排序和返回行数。&lt;/li&gt;
&lt;li&gt;小范围修改 SQL、索引或统计信息。&lt;/li&gt;
&lt;li&gt;在相同数据规模下重新执行并比较。&lt;/li&gt;
&lt;li&gt;同时评估写入成本和磁盘占用。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/using-explain.html&quot;&gt;PostgreSQL 使用 EXPLAIN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/indexes-examine.html&quot;&gt;检查索引使用情况&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Linux 网络、磁盘与空间排查</title><link>https://zh19990906.github.io/fuwari/posts/linux-network-and-disk/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/linux-network-and-disk/</guid><description>使用 ip、ss、curl、df、du、lsblk 等工具定位网络与磁盘问题。</description><pubDate>Sun, 15 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;网络和磁盘问题经常表现为“应用超时”“写入失败”或“服务突然退出”。排查时应从本机状态开始，再逐层确认 DNS、路由、端口、文件系统和底层设备。&lt;/p&gt;
&lt;h2&gt;网络接口与路由&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ip address
ip link
ip route
ip route get 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ip route get&lt;/code&gt; 可以显示访问目标地址时系统会选择的接口、网关和源地址。&lt;/p&gt;
&lt;h2&gt;DNS 排查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;getent hosts example.com
resolvectl status
resolvectl query example.com
cat /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先区分“域名无法解析”和“目标地址无法连接”。直接使用 IP 成功但域名失败，通常应优先检查 DNS。&lt;/p&gt;
&lt;h2&gt;端口与连接&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ss -lntup
ss -ntp
ss -s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试 HTTP：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -I https://example.com
curl -v --connect-timeout 5 https://example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试 TCP 端口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nc -vz database.example.com 5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果系统没有 &lt;code&gt;nc&lt;/code&gt;，可以使用 Bash 的 &lt;code&gt;/dev/tcp&lt;/code&gt;，但可读性和兼容性不如专用工具。&lt;/p&gt;
&lt;h2&gt;防火墙&lt;/h2&gt;
&lt;p&gt;不同发行版可能使用 nftables、firewalld 或 ufw：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nft list ruleset
sudo firewall-cmd --list-all
sudo ufw status verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改防火墙前确认远程管理端口，避免把自己锁在服务器外。&lt;/p&gt;
&lt;h2&gt;文件系统空间&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;df -hT
df -i
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;磁盘容量充足但无法创建文件时，要检查 inode 是否耗尽。定位大目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo du -xhd1 /var | sort -h
sudo du -xhd1 /var/lib | sort -h
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-x&lt;/code&gt; 限制在同一个文件系统内，避免进入挂载的网络盘或其他分区。&lt;/p&gt;
&lt;h2&gt;查找大文件&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo find /var -xdev -type f -size +1G -printf &apos;%s %p\n&apos; | sort -n
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;文件被删除但空间没有释放时，可能仍被进程打开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo lsof +L1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;处理方式通常是让对应进程正常重载或重启，而不是直接操作 &lt;code&gt;/proc&lt;/code&gt; 中的文件描述符。&lt;/p&gt;
&lt;h2&gt;块设备与挂载&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;lsblk -f
findmnt
mount | column -t
blkid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看内核最近的存储错误：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dmesg -T | tail -n 100
journalctl -k -p warning --since today
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;挂载配置&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;/etc/fstab&lt;/code&gt; 修改错误可能导致启动失败。修改后先验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mount -a
findmnt --verify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对网络存储和可选磁盘可以考虑 &lt;code&gt;nofail&lt;/code&gt;、&lt;code&gt;x-systemd.automount&lt;/code&gt; 等选项，但应理解它们对启动和访问延迟的影响。&lt;/p&gt;
&lt;h2&gt;排查流程&lt;/h2&gt;
&lt;h3&gt;网络&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;ip address&lt;/code&gt;：接口是否有正确地址。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ip route&lt;/code&gt;：默认路由是否存在。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getent hosts&lt;/code&gt;：DNS 是否正常。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ss -lntup&lt;/code&gt;：本地服务是否监听。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;curl -v&lt;/code&gt; 或 &lt;code&gt;nc -vz&lt;/code&gt;：连接在哪一步失败。&lt;/li&gt;
&lt;li&gt;检查防火墙、云安全组、代理和容器网络。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;磁盘&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;df -hT&lt;/code&gt;：容量和文件系统类型。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;df -i&lt;/code&gt;：inode。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;du&lt;/code&gt;：空间主要消耗位置。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lsof +L1&lt;/code&gt;：已删除但仍占用的文件。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dmesg&lt;/code&gt;、&lt;code&gt;journalctl -k&lt;/code&gt;：设备或文件系统错误。&lt;/li&gt;
&lt;li&gt;确认备份后再执行清理或修复。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://wiki.linuxfoundation.org/networking/iproute2&quot;&gt;iproute2 文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.kernel.org/pub/linux/utils/util-linux/&quot;&gt;util-linux 手册&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Docker 数据持久化与常见故障排查</title><link>https://zh19990906.github.io/fuwari/posts/docker-storage-and-troubleshooting/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-storage-and-troubleshooting/</guid><description>区分卷与绑定挂载，并按日志、端口、网络、权限和资源排查容器故障。</description><pubDate>Sun, 18 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;容器可写层不适合保存重要数据。容器被删除或重建时，业务数据应仍然存在于卷、绑定挂载或外部存储中。&lt;/p&gt;
&lt;h2&gt;卷与绑定挂载&lt;/h2&gt;
&lt;p&gt;命名卷：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker volume create app-data
docker run --rm -v app-data:/data alpine sh -c &apos;date &amp;gt; /data/created-at&apos;
docker run --rm -v app-data:/data alpine cat /data/created-at
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;绑定挂载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm -v &quot;$PWD/config:/app/config:ro&quot; alpine ls -la /app/config
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;命名卷由 Docker 管理，适合数据库和应用持久化数据。&lt;/li&gt;
&lt;li&gt;绑定挂载直接映射宿主机路径，适合配置、源码和明确需要宿主机管理的文件。&lt;/li&gt;
&lt;li&gt;配置文件尽量使用只读挂载 &lt;code&gt;:ro&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;备份命名卷&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm \
  -v app-data:/source:ro \
  -v &quot;$PWD/backups:/backup&quot; \
  alpine tar -czf /backup/app-data.tar.gz -C /source .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恢复前先停止写入该卷的服务，并验证备份文件可读取。&lt;/p&gt;
&lt;h2&gt;容器反复退出&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker ps -a
docker logs --tail 200 container-name
docker inspect container-name --format &apos;{{.State.Status}} {{.State.ExitCode}} {{.State.Error}}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见退出码：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0&lt;/code&gt;：主进程正常结束，可能是启动命令本来就不是长期进程。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1&lt;/code&gt;：应用通用错误，应查看日志。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;126&lt;/code&gt;：命令存在但不可执行，常见于权限问题。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;127&lt;/code&gt;：命令不存在或 PATH 不正确。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;137&lt;/code&gt;：常见于被 &lt;code&gt;SIGKILL&lt;/code&gt; 终止，包括内存不足。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;检查 OOM：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker inspect container-name --format &apos;{{.State.OOMKilled}}&apos;
dmesg -T | grep -i -E &apos;out of memory|killed process&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;端口无法访问&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker port container-name
docker inspect container-name --format &apos;{{json .NetworkSettings.Ports}}&apos;
ss -lntp
curl -v http://127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认四件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;应用在容器内实际监听目标端口；&lt;/li&gt;
&lt;li&gt;应用监听 &lt;code&gt;0.0.0.0&lt;/code&gt;，而不是只监听容器内 &lt;code&gt;127.0.0.1&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-p&lt;/code&gt; 或 Compose 的端口映射正确；&lt;/li&gt;
&lt;li&gt;宿主机防火墙和云安全组允许访问。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;容器之间无法连接&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker network ls
docker network inspect network-name
docker exec app getent hosts db
docker exec app sh -c &apos;nc -vz db 5432&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一 Compose 项目的服务通常通过服务名互相解析。不要把其他容器地址写成 &lt;code&gt;localhost&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;权限问题&lt;/h2&gt;
&lt;p&gt;宿主机目录的 UID/GID 与容器内用户可能不同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec container-name id
ls -ln mounted-directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;优先让容器以明确 UID/GID 运行，并设置宿主机目录所有者。避免用 &lt;code&gt;chmod 777&lt;/code&gt; 解决数据库目录或密钥文件权限。&lt;/p&gt;
&lt;h2&gt;空间占用&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker system df -v
docker container ls -a
docker image ls
docker volume ls
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;清理前逐项确认：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker container prune
docker image prune
docker builder prune
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在不清楚卷用途时执行 &lt;code&gt;docker volume prune&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;标准排障流程&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;docker ps -a&lt;/code&gt; 确认状态和退出时间。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker logs&lt;/code&gt; 查看应用错误。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker inspect&lt;/code&gt; 检查命令、环境、挂载、网络和退出码。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker exec&lt;/code&gt; 在容器内验证进程、DNS、端口和文件权限。&lt;/li&gt;
&lt;li&gt;检查宿主机内存、磁盘、防火墙和内核日志。&lt;/li&gt;
&lt;li&gt;修复 Dockerfile 或 Compose 配置，不依赖手工修改运行中的容器。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/engine/storage/volumes/&quot;&gt;Docker volumes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/engine/network/&quot;&gt;Docker networking&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>PostgreSQL 角色、权限、备份与恢复</title><link>https://zh19990906.github.io/fuwari/posts/postgresql-roles-backup-restore/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/postgresql-roles-backup-restore/</guid><description>管理最小权限角色，并使用 pg_dump、pg_restore 和 pg_dumpall 完成可验证备份。</description><pubDate>Sun, 16 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;数据库备份只有在成功恢复后才算可靠。生产环境应明确备份范围、保留周期、加密方式、恢复目标时间以及谁可以读取备份。&lt;/p&gt;
&lt;h2&gt;角色与权限&lt;/h2&gt;
&lt;p&gt;创建只用于应用连接的角色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE ROLE app LOGIN PASSWORD &apos;replace-me&apos;;
CREATE DATABASE app OWNER app;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建只读角色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE ROLE app_readonly;
GRANT CONNECT ON DATABASE app TO app_readonly;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO app_readonly;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把登录用户加入角色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE ROLE analyst LOGIN PASSWORD &apos;replace-me&apos;;
GRANT app_readonly TO analyst;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ALTER DEFAULT PRIVILEGES&lt;/code&gt; 只影响之后由指定创建者建立的对象。多人创建对象时，要按实际拥有者配置默认权限。&lt;/p&gt;
&lt;h2&gt;逻辑备份&lt;/h2&gt;
&lt;p&gt;自定义格式适合配合 &lt;code&gt;pg_restore&lt;/code&gt; 选择对象和并行恢复：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dump \
  --format=custom \
  --file=app-$(date +%F).dump \
  --dbname=&apos;postgresql://backup@127.0.0.1/app&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;纯 SQL 格式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dump --format=plain --file=app.sql app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;备份全局角色和表空间定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dumpall --globals-only &amp;gt; globals.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑备份通常需要分别考虑数据库内容和全局对象。&lt;/p&gt;
&lt;h2&gt;恢复到新数据库&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;createdb app_restore
pg_restore \
  --dbname=app_restore \
  --clean \
  --if-exists \
  app-2026-07-30.dump
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恢复完成后检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;psql app_restore -c &apos;\dt+&apos;
psql app_restore -c &apos;select count(*) from important_table;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--clean&lt;/code&gt; 会删除将要恢复的对象，只应在明确的恢复目标库中使用。&lt;/p&gt;
&lt;h2&gt;并行备份和恢复&lt;/h2&gt;
&lt;p&gt;目录格式支持并行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dump --format=directory --jobs=4 --file=app-backup app
pg_restore --jobs=4 --dbname=app_restore app-backup
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并行度不是越高越好，应根据磁盘 I/O、CPU、锁和业务负载评估。&lt;/p&gt;
&lt;h2&gt;压缩与校验&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sha256sum app-2026-07-30.dump &amp;gt; app-2026-07-30.dump.sha256
sha256sum -c app-2026-07-30.dump.sha256
pg_restore --list app-2026-07-30.dump | head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将备份复制到与数据库主机故障域不同的位置，并对传输和静态存储进行加密。&lt;/p&gt;
&lt;h2&gt;定期恢复演练&lt;/h2&gt;
&lt;p&gt;至少验证以下内容：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;备份文件能够读取且校验通过；&lt;/li&gt;
&lt;li&gt;新建空数据库并成功恢复；&lt;/li&gt;
&lt;li&gt;关键表行数、约束和索引存在；&lt;/li&gt;
&lt;li&gt;应用可以连接恢复库并完成核心读写；&lt;/li&gt;
&lt;li&gt;记录实际恢复耗时；&lt;/li&gt;
&lt;li&gt;恢复文档不依赖某个个人记忆中的步骤。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;物理备份说明&lt;/h2&gt;
&lt;p&gt;大型数据库、时间点恢复和高可用场景通常需要 &lt;code&gt;pg_basebackup&lt;/code&gt;、WAL 归档或专业备份工具。逻辑备份方便迁移和选择对象，但不能替代所有灾难恢复需求。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/app-pgdump.html&quot;&gt;pg_dump&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/app-pgrestore.html&quot;&gt;pg_restore&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/user-manag.html&quot;&gt;数据库角色&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Docker Compose 多容器实操</title><link>https://zh19990906.github.io/fuwari/posts/docker-compose-practice/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-compose-practice/</guid><description>使用 Compose 管理应用、PostgreSQL、网络、健康检查和环境变量。</description><pubDate>Sun, 09 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Docker Compose 使用一个 YAML 文件描述服务、网络、卷和依赖关系，适合本地开发、小型部署以及可复现的集成测试环境。&lt;/p&gt;
&lt;h2&gt;示例结构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;example/
├── compose.yaml
├── .env
├── app/
│   └── Dockerfile
└── data/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;compose.yaml&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  app:
    build: ./app
    ports:
      - &quot;8080:8000&quot;
    environment:
      DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:18
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U app -d app&quot;]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  postgres-data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.env&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POSTGRES_PASSWORD=replace-with-a-strong-password
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; 不应提交到公共仓库。提交一份不含敏感值的 &lt;code&gt;.env.example&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;启动与停止&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose config
docker compose up -d --build
docker compose ps
docker compose logs -f
docker compose down
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;docker compose config&lt;/code&gt; 会展开变量并校验最终配置，适合在启动前检查缩进、引用和合并结果。&lt;/p&gt;
&lt;h2&gt;服务名就是默认网络中的主机名&lt;/h2&gt;
&lt;p&gt;应用应连接 &lt;code&gt;db:5432&lt;/code&gt;，而不是 &lt;code&gt;localhost:5432&lt;/code&gt;。容器内的 &lt;code&gt;localhost&lt;/code&gt; 指当前容器自身。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec app getent hosts db
docker compose exec db psql -U app -d app
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;更新单个服务&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose build app
docker compose up -d --no-deps app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拉取新镜像：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose pull
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;查看与执行命令&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose logs --tail 100 app
docker compose exec app sh
docker compose exec db pg_isready -U app -d app
docker compose top
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;exec&lt;/code&gt; 在已运行容器中执行命令；一次性任务可用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm app python -m pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;卷与数据&lt;/h2&gt;
&lt;p&gt;普通停止不会删除命名卷：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;删除命名卷：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose down --volumes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
&lt;code&gt;--volumes&lt;/code&gt; 会永久删除 Compose 管理的数据库数据。执行前确认备份和卷名称。
:::&lt;/p&gt;
&lt;h2&gt;健康检查与依赖&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;depends_on&lt;/code&gt; 的健康条件能减少启动竞争，但不能替代应用自身的重试机制。数据库可能在运行中重启或短暂不可用，应用仍应设置连接超时和有限重试。&lt;/p&gt;
&lt;h2&gt;多环境配置&lt;/h2&gt;
&lt;p&gt;可以使用多个文件覆盖配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;基础文件保存通用配置，覆盖文件只描述环境差异，避免复制整份服务定义。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/compose/gettingstarted/&quot;&gt;Docker Compose Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/reference/compose-file/&quot;&gt;Compose file reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Python 调试、日志与常见异常排查</title><link>https://zh19990906.github.io/fuwari/posts/python-debugging-and-logging/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-debugging-and-logging/</guid><description>使用 traceback、断点、logging 和最小复现定位 Python 程序问题。</description><pubDate>Sun, 12 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;调试的核心不是不断添加 &lt;code&gt;print()&lt;/code&gt;，而是缩小问题范围、保留上下文，并让错误能够稳定复现。&lt;/p&gt;
&lt;h2&gt;先读完整 traceback&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Traceback (most recent call last):
  File &quot;main.py&quot;, line 12, in &amp;lt;module&amp;gt;
    load_config()
  File &quot;main.py&quot;, line 8, in load_config
    return data[&quot;database&quot;]
KeyError: &apos;database&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从最后一行确认异常类型，再向上追踪调用链。不要只复制最后一句错误，因为真正的触发位置通常在前面的业务代码中。&lt;/p&gt;
&lt;h2&gt;使用断点&lt;/h2&gt;
&lt;p&gt;Python 内置调试器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def calculate_total(prices: list[float]) -&amp;gt; float:
    breakpoint()
    return sum(prices)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行后常用命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;n        下一行
s        进入函数
c        继续运行
p value  打印表达式
q        退出
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以使用编辑器的图形化断点，但仍要理解变量、调用栈和执行路径。&lt;/p&gt;
&lt;h2&gt;使用 logging&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import logging

logging.basicConfig(
    level=logging.INFO,
    format=&quot;%(asctime)s %(levelname)s %(name)s %(message)s&quot;,
)
logger = logging.getLogger(__name__)


def load_user(user_id: int) -&amp;gt; None:
    logger.info(&quot;loading user&quot;, extra={&quot;user_id&quot;: user_id})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记录异常时保留堆栈：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try:
    result = 10 / 0
except ZeroDivisionError:
    logger.exception(&quot;calculation failed&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在日志中输出密码、访问令牌、完整身份证号或数据库连接串。&lt;/p&gt;
&lt;h2&gt;区分日志级别&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DEBUG&lt;/code&gt;：开发期细节，例如参数和分支。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;INFO&lt;/code&gt;：正常业务节点，例如任务开始和完成。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WARNING&lt;/code&gt;：可恢复异常或即将失效的配置。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ERROR&lt;/code&gt;：当前操作失败，但进程仍可继续。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CRITICAL&lt;/code&gt;：系统无法继续提供核心服务。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;制作最小复现&lt;/h2&gt;
&lt;p&gt;遇到复杂问题时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;固定输入数据；&lt;/li&gt;
&lt;li&gt;去掉网络、数据库等无关依赖；&lt;/li&gt;
&lt;li&gt;删除不影响错误的代码；&lt;/li&gt;
&lt;li&gt;保留能够稳定触发错误的最短脚本；&lt;/li&gt;
&lt;li&gt;记录 Python 与依赖版本。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;python --version
python -m pip freeze
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;常见异常方向&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;ModuleNotFoundError&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;确认解释器和包安装位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -c &quot;import sys; print(sys.executable)&quot;
python -m pip show package-name
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;code&gt;PermissionError&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;检查文件路径、父目录权限和当前用户，不要第一时间使用管理员权限运行整个程序。&lt;/p&gt;
&lt;h3&gt;编码错误&lt;/h3&gt;
&lt;p&gt;明确指定 UTF-8：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pathlib import Path

text = Path(&quot;data.txt&quot;).read_text(encoding=&quot;utf-8&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;程序卡住&lt;/h3&gt;
&lt;p&gt;检查是否等待网络、锁、子进程或标准输入。为外部请求设置合理超时，并在关键阶段记录日志。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/library/pdb.html&quot;&gt;Python pdb 调试器&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/library/logging.html&quot;&gt;Python logging&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>PostgreSQL 安装、初始化与基础部署</title><link>https://zh19990906.github.io/fuwari/posts/postgresql-install-and-deploy/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/postgresql-install-and-deploy/</guid><description>完成 PostgreSQL 服务安装、角色数据库创建、网络访问和部署检查。</description><pubDate>Sun, 22 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;PostgreSQL 部署的重点不只是“服务能启动”，还包括数据目录、监听地址、认证规则、备份位置和运行账户都可控。本文以现代受支持版本为主，命令需根据发行版调整。&lt;/p&gt;
&lt;h2&gt;安装后检查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;psql --version
systemctl status postgresql
pg_isready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部分发行版会同时安装多个 PostgreSQL 版本，服务名和数据目录可能包含版本号。先确认实际运行实例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ps aux | grep &apos;[p]ostgres&apos;
sudo -u postgres psql -c &apos;select version();&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;创建角色和数据库&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo -u postgres psql
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;CREATE ROLE app LOGIN PASSWORD &apos;replace-with-a-strong-password&apos;;
CREATE DATABASE app OWNER app;
REVOKE ALL ON DATABASE app FROM PUBLIC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;psql &apos;postgresql://app@127.0.0.1:5432/app&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;密码不应直接写在 Shell 历史、代码仓库或镜像中。使用秘密管理服务、受限环境变量或权限严格的凭据文件。&lt;/p&gt;
&lt;h2&gt;监听地址&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;postgresql.conf&lt;/code&gt; 中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;listen_addresses = &apos;localhost&apos;
port = 5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只供本机应用使用时保持本地监听。需要远程访问时，应绑定明确的内部地址，并结合防火墙和 &lt;code&gt;pg_hba.conf&lt;/code&gt; 限制来源。&lt;/p&gt;
&lt;p&gt;查看配置文件位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SHOW config_file;
SHOW hba_file;
SHOW data_directory;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;客户端认证&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pg_hba.conf&lt;/code&gt; 示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# TYPE  DATABASE  USER  ADDRESS          METHOD
host    app       app   10.20.0.0/16     scram-sha-256
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改后重新加载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl reload postgresql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo -u postgres psql -c &apos;select pg_reload_conf();&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
不要使用 &lt;code&gt;trust&lt;/code&gt; 暴露远程连接，也不要用 &lt;code&gt;0.0.0.0/0&lt;/code&gt; 配合弱密码开放数据库端口。
:::&lt;/p&gt;
&lt;h2&gt;Docker Compose 示例&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U app -d app&quot;]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  postgres-data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据库容器的端口不必默认映射到公网。与应用位于同一 Compose 网络时，应用可以直接连接 &lt;code&gt;db:5432&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;基础安全检查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT current_user, current_database();
SHOW password_encryption;
SHOW listen_addresses;
SELECT rolname, rolsuper, rolcanlogin FROM pg_roles ORDER BY rolname;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用角色通常不应拥有超级用户、创建角色或创建数据库权限。&lt;/p&gt;
&lt;h2&gt;上线前检查&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;数据目录位于持久化存储，并监控剩余空间。&lt;/li&gt;
&lt;li&gt;时区和系统时间同步正常。&lt;/li&gt;
&lt;li&gt;已验证备份与恢复流程。&lt;/li&gt;
&lt;li&gt;数据库端口只对需要的网络开放。&lt;/li&gt;
&lt;li&gt;应用连接池设置上限，避免耗尽连接。&lt;/li&gt;
&lt;li&gt;日志能够记录启动、认证失败和慢查询线索。&lt;/li&gt;
&lt;li&gt;升级前有兼容性测试和回滚计划。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/&quot;&gt;PostgreSQL 当前版本文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/client-authentication.html&quot;&gt;PostgreSQL 客户端认证&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>YOLO11 生产基线与迁移实践</title><link>https://zh19990906.github.io/fuwari/posts/yolo11-practice/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolo11-practice/</guid><description>使用 YOLO11 建立检测基线，并从 YOLOv8 迁移时验证精度、延迟和导出兼容性。</description><pubDate>Tue, 10 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;YOLO11 于 2024 年 9 月 10 日发布，延续了 Ultralytics 的统一 API，同时优化了模型效率。对于已经使用 YOLOv8 的团队，迁移通常不难，但不能只替换权重文件后直接上线。&lt;/p&gt;
&lt;h2&gt;环境与模型&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade ultralytics
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo predict model=yolo11n.pt source=images/ imgsz=640 conf=0.25
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ultralytics import YOLO

model = YOLO(&quot;yolo11n.pt&quot;)
results = model(&quot;images/example.jpg&quot;, imgsz=640, conf=0.25)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;训练基线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo detect train \
  model=yolo11n.pt \
  data=data.yaml \
  epochs=100 \
  imgsz=640 \
  batch=16 \
  device=0 \
  project=runs/yolo11 \
  name=baseline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先保持数据、图像尺寸、训练轮数、设备和评估脚本与旧模型一致，建立公平比较。&lt;/p&gt;
&lt;h2&gt;从 YOLOv8 迁移&lt;/h2&gt;
&lt;p&gt;至少比较：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同一验证集上的每类 AP、召回率和混淆矩阵；&lt;/li&gt;
&lt;li&gt;真实业务图片和视频中的误检、漏检；&lt;/li&gt;
&lt;li&gt;PyTorch、ONNX、TensorRT 等实际后端延迟；&lt;/li&gt;
&lt;li&gt;CPU、GPU、边缘设备上的内存峰值；&lt;/li&gt;
&lt;li&gt;批量大小为 1 和真实并发下的吞吐；&lt;/li&gt;
&lt;li&gt;预处理、输出张量、NMS 与阈值行为。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要使用官方 COCO 指标替代自己的业务验证。&lt;/p&gt;
&lt;h2&gt;模型尺寸&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n&lt;/code&gt;：最轻，适合快速基线与资源受限设备。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt;：常见的速度和精度折中。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;m&lt;/code&gt;：需要更高精度且有足够算力。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;l&lt;/code&gt;、&lt;code&gt;x&lt;/code&gt;：更大模型，需评估显存、延迟和部署成本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;模型越大不一定业务效果越好。小数据集、标注噪声或域偏移可能成为主要瓶颈。&lt;/p&gt;
&lt;h2&gt;验证与错误分析&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo detect val \
  model=runs/yolo11/baseline/weights/best.pt \
  data=data.yaml \
  imgsz=640 \
  plots=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把错误按场景分组：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小目标和密集目标；&lt;/li&gt;
&lt;li&gt;遮挡、模糊和低照度；&lt;/li&gt;
&lt;li&gt;新设备或新摄像头；&lt;/li&gt;
&lt;li&gt;背景相似导致误检；&lt;/li&gt;
&lt;li&gt;类别定义重叠或标注不一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;错误分析通常比盲目增加 epoch 更能改善模型。&lt;/p&gt;
&lt;h2&gt;导出与回归&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo export model=best.pt format=onnx imgsz=640 dynamic=True simplify=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;导出后建立回归样本集，比较：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检测数量；&lt;/li&gt;
&lt;li&gt;类别 ID；&lt;/li&gt;
&lt;li&gt;置信度偏差；&lt;/li&gt;
&lt;li&gt;坐标偏差；&lt;/li&gt;
&lt;li&gt;端到端延迟。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;何时继续使用 YOLO11&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;已经通过完整生产验证；&lt;/li&gt;
&lt;li&gt;现有硬件和导出后端稳定；&lt;/li&gt;
&lt;li&gt;YOLO26 的收益尚未覆盖迁移成本；&lt;/li&gt;
&lt;li&gt;项目需要成熟而不是追逐最新版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolo11/&quot;&gt;YOLO11 官方文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/modes/val/&quot;&gt;Ultralytics 验证模式&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Linux 进程、systemd 服务与日志排查</title><link>https://zh19990906.github.io/fuwari/posts/linux-process-services-logs/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/linux-process-services-logs/</guid><description>从进程状态、systemd 单元到 journal 日志建立标准排障流程。</description><pubDate>Sun, 11 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;服务不可用时，不要直接反复重启。先确认进程是否存在、端口是否监听、systemd 为什么判定失败，以及应用在退出前记录了什么。&lt;/p&gt;
&lt;h2&gt;查看进程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ps aux
ps -eo pid,ppid,user,stat,%cpu,%mem,etime,cmd --sort=-%cpu | head
pgrep -af nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见进程状态：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;R&lt;/code&gt;：正在运行或等待 CPU。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;S&lt;/code&gt;：可中断睡眠，通常在等待事件。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;D&lt;/code&gt;：不可中断睡眠，常见于磁盘或网络存储等待。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Z&lt;/code&gt;：僵尸进程，子进程已退出但父进程未回收。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;查看资源&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;top
free -h
vmstat 1
iostat -xz 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;top&lt;/code&gt; 适合快速观察，&lt;code&gt;vmstat&lt;/code&gt; 可以区分 CPU、内存和 I/O 压力。&lt;code&gt;iostat&lt;/code&gt; 通常由 &lt;code&gt;sysstat&lt;/code&gt; 软件包提供。&lt;/p&gt;
&lt;h2&gt;systemd 常用命令&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;systemctl status nginx
sudo systemctl start nginx
sudo systemctl stop nginx
sudo systemctl restart nginx
sudo systemctl reload nginx
sudo systemctl enable nginx
systemctl is-enabled nginx
systemctl is-active nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置支持热加载时优先使用 &lt;code&gt;reload&lt;/code&gt;，可以减少连接中断。修改单元文件后运行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload
sudo systemctl restart myapp
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;查看单元配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;systemctl cat myapp
systemctl show myapp
systemctl list-dependencies myapp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要直接修改软件包提供的 &lt;code&gt;/usr/lib/systemd/system/*.service&lt;/code&gt;。使用覆盖配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl edit myapp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如设置环境变量和重启策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Service]
Environment=&quot;APP_ENV=production&quot;
Restart=on-failure
RestartSec=5s
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;journalctl&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;journalctl -u myapp
journalctl -u myapp -n 100 --no-pager
journalctl -u myapp -f
journalctl -u myapp --since &apos;2026-07-30 08:00&apos;
journalctl -p err..alert --since today
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看本次启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;journalctl -b
journalctl -u myapp -b
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看上一次启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;journalctl -b -1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;端口与连接&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ss -lntup
ss -lntp &apos;sport = :8080&apos;
curl -v http://127.0.0.1:8080/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;进程存在但服务不可访问时，确认它监听的是 &lt;code&gt;127.0.0.1&lt;/code&gt;、&lt;code&gt;0.0.0.0&lt;/code&gt; 还是 IPv6 地址，并检查防火墙、反向代理和容器端口映射。&lt;/p&gt;
&lt;h2&gt;信号与停止进程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;kill -TERM PID
kill -HUP PID
kill -KILL PID
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;TERM&lt;/code&gt; 请求正常退出，&lt;code&gt;HUP&lt;/code&gt; 常被服务用于重新加载配置，&lt;code&gt;KILL&lt;/code&gt; 无法被捕获，会跳过清理逻辑。优先使用服务管理器停止服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl stop myapp
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;标准排障顺序&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;systemctl status myapp&lt;/code&gt; 查看摘要和退出码。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;journalctl -u myapp -n 200&lt;/code&gt; 查看完整错误。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;systemctl cat myapp&lt;/code&gt; 检查启动命令、用户和环境。&lt;/li&gt;
&lt;li&gt;以服务账户手动验证配置或启动命令。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;ss&lt;/code&gt; 确认端口。&lt;/li&gt;
&lt;li&gt;检查磁盘、内存、权限和依赖服务。&lt;/li&gt;
&lt;li&gt;修复根因后再重启并观察日志。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.freedesktop.org/software/systemd/man/latest/systemctl.html&quot;&gt;systemctl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.freedesktop.org/software/systemd/man/latest/journalctl.html&quot;&gt;journalctl&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Dockerfile、镜像构建与容器运行</title><link>https://zh19990906.github.io/fuwari/posts/docker-images-and-containers/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-images-and-containers/</guid><description>编写可缓存、体积合理且安全的 Dockerfile，并掌握容器运行参数。</description><pubDate>Sun, 14 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Dockerfile 是镜像的构建说明。好的镜像应当可复现、包含最少依赖、默认以非 root 用户运行，并将配置与数据留在镜像之外。&lt;/p&gt;
&lt;h2&gt;一个 Python 示例&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd --create-home appuser &amp;amp;&amp;amp; chown -R appuser:appuser /app
USER appuser

CMD [&quot;python&quot;, &quot;main.py&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker build -t example-app:1.0 .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm --name example-app example-app:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;构建缓存&lt;/h2&gt;
&lt;p&gt;Docker 通常按层复用缓存。先复制依赖清单并安装，再复制经常变化的源码，可以减少重复安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;COPY requirements.txt .
RUN python -m pip install -r requirements.txt
COPY . .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用 &lt;code&gt;.dockerignore&lt;/code&gt; 排除无关内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.git
.venv
__pycache__
*.pyc
.env
node_modules
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要把密钥写进 Dockerfile、构建参数或镜像层。即使之后删除文件，它仍可能存在于历史层中。&lt;/p&gt;
&lt;h2&gt;标签与摘要&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker image ls
docker image inspect example-app:1.0
docker image history example-app:1.0
docker tag example-app:1.0 registry.example.com/team/example-app:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产部署优先使用不可变版本标签或镜像摘要，不要只依赖会变化的 &lt;code&gt;latest&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;运行参数&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker run -d \
  --name example-app \
  --restart unless-stopped \
  -p 8080:8000 \
  --env-file .env \
  --memory 512m \
  --cpus 1.0 \
  example-app:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker logs -f example-app
docker stats example-app
docker inspect example-app
docker exec -it example-app sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;多阶段构建&lt;/h2&gt;
&lt;p&gt;编译型应用可以把构建工具留在构建阶段，只复制运行产物：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;健康检查&lt;/h2&gt;
&lt;p&gt;健康检查用于判断服务是否真正可用，而不只是进程仍然存在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8000/health || exit 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;健康检查命令必须存在于镜像内，并且不应执行昂贵操作。&lt;/p&gt;
&lt;h2&gt;镜像优化原则&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;使用明确版本的基础镜像。&lt;/li&gt;
&lt;li&gt;合并相关安装步骤并清理包管理器缓存。&lt;/li&gt;
&lt;li&gt;不安装调试工具到最小生产镜像。&lt;/li&gt;
&lt;li&gt;使用非 root 用户。&lt;/li&gt;
&lt;li&gt;为依赖和系统包进行漏洞扫描。&lt;/li&gt;
&lt;li&gt;构建和运行阶段分离。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/reference/dockerfile/&quot;&gt;Dockerfile reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/building/best-practices/&quot;&gt;Build best practices&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Docker 核心概念与安装检查</title><link>https://zh19990906.github.io/fuwari/posts/docker-concepts-and-install/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-concepts-and-install/</guid><description>理解镜像、容器、仓库、网络和卷，并完成 Docker 环境检查。</description><pubDate>Sun, 02 Jun 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Docker 将应用及其运行依赖打包为镜像，再从镜像创建隔离的容器进程。容器不是虚拟机：它通常共享宿主机内核，但拥有独立的文件系统视图、网络和进程空间。&lt;/p&gt;
&lt;h2&gt;核心对象&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;镜像（image）&lt;/strong&gt;：只读模板，由多层文件系统组成。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;容器（container）&lt;/strong&gt;：镜像的运行实例，拥有可写层和运行状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;仓库（registry）&lt;/strong&gt;：保存与分发镜像，例如 Docker Hub 或私有仓库。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;卷（volume）&lt;/strong&gt;：由 Docker 管理的持久化数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网络（network）&lt;/strong&gt;：容器之间以及容器与外部通信的连接方式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compose&lt;/strong&gt;：用一个 YAML 文件描述多容器应用。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;安装后检查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker version
docker info
docker compose version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行测试容器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm hello-world
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看当前对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker ps
docker images
docker volume ls
docker network ls
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Linux 权限说明&lt;/h2&gt;
&lt;p&gt;Docker 守护进程通常拥有较高系统权限。把用户加入 &lt;code&gt;docker&lt;/code&gt; 组，基本等同于授予其控制宿主机 Docker 的高权限能力：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo usermod -aG docker &quot;$USER&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重新登录后生效。生产服务器应限制 Docker Socket 的访问，不要将 &lt;code&gt;/var/run/docker.sock&lt;/code&gt; 随意挂载给不可信容器。&lt;/p&gt;
&lt;h2&gt;第一个容器&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker run --name web-demo -d -p 8080:80 nginx:alpine
curl http://127.0.0.1:8080
docker logs web-demo
docker stop web-demo
docker rm web-demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-p 8080:80&lt;/code&gt; 表示把宿主机 8080 端口映射到容器 80 端口。容器内监听地址也必须允许外部连接。&lt;/p&gt;
&lt;h2&gt;查看容器细节&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker inspect web-demo
docker stats
docker top web-demo
docker exec -it web-demo sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;容器镜像不一定包含 Bash，应根据镜像使用 &lt;code&gt;sh&lt;/code&gt; 或其他 Shell。&lt;/p&gt;
&lt;h2&gt;生命周期原则&lt;/h2&gt;
&lt;p&gt;容器应以前台主进程为生命周期。不要依赖容器内手工启动后台服务，也不要把临时修改留在容器可写层中。可重复的修改应该进入 Dockerfile，数据应该进入卷或外部存储。&lt;/p&gt;
&lt;h2&gt;常用清理&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker container prune
docker image prune
docker system df
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
&lt;code&gt;docker system prune&lt;/code&gt; 可能删除未使用的镜像、容器和网络；增加 &lt;code&gt;--volumes&lt;/code&gt; 还会删除未使用卷。执行前必须确认数据是否已经备份。
:::&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/get-started/&quot;&gt;Docker Get Started&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/reference/cli/docker/&quot;&gt;Docker CLI Reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Python 虚拟环境与依赖管理</title><link>https://zh19990906.github.io/fuwari/posts/python-packages-and-venv/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-packages-and-venv/</guid><description>理解 venv、pip、requirements.txt 和 pyproject.toml 的职责，减少环境冲突。</description><pubDate>Sun, 05 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一个 Python 项目应该拥有独立环境。把依赖直接安装进系统 Python，容易造成版本冲突，也不利于部署和复现。&lt;/p&gt;
&lt;h2&gt;创建与激活环境&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m venv .venv

# Linux / macOS
source .venv/bin/activate

# Windows PowerShell
.venv\Scripts\Activate.ps1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -c &quot;import sys; print(sys.executable)&quot;
python -m pip --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;退出环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;deactivate
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;pip 的常用操作&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install requests
python -m pip install &quot;django&amp;gt;=5,&amp;lt;6&quot;
python -m pip uninstall requests
python -m pip list
python -m pip show requests
python -m pip check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pip check&lt;/code&gt; 可以检查已安装包之间是否存在依赖冲突。&lt;/p&gt;
&lt;h2&gt;requirements.txt&lt;/h2&gt;
&lt;p&gt;记录当前环境完整版本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m pip freeze &amp;gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恢复环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应用项目来说，完整锁定版本有利于复现。对库项目来说，不宜把所有间接依赖都写死，应在项目元数据中声明合理的版本范围。&lt;/p&gt;
&lt;h2&gt;pyproject.toml&lt;/h2&gt;
&lt;p&gt;现代 Python 项目通常使用 &lt;code&gt;pyproject.toml&lt;/code&gt; 描述项目和直接依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[build-system]
requires = [&quot;setuptools&amp;gt;=75&quot;]
build-backend = &quot;setuptools.build_meta&quot;

[project]
name = &quot;example-app&quot;
version = &quot;0.1.0&quot;
requires-python = &quot;&amp;gt;=3.12&quot;
dependencies = [
  &quot;requests&amp;gt;=2.32,&amp;lt;3&quot;,
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;开发模式安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install -e .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样修改源码后不需要反复重新安装。&lt;/p&gt;
&lt;h2&gt;依赖升级策略&lt;/h2&gt;
&lt;p&gt;不要一次性无条件升级所有包。更稳妥的流程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;查看过期包；&lt;/li&gt;
&lt;li&gt;阅读目标版本变更说明；&lt;/li&gt;
&lt;li&gt;单独升级一个直接依赖；&lt;/li&gt;
&lt;li&gt;运行测试；&lt;/li&gt;
&lt;li&gt;更新依赖快照。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;python -m pip list --outdated
python -m pip install --upgrade requests
python -m pip check
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;缓存与镜像排查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m pip cache dir
python -m pip cache purge
python -m pip config list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;网络异常时先检查代理、证书和 pip 配置，不要随意关闭 TLS 校验。&lt;/p&gt;
&lt;h2&gt;不要提交虚拟环境&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;.gitignore&lt;/code&gt; 中加入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.venv/
__pycache__/
*.py[cod]
.pytest_cache/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虚拟环境是可生成产物，项目仓库应保存依赖声明，而不是保存环境目录。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/tutorial/venv.html&quot;&gt;Python 虚拟环境与包&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://packaging.python.org/&quot;&gt;Python Packaging User Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Python 常用语法与代码组织</title><link>https://zh19990906.github.io/fuwari/posts/python-language-basics/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-language-basics/</guid><description>掌握变量、容器、函数、异常、类型提示和模块组织等日常 Python 基础。</description><pubDate>Sun, 10 Mar 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这篇文档只覆盖日常开发中最常用的 Python 写法，重点是写出清晰、可维护的代码。&lt;/p&gt;
&lt;h2&gt;基本数据结构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;name = &quot;alice&quot;
age = 20
scores = [88, 91, 95]
profile = {&quot;name&quot;: name, &quot;age&quot;: age}
unique_tags = {&quot;python&quot;, &quot;backend&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;列表适合有顺序的数据，字典适合键值映射，集合适合去重和成员判断，元组适合表达不应随意修改的固定组合。&lt;/p&gt;
&lt;h2&gt;条件与循环&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;for score in scores:
    if score &amp;gt;= 90:
        print(&quot;优秀&quot;, score)
    elif score &amp;gt;= 60:
        print(&quot;合格&quot;, score)
    else:
        print(&quot;需要改进&quot;, score)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要索引时使用 &lt;code&gt;enumerate()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for index, score in enumerate(scores, start=1):
    print(index, score)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时遍历两组数据时使用 &lt;code&gt;zip()&lt;/code&gt;，不要手工维护多个下标。&lt;/p&gt;
&lt;h2&gt;函数与类型提示&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def average(values: list[float]) -&amp;gt; float:
    if not values:
        raise ValueError(&quot;values 不能为空&quot;)
    return sum(values) / len(values)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;类型提示不会自动限制运行时数据，但能帮助编辑器、静态检查工具和读代码的人理解接口。&lt;/p&gt;
&lt;p&gt;参数较多时优先使用关键字参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def connect(host: str, port: int = 5432, *, timeout: float = 5.0) -&amp;gt; None:
    print(host, port, timeout)


connect(&quot;127.0.0.1&quot;, timeout=2.5)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;*&lt;/code&gt; 后的参数必须显式写参数名，可以减少调用顺序错误。&lt;/p&gt;
&lt;h2&gt;推导式&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;passed = [score for score in scores if score &amp;gt;= 60]
score_map = {index: score for index, score in enumerate(scores, start=1)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推导式适合一层简单转换。逻辑过长、包含多个分支时，普通循环通常更清晰。&lt;/p&gt;
&lt;h2&gt;异常处理&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from pathlib import Path


def read_config(path: Path) -&amp;gt; str:
    try:
        return path.read_text(encoding=&quot;utf-8&quot;)
    except FileNotFoundError as error:
        raise RuntimeError(f&quot;配置文件不存在：{path}&quot;) from error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只捕获你能处理的异常。避免使用裸 &lt;code&gt;except:&lt;/code&gt;，它会把程序退出、键盘中断等信号也吞掉。&lt;/p&gt;
&lt;h2&gt;上下文管理器&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from pathlib import Path

with Path(&quot;data.txt&quot;).open(&quot;w&quot;, encoding=&quot;utf-8&quot;) as file:
    file.write(&quot;hello\n&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;with&lt;/code&gt; 能保证文件、锁和连接在异常情况下也被正确释放。&lt;/p&gt;
&lt;h2&gt;模块组织&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;my_project/
├── pyproject.toml
├── src/
│   └── my_app/
│       ├── __init__.py
│       ├── config.py
│       └── main.py
└── tests/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;避免把所有逻辑写进一个脚本。按职责拆分模块，并把程序入口放在 &lt;code&gt;main()&lt;/code&gt; 中。&lt;/p&gt;
&lt;h2&gt;实用原则&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;变量名表达含义，不使用无意义缩写。&lt;/li&gt;
&lt;li&gt;函数尽量只承担一个职责。&lt;/li&gt;
&lt;li&gt;优先返回结果，不在底层函数里到处打印。&lt;/li&gt;
&lt;li&gt;对外接口写类型提示和简短文档字符串。&lt;/li&gt;
&lt;li&gt;先写可读代码，再考虑微小性能优化。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/tutorial/&quot;&gt;Python 官方教程&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/library/typing.html&quot;&gt;Python 类型提示&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Python 环境搭建与版本检查</title><link>https://zh19990906.github.io/fuwari/posts/python-environment/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-environment/</guid><description>从解释器、pip 到第一个虚拟环境，建立可复现的 Python 开发环境。</description><pubDate>Sun, 18 Feb 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Python 项目最常见的问题通常不是语法，而是“命令调用了哪个解释器”“包装到了哪个环境”。因此，开始写代码前先把解释器、包管理器和虚拟环境理清楚。&lt;/p&gt;
&lt;h2&gt;检查解释器&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python --version
python -c &quot;import sys; print(sys.executable)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在部分 Linux 发行版中命令名是 &lt;code&gt;python3&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3 --version
python3 -c &quot;import sys; print(sys.executable)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要只看版本号，还要看可执行文件路径。系统 Python、Homebrew Python、pyenv 与虚拟环境可能同时存在。&lt;/p&gt;
&lt;h2&gt;创建项目目录&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;mkdir hello-python
cd hello-python
python -m venv .venv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;激活环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Linux / macOS
source .venv/bin/activate

# Windows PowerShell
.venv\Scripts\Activate.ps1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;激活后再次检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -c &quot;import sys; print(sys.prefix)&quot;
python -m pip --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推荐使用 &lt;code&gt;python -m pip&lt;/code&gt;，这样可以明确由当前解释器运行 pip，减少“pip 和 python 指向不同环境”的问题。&lt;/p&gt;
&lt;h2&gt;安装与记录依赖&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m pip install --upgrade pip
python -m pip install requests
python -m pip freeze &amp;gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在另一台机器恢复：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pip freeze&lt;/code&gt; 适合记录当前环境快照。正式项目也可以使用 &lt;code&gt;pyproject.toml&lt;/code&gt; 管理直接依赖，把锁定版本交给专门的依赖工具处理。&lt;/p&gt;
&lt;h2&gt;第一个脚本&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;main.py&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pathlib import Path


def main() -&amp;gt; None:
    project_dir = Path.cwd()
    print(f&quot;Python 环境正常，当前目录：{project_dir}&quot;)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;常见排查&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;which python       # Linux / macOS
where python       # Windows
python -m site
python -m pip list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;遇到导入失败时，先确认当前解释器路径，再确认包是否安装在这个解释器对应的环境中。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/tutorial/venv.html&quot;&gt;Python 虚拟环境与包&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/zh-cn/3/installing/index.html&quot;&gt;Python 模块安装指南&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Linux 文件系统、用户与权限</title><link>https://zh19990906.github.io/fuwari/posts/linux-files-permissions/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/linux-files-permissions/</guid><description>理解目录结构、所有者、权限位、sudo 和安全的文件操作方式。</description><pubDate>Sun, 05 Nov 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Linux 权限问题通常来自三个方面：文件属于谁、当前进程以谁的身份运行、路径上的每一级目录是否允许访问。&lt;/p&gt;
&lt;h2&gt;常见目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/etc&lt;/code&gt;：系统和服务配置。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/var&lt;/code&gt;：日志、缓存、数据库等可变数据。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/home&lt;/code&gt;：普通用户主目录。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/root&lt;/code&gt;：root 用户主目录。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/tmp&lt;/code&gt;：临时文件，可能在重启后清理。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/usr&lt;/code&gt;：系统安装的软件和共享资源。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/opt&lt;/code&gt;：第三方或自包含应用。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/srv&lt;/code&gt;：服务提供的数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要把应用数据随意散落在系统目录中。部署时明确配置、程序、日志和持久化数据的位置。&lt;/p&gt;
&lt;h2&gt;查看权限&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ls -ld /path/to/file
stat /path/to/file
namei -l /path/to/file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-rw-r----- 1 app app 1280 Jul 30 09:00 config.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一组是所有者权限，第二组是所属组权限，第三组是其他用户权限。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;r&lt;/code&gt;：读取。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;w&lt;/code&gt;：写入。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x&lt;/code&gt;：执行；对目录来说表示可以进入和访问其中条目。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;修改权限&lt;/h2&gt;
&lt;p&gt;符号写法更容易读：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chmod u+x deploy.sh
chmod g+r config.yaml
chmod o-rwx secret.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数字写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chmod 640 config.yaml
chmod 750 deploy.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应关系：读取 &lt;code&gt;4&lt;/code&gt;、写入 &lt;code&gt;2&lt;/code&gt;、执行 &lt;code&gt;1&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;修改所有者&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo chown app:app /srv/myapp/config.yaml
sudo chown -R app:app /srv/myapp/data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;递归修改前先确认路径。对系统根目录或不确定变量使用 &lt;code&gt;-R&lt;/code&gt; 风险很高。&lt;/p&gt;
&lt;h2&gt;用户与组&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;id
id app
getent passwd app
getent group docker
sudo usermod -aG docker app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;新增组成员后，用户通常需要重新登录才能获得新的组列表。&lt;/p&gt;
&lt;h2&gt;sudo 的正确使用&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo -l
sudo systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;尽量只让需要权限的命令通过 &lt;code&gt;sudo&lt;/code&gt; 执行，不要长期使用 root Shell。编辑受保护文件时可使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudoedit /etc/nginx/nginx.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;默认权限与 umask&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;umask
umask -S
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;umask&lt;/code&gt; 会从程序请求的默认权限中屏蔽位。服务创建的文件权限异常时，应同时检查服务账户、启动脚本和 systemd 中的 &lt;code&gt;UMask=&lt;/code&gt; 设置。&lt;/p&gt;
&lt;h2&gt;特殊权限&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;setuid：程序以文件所有者身份执行。&lt;/li&gt;
&lt;li&gt;setgid：目录中的新文件继承目录所属组。&lt;/li&gt;
&lt;li&gt;sticky bit：共享目录中用户只能删除自己拥有的文件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;共享协作目录常见配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown -R root:developers /srv/project
sudo chmod 2775 /srv/project
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;权限排查顺序&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;使用 &lt;code&gt;id&lt;/code&gt; 确认当前身份和组。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;namei -l&lt;/code&gt; 检查完整路径。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;ls -l&lt;/code&gt;、&lt;code&gt;stat&lt;/code&gt; 检查目标文件。&lt;/li&gt;
&lt;li&gt;确认进程实际运行账户。&lt;/li&gt;
&lt;li&gt;再判断是否需要修改权限或所有者。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要用 &lt;code&gt;chmod 777&lt;/code&gt; 掩盖根因。它会让所有用户可写，可能引入配置篡改和代码执行风险。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.gnu.org/software/coreutils/manual/html_node/File-permissions.html&quot;&gt;GNU Coreutils：文件权限&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html&quot;&gt;systemd.exec&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Linux inode 不足与小文件排查</title><link>https://zh19990906.github.io/fuwari/posts/linux-inode-troubleshooting/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/linux-inode-troubleshooting/</guid><description>从 inode 使用率定位到目录分析、归档清理和文件系统规划的完整排障流程。</description><pubDate>Sat, 07 Oct 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;磁盘还有剩余空间，却无法创建文件，并不一定是容量问题。Linux 文件系统还需要 inode 保存文件类型、权限、时间戳和数据块位置等元数据；大量小文件可能先耗尽 inode。&lt;/p&gt;
&lt;h2&gt;inode 是什么&lt;/h2&gt;
&lt;p&gt;一个常规文件、目录、符号链接通常都会占用一个 inode。文件内容大小与 inode 数量不是同一个维度：一百万个 1 KB 小文件只占约 1 GB 数据空间，却可能耗尽为分区预留的 inode。&lt;/p&gt;
&lt;h2&gt;先确认是否真的耗尽&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 查看数据块容量
findmnt -T /var/lib/app
df -h /var/lib/app

# 查看 inode 使用率
df -i /var/lib/app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型现象是 &lt;code&gt;IUse%&lt;/code&gt; 接近 &lt;code&gt;100%&lt;/code&gt;，同时应用出现 &lt;code&gt;No space left on device&lt;/code&gt;。还要同时排除只读挂载、用户配额和目录权限问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;findmnt -no OPTIONS /var/lib/app
quota -s 2&amp;gt;/dev/null || true
namei -l /var/lib/app
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;定位 inode 数量最多的目录&lt;/h2&gt;
&lt;p&gt;先在同一个文件系统内统计目录分布，&lt;code&gt;-xdev&lt;/code&gt; 可以避免进入其他挂载点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo find /var -xdev -printf &apos;%h\n&apos; \
  | sort \
  | uniq -c \
  | sort -nr \
  | head -n 30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对可疑目录继续缩小范围：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo find /var/lib -xdev -type f | wc -l
sudo find /var/lib/app -xdev -maxdepth 2 -type f \
  -printf &apos;%h\n&apos; | sort | uniq -c | sort -nr | head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只看 &lt;code&gt;du -sh&lt;/code&gt; 容易漏掉问题，因为它主要回答“占了多少数据块”，而不是“创建了多少目录项”。&lt;/p&gt;
&lt;h2&gt;安全清理顺序&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;查阅应用文档，确认哪些缓存、临时文件和任务产物允许删除。&lt;/li&gt;
&lt;li&gt;检查日志轮转是否生效，优先压缩或归档历史日志。&lt;/li&gt;
&lt;li&gt;对长期保存的大量小文件先打包，再转移到对象存储或归档盘。&lt;/li&gt;
&lt;li&gt;删除前检查文件是否仍被进程打开。&lt;/li&gt;
&lt;li&gt;清理后再次执行 &lt;code&gt;df -i&lt;/code&gt;，确认 inode 使用率确实下降。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;查看已删除但仍被进程占用的文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo lsof +L1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查找零字节文件只能作为线索，不能直接批量删除：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo find /var/lib/app -xdev -type f -size 0c -print
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
不要把 &lt;code&gt;find ... -delete&lt;/code&gt; 直接用于不了解的数据目录。先输出文件列表、抽样检查并准备备份，再执行删除。
:::&lt;/p&gt;
&lt;h2&gt;ext4 创建时的 inode 规划&lt;/h2&gt;
&lt;p&gt;inode 数量通常在创建文件系统时决定。面向大量小文件的分区，可以在确认业务模型后选择更适合的使用类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkfs.ext4 -T small /dev/sdX1
sudo tune2fs -l /dev/sdX1 | grep -E &apos;Inode count|Block size|Inode size&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-T small&lt;/code&gt; 会影响块大小和 inode 密度，并不适合所有负载。小块可能增加大文件的元数据与寻址开销，因此需要用真实文件规模和读写模式做基准测试。&lt;/p&gt;
&lt;p&gt;:::danger
&lt;code&gt;mkfs.ext4&lt;/code&gt; 会重新创建文件系统并破坏原有数据。已有 ext4 文件系统的 inode 总量通常不能在原地安全扩大；正确流程是备份、重新规划文件系统、格式化并恢复数据。
:::&lt;/p&gt;
&lt;h2&gt;常见误区&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;磁盘没满就不是空间问题：&lt;/strong&gt; inode 耗尽同样会返回空间不足。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;删除几个大文件就能恢复：&lt;/strong&gt; 一个大文件只释放一个 inode；应处理数量最多的小文件集合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;重启会恢复 inode：&lt;/strong&gt; inode 是文件系统资源，重启不会自动增加。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只提高 inode 数量：&lt;/strong&gt; 如果应用无上限地产生文件，最终仍会再次耗尽，应同时增加生命周期管理。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;排障清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. df -h 与 df -i 同时检查
2. 确认挂载点、只读状态、配额和权限
3. 使用 find -xdev 按目录统计文件数量
4. 找到文件产生者和保留策略
5. 先归档、再清理、最后复查
6. 长期修复写入日志轮转、缓存上限或对象存储方案
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Linux 常用命令速查</title><link>https://zh19990906.github.io/fuwari/posts/linux-common-commands/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/linux-common-commands/</guid><description>按文件、文本、进程、网络和系统信息整理常用 Linux 命令。</description><pubDate>Sun, 18 Jun 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这份速查表以 GNU/Linux 常见工具为主。macOS 的部分参数不同，执行破坏性命令前应先阅读 &lt;code&gt;man&lt;/code&gt; 页面。&lt;/p&gt;
&lt;h2&gt;文件与目录&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pwd                    # 当前目录
ls -lah                # 包含隐藏文件和可读大小
cd /path/to/dir
mkdir -p app/logs
cp -a source target    # 尽量保留属性
mv old new
rm -i file             # 删除前确认
find . -type f -name &apos;*.log&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
&lt;code&gt;rm -rf&lt;/code&gt; 不经过回收站。变量为空、路径拼错或当前目录判断错误都可能造成严重数据损失。
:::&lt;/p&gt;
&lt;h2&gt;查看文件&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cat file.txt
less file.txt
head -n 20 file.txt
tail -n 100 file.txt
tail -f app.log
wc -l file.txt
file archive.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;文本搜索与处理&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;grep -Rni &apos;error&apos; ./logs
grep -v &apos;^#&apos; app.conf
cut -d: -f1 /etc/passwd
sort names.txt | uniq -c | sort -nr
sed -n &apos;10,30p&apos; file.txt
awk &apos;{print $1, $NF}&apos; access.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;组合命令时，先逐段确认输出，再用管道连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;journalctl -u nginx --since today | grep -i error | tail -n 50
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;压缩与归档&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;tar -czf backup.tar.gz directory/
tar -xzf backup.tar.gz
zip -r backup.zip directory/
unzip backup.zip
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;系统信息&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;uname -a
cat /etc/os-release
hostnamectl
uptime
date
timedatectl
free -h
lscpu
lsblk
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;进程与资源&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ps aux
ps -ef | grep process-name
pgrep -af process-name
top
kill PID
kill -TERM PID
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;优先发送 &lt;code&gt;TERM&lt;/code&gt; 让程序清理资源；只有进程无法响应时才考虑 &lt;code&gt;KILL&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kill -KILL PID
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;网络基础&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ip address
ip route
ss -lntup
ping -c 4 example.com
curl -I https://example.com
curl -v https://example.com
getent hosts example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;权限和用户&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;id
whoami
sudo -l
chmod u+x script.sh
chown user:group file
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;获取帮助&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;man command
command --help
apropos keyword
type command
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;type&lt;/code&gt; 可以确认一个名称究竟是外部程序、Shell 内建命令、函数还是别名。&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.gnu.org/software/coreutils/manual/&quot;&gt;GNU Coreutils 手册&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.kernel.org/doc/man-pages/&quot;&gt;Linux man-pages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Docker Engine API 的安全访问方式</title><link>https://zh19990906.github.io/fuwari/posts/docker-engine-api-security/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-engine-api-security/</guid><description>对比 Unix Socket、SSH Context 与双向 TLS，避免将未认证的 Docker API 暴露到网络。</description><pubDate>Mon, 15 May 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Docker Engine API 可以创建特权容器、挂载宿主机目录、读取环境变量并控制网络，因此它的权限通常接近宿主机 root。远程访问方案的第一目标不是“能连上”，而是限制谁能连接、通信是否加密，以及操作是否可审计。&lt;/p&gt;
&lt;h2&gt;本机优先使用 Unix Socket&lt;/h2&gt;
&lt;p&gt;默认 Docker CLI 通过 &lt;code&gt;/var/run/docker.sock&lt;/code&gt; 与本机守护进程通信：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker version
docker info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通用户无法访问时，可以临时使用 &lt;code&gt;sudo&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo docker version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以把受信任用户加入 &lt;code&gt;docker&lt;/code&gt; 组，但需要明确：该组成员通常能够获得宿主机 root 级能力。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo usermod -aG docker &quot;$USER&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重新登录后生效。生产服务器不应为了方便把大量账号加入该组。&lt;/p&gt;
&lt;h2&gt;推荐：通过 SSH 使用 Docker Context&lt;/h2&gt;
&lt;p&gt;Docker Context 可以复用 SSH 的密钥、主机校验和账号权限，无需额外暴露 Docker TCP 端口。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker context create production \
  --docker &quot;host=ssh://deploy@example.com&quot;

docker --context production version
docker --context production ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;切回本机：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker context use default
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SSH 侧应继续采用常规安全措施：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;禁止密码登录或限制为跳板机访问；&lt;/li&gt;
&lt;li&gt;使用独立部署账号；&lt;/li&gt;
&lt;li&gt;校验 &lt;code&gt;known_hosts&lt;/code&gt;，不要长期关闭主机指纹检查；&lt;/li&gt;
&lt;li&gt;限制账号能访问的 Docker 主机与网络范围；&lt;/li&gt;
&lt;li&gt;定期轮换密钥并移除离职或失效账号。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;危险反例：未认证的 2375&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;危险：tcp://0.0.0.0:2375 没有传输加密和客户端认证，等同于把宿主机 root 级控制权交给能访问该端口的人。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即使端口暂时只在内网可见，也可能被同网段主机、错误路由、容器网络或后续防火墙变更访问。不要把“内网”当作认证机制。&lt;/p&gt;
&lt;h2&gt;必须使用 TCP 时启用双向 TLS&lt;/h2&gt;
&lt;p&gt;TCP 场景应使用 &lt;code&gt;2376&lt;/code&gt;、服务端证书和客户端证书。证书至少需要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;独立 CA；&lt;/li&gt;
&lt;li&gt;服务端证书包含正确的 DNS 名称或 IP SAN；&lt;/li&gt;
&lt;li&gt;客户端证书具有客户端用途；&lt;/li&gt;
&lt;li&gt;私钥权限严格限制；&lt;/li&gt;
&lt;li&gt;防火墙只允许固定管理网段；&lt;/li&gt;
&lt;li&gt;明确的到期时间与轮换流程。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一种 systemd drop-in 配置示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/systemd/system/docker.service.d/remote-api.conf
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd \
  -H unix:///var/run/docker.sock \
  -H tcp://0.0.0.0:2376 \
  --tlsverify \
  --tlscacert=/etc/docker/tls/ca.pem \
  --tlscert=/etc/docker/tls/server-cert.pem \
  --tlskey=/etc/docker/tls/server-key.pem
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用前先检查配置目录、证书和备份终端：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload
sudo systemctl restart docker
sudo systemctl status docker --no-pager
sudo ss -lntp | grep 2376
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端使用环境变量或 Docker Context，避免在每条命令重复证书路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export DOCKER_HOST=&apos;tcp://docker.example.com:2376&apos;
export DOCKER_TLS_VERIFY=&apos;1&apos;
export DOCKER_CERT_PATH=&quot;$HOME/.docker/production-tls&quot;

docker version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
修改 Docker 的 systemd &lt;code&gt;ExecStart&lt;/code&gt; 有使守护进程无法启动的风险。操作前保留当前 SSH 会话、备份配置，并准备通过控制台回滚。
:::&lt;/p&gt;
&lt;h2&gt;Python SDK&lt;/h2&gt;
&lt;p&gt;本机或已经配置好 Docker 环境变量时，优先让 SDK 读取标准配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import docker

client = docker.from_env()
print(client.version()[&quot;Version&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在源码中写固定的远程地址、证书私钥或仓库密码。应用需要远程管理 Docker 时，应通过受控服务账号、最小网络范围和审计日志限制风险。&lt;/p&gt;
&lt;h2&gt;验证与审计&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 确认监听地址
sudo ss -lntp | grep dockerd

# 查看最近的守护进程日志
sudo journalctl -u docker --since today --no-pager

# 查看 Context 配置
docker context ls
docker context inspect production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;建议定期检查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;是否仍有 &lt;code&gt;2375&lt;/code&gt; 监听；&lt;/li&gt;
&lt;li&gt;防火墙规则是否超出预期；&lt;/li&gt;
&lt;li&gt;客户端证书是否即将过期；&lt;/li&gt;
&lt;li&gt;SSH 密钥和服务账号是否仍然有效；&lt;/li&gt;
&lt;li&gt;自动化系统是否记录了高风险操作。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;选择建议&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;推荐方案&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;本机开发&lt;/td&gt;
&lt;td&gt;Unix Socket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;少量远程主机运维&lt;/td&gt;
&lt;td&gt;Docker Context over SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自动化平台必须走 TCP&lt;/td&gt;
&lt;td&gt;双向 TLS + 防火墙白名单&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;公网直接暴露&lt;/td&gt;
&lt;td&gt;不推荐；增加 VPN、专用网络或受控网关&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python 安全访问 PostgreSQL：参数化查询、事务与连接池</title><link>https://zh19990906.github.io/fuwari/posts/postgresql-python-access/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/postgresql-python-access/</guid><description>使用 psycopg 编写参数化 SQL、管理事务，并通过连接池支撑 Web 与批处理任务。</description><pubDate>Mon, 20 Feb 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Python 访问 PostgreSQL 时，最重要的不是把连接代码封装成一个“万能工具类”，而是明确连接生命周期、参数化查询、事务边界和并发上限。本篇使用 psycopg 3。&lt;/p&gt;
&lt;h2&gt;安装与连接字符串&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install &quot;psycopg[binary,pool]&quot;
export DATABASE_URL=&apos;postgresql://app_user:replace-me@db.example.com:5432/app&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;连接字符串应由环境变量、密钥管理服务或部署平台注入，不要写进源码和镜像。&lt;/p&gt;
&lt;h2&gt;参数化查询&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import os

import psycopg
from psycopg.rows import dict_row

with psycopg.connect(
    os.environ[&quot;DATABASE_URL&quot;],
    row_factory=dict_row,
) as conn:
    with conn.cursor() as cursor:
        cursor.execute(
            &quot;&quot;&quot;
            SELECT id, title, published_at
            FROM articles
            WHERE published_at &amp;gt;= %s
            ORDER BY published_at DESC
            &quot;&quot;&quot;,
            (&quot;2026-01-01&quot;,),
        )
        rows = cursor.fetchall()

for row in rows:
    print(row[&quot;id&quot;], row[&quot;title&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;占位符由驱动处理。不要使用 f-string、字符串拼接或 &lt;code&gt;%&lt;/code&gt; 运算把用户输入直接放进 SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 不安全：不要这样写
# cursor.execute(f&quot;SELECT * FROM users WHERE name = &apos;{name}&apos;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数只能替换值，不能直接替换表名、列名或排序方向。动态标识符要使用 &lt;code&gt;psycopg.sql&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from psycopg import sql

query = sql.SQL(&quot;SELECT {field} FROM {table} LIMIT %s&quot;).format(
    field=sql.Identifier(&quot;title&quot;),
    table=sql.Identifier(&quot;articles&quot;),
)
cursor.execute(query, (20,))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;标识符仍应来自应用允许列表，而不是完全信任外部输入。&lt;/p&gt;
&lt;h2&gt;插入并获取主键&lt;/h2&gt;
&lt;p&gt;PostgreSQL 使用 &lt;code&gt;RETURNING&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with psycopg.connect(os.environ[&quot;DATABASE_URL&quot;]) as conn:
    with conn.cursor() as cursor:
        cursor.execute(
            &quot;&quot;&quot;
            INSERT INTO articles (title, body)
            VALUES (%s, %s)
            RETURNING id
            &quot;&quot;&quot;,
            (&quot;参数化查询&quot;, &quot;正文内容&quot;),
        )
        article_id = cursor.fetchone()[0]

print(article_id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要使用其他数据库专有的自增 ID 查询语句。&lt;/p&gt;
&lt;h2&gt;事务边界&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;with psycopg.connect(...) as conn&lt;/code&gt; 在正常退出时提交事务，异常时回滚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import psycopg


def transfer_points(source_id: int, target_id: int, amount: int) -&amp;gt; None:
    if amount &amp;lt;= 0:
        raise ValueError(&quot;amount must be positive&quot;)

    with psycopg.connect(os.environ[&quot;DATABASE_URL&quot;]) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                &quot;&quot;&quot;
                UPDATE accounts
                SET points = points - %s
                WHERE id = %s AND points &amp;gt;= %s
                RETURNING id
                &quot;&quot;&quot;,
                (amount, source_id, amount),
            )
            if cursor.fetchone() is None:
                raise ValueError(&quot;insufficient points or source not found&quot;)

            cursor.execute(
                &quot;UPDATE accounts SET points = points + %s WHERE id = %s&quot;,
                (amount, target_id),
            )
            if cursor.rowcount != 1:
                raise ValueError(&quot;target not found&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;异常会让两次更新一起回滚。不要在业务流程中间随意 &lt;code&gt;commit()&lt;/code&gt;，否则无法保持原子性。&lt;/p&gt;
&lt;h2&gt;批量写入&lt;/h2&gt;
&lt;p&gt;小批量可以使用 &lt;code&gt;executemany&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rows = [
    (&quot;first&quot;, &quot;body-1&quot;),
    (&quot;second&quot;, &quot;body-2&quot;),
]

with psycopg.connect(os.environ[&quot;DATABASE_URL&quot;]) as conn:
    with conn.cursor() as cursor:
        cursor.executemany(
            &quot;INSERT INTO articles (title, body) VALUES (%s, %s)&quot;,
            rows,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大量导入应评估 &lt;code&gt;COPY&lt;/code&gt;，并控制批次大小，避免单个事务占用过多 WAL、锁和内存。&lt;/p&gt;
&lt;h2&gt;连接池&lt;/h2&gt;
&lt;p&gt;Web 服务和并发任务不应为每个小查询无限创建新连接。使用连接池限制并发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

from psycopg_pool import ConnectionPool

pool = ConnectionPool(
    os.environ[&quot;DATABASE_URL&quot;],
    min_size=1,
    max_size=10,
    timeout=10,
)

with pool.connection() as conn:
    with conn.cursor() as cursor:
        cursor.execute(&quot;SELECT now()&quot;)
        print(cursor.fetchone()[0])

pool.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;池大小应结合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL &lt;code&gt;max_connections&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;应用实例数量；&lt;/li&gt;
&lt;li&gt;后台任务和管理连接；&lt;/li&gt;
&lt;li&gt;查询耗时；&lt;/li&gt;
&lt;li&gt;是否使用 PgBouncer。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如每个实例配置 20 个连接、部署 20 个实例，就可能产生 400 个连接。不要只看单个进程的配置。&lt;/p&gt;
&lt;h2&gt;游标和连接不要全局常驻&lt;/h2&gt;
&lt;p&gt;一个全局连接或游标容易出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;网络中断后对象失效；&lt;/li&gt;
&lt;li&gt;多线程并发使用同一游标；&lt;/li&gt;
&lt;li&gt;事务长时间未提交；&lt;/li&gt;
&lt;li&gt;空闲事务持有锁；&lt;/li&gt;
&lt;li&gt;应用关闭时资源没有释放。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;正确做法是从连接池短暂借用连接，在清晰的事务范围内完成操作后归还。&lt;/p&gt;
&lt;h2&gt;超时与保护&lt;/h2&gt;
&lt;p&gt;可以在数据库角色、连接或事务层设置超时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SET LOCAL statement_timeout = &apos;5s&apos;;
SET LOCAL lock_timeout = &apos;2s&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python 中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with pool.connection() as conn:
    with conn.transaction():
        conn.execute(&quot;SET LOCAL statement_timeout = &apos;5s&apos;&quot;)
        rows = conn.execute(
            &quot;SELECT id FROM jobs WHERE status = %s&quot;,
            (&quot;pending&quot;,),
        ).fetchall()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还应限制返回行数，避免无条件读取整张大表。&lt;/p&gt;
&lt;h2&gt;日志与错误处理&lt;/h2&gt;
&lt;p&gt;不要把完整连接字符串、SQL 参数中的个人数据或密码写入日志。建议记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;操作名称；&lt;/li&gt;
&lt;li&gt;受影响行数；&lt;/li&gt;
&lt;li&gt;耗时；&lt;/li&gt;
&lt;li&gt;PostgreSQL 错误类型和 SQLSTATE；&lt;/li&gt;
&lt;li&gt;请求或任务关联 ID。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对唯一约束、外键约束和序列化冲突分别处理，不要用一个宽泛 &lt;code&gt;except Exception&lt;/code&gt; 把所有错误都变成“SQL 有问题”。&lt;/p&gt;
&lt;h2&gt;检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 连接信息来自环境变量或密钥管理
2. 所有外部值使用参数化查询
3. 动态表名和列名使用 Identifier 与允许列表
4. 事务范围短且清晰
5. 插入主键通过 RETURNING 获取
6. 连接池总量不超过数据库预算
7. 查询设置超时并限制返回规模
8. 日志不输出凭据和敏感参数
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>YOLOv8 训练、验证与推理实践</title><link>https://zh19990906.github.io/fuwari/posts/yolov8-practice/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolov8-practice/</guid><description>使用统一 Ultralytics API 完成 YOLOv8 环境准备、训练、验证和预测。</description><pubDate>Tue, 10 Jan 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;YOLOv8 于 2023 年 1 月 10 日发布，它把检测、分割、姿态、分类等任务统一到 &lt;code&gt;ultralytics&lt;/code&gt; 包中。已有 YOLOv8 项目可以继续稳定维护，但应固定环境版本，避免包升级改变训练或导出行为。&lt;/p&gt;
&lt;h2&gt;环境准备&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install ultralytics
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记录环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo checks
python -c &quot;import ultralytics; print(ultralytics.__version__)&quot;
python -m pip freeze &amp;gt; requirements-lock.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;历史项目应安装当时验证过的 &lt;code&gt;ultralytics&lt;/code&gt; 版本，而不是无条件使用最新版。&lt;/p&gt;
&lt;h2&gt;推理&lt;/h2&gt;
&lt;p&gt;CLI：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yolo detect predict model=yolov8n.pt source=images/ imgsz=640 conf=0.25
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ultralytics import YOLO

model = YOLO(&quot;yolov8n.pt&quot;)
results = model.predict(source=&quot;images/&quot;, imgsz=640, conf=0.25)

for result in results:
    print(result.boxes.xyxy)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;数据集配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;path: /data/example
train: images/train
val: images/val
test: images/test
names:
  0: person
  1: vehicle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;开始正式训练前，先用少量样本检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图像和标签是否一一对应；&lt;/li&gt;
&lt;li&gt;类别 ID 是否与 &lt;code&gt;names&lt;/code&gt; 一致；&lt;/li&gt;
&lt;li&gt;检测框是否归一化且没有负数；&lt;/li&gt;
&lt;li&gt;训练集和验证集是否存在重复或泄漏；&lt;/li&gt;
&lt;li&gt;类别是否严重不平衡。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;训练&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo detect train \
  model=yolov8n.pt \
  data=data.yaml \
  epochs=100 \
  imgsz=640 \
  batch=16 \
  device=0 \
  project=runs/example \
  name=yolov8n-baseline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ultralytics import YOLO

model = YOLO(&quot;yolov8n.pt&quot;)
model.train(
    data=&quot;data.yaml&quot;,
    epochs=100,
    imgsz=640,
    batch=16,
    device=0,
    project=&quot;runs/example&quot;,
    name=&quot;yolov8n-baseline&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先建立可复现基线，再调整增强、学习率和模型尺寸。不要在第一轮同时改变大量参数。&lt;/p&gt;
&lt;h2&gt;验证&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo detect val \
  model=runs/example/yolov8n-baseline/weights/best.pt \
  data=data.yaml \
  imgsz=640
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除了总体 mAP，还要检查每类指标、混淆矩阵、误检漏检样本，以及目标尺寸分布。&lt;/p&gt;
&lt;h2&gt;导出&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;yolo export \
  model=runs/example/yolov8n-baseline/weights/best.pt \
  format=onnx \
  imgsz=640 \
  dynamic=True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;导出后必须使用目标推理引擎复测预处理、输出解析、阈值和 NMS。PyTorch 验证通过不代表 ONNX 或 TensorRT 结果完全一致。&lt;/p&gt;
&lt;h2&gt;维护注意事项&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;固定训练代码、包版本、随机种子和数据集版本。&lt;/li&gt;
&lt;li&gt;保存 &lt;code&gt;args.yaml&lt;/code&gt;、指标曲线和最佳权重。&lt;/li&gt;
&lt;li&gt;重新训练前确认默认参数是否随包版本变化。&lt;/li&gt;
&lt;li&gt;迁移到 YOLO11 或 YOLO26 时，用同一验证集和硬件比较。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/models/yolov8/&quot;&gt;YOLOv8 官方文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/zh/modes/train/&quot;&gt;Ultralytics 训练模式&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Nginx 反向代理与常用代理头</title><link>https://zh19990906.github.io/fuwari/posts/nginx-reverse-proxy/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/nginx-reverse-proxy/</guid><description>从基础代理配置到 WebSocket、超时、上传限制和 502/504 排查。</description><pubDate>Wed, 14 Sep 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Nginx 反向代理接收客户端请求，再把请求转发到后端应用。它常用于统一域名、终止 TLS、限制上传大小、设置超时和隐藏内部服务端口。&lt;/p&gt;
&lt;h2&gt;基础配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name app.example.com;

    client_max_body_size 20m;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;保存后先检查语法，再平滑重载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在未通过 &lt;code&gt;nginx -t&lt;/code&gt; 时直接重启，错误配置可能让服务无法恢复。&lt;/p&gt;
&lt;h2&gt;常用代理头&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把用户访问的域名传给后端&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Real-IP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;传递当前客户端地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Forwarded-For&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;追加经过的代理链路地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Forwarded-Proto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;告诉后端原请求是 HTTP 还是 HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;后端框架必须只信任受控代理写入的这些头。应用直接暴露到公网时，客户端可以伪造 &lt;code&gt;X-Forwarded-For&lt;/code&gt;；应在框架中配置可信代理数量或可信网段，而不是无条件接受任意值。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;proxy_pass&lt;/code&gt; 尾斜杠&lt;/h2&gt;
&lt;p&gt;路径是否保留与尾斜杠有关：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;location /api/ {
    proxy_pass http://127.0.0.1:8000/;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求 &lt;code&gt;/api/users&lt;/code&gt; 会被转发为 &lt;code&gt;/users&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;location /api/ {
    proxy_pass http://127.0.0.1:8000;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求路径通常保留 &lt;code&gt;/api/users&lt;/code&gt;。修改代理路径后应使用真实 URI 做测试，避免接口突然出现 404。&lt;/p&gt;
&lt;h2&gt;WebSocket&lt;/h2&gt;
&lt;p&gt;WebSocket 升级需要 HTTP/1.1 和升级头：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;location /ws/ {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection &quot;upgrade&quot;;
    proxy_set_header Host $host;
    proxy_read_timeout 300s;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个 location 都需要 WebSocket 时，可以使用 &lt;code&gt;map&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;map $http_upgrade $connection_upgrade {
    default upgrade;
    &apos;&apos; close;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;proxy_set_header Connection $connection_upgrade;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;超时&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
send_timeout 60s;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;proxy_connect_timeout&lt;/code&gt;：建立到上游连接的时间；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;proxy_send_timeout&lt;/code&gt;：把请求发送给上游时，两次写操作之间的等待；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;proxy_read_timeout&lt;/code&gt;：从上游读取响应时，两次读操作之间的等待；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;send_timeout&lt;/code&gt;：向客户端发送响应时的等待。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要为了掩盖慢查询把所有超时改成数小时。先定位上游性能、数据库锁和外部依赖，再为长任务设计异步接口。&lt;/p&gt;
&lt;h2&gt;上传大小与缓冲&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;client_max_body_size 100m;
client_body_timeout 30s;
proxy_request_buffering off;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;proxy_request_buffering off&lt;/code&gt; 会让请求体流式发送到后端，适合某些大文件上传，但后端必须能处理慢客户端和中断。普通接口保持默认缓冲更容易保护上游。&lt;/p&gt;
&lt;p&gt;响应流或 Server-Sent Events 可以考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;proxy_buffering off;
proxy_cache off;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要全局关闭缓冲，否则会增加上游连接占用。&lt;/p&gt;
&lt;h2&gt;静态文件与应用分离&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name app.example.com;

    location /assets/ {
        alias /srv/app/assets/;
        expires 7d;
        add_header Cache-Control &quot;public&quot;;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;alias&lt;/code&gt; 与 &lt;code&gt;root&lt;/code&gt; 的路径拼接规则不同。改动后用实际文件验证，并确保 Nginx 工作用户有读取权限。&lt;/p&gt;
&lt;h2&gt;502 与 504 排查&lt;/h2&gt;
&lt;h3&gt;502 Bad Gateway&lt;/h3&gt;
&lt;p&gt;通常表示 Nginx 无法与上游正常通信：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -v http://127.0.0.1:8000/health
sudo ss -lntp | grep 8000
sudo journalctl -u nginx --since today --no-pager
sudo tail -n 100 /var/log/nginx/error.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用是否启动；&lt;/li&gt;
&lt;li&gt;监听地址与端口是否一致；&lt;/li&gt;
&lt;li&gt;Unix Socket 权限；&lt;/li&gt;
&lt;li&gt;容器端口映射；&lt;/li&gt;
&lt;li&gt;SELinux 或防火墙；&lt;/li&gt;
&lt;li&gt;上游是否提前关闭连接。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;504 Gateway Timeout&lt;/h3&gt;
&lt;p&gt;表示连接成功，但上游在超时前没有返回完整响应。应同时检查应用日志、数据库慢查询、外部 API 和线程/连接池耗尽。&lt;/p&gt;
&lt;h2&gt;配置拆分&lt;/h2&gt;
&lt;p&gt;可以把通用代理头放入片段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/nginx/snippets/proxy-headers.conf
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;引用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;include snippets/proxy-headers.conf;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拆分配置后仍应通过 &lt;code&gt;nginx -T&lt;/code&gt; 查看最终展开结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -T | less
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;上线检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. nginx -t 通过
2. 域名解析到正确主机
3. 后端只监听必要的本地或容器网络地址
4. 代理头与框架可信代理配置一致
5. 上传大小和超时符合业务需求
6. WebSocket/SSE 路径单独验证
7. 日志中没有持续 502/504
8. reload 后旧连接可正常完成
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Docker 私有镜像仓库与证书信任</title><link>https://zh19990906.github.io/fuwari/posts/docker-private-registry/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/docker-private-registry/</guid><description>从本地 Registry 到 Harbor，整理证书信任、登录、推送和常见错误排查。</description><pubDate>Thu, 08 Sep 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;私有镜像仓库用于保存组织内部镜像、控制访问权限并减少对外部网络的依赖。简单实验可以使用官方 Registry，团队环境通常更适合带权限、审计和扫描能力的 Harbor。&lt;/p&gt;
&lt;h2&gt;启动一个本地 Registry&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker volume create registry-data

docker run -d \
  --restart=always \
  --name registry \
  -p 5000:5000 \
  -v registry-data:/var/lib/registry \
  registry:2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker ps --filter name=registry
curl http://127.0.0.1:5000/v2/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回 &lt;code&gt;{}&lt;/code&gt; 通常表示 Registry API 可访问。这个示例只适合隔离的本机实验，不包含 TLS 和认证。&lt;/p&gt;
&lt;h2&gt;标记并推送镜像&lt;/h2&gt;
&lt;p&gt;假设仓库域名为 &lt;code&gt;registry.example.com&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker tag app:1.0 registry.example.com/team/app:1.0
docker login registry.example.com
docker push registry.example.com/team/app:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在其他主机拉取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker pull registry.example.com/team/app:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;镜像名称应包含完整仓库地址、命名空间和明确版本。生产环境不要只发布 &lt;code&gt;latest&lt;/code&gt;，建议同时保留语义化版本或不可变提交 SHA 标签。&lt;/p&gt;
&lt;h2&gt;配置受信任证书&lt;/h2&gt;
&lt;p&gt;使用内部 CA 或自签 CA 时，需要把 CA 证书放到 Docker 约定目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /etc/docker/certs.d/registry.example.com
sudo install -m 644 ca.crt \
  /etc/docker/certs.d/registry.example.com/ca.crt
sudo systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果仓库使用非标准端口，目录名必须包含端口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/docker/certs.d/registry.example.com:5000/ca.crt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证证书链：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openssl s_client \
  -connect registry.example.com:443 \
  -servername registry.example.com \
  -CAfile ca.crt &amp;lt;/dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;证书的 SAN 必须包含客户端实际访问的域名。只把证书复制到服务器而不配置客户端信任，仍会出现 &lt;code&gt;x509: certificate signed by unknown authority&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;认证与凭据&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker login registry.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Docker 会把认证信息写入客户端配置。个人电脑建议配置系统 Credential Helper；CI 环境应从密钥管理服务注入短期凭据，并在作业结束后清理。&lt;/p&gt;
&lt;p&gt;不要把下面内容提交到 Git：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.docker/config.json
私有 CA 的私钥
Registry 或 Harbor 管理员密码
CI 的机器人账号令牌
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Harbor 适合团队环境的原因&lt;/h2&gt;
&lt;p&gt;Harbor 在 Registry 基础上增加了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;项目与角色权限；&lt;/li&gt;
&lt;li&gt;机器人账号；&lt;/li&gt;
&lt;li&gt;镜像复制；&lt;/li&gt;
&lt;li&gt;漏洞扫描；&lt;/li&gt;
&lt;li&gt;保留策略与垃圾回收；&lt;/li&gt;
&lt;li&gt;审计日志；&lt;/li&gt;
&lt;li&gt;Web 管理界面。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;部署 Harbor 时，应提前规划域名、证书、持久化、备份和外部数据库/对象存储需求。不要只备份容器本身，真正需要保护的是配置和持久化数据。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;insecure-registries&lt;/code&gt; 的边界&lt;/h2&gt;
&lt;p&gt;Docker 可以通过 &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; 信任明文 HTTP 仓库：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;insecure-registries&quot;: [&quot;registry.example.com:5000&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::danger
&lt;code&gt;insecure-registries&lt;/code&gt; 会降低传输安全，只应作为隔离实验网络中的临时措施。正常团队仓库应部署可信 TLS，而不是长期关闭证书校验。
:::&lt;/p&gt;
&lt;h2&gt;常见错误&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;server gave HTTP response to HTTPS client&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;客户端默认按 HTTPS 访问，而服务端只提供 HTTP。正确方案是为仓库配置 TLS；临时实验才考虑 &lt;code&gt;insecure-registries&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;x509: certificate signed by unknown authority&lt;/code&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;CA 文件路径或目录名错误；&lt;/li&gt;
&lt;li&gt;证书链不完整；&lt;/li&gt;
&lt;li&gt;证书 SAN 不包含访问域名；&lt;/li&gt;
&lt;li&gt;修改后 Docker 守护进程未重启。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;code&gt;unauthorized: authentication required&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker logout registry.example.com
docker login registry.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时检查账号对目标项目是否有 push 权限。&lt;/p&gt;
&lt;h3&gt;推送后磁盘持续增长&lt;/h3&gt;
&lt;p&gt;删除标签不会立即回收所有层。需要结合保留策略和垃圾回收，并在执行前确认当前 Registry/Harbor 版本的停机或只读要求。&lt;/p&gt;
&lt;h2&gt;运维检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 仓库使用受信任 TLS
2. 普通用户不共享管理员账号
3. CI 使用独立机器人账号和最小权限
4. 镜像标签可追踪到源码提交
5. 配置、数据库和存储都有备份
6. 定期执行保留策略、扫描和容量告警
7. 客户端证书信任方式已文档化
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Linux 挂载 Windows SMB/CIFS 共享目录</title><link>https://zh19990906.github.io/fuwari/posts/linux-mount-cifs-share/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/linux-mount-cifs-share/</guid><description>使用 CIFS 手动挂载共享目录，并通过凭据文件和 fstab 实现安全的持久挂载。</description><pubDate>Mon, 29 Aug 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SMB/CIFS 常用于在 Windows、NAS 和 Linux 之间共享文件。Linux 可以通过内核的 CIFS 客户端把远程共享目录挂载到本地路径。&lt;/p&gt;
&lt;h2&gt;安装客户端工具&lt;/h2&gt;
&lt;p&gt;Ubuntu/Debian：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
sudo apt install -y cifs-utils
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RHEL/Fedora：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf install -y cifs-utils
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;手动挂载&lt;/h2&gt;
&lt;p&gt;先创建挂载点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /mnt/team-share
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用环境变量避免把用户名直接写死在命令中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export SMB_USER=&apos;your-user&apos;

sudo mount -t cifs //fileserver.example.com/team /mnt/team-share \
  -o username=&quot;$SMB_USER&quot;,vers=3.0,uid=$(id -u),gid=$(id -g),file_mode=0640,dir_mode=0750
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命令会交互式询问密码。挂载完成后检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;findmnt /mnt/team-share
mountpoint /mnt/team-share
ls -la /mnt/team-share
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;使用凭据文件&lt;/h2&gt;
&lt;p&gt;长期挂载不应把密码直接写在命令历史或 &lt;code&gt;/etc/fstab&lt;/code&gt; 中。创建仅 root 可读的凭据文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo install -m 600 /dev/null /etc/samba/credentials-team
sudoedit /etc/samba/credentials-team
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内容示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;username=your-user
password=replace-with-a-secret
# domain=EXAMPLE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再次确认权限：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown root:root /etc/samba/credentials-team
sudo chmod 600 /etc/samba/credentials-team
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置开机挂载&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;/etc/fstab&lt;/code&gt; 中增加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//fileserver.example.com/team /mnt/team-share cifs credentials=/etc/samba/credentials-team,vers=3.0,_netdev,nofail,uid=1000,gid=1000,file_mode=0640,dir_mode=0750 0 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数含义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;credentials&lt;/code&gt;：读取独立凭据文件。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vers=3.0&lt;/code&gt;：优先使用现代 SMB 协议，旧设备可能需要单独确认版本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_netdev&lt;/code&gt;：标记为依赖网络的挂载。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nofail&lt;/code&gt;：远程服务不可用时不阻塞系统启动。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uid&lt;/code&gt;、&lt;code&gt;gid&lt;/code&gt;：把远程文件映射给本地用户。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;file_mode&lt;/code&gt;、&lt;code&gt;dir_mode&lt;/code&gt;：控制本地看到的默认权限。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;测试配置时不要直接重启：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo umount /mnt/team-share 2&amp;gt;/dev/null || true
sudo mount -a
findmnt /mnt/team-share
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;systemd 自动挂载&lt;/h2&gt;
&lt;p&gt;网络环境不稳定时，可以在 &lt;code&gt;fstab&lt;/code&gt; 参数中加入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;x-systemd.automount,x-systemd.idle-timeout=300
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样访问目录时才触发挂载，空闲后可以自动释放连接。修改后执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload
sudo systemctl restart remote-fs.target
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;常见错误&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;mount error(13): Permission denied&lt;/code&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;检查共享目录权限与账号权限。&lt;/li&gt;
&lt;li&gt;检查用户名是否需要域前缀。&lt;/li&gt;
&lt;li&gt;确认凭据文件没有多余空格或不可见字符。&lt;/li&gt;
&lt;li&gt;查看内核日志：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;sudo dmesg | tail -n 50
journalctl -b --no-pager | grep -i cifs
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;code&gt;mount error(95): Operation not supported&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;服务端与客户端协议版本可能不匹配。先确认服务端支持的 SMB 版本，再尝试 &lt;code&gt;vers=3.1.1&lt;/code&gt;、&lt;code&gt;3.0&lt;/code&gt; 或 &lt;code&gt;2.1&lt;/code&gt;。不要为了兼容轻易退回 SMB 1.0。&lt;/p&gt;
&lt;h3&gt;主机名无法解析&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;getent hosts fileserver.example.com
nc -vz fileserver.example.com 445
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认 DNS、路由和防火墙允许 TCP 445。&lt;/p&gt;
&lt;h3&gt;文件归属不正确&lt;/h3&gt;
&lt;p&gt;本地 &lt;code&gt;uid&lt;/code&gt;、&lt;code&gt;gid&lt;/code&gt; 与实际登录用户不一致时，可以查询：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再调整挂载参数。多人共用时应结合组权限、&lt;code&gt;forceuid&lt;/code&gt;/&lt;code&gt;forcegid&lt;/code&gt; 的实际效果和服务端 ACL 设计，不要只依赖宽松的 &lt;code&gt;0777&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;:::warning
不要把密码直接写进 shell 命令、脚本、Git 仓库或 &lt;code&gt;/etc/fstab&lt;/code&gt;。凭据泄露后应立即修改服务端密码，并清理命令历史和日志副本。
:::&lt;/p&gt;
&lt;h2&gt;卸载&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo umount /mnt/team-share
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;目录繁忙时先定位占用进程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo fuser -vm /mnt/team-share
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Nginx 负载均衡：轮询、权重与最少连接</title><link>https://zh19990906.github.io/fuwari/posts/nginx-load-balancing/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/nginx-load-balancing/</guid><description>使用 upstream 配置轮询、权重、最少连接和会话策略，并整理故障与观测要点。</description><pubDate>Tue, 23 Aug 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Nginx 可以把请求分发到多个上游实例，降低单机压力并支持滚动发布。负载均衡算法只能决定“把请求发给谁”，不能替代应用健康检查、会话设计、容量规划和数据库高可用。&lt;/p&gt;
&lt;h2&gt;基础 upstream&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;upstream app_backend {
    server 192.0.2.11:8000 max_fails=3 fail_timeout=30s;
    server 192.0.2.12:8000 max_fails=3 fail_timeout=30s;
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://app_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认算法是轮询。请求依次分配给可用上游，但不会保证在任意短时间窗口内绝对平均。&lt;/p&gt;
&lt;h2&gt;权重轮询&lt;/h2&gt;
&lt;p&gt;机器规格或实例容量不同，可以设置权重：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;upstream app_backend {
    server 192.0.2.11:8000 weight=3;
    server 192.0.2.12:8000 weight=1;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理论上第一台获得约三倍请求。权重应基于压测和真实监控调整，而不是只按 CPU 核数猜测；数据库、缓存、网络和接口类型都会影响实际容量。&lt;/p&gt;
&lt;h2&gt;最少连接&lt;/h2&gt;
&lt;p&gt;请求耗时差异较大时，&lt;code&gt;least_conn&lt;/code&gt; 往往比简单轮询更合理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;upstream app_backend {
    least_conn;
    server 192.0.2.11:8000;
    server 192.0.2.12:8000;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它优先选择当前活动连接较少的实例。长连接、SSE 和 WebSocket 会显著影响连接数量，需要结合业务协议判断。&lt;/p&gt;
&lt;h2&gt;IP Hash&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;upstream app_backend {
    ip_hash;
    server 192.0.2.11:8000;
    server 192.0.2.12:8000;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ip_hash&lt;/code&gt; 尝试让相同客户端地址落到相同上游，常被用作简单会话粘性方案。但它有明显限制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大量用户经过同一个 NAT 时会集中到同一实例；&lt;/li&gt;
&lt;li&gt;代理链下客户端地址可能不真实；&lt;/li&gt;
&lt;li&gt;扩缩容会改变映射；&lt;/li&gt;
&lt;li&gt;不能替代共享 Session、外部缓存或无状态认证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;优先让应用无状态化，把 Session 放到共享存储，或使用签名令牌。只有明确需要时才依赖粘性会话。&lt;/p&gt;
&lt;h2&gt;备用节点与临时下线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;upstream app_backend {
    server 192.0.2.11:8000;
    server 192.0.2.12:8000;
    server 192.0.2.13:8000 backup;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;备用节点只在普通节点不可用时接收请求。&lt;/p&gt;
&lt;p&gt;维护期间可以标记：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server 192.0.2.12:8000 down;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改后仍需 reload。频繁通过编辑配置上下线实例时，应该评估服务发现、容器编排或自动化配置生成，而不是长期手工维护地址列表。&lt;/p&gt;
&lt;h2&gt;被动失败判断&lt;/h2&gt;
&lt;p&gt;开源 Nginx 常见参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server 192.0.2.11:8000 max_fails=3 fail_timeout=30s;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它主要依据真实代理请求中的连接或响应失败进行被动判断，并不是独立的主动 &lt;code&gt;/health&lt;/code&gt; 探测。应用应提供健康接口，外部负载均衡器或监控系统也应持续检查。&lt;/p&gt;
&lt;p&gt;不要把所有 HTTP 5xx 都简单视为节点宕机。某些 5xx 是业务输入、数据库或依赖故障，盲目重试可能放大压力。&lt;/p&gt;
&lt;h2&gt;重试与幂等性&lt;/h2&gt;
&lt;p&gt;Nginx 可以在特定错误后尝试下一个上游：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于 POST、支付、写库等非幂等请求，自动重试可能产生重复操作。只有应用具备幂等键、事务约束和明确重试语义时，才扩大重试范围。&lt;/p&gt;
&lt;h2&gt;Keepalive&lt;/h2&gt;
&lt;p&gt;Nginx 到上游可以复用连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;upstream app_backend {
    server 192.0.2.11:8000;
    server 192.0.2.12:8000;
    keepalive 32;
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Connection &quot;&quot;;
        proxy_pass http://app_backend;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;连接池过大可能长期占用上游文件描述符，过小则增加握手成本。应结合并发和上游限制调整。&lt;/p&gt;
&lt;h2&gt;滚动发布&lt;/h2&gt;
&lt;p&gt;一个简单流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从 upstream 中移除或标记一个节点；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nginx -t&lt;/code&gt; 后 reload；&lt;/li&gt;
&lt;li&gt;等待旧连接完成；&lt;/li&gt;
&lt;li&gt;部署并验证该节点；&lt;/li&gt;
&lt;li&gt;加回 upstream；&lt;/li&gt;
&lt;li&gt;继续下一台。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果使用容器平台，应把 readiness 与滚动更新交给平台，Nginx 只消费稳定的服务地址或服务发现结果。&lt;/p&gt;
&lt;h2&gt;观测&lt;/h2&gt;
&lt;p&gt;访问日志加入上游信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;log_format upstream_timing &apos;$remote_addr $request &apos;
                           &apos;status=$status upstream=$upstream_addr &apos;
                           &apos;upstream_status=$upstream_status &apos;
                           &apos;request_time=$request_time &apos;
                           &apos;upstream_time=$upstream_response_time&apos;;

access_log /var/log/nginx/access.log upstream_timing;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重点关注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个上游的请求量；&lt;/li&gt;
&lt;li&gt;连接和响应失败；&lt;/li&gt;
&lt;li&gt;P50/P95/P99 延迟；&lt;/li&gt;
&lt;li&gt;502、503、504 比例；&lt;/li&gt;
&lt;li&gt;实例 CPU、内存、连接池和队列；&lt;/li&gt;
&lt;li&gt;reload 后配置是否符合预期。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;$upstream_addr&lt;/code&gt; 可能包含多个地址，表示请求发生了重试。&lt;/p&gt;
&lt;h2&gt;常见误区&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;上游多了就等于高可用：&lt;/strong&gt; Nginx 本身仍可能是单点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;轮询必然平均：&lt;/strong&gt; 长短请求、Keepalive 和连接状态会造成差异。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IP Hash 能解决 Session：&lt;/strong&gt; 它只是流量粘性，不提供 Session 持久化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;失败就无限重试：&lt;/strong&gt; 写操作可能被重复执行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看 Nginx 日志：&lt;/strong&gt; 应同时观察应用、数据库和依赖服务。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;上线检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. upstream 地址和端口可从 Nginx 主机访问
2. 所有实例运行同一兼容版本
3. 会话和上传数据不依赖单机目录
4. 重试规则符合接口幂等性
5. 超时和 Keepalive 与上游容量匹配
6. 日志记录 upstream 地址、状态和耗时
7. Nginx 自身也有高可用或快速恢复方案
8. 扩缩容、节点故障和滚动发布已经演练
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python 轻量定时任务与日期时间处理</title><link>https://zh19990906.github.io/fuwari/posts/python-scheduling-and-datetime/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-scheduling-and-datetime/</guid><description>组合 schedule 与 dateutil 处理简单定时任务、字符串时间、时区和失败重试。</description><pubDate>Fri, 19 Aug 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;小型脚本经常需要每天执行一次、定期检查状态或解析外部时间字符串。&lt;code&gt;schedule&lt;/code&gt; 适合单进程、允许短暂停机的轻量任务；日期处理则应优先使用带时区的 &lt;code&gt;datetime&lt;/code&gt;，并明确输入格式。&lt;/p&gt;
&lt;h2&gt;一个可维护的轻量任务&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import logging
import time

import schedule

logging.basicConfig(
    level=logging.INFO,
    format=&quot;%(asctime)s %(levelname)s %(message)s&quot;,
)


def job() -&amp;gt; None:
    logging.info(&quot;job started&quot;)
    try:
        # 直接调用应用函数，不要再通过 os.system 启动另一个 Python 进程。
        logging.info(&quot;job finished&quot;)
    except Exception:
        logging.exception(&quot;job failed&quot;)


schedule.every().day.at(&quot;08:20&quot;).do(job)

while True:
    schedule.run_pending()
    time.sleep(1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个进程必须持续运行。应用退出、服务器重启或进程被杀死后，错过的任务不会自动补执行。&lt;/p&gt;
&lt;h2&gt;不要用 shell 拼接业务命令&lt;/h2&gt;
&lt;p&gt;旧脚本常见做法是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 不推荐
# os.system(&quot;python task.py&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;无法可靠获取结构化返回值；&lt;/li&gt;
&lt;li&gt;shell 参数可能产生注入风险；&lt;/li&gt;
&lt;li&gt;Python 解释器和虚拟环境不一定与当前进程一致；&lt;/li&gt;
&lt;li&gt;超时、日志和异常处理困难。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;优先把任务逻辑提取为普通函数，再由命令行入口和定时任务共同调用。&lt;/p&gt;
&lt;h2&gt;给任务增加防重入&lt;/h2&gt;
&lt;p&gt;任务执行时间超过调度间隔时，可能发生重叠。单进程可以用锁避免重复执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from threading import Lock

job_lock = Lock()


def guarded_job() -&amp;gt; None:
    if not job_lock.acquire(blocking=False):
        logging.warning(&quot;previous job is still running&quot;)
        return

    try:
        job()
    finally:
        job_lock.release()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多进程或多主机场景不能依赖内存锁，应使用数据库锁、Redis 锁、任务队列或由平台保证单实例运行。&lt;/p&gt;
&lt;h2&gt;失败重试&lt;/h2&gt;
&lt;p&gt;重试只适合短暂网络错误，不能掩盖参数错误和数据错误：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import time
from collections.abc import Callable
from typing import TypeVar

T = TypeVar(&quot;T&quot;)


def retry(
    operation: Callable[[], T],
    *,
    attempts: int = 3,
    base_delay: float = 1.0,
) -&amp;gt; T:
    for attempt in range(1, attempts + 1):
        try:
            return operation()
        except OSError:
            if attempt == attempts:
                raise
            time.sleep(base_delay * attempt)
    raise RuntimeError(&quot;unreachable&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;任务需要幂等设计：重复执行不能产生重复订单、重复消息或不可逆副作用。&lt;/p&gt;
&lt;h2&gt;解析标准时间字符串&lt;/h2&gt;
&lt;p&gt;优先处理 ISO 8601 格式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dateutil import parser

value = parser.isoparse(&quot;2026-07-30T10:00:00+08:00&quot;)
print(value)
print(value.astimezone())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;isoparse&lt;/code&gt; 比通用 &lt;code&gt;parse&lt;/code&gt; 更严格，适合 API 和配置文件。面对历史数据中不统一的格式时，可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dateutil.parser import ParserError, parse

try:
    value = parse(&quot;July 30, 2026 10:00 +08:00&quot;)
except (ParserError, OverflowError) as exc:
    raise ValueError(&quot;unsupported datetime value&quot;) from exc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要对来源不受控的大批量字符串无限制尝试模糊解析；应记录失败样本并收敛输入格式。&lt;/p&gt;
&lt;h2&gt;使用带时区的时间&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from datetime import datetime, timezone

now_utc = datetime.now(timezone.utc)
print(now_utc.isoformat())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不带时区信息的 &lt;code&gt;datetime&lt;/code&gt; 称为 naive datetime。它无法说明“10:00”属于哪个时区，跨服务器、数据库或夏令时地区时容易产生歧义。&lt;/p&gt;
&lt;p&gt;把本地时间转换为 UTC 保存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from datetime import datetime
from zoneinfo import ZoneInfo

local = datetime(
    2026,
    7,
    30,
    10,
    0,
    tzinfo=ZoneInfo(&quot;Asia/Shanghai&quot;),
)
print(local.astimezone(ZoneInfo(&quot;UTC&quot;)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据库通常保存 UTC，展示层再转换为用户时区。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;schedule&lt;/code&gt; 的适用边界&lt;/h2&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单机个人脚本；&lt;/li&gt;
&lt;li&gt;短时任务；&lt;/li&gt;
&lt;li&gt;允许人工恢复；&lt;/li&gt;
&lt;li&gt;不要求错过后补执行；&lt;/li&gt;
&lt;li&gt;没有复杂依赖关系。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不适合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多实例服务；&lt;/li&gt;
&lt;li&gt;任务必须执行且可追踪；&lt;/li&gt;
&lt;li&gt;需要持久化重试；&lt;/li&gt;
&lt;li&gt;需要并发控制、优先级或工作节点；&lt;/li&gt;
&lt;li&gt;任务执行数小时。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;更稳妥的替代方案：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Linux 单机命令&lt;/td&gt;
&lt;td&gt;systemd timer 或 cron&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;容器平台&lt;/td&gt;
&lt;td&gt;Kubernetes CronJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 分布式任务&lt;/td&gt;
&lt;td&gt;Celery、RQ 等任务队列&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;云平台&lt;/td&gt;
&lt;td&gt;托管调度器和消息队列&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;systemd timer 的优势&lt;/h2&gt;
&lt;p&gt;systemd 可以记录日志、限制权限并在重启后恢复调度。服务单元调用应用命令，定时器负责周期：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/systemd/system/report.timer
[Unit]
Description=Run report task every day

[Timer]
OnCalendar=*-*-* 08:20:00
Persistent=true

[Install]
WantedBy=timers.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Persistent=true&lt;/code&gt; 可以在机器恢复后触发错过的日历任务，但仍需保证任务幂等。&lt;/p&gt;
&lt;h2&gt;检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 时间输入格式是否明确
2. datetime 是否包含时区
3. 调度进程停止后是否允许漏执行
4. 任务是否幂等
5. 是否限制重试次数和总耗时
6. 是否有结构化日志和失败告警
7. 多实例是否使用了分布式协调
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Python ThreadPoolExecutor 并发实操</title><link>https://zh19990906.github.io/fuwari/posts/python-thread-pool/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/python-thread-pool/</guid><description>使用线程池执行 I/O 密集任务，并正确处理结果、异常、超时和资源释放。</description><pubDate>Tue, 02 Aug 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 适合把多个阻塞型 I/O 操作并发执行，例如 HTTP 请求、文件读取和数据库访问。它提供固定大小的工作线程池，比手动创建线程更容易回收资源和传播异常。&lt;/p&gt;
&lt;h2&gt;一个完整示例&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.request import urlopen


def fetch(url: str) -&amp;gt; tuple[str, int]:
    with urlopen(url, timeout=10) as response:
        return url, response.status


urls = [
    &quot;https://example.com&quot;,
    &quot;https://www.python.org&quot;,
]

with ThreadPoolExecutor(
    max_workers=4,
    thread_name_prefix=&quot;fetch&quot;,
) as executor:
    futures = {executor.submit(fetch, url): url for url in urls}

    for future in as_completed(futures):
        url = futures[future]
        try:
            _, status = future.result(timeout=15)
            print(url, status)
        except Exception as exc:
            print(url, type(exc).__name__, exc)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;with&lt;/code&gt; 会在退出时调用 &lt;code&gt;shutdown()&lt;/code&gt;，等待工作线程结束；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;submit()&lt;/code&gt; 返回 &lt;code&gt;Future&lt;/code&gt;，可以关联输入参数；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;as_completed()&lt;/code&gt; 按完成顺序返回，而不是提交顺序；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;future.result()&lt;/code&gt; 会把工作线程中的异常重新抛到主线程。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;code&gt;map&lt;/code&gt; 与 &lt;code&gt;as_completed&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;需要保持输入顺序且任务处理方式一致时，可以使用 &lt;code&gt;map&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from concurrent.futures import ThreadPoolExecutor


def normalize(value: str) -&amp;gt; str:
    return value.strip().lower()


with ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(normalize, [&quot; A &quot;, &quot; B &quot;, &quot; C &quot;]))

print(results)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;map&lt;/code&gt; 返回结果时保持输入顺序。某个靠前任务很慢时，即使后续任务已经完成，消费结果仍可能被阻塞。&lt;/p&gt;
&lt;p&gt;需要尽快处理已完成任务、记录每个输入的异常或做增量输出时，优先使用 &lt;code&gt;submit&lt;/code&gt; + &lt;code&gt;as_completed&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;线程数如何设置&lt;/h2&gt;
&lt;p&gt;线程数不是越多越快。需要考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;下游服务的并发限制；&lt;/li&gt;
&lt;li&gt;数据库连接池大小；&lt;/li&gt;
&lt;li&gt;单个请求的内存占用；&lt;/li&gt;
&lt;li&gt;文件描述符限制；&lt;/li&gt;
&lt;li&gt;超时和重试带来的请求放大。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以从较小值开始，通过吞吐、延迟和错误率逐步调整：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;max_workers = min(16, max(4, len(urls)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在每次函数调用时都创建一个巨大线程池。长期服务可以在明确的生命周期内复用线程池，退出时必须关闭。&lt;/p&gt;
&lt;h2&gt;超时与取消&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;future.result(timeout=5)&lt;/code&gt; 的超时只表示主线程等待结束，不会自动终止已经运行的底层阻塞调用。因此，真正的网络或数据库函数也必须设置自己的超时。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from concurrent.futures import TimeoutError

try:
    result = future.result(timeout=5)
except TimeoutError:
    cancelled = future.cancel()
    print(&quot;cancelled before start:&quot;, cancelled)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cancel()&lt;/code&gt; 只能取消尚未开始执行的任务。已经运行的线程不能被安全强杀，应让任务函数支持连接超时、截止时间或协作式停止标志。&lt;/p&gt;
&lt;h2&gt;异常处理&lt;/h2&gt;
&lt;p&gt;不要在工作线程中使用裸 &lt;code&gt;except&lt;/code&gt; 后静默返回 &lt;code&gt;None&lt;/code&gt;，这会让调用方误以为任务成功。可以让异常自然传播，并在主线程记录上下文：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for future in as_completed(futures):
    item = futures[future]
    try:
        value = future.result()
    except OSError as exc:
        logger.warning(&quot;I/O failed for %s: %s&quot;, item, exc)
    except Exception:
        logger.exception(&quot;unexpected failure for %s&quot;, item)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;批处理任务还应统计成功、失败和重试数量，而不是只打印异常。&lt;/p&gt;
&lt;h2&gt;什么时候不该使用线程池&lt;/h2&gt;
&lt;h3&gt;CPU 密集任务&lt;/h3&gt;
&lt;p&gt;纯 Python 的压缩、图像像素循环、密码学计算等 CPU 密集代码通常受 GIL 限制。可以考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ProcessPoolExecutor&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;NumPy、PyTorch 等会释放 GIL 的底层库；&lt;/li&gt;
&lt;li&gt;原生扩展或任务队列。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;超大任务集合&lt;/h3&gt;
&lt;p&gt;一次提交数百万个 &lt;code&gt;Future&lt;/code&gt; 会占用大量内存。应分批提交，或使用有界队列控制生产速度。&lt;/p&gt;
&lt;h3&gt;异步应用&lt;/h3&gt;
&lt;p&gt;在 FastAPI、aiohttp 等异步应用中，原生异步客户端通常更合适。只有无法替换的同步阻塞函数才放入线程池，并避免在线程池中再次创建事件循环。&lt;/p&gt;
&lt;h2&gt;实用封装&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from collections.abc import Callable, Iterable
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TypeVar

T = TypeVar(&quot;T&quot;)
R = TypeVar(&quot;R&quot;)


def run_parallel(
    items: Iterable[T],
    worker: Callable[[T], R],
    *,
    max_workers: int = 8,
) -&amp;gt; list[R]:
    results: list[R] = []
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(worker, item): item for item in items}
        for future in as_completed(futures):
            results.append(future.result())
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个封装保留了异常传播，但返回顺序是完成顺序。业务需要输入顺序时应保存索引，或直接使用 &lt;code&gt;executor.map&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;检查清单&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. 任务是否主要等待 I/O
2. 底层网络和数据库调用是否设置超时
3. 线程数是否低于下游连接限制
4. Future 异常是否被读取和记录
5. 线程池是否在退出时关闭
6. 是否避免一次提交过多任务
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;本文根据早期个人笔记重新整理，并结合当前通用实践进行了校对。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>YOLOv5 历史项目复现与维护</title><link>https://zh19990906.github.io/fuwari/posts/yolov5-legacy-practice/</link><guid isPermaLink="true">https://zh19990906.github.io/fuwari/posts/yolov5-legacy-practice/</guid><description>面向遗留系统说明 YOLOv5 原始仓库的环境隔离、训练、推理和迁移注意事项。</description><pubDate>Thu, 25 Jun 2020 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;YOLOv5 仍广泛存在于历史项目、行业部署和教学代码中。维护它时最重要的是保持原仓库、依赖和权重格式一致，不要直接套用新版本 &lt;code&gt;ultralytics&lt;/code&gt; 包的命令。&lt;/p&gt;
&lt;h2&gt;建立隔离环境&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/ultralytics/yolov5.git
cd yolov5
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果项目记录了具体提交或标签，应切换到对应版本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git checkout &amp;lt;tag-or-commit&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时保存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git rev-parse HEAD
python --version
python -m pip freeze &amp;gt; environment-lock.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;数据集结构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;datasets/example/
├── images/
│   ├── train/
│   └── val/
└── labels/
    ├── train/
    └── val/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;YOLO 检测标签通常每行表示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class_id x_center y_center width height
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;坐标按图像宽高归一化到 &lt;code&gt;0-1&lt;/code&gt;。训练前检查类别 ID 连续、框未越界、空标签符合预期。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;data.yaml&lt;/code&gt; 示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;path: /data/example
train: images/train
val: images/val
names:
  0: person
  1: vehicle
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;训练与验证&lt;/h2&gt;
&lt;p&gt;历史仓库常见命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python train.py \
  --img 640 \
  --batch 16 \
  --epochs 100 \
  --data data.yaml \
  --weights yolov5s.pt \
  --name example-v5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python val.py \
  --weights runs/train/example-v5/weights/best.pt \
  --data data.yaml \
  --img 640
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python detect.py \
  --weights runs/train/example-v5/weights/best.pt \
  --source images/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;具体参数以项目锁定版本的 &lt;code&gt;--help&lt;/code&gt; 和仓库文档为准。&lt;/p&gt;
&lt;h2&gt;权重兼容性&lt;/h2&gt;
&lt;p&gt;官方当前文档中的 YOLOv5u 属于现代 &lt;code&gt;ultralytics&lt;/code&gt; 包中的变体。原始 &lt;code&gt;ultralytics/yolov5&lt;/code&gt; 仓库训练权重与现代包不应默认互换。&lt;/p&gt;
&lt;p&gt;迁移前应明确：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;权重来自哪个仓库和提交；&lt;/li&gt;
&lt;li&gt;模型结构 YAML 是否自定义；&lt;/li&gt;
&lt;li&gt;推理输出和 NMS 逻辑是否被修改；&lt;/li&gt;
&lt;li&gt;导出脚本使用哪个版本；&lt;/li&gt;
&lt;li&gt;类别顺序、预处理和缩放方式是否一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;维护建议&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;将原环境构建成容器或可复现脚本。&lt;/li&gt;
&lt;li&gt;为关键图片保存期望检测结果和容差。&lt;/li&gt;
&lt;li&gt;不在原项目中直接升级全部依赖。&lt;/li&gt;
&lt;li&gt;新旧模型并行评估，再决定是否迁移。&lt;/li&gt;
&lt;li&gt;对外分发时重新确认当前许可证要求。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/ultralytics/yolov5&quot;&gt;Ultralytics YOLOv5 仓库&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ultralytics.com/yolov5/&quot;&gt;YOLOv5 官方指南&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item></channel></rss>