Akawa

ETY001的博客

背景

最近在本地一台 Jetson AGX Orin 设备(64GB 统一内存)上用 Ollama 跑 Qwen3.8-27B(IQ4_NL 量化,约 16GB)。这代模型原生支持 256K 上下文,官方文档明确说可以通过 YaRN 技术扩展到 1M。

我很好奇这个扩展到底怎么落地——不是用 vLLM 或 SGLang,而是 Ollama 这种”开箱即用”的推理框架。折腾了一晚上,踩了不少坑,记录一下完整过程。

先交代一下环境:一台懒猫算力仓(Jetson AGX Orin 开发套件),Ollama 跑在 Docker 容器里(懒猫 AI Pod 预装的 Ollama 服务,镜像名带 jetson-ollama 字样),模型文件挂载在 SSD 上。

一、前置知识:YaRN 是什么

YaRN(Yet another RoPE extensioN)是一种位置编码扩展技术,可以让训练时上下文有限的模型在推理时处理更长的文本,不需要重新训练。

Qwen3.8-27B 官方 README 给出了明确的 YaRN 配置:

  • rope_type: yarn
  • factor: 4.0(从 262144 扩展到 1048576,正好 4 倍)
  • original_max_position_embeddings: 262144
  • partial_rotary_factor: 0.25
  • rope_theta: 10000000

也就是说,模型权重不用动,只要让推理引擎在加载时启用 YaRN 缩放即可。

二、核心思路:改 GGUF 元数据

Ollama 底层是 llama.cpp,而 llama.cpp 会读取 GGUF 文件头部的元数据来决定 rope scaling 行为。所以思路很直接:

  1. 复制一份现有的 GGUF 文件
  2. 往元数据里添加 rope.scaling.* 字段(type=yarn, factor=4.0, original_context_length=262144)
  3. context_length 字段改成 1048576(这一步很关键,原因见下文)
  4. ollama create 基于新 GGUF 创建新模型

坑 1:gguf-set-metadata 不能新增字段

llama.cpp 官方提供了 gguf-set-metadata 工具,但它只能修改已存在的字段,新增字段会报 Field not found。而 rope.scaling.* 在原 GGUF 里根本不存在。

解决办法:用 Python 的 gguf 库写脚本,重新生成整个 GGUF。流程是读取原文件 → 复制所有元数据字段和 tensor → 添加新字段 → 写出新文件。16GB 的文件必须流式处理,不能一次性载入内存。

坑 2:tokenizer 数组复制会翻车

tokenizer.ggml.tokens 这类字符串数组,GGUF 里每个元素是”长度前缀 + 内容”两部分。Python 库读出来之后,如果索引取错,会把长度前缀当成字符串内容,导致 tokens 数量翻倍(248320 → 496640),加载时报 Index out of array bounds for toktypes

还有更隐蔽的:tokenizer 里有些字节不是合法 UTF-8,如果先 decode 再 encode 会直接崩,必须全程按原始字节处理。

坑 3:tensor shape 会被反转

GGUF 的 tensor shape 存储顺序和 numpy 的布局相反。写脚本时如果传错 shape,加载时会报 check_tensor_dims: wrong shape。这个坑不亲自踩一遍很难发现,因为大部分 tensor 恰好不受影响,只有特定结构的(比如 SSM 卷积层)会暴露出来。

坑 4:ollama 会把 num_ctx 钳制在 context_length

新模型创建好之后,我用 API 请求 num_ctx: 1048576,结果 ollama ps 显示 CONTEXT 还是 262144——ollama 的调度器会参考 GGUF 里的 context_length 字段做上限钳制。

解决办法:把 GGUF 的 context_length 元数据也改成 1048576。这相当于告诉 ollama”这个模型支持 1M”,加上 YaRN 字段告诉 llama.cpp”1M 时用 YaRN 缩放”,两边配合才完整。

改完重新 ollama createollama show 显示的 context length 就变成 1048576 了。

三、内存才是真正的瓶颈

模型建好了,但实测 1M 上下文加载时进程反复被杀。看日志:

1
2
llama_kv_cache: size = 34816.00 MiB (1048576 cells, 16 layers, 1/1 seqs)
llama_kv_cache: K (q8_0): 17408.00 MiB, V (q8_0): 17408.00 MiB

KV cache 直接吃了 34GB!加上模型权重 14GB 和各种缓冲,总需求 55GB 以上,逼近 61GB 物理内存上限。

这台设备上还跑着一个 earlyoom 守护进程(可用内存低于 5% 就主动杀进程),所以进程不是被 OOM killer 杀的,而是被 earlyoom 提前终止的——日志里表现为 signal: terminated 而不是 killed,排查起来更隐蔽。

解决方案:KV cache 量化

KV cache 精度从 q8_0 降到 q4_0,内存直接减半:

1
2
llama_kv_cache: size = 18432.00 MiB (1048576 cells, 16 layers, 1/1 seqs)
llama_kv_cache: K (q4_0): 9216.00 MiB, V (q4_0): 9216.00 MiB

总需求降到约 39GB,1M 上下文稳定运行,100% GPU,ollama ps 显示 CONTEXT=1048576。

q4_0 KV 对输出质量的影响很小(KV cache 量化是业界公认的低风险优化),但为了不动懒猫算力仓预装服务管理的原始 compose 文件,我用了 Docker Compose 的 override 机制:

1
2
3
4
services:
ollama:
environment:
- OLLAMA_KV_CACHE_TYPE=q4_0

override 文件放 compose 同目录自动加载,原始文件一行没改,以后懒猫算力仓系统升级也不会冲突。

四、别忘了视觉能力

Qwen3.8-27B 是多模态模型,原模型在 Ollama 里有主模型 GGUF + 两个 CLIP projector(F32 和 F16 版本各一个,tensor 结构完全一样)。

只 FROM 主模型创建的新模型会丢掉 vision 能力。Modelfile 里要把 projector 一起挂上:

1
2
3
4
FROM <模型存储路径>/blobs/sha256-<主模型>
FROM <模型存储路径>/blobs/sha256-<projector1>
FROM <模型存储路径>/blobs/sha256-<projector2>
TEMPLATE {{ .Prompt }}

挂上之后 ollama show 的 Capabilities 里就有 vision 了,传图测试识别正常。

五、最终结果与限制

最终模型参数:

  • 架构 qwen35,27.3B 参数,IQ4_NL 量化,约 16GB
  • 上下文:YaRN 扩展,context length = 1048576(1M)
  • 视觉:支持(CLIP projector 已挂载)

实测数据:

上下文 KV cache 总内存 状态
256K(原生) ~10GB ~26GB 正常
512K q8_0 17GB ~36GB 正常
1M q8_0 34GB ~55GB+ earlyoom 杀进程
1M q4_0 17GB ~39GB 正常

一个现实限制:1M 上下文 + 视觉编码同时使用会 OOM(视觉编码需要额外显存缓冲)。实测 512K + 视觉没问题,1M 下纯文本没问题。如果需要 1M 长上下文 + 视觉,得换更大的内存设备,或者把 KV 再压一压。

六、补充:外部客户端接入的隐藏限制

模型调好之后,还有个小插曲。另一台机器把懒猫算力仓的 Ollama 配成了 OpenAI 兼容的推理端点,跑 Hermes 之类的 AI 客户端。客户端在上下文较长时会做”压缩”操作(把历史对话总结成摘要),压缩请求一次会发几万 tokens。

结果压缩一直失败,报错:

1
request (24697 tokens) exceeds the available context size (8192 tokens)

排查后发现是第二个”数字陷阱”:Ollama 的 OpenAI 兼容 API 默认上下文是 OLLAMA_CONTEXT_LENGTH 环境变量,默认 8192。客户端走 /v1/chat/completions 时不传 num_ctx 参数(OpenAI 格式里没有这个字段),Ollama 就用 8192 兜底——哪怕模型本身支持 1M。

而我们前面改了 GGUF 的 context_length 元数据,客户端从模型信息里读到”这个模型支持 1M”,于是放心发大请求,结果被服务器的默认值卡住。

修复同样走 compose override,把默认上下文调大:

1
2
3
4
5
services:
ollama:
environment:
- OLLAMA_KV_CACHE_TYPE=q4_0
- OLLAMA_CONTEXT_LENGTH=131072

调成 128K 是因为:q4_0 KV 下 128K 大约 2GB/seq,内存无压力,同时覆盖了客户端压缩请求的规模(几万 tokens 级别)。如果以后客户端对话更长,再往上调即可;显式传 num_ctx 的请求不受这个默认值影响,仍然可以到 1M。

这个坑的启示:GGUF 元数据说”模型支持多少上下文”是一回事,服务器端实际允许多少是另一回事。Ollama 有两个不同的”上下文数字”:

数字 位置 作用
context_length(GGUF 元数据) 模型文件里 告诉调度器模型上限,决定 num_ctx 钳制值
OLLAMA_CONTEXT_LENGTH(环境变量) 服务器配置 OpenAI 兼容 API 的默认上下文,客户端不传参时兜底

排查这类问题先分清客户端走到的是哪条 API 路径、有没有显式传 num_ctx,再看对应的那个”上下文数字”。

七、总结

这次折腾的核心收获:

  1. Ollama 里做 YaRN 扩展 = 改 GGUF 元数据,不需要动模型权重,rope.scaling.* + context_length 两个字段配合
  2. 16GB 量级的 GGUF 重写必须流式处理,Python 库的 field.parts 布局和字符串数组结构有坑
  3. 大上下文的真正敌人是 KV cache 内存,q8_0 → q4_0 是最有效的调优手段
  4. 嵌入式设备的 earlyoom 会让 OOM 排查更难terminated 不等于崩溃,先查内存守护
  5. Docker Compose override 是改第三方服务参数的安全姿势,不动原始配置,可随时回退
  6. 模型支持多少上下文 ≠ 服务器默认允许多少,GGUF 元数据管 num_ctx 钳制,OLLAMA_CONTEXT_LENGTH 管 API 默认值,两套数字都要对齐

整个过程踩的坑已经整理成内部 skill,下次给其他模型做上下文扩展可以直接复用。

如果对模型上下文扩展感兴趣,可以看看:

背景

Dell R730xd 上跑着 PVE(Proxmox VE),12 块物理盘挂在 PERC H730P Mini 硬件 RAID 卡下面。某天 Steem witness 节点开始极度不稳定,频繁出块失败,同时 SSH 到内网其他机器也变慢。

本文记录了从故障排查到磁盘迁移、再到 iDRAC 邮件告警配置的完整过程。

一、故障现象

  • witness 节点频繁 RCU stall,steemd 进程周期性冻结
  • 区块同步出现 90+ 秒的 Block Time Offset
  • 532 次 unlinkable_block / fork 事件
  • 从其他网段 SSH 到 PVE 内网机器明显变慢

二、排查过程

2.1 定位故障盘

PVE 宿主机 dmesg 中发现大量 I/O 错误:

1
2
sd 0:0:9:0: [sdb] tag#518 FAILED Result: hostbyte=DID_ERROR driverbyte=DRIVER_OK cmd_age=170s
I/O error, dev sdb, sector XXXXXXXXXX op 0x0:(READ) flags 0x80700 phys_seg 64 prio class 0

cmd_age 最长达到 170 秒,意味着一个 I/O 请求卡了将近 3 分钟。

SMART 检查发现这块 Seagate ST6000NM0115 (6TB) 已经严重损坏:

SMART 属性 说明
Reallocated_Sector_Ct 38000+ 重映射扇区数万
Reported_Uncorrect 180+ 不可纠正错误
Seek_Error_Rate 数十亿 磁头寻道异常
G-Sense_Error_Rate 10 万+ 物理冲击记录
通电时间 42000+ 小时 约 5 年

2.2 根因分析

关键发现:sdb 每次 I/O 错误卡住时,PVE 宿主机内核 I/O 子系统全局阻塞,所有 VM 跟着冻结。

虽然 sdb 只挂在一个 VM 上作为数据盘,但内核处理 I/O 错误时是全局阻塞的,导致:

  • witness VM 内核触发 RCU soft lockup
  • steemd 停摆,收到的区块 Block Time Offset 飙到 90+ 秒
  • 分叉和 unlinkable block 大量出现

witness VM 的 RCU stall 时间和 sdb 的 I/O 错误时间完全吻合。

2.3 硬件 RAID 卡下的磁盘监控

PVE 是 Dell R730xd,12 块物理盘在 PERC H730P Mini 硬件 RAID 卡后面。OS 看到的 /dev/sdX 大多是 RAID 逻辑卷,不是物理盘。

smartmontools 可以穿透硬件 RAID 卡,使用 -d megaraid,N 参数直接读取每块物理盘的 SMART 数据:

1
2
3
4
5
6
7
8
# 查看 RAID 卡后面的物理盘
smartctl -a -d megaraid,9 /dev/sdc

# 物理盘列表(megaraid,0 ~ megaraid,11)
for i in $(seq 0 11); do
echo "=== megaraid,$i ==="
smartctl -i -d megaraid,$i /dev/sdc 2>&1 | grep -E "Device Model|Serial|Capacity"
done

12 块物理盘的映射关系:

megaraid 型号 容量 RAID 卷 用途
0-5 ST4000NM 系列 4TB ×6 RAID-10 VM 数据存储
6-7 SSD ×2 2TB ×2 RAID-0 VM SSD 存储
8 WDC WD20EARS 2TB JBOD VM 数据盘
9 ST6000NM0115 6TB JBOD 备份盘(故障盘)
10-11 ST1000DM010 1TB ×2 RAID-1 系统盘

三、磁盘迁移

3.1 创建新盘

在健康的 RAID-10 存储上创建一块 4T 虚拟磁盘替代故障盘:

1
2
3
4
# <storage> 替换为你的健康存储池名称
# <vmid> 替换为你的 VM ID
fallocate -l 4096G /mnt/pve/<storage>/images/<vmid>/vm-<vmid>-disk-N.raw
qm set <vmid> --scsi3 <storage>:<vmid>/vm-<vmid>-disk-N.raw,cache=writeback,size=4T

3.2 数据迁移

通过 qemu-nbd 在 PVE 宿主机上直接操作两块虚拟磁盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 挂载旧盘和新盘
qemu-nbd -c /dev/nbd1 -f raw /mnt/pve/<old_storage>/images/<vmid>/vm-<vmid>-disk-0.raw
qemu-nbd -c /dev/nbd2 -f raw /mnt/pve/<new_storage>/images/<vmid>/vm-<vmid>-disk-N.raw

mount -o ro /dev/nbd1 /mnt/tmp
mount /dev/nbd2 /mnt/newbackup

# rsync 迁移
rsync -aHAXx --info=progress2 /mnt/tmp/ /mnt/newbackup/

# 清理
umount /mnt/tmp /mnt/newbackup
qemu-nbd --disconnect /dev/nbd1
qemu-nbd --disconnect /dev/nbd2

3.3 移除故障盘配置

1
2
3
4
5
# 从 VM 配置移除旧盘
qm set <vmid> --delete scsi1

# 从 PVE 存储配置移除旧存储池
pvesm remove <old_storage_name>

四、smartd 配置

默认的 DEVICESCAN 只能扫描到 JBOD 直通的盘,RAID 卡后面的 10 块物理盘完全没监控到。

替换 /etc/smartd.conf,显式列出所有 12 块物理盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# RAID-10 卷后面的物理盘
/dev/sdc -d megaraid,0 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
/dev/sdc -d megaraid,1 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
# ...(0-5 共 6 块)

# SSD RAID-0 卷后面的物理盘
/dev/sdc -d megaraid,6 -a -H -l error -l selftest -m root
/dev/sdc -d megaraid,7 -a -H -l error -l selftest -m root

# JBOD 直通盘
/dev/sda -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
/dev/sdb -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root

# RAID-1 系统盘后面的物理盘
/dev/sdc -d megaraid,10 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
/dev/sdc -d megaraid,11 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root

五、磁盘健康巡检脚本

写了一个 cron 脚本每 4 小时巡检一次,检测到 SMART 异常时通过 Telegram 通知。

脚本的关键点:

  1. 使用 smartctl 绝对路径:cron 环境下 PATH 可能不包含 /usr/sbin
  2. timeout 包裹 smartctl:坏盘的 SMART 查询可能卡住
  3. 检测关键指标:Reallocated_Sector_Ct、Current_Pending_Sector、Offline_Uncorrectable、Reported_Uncorrect
  4. Telegram 通知走 HTTP 代理:国内服务器直连不了 Telegram API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
source <你的环境变量文件路径>

export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
SMARTCTL=/usr/sbin/smartctl
PROXY="http://<你的代理地址>:<端口>"

check_disk() {
local label="$1" dev="$2" opt="$3"
local result
result=$(timeout 15 $SMARTCTL -H $opt $dev 2>&1)
if ! echo "$result" | grep -q "PASSED"; then
ALERT="${ALERT}⚠️ ${label}: SMART FAILED\n"
fi
}

# ...检查所有 12 块盘...

crontab 配置:

1
0 */4 * * * <脚本路径>/disk-health-check.sh > /dev/null 2>&1

六、iDRAC 邮件告警配置

6.1 网络层问题排查

iDRAC 配置 SMTP 发送测试邮件一直报 RAC0225:发送测试邮件失败。通过在路由器上用 tcpdump 抓包定位:

1
2
3
# 在 OpenWrt 路由器上抓 iDRAC 的 SMTP 流量
# <idrac_ip> 替换为你的 iDRAC IP
tcpdump -i br-lan host <idrac_ip> and port 25 -n -A -s 0

抓到了 iDRAC 到邮件服务器的完整 SMTP 交互:

1
2
3
4
5
6
iDRAC: EHLO <mail_domain>
Server: 250-STARTTLS, 250-AUTH PLAIN LOGIN ...
iDRAC: STARTTLS
Server: 220 Ready to start TLS
iDRAC: <TLS ClientHello>
Server: <7 bytes> → FIN 断开

6.2 TLS 证书问题

根因:邮件服务器只有 ECC (ECDSA) 证书,iDRAC 的 TLS 客户端不支持。

iDRAC 强制要求 STARTTLS,但 Dell iDRAC 的 TLS 实现比较老,只支持 RSA 证书。TLS 握手时服务器只有 ECC 证书,无法完成 RSA 密钥交换,握手失败。

6.3 解决方案:Postfix 双证书

Postfix 3.4+ 支持 smtpd_tls_chain_files,可以同时配置 RSA + ECC 证书,TLS 握手时根据客户端 ClientHello 自动选择。

快速搭建一个SMTP邮件服务 中部署的 postfix 基础上:

1. 用 acme.sh 签一张 RSA 证书(已有 ECC 证书保留):

1
2
# <domain> 替换为你的域名
~/.acme.sh/acme.sh --issue --dns dns_cf -d <domain> --keylength 2048 --server letsencrypt

2. 安装 RSA 证书到 TLS 目录:

1
2
3
cp ~/.acme.sh/<domain>/<domain>.key /etc/postfix_conf/tls/<domain>.rsa.key
cp ~/.acme.sh/<domain>/fullchain.cer /etc/postfix_conf/tls/<domain>.rsa.crt
chmod 400 /etc/postfix_conf/tls/<domain>.rsa.*

3. 配置 Postfix 双证书:

1
2
3
4
5
6
7
8
9
10
11
# 用 smtpd_tls_chain_files 替代单独的 cert/key 配置
# <domain> 替换为你的域名
postconf -e "smtpd_tls_chain_files = /etc/postfix/tls/<domain>.key /etc/postfix/tls/<domain>.crt /etc/postfix/tls/<domain>.rsa.key /etc/postfix/tls/<domain>.rsa.crt"
postconf -e "smtpd_tls_cert_file ="
postconf -e "smtpd_tls_key_file ="
# 出站邮件同样配置
postconf -e "smtp_tls_chain_files = /etc/postfix/tls/<domain>.key /etc/postfix/tls/<domain>.crt /etc/postfix/tls/<domain>.rsa.key /etc/postfix/tls/<domain>.rsa.crt"
postconf -e "smtp_tls_cert_file ="
postconf -e "smtp_tls_key_file ="

postfix reload

4. iDRAC 面板 SMTP 配置:

  • SMTP 服务器:邮件服务器 IP
  • 端口:25
  • 启用 SMTP 认证
  • 用户名/密码:见 SASL 配置

6.4 验证

配置完成后,从 iDRAC 面板发送测试邮件成功。

七、经验总结

  1. 硬件 RAID 卡不是 SMART 监控的黑洞smartctl -d megaraid,N 可以穿透 RAID 卡读取物理盘 SMART 数据
  2. 一块坏盘可以拖垮整个宿主机:内核 I/O 子系统全局阻塞,所有 VM 跟着冻结,不要误以为是单个 VM 的问题
  3. cron 环境的 PATH 陷阱:cron 的默认 PATH 可能不包含 /usr/sbin,脚本中必须用绝对路径或显式 export PATH
  4. ECC 证书兼容性:老设备(iDRAC、旧客户端)可能不支持 ECDSA 证书,配置双证书(RSA + ECC)是最通用的方案
  5. tcpdump 是排查 SMTP 问题的利器:当应用层日志不可见时,在网络层抓包能看到完整的 SMTP 交互过程

问题现象

最近本地键盘出现一个诡异的小 bug:常按方向键时光标只移动一次,不会连续移动。而且不是 100% 复现:

  • 重启后通常能恢复;
  • 有时候莫名其妙就好了;
  • 刚刚插拔一次 USB 耳机(AB13x)之后也好了。

系统环境:Archlinux + XFCE + X11,键盘是 HHKB Classic,同时挂着 keyd 做键位映射。

排查过程

1. 先看 X11 的 key repeat 设置

1
xset q | grep -A 2 "auto repeat"

输出正常:

1
2
auto repeat:  on    key click percent:  0    LED mask:  00002
auto repeat delay: 500 repeat rate: 20

连发开关是开着的,参数也正常,问题不在 X11 的 repeat 配置。

2. 查看 X11 识别到的键盘设备

1
xinput list

输出里出现了很多“键盘”:

1
2
3
4
5
6
7
8
9
10
11
12
⎣ Virtual core keyboard                    id=3
↳ Virtual core XTEST keyboard id=5
↳ C-Media Electronics Inc. USB Audio Device id=11
↳ Video Bus id=12
↳ Power Button id=13
↳ AT Translated Set 2 keyboard id=14
↳ keyd virtual keyboard id=15
↳ Logitech MX Vertical id=17
↳ PFU Limited HHKB-Classic id=8
↳ PFU Limited HHKB-Classic Keyboard id=9
↳ PFU Limited HHKB-Classic Consumer Control id=10
↳ Generic AB13X USB Audio id=6

注意其中两个设备:

  • Generic AB13X USB Audio(就是插拔后能让问题暂时消失的 USB 耳机);
  • C-Media Electronics Inc. USB Audio Device(另一个 USB 音频设备)。

它们明明是音频设备,却被 X11 识别成了键盘。

3. 查看内核 input 设备

1
cat /proc/bus/input/devices

相关片段:

1
2
3
4
5
6
7
8
9
N: Name="C-Media Electronics Inc. USB Audio Device"
H: Handlers=kbd event8
B: EV=13
B: KEY=e000000000000 0

N: Name="Generic AB13X USB Audio"
H: Handlers=kbd event3
B: EV=13
B: KEY=3800000000 c000000000000 0

两个设备都被内核挂到了 kbd handler 上,意味着它们会通过 evdev 向用户空间发送 key 事件。AB13X 的 KEY bitmap 里包含音量加减键(114/115),这就是 USB 耳机上的媒体控制被暴露成了 HID 键盘。

4. 为什么音频设备会干扰真实键盘的连发?

X11 的 key repeat 是全局计时器管理的。当系统里存在多个“键盘”时,某些 USB 音频设备的 HID 接口会时不时发送事件(或者仅仅因为被枚举为键盘),这会干扰 X11 对真实键盘长按状态的计时,表现为“按一次只触发一次”。

这也能解释为什么:插拔 AB13X 耳机后问题会暂时消失——因为插拔让 X11 重新枚举了键盘设备,清除了干扰状态。

解决方案

目标很明确:让 X11 不再把这两个 USB 音频设备当作键盘。音频功能完全不受影响,只是禁用它们的 HID 媒体按键(如果原本能用的话)。

1. 立即生效(当前会话)

1
2
xinput disable 'Generic AB13X USB Audio'
xinput disable 'C-Media Electronics Inc. USB Audio Device'

执行后 xinput list 会显示它们变成了 [floating slave],不再挂载到主键盘下。

2. 持久生效(重启后仍然有效)

创建 X11 InputClass 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sudo tee /etc/X11/xorg.conf.d/50-ignore-usb-audio-keyboards.conf <<'XORG'
# USB audio devices sometimes expose HID media/volume controls as keyboards.
# When X11 treats them as keyboards, they can interfere with key repeat
# on real keyboards (e.g. long-press arrow keys only fire once).
# These devices still work for audio; we just prevent X from using them
# as input devices.

Section "InputClass"
Identifier "Ignore AB13X USB Audio as keyboard"
MatchProduct "AB13X"
Option "Ignore" "on"
EndSection

Section "InputClass"
Identifier "Ignore C-Media USB Audio as keyboard"
MatchProduct "C-Media Electronics Inc. USB Audio Device"
Option "Ignore" "on"
EndSection
XORG

下次登录/重启 X11 后配置自动生效。

验证

执行完 xinput disable 后,立即长按方向键测试,连发应该已经恢复。如果想确认持久配置是否生效,可以重启后在终端里再看:

1
xinput list | grep -i "AB13X\|C-Media"

应该看到类似下面这样(floating slave 表示被忽略):

1
2
∼ C-Media Electronics Inc. USB Audio Device  id=11  [floating slave]
∼ Generic AB13X USB Audio id=6 [floating slave]

副作用

  • 这两个 USB 音频设备的音量/静音等物理按键会失效(如果它们之前有效的话)。
  • 音频播放、通话、麦克风功能完全不受影响

如果之后发现还需要用耳机上的音量键,可以只保留 AB13X 的配置,或者改用 xinput float 而不是 Ignore,灵活处理。

附:keyd 的小提示

排查过程中还发现 keyd 的配置里有警告:

1
WARNING: ... You should use layer(control) instead of assigning to leftcontrol directly.

当前配置:

1
2
leftcontrol = capslock
capslock = leftcontrol

虽然这和连发问题关系不大,但建议把 capslock = leftcontrol 改成 capslock = layer(control),避免 keyd 后续版本行为变化。

总结

Linux 下 USB 音频设备(尤其是带线控的耳机)经常会把自己的媒体控制暴露成 HID 键盘。当 X11 同时管理这些“伪键盘”和真实键盘时,就可能出现长按不连发这种玄学问题。通过 xinput disable 临时禁用、配合 xorg.conf.d 持久忽略,可以彻底解决问题。

最近公司开发人员的 VPN 从 Easyconnect 迁移到 Cloudflare WARP 了,真的是令人振奋人心,比起 Easyconnect 来说, WARP 对于 Linux 环境友好太多太多了!

由于流量走公司 VPN 的时候,会有审计行为,因此作为在家工作的我,都是用 Mihomo 把涉及公司资源的访问单独路由到一个虚拟机,虚拟机里登陆公司 VPN。

为了节省资源,我的虚拟机安装的是不带桌面的 Archlinux,因为 WARP 提供 CLI 方式登陆。另外安装 Mihomo 作为 http 代理。

VPN 登陆后,路由表会发生变化,还需要安装 FRP 把 http 代理映射到路由器上。

最后就是在本地的 Mihomo 做好各种转发规则。

用了半天,发现处于 VPN 内的公司 JumpServer 服务经常出现 AccessDenied 的情况。一开始以为是自己触发了什么规则,后来在 Windows 下面测试,才知道是会话过期的原因。

这就有些蛋疼了。不能每次会话过期就要登陆虚拟机,重新登陆 vpn 吧。

经过研究,打算用 webhook 在虚拟机里做个轻量钩子,本地登陆后把 token 通过钩子传给虚拟机的指定脚本,完成自动重登录。

借助 Deepseek ,很快就把几个脚本完成了。下面就是各个脚本:

/etc/webhook/hooks.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- id: re-reg
execute-command: /data/warp/webhook.sh
command-working-directory: /home/ety001
incoming-payload-content-type: application/json
response-headers:
- name: Access-Control-Allow-Origin
value: "*"
- name: Access-Control-Allow-Methods
value: "GET, POST, OPTIONS"
- name: Access-Control-Allow-Headers
value: "Content-Type"
response-message: ok
http-methods:
- "POST"
- "OPTIONS"
pass-arguments-to-command:
- source: payload
name: token

执行 sudo systemctl enable --now webhook 即可启动钩子。

/data/warp/webhook.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash

warp-cli disconnect
warp-cli registration delete

if [ -z "$1" ]; then
exit 1
fi

#token=$(echo "$1" | awk -F"'" '{print $2}')
token=$1

warp-cli --accept-tos registration token "${token}"

sudo warp-cli dns endpoint set x.x.x.x
sudo warp-cli api endpoint set x.x.x.x
sudo warp-cli tunnel endpoint set x.x.x.x:2408

warp-cli connect

本地使用下面的命令创建 xdg-open 处理程序

1
2
3
4
5
6
7
8
9
10
11
12
mkdir -p ~/.local/share/applications

cat > ~/.local/share/applications/warp-handler.desktop <<EOF
[Desktop Entry]
Type=Application
Name=WARP Handler
Exec=/data/app/handler.sh %u
MimeType=x-scheme-handler/com.cloudflare.warp;
NoDisplay=true
EOF

update-desktop-database ~/.local/share/applications

handler.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash

# 提取完整 URL
full_url="$1"

# 提取 token 部分(去掉协议头)
#token="${full_url#com.cloudflare.warp://}"
token=${full_url}

# 构造 JSON 数据
json_data="{\"token\": \"$token\"}"

echo $json_data
# 发送 POST 请求
curl -X POST \
-H "Content-Type: application/json" \
-d "$json_data" \
http://192.168.x.1:9000/hooks/re-reg

测试一下

当出现 JumpServer 无法访问的情况,那么直接在本地浏览器访问一下公司的 WARP 地址,完成登陆后,如下图:

image.png

点击弹出的对话框的打开 xdg-open,就可以把 token 传到虚拟机完成 warp 的重新登陆。

网络折腾好以后,终于可以继续安心工作了!本来就紧凑的工作节奏,耽误两天就积压了一大堆。。。。

背景

手里有一台 dzire 的廉价独服,配置如下:

项目 参数
CPU Intel Xeon E31270(8 核)
内存 16GB DDR3
磁盘 机械硬盘(HDD)
用途 Hivemind 同步 Steem 区块数据

Hivemind 是 Steem 链的社交层索引器,通过 Docker Compose 部署,其中 PostgreSQL 数据库承载了大量的写入和查询操作。同步一段时间后,数据库体积达到了 431GB,其中最大的几张表:

表名 大小
hive_posts_cache 216GB
hive_trxid_block_num 110GB
其他表 ~105GB

问题来了:同步进程始终落后链头几百个区块,追不上。

问题诊断

症状

tophtop 里最显眼的指标是 **IO Wait 持续 25%~35%**,CPU 大量时间在等磁盘。

确认瓶颈

iostatvmstat 进一步确认:

1
2
3
4
5
6
7
8
# iostat -x 1 观察
Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s %util await
sda 0.00 45.20 12.30 120.50 0.15 3.80 95.2 28.5

# vmstat 1 观察
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 3 0 2100000 450000 8500000 0 0 120 380 2500 4500 15 8 42 32 3

关键信号:

  • **%util 接近 100%**:磁盘已经满负荷
  • **wa(IO Wait)持续 25~35%**:CPU 大量时间在等 IO
  • **w/s 高达 120+**:大量小随机写(WAL + 数据文件)

结论:磁盘 IO 是瓶颈,毫无疑问。

在 SSD 上这些问题可能根本不会出现,但在 HDD 上,PostgreSQL 默认配置的检查点频率、WAL 刷写策略会导致大量小随机写,对机械硬盘来说是灾难性的。

调优思路

核心策略:减少磁盘写频率,增大批量写入,降低 fsync 开销

具体来说:

  1. **增大 shared_buffers**:让更多热数据留在内存,减少磁盘读取
  2. 增大 max_wal_size + **延长 checkpoint_timeout**:减少检查点频率,让每次检查点写出更多数据(顺序写更友好)
  3. 放宽 WAL 刷写策略wal_writer_delaywal_writer_flush_after 调大,减少 fsync 次数
  4. 激进调优 bgwriter:让后台写出进程多干活,减少检查点时的突发写入
  5. 关闭 full_page_writes:HDD 上这个特性会导致每次检查点后首次修改页写出完整页,极大放大写入量。注意:这依赖文件系统支持原子写(如 ZFS),否则断电有数据损坏风险。 本场景是同步数据,可以接受这个风险。

⚠️ 警告:第二轮激进配置的前提是——这台机器只做同步,不对外提供服务。如果你在生产环境中使用,请谨慎评估每一个参数。

具体配置

第一轮调优(保守)

初步调整,先把 PostgreSQL 的默认参数改到合理范围:

参数 默认值 调整后
shared_buffers 128MB(或 4GB) 6GB
max_wal_size 1GB(或 2GB) 4GB
checkpoint_timeout 5min(或 15min) 30min

这一轮调完后,IO Wait 有所下降但仍然不够,同步仍然落后。

第二轮调优(激进)

因为这台机器的唯一任务就是追赶同步,不需要担心崩溃恢复后的数据一致性(大不了重新同步),于是上了激进方案:

参数 第一轮值 激进值 说明
shared_buffers 6GB 8GB 占总内存 50%
max_wal_size 4GB 8GB 允许 WAL 累积更多再检查点
checkpoint_timeout 30min 1h 每小时才做一次检查点
wal_writer_delay 200ms 2s WAL writer 每 2 秒才醒来一次
wal_writer_flush_after 1MB 16MB 累积 16MB 才 fsync
bgwriter_delay 200ms 5s 后台写出进程降低唤醒频率
bgwriter_lru_maxpages 100 5000 每次醒来多写出一些脏页
bgwriter_lru_multiplier 2.0 10.0 更激进地预写出
full_page_writes on off 关闭全页写,大幅减少 IO

完整的 postgresql.conf(16g-postgres.conf)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# =============================================================
# PostgreSQL 16GB HDD 独服调优配置 - Hivemind 同步专用
# 注意:激进配置,仅适用于同步/非生产场景
# =============================================================

# ---- 内存 ----
shared_buffers = 8GB
work_mem = 64MB
maintenance_work_mem = 1GB
effective_cache_size = 12GB

# ---- WAL / 检查点 ----
max_wal_size = 8GB
min_wal_size = 2GB
checkpoint_timeout = 1h
checkpoint_completion_target = 0.9
wal_writer_delay = 2s
wal_writer_flush_after = 16MB
full_page_writes = off
wal_buffers = 64MB

# ---- 后台写出 ----
bgwriter_delay = 5s
bgwriter_lru_maxpages = 5000
bgwriter_lru_multiplier = 10.0

# ---- 查询规划 ----
random_page_cost = 4.0
effective_io_concurrency = 2
max_parallel_workers_per_gather = 2
max_parallel_workers = 4
max_parallel_maintenance_workers = 2

# ---- 连接 ----
max_connections = 100

# ---- 日志(方便观察) ----
log_checkpoint = on
log_connections = on
log_disconnections = on
log_lock_waits = on
log_temp_files = 0

# ---- autovacuum ----
autovacuum = on
autovacuum_max_workers = 4
autovacuum_naptime = 30s

几个值得解释的点:

  • **random_page_cost = 4.0**:HDD 的随机访问代价远高于 SSD(SSD 通常设 1.1),告诉规划器尽量走顺序扫描或索引扫描。
  • **effective_io_concurrency = 2**:HDD 的并发 IO 能力有限,设高了没意义。
  • **autovacuum_naptime = 30s**:Hivemind 同步产生大量 UPDATE/INSERT,频繁 vacuum 有助于避免表膨胀。
  • **full_page_writes = off**:这个最激进。正常情况下 PostgreSQL 在检查点后首次修改一页时会写出完整页(防止部分写撕裂)。关闭后每次只写差异,大幅减少写入量。如果你的文件系统是 ZFS 或使用了带电池保护的 RAID 控制器,风险可控。

Docker Compose 配置片段

PostgreSQL 配置文件放在 docker-compose.yml 同目录下,通过 volume 挂载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
postgres:
image: postgres:15
restart: unless-stopped
shm_size: '2gb'
ports:
- "5432:5432"
environment:
POSTGRES_USER: hivemind
POSTGRES_PASSWORD: your_password_here
POSTGRES_DB: hivemind
volumes:
- ./data/postgresql:/var/lib/postgresql/data
- ./16g-postgres.conf:/etc/postgresql/postgresql.conf
command: postgres -c config_file=/etc/postgresql/postgresql.conf
deploy:
resources:
limits:
memory: 14g
reservations:
memory: 10g

注意 shm_size: '2gb' 不能省——PostgreSQL 的 shared_buffers 依赖共享内存,默认的 64MB 远远不够。

效果对比

指标 默认配置 第一轮(保守) 第二轮(激进)
IO Wait 25~35% ~22% ~15%
同步速度 落后链头数百块 略有改善 稳定追赶,差距持续缩小
检查点写入突发 频繁且剧烈 减少但仍有突发 平滑,几乎无感

IO Wait 从 25~35% 降到 15%,看似降幅不大,但对于 HDD 来说这已经是巨大的改善。关键改善在于写入模式——从大量小随机写变成了少量大批量顺序写,这正是机械硬盘最擅长的工作模式。

总结

  1. 先诊断再动手:用 iostatvmstatpg_stat_bgwriter 定位瓶颈,不要盲目改参数
  2. HDD 的核心敌人是随机写:所有调优方向都围绕”把随机写变顺序写、把频繁写变批量写”
  3. 激进配置有代价full_page_writes = off 在断电时有数据损坏风险,生产环境慎用
  4. 资源要给够shared_buffers 给到总内存的 50%,shm_size 别忘了调大
  5. 同步场景可以激进:最坏情况就是重新同步,不用过于保守

如果你的独服也是 HDD,且在跑重 IO 的数据库同步任务,希望这篇能帮到你。

背景

日常开发中,本地工作机的磁盘空间和 CPU 资源往往比较紧张,尤其是在编译大型 Docker 镜像时,构建缓存和中间层会占用大量磁盘空间。与此同时,家里可能有一台配置更强的服务器,磁盘充裕但平时处于闲置状态。

有没有一种优雅的方式,能让本地开发机把 Docker 构建任务分发到远程服务器上执行,同时又不影响本地开发体验?

答案是 Docker Context + Buildx Remote Builder

原理

Docker CLI 原生支持通过 SSH 连接远程的 Docker daemon。配合 buildx,可以创建一个远程 builder,所有 docker buildx build 命令会自动通过 SSH 把构建任务发送到远程机器上执行。

1
2
3
4
5
6
7
8
9
本地工作机                         远程服务器
┌──────────────┐ SSH 隧道 ┌──────────────┐
│ docker CLI │ ─────────────────▶│ Docker D │
│ buildx │ ssh://remote │ BuildKit │
│ 源码目录 │ │ 构建缓存 │
└──────────────┘ └──────────────┘
│ ▲
│ docker buildx build ────────────┘
│ (自动通过 SSH 发送构建指令)

整个过程中,源码通过 SSH 上下文传递给远程 BuildKit,构建在远程服务器上执行,构建缓存也累积在远程机器上。本地工作机只负责发送构建指令和接收构建日志。

具体步骤

1. 创建 Docker Context

1
docker context create remote --docker "host=ssh://user@remote-server"

这样 docker -c remote ps 就等价于 ssh user@remote-server docker ps,Docker CLI 自动走 SSH 隧道。

如果已经在 ~/.ssh/config 中配置了主机别名(比如 Host remote),直接写 ssh://remote 即可。

2. 创建远程 buildx builder

1
docker buildx create --name remote-builder remote --use

这会在远程服务器上启动一个 BuildKit 容器,所有 docker buildx build 都会走这个 builder。

首次创建时需要拉取 moby/buildkit 镜像,取决于网络情况可能需要一些时间。

3. 使用方式

在项目目录下执行:

1
2
3
4
5
6
7
8
# 构建镜像(镜像存在远程服务器上)
docker buildx build -t myapp:latest -f Dockerfile .

# 构建并推送到 registry
docker buildx build -t myapp:latest -f Dockerfile --push .

# 构建后拉回本地(镜像通过 SSH 传回工作机)
docker buildx build -t myapp:latest -f Dockerfile --load .

4. 切换回本地构建

1
docker buildx use default

注意事项

镜像位置

默认情况下,docker buildx build 构建出来的镜像存在于远程服务器上,本地工作机的 docker images 是看不到的。

如果需要在本地运行该镜像,需要加 --load 参数,但这会把镜像通过 SSH 拉回本地,占用本地磁盘空间。更推荐的做法是加 --push 直接推送到 registry。

首次 bootstrap 较慢

首次创建 builder 时,远程服务器需要拉取 moby/buildkit 镜像。如果通过跳板机连接,网络延迟较高,拉取可能需要几分钟。耐心等一下就好。

构建缓存

远程 builder 有自己的 BuildKit 缓存,同一个项目第二次构建时会命中缓存,速度明显更快。不同项目的缓存互相隔离,不会互相干扰。

不侵入现有流程

这个方案不需要修改 Dockerfile,不需要改 CI/CD 配置。只需要在构建时指定用哪个 builder,其他一切照旧。

总结

通过 Docker Context + Buildx,可以零成本地把重型构建任务卸载到闲置的远程服务器上,释放本地工作机的磁盘和 CPU 资源。对于本地磁盘紧张但又需要频繁构建大型镜像的场景,这是一个非常优雅的解决方案。

之前在我的 用 Docker 快速部署 PPTP VPN 和 L2TP + IPSEC VPN 一文中,我介绍了如何使用 Docker k快速署 L2TP IPSec VPN,但是在纯血鸿蒙下一直无法成功。

发现是因为无法匹配通讯加密方法。

进入容器,如下修改一下 /etc/ipsec.conf 文件的通讯加密方法,

1
2
3
4
5
conn l2tp-psk
...
ike=aes128-sha1-modp1024,aes256-sha1-modp1024 # 匹配客户端的AES-CBC + SHA1 + MODP1024
phase2alg=aes128-sha1 # 第二阶段算法也需匹配
...

之后重启服务即可,service ipsec restart

简介

hclient-cli 是官方推出的一个面向无图形界面的机器的接入方案。

有这个工具的情况下,我们可以让我们远端的服务器接入到懒猫的网络中,访问懒猫上的数据资源。

使用

官方库文档有接口的使用说明,这里就不再阐述,我提交了一个 PR,对官方的接口简单的用 bash 脚本封装了一下,减少使用过程中的输入量。同时还包含了一个构建 Docker 镜像的方案,其实就是把 cli 程序加入到镜像中。

目前我的远端服务是 Docker 容器形式运行的,因此这里分享一下 Docker 使用样例。

这里的远端应用以我的远端服务器上的青龙面板为例。

首先给出一个 Docker 容器启动命令示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
docker run -itd \
--privileged \
--name lazycat \
--hostname lazycat_in_docker \
--restart always \
--network lnmp \
--ip 172.20.0.66 \
-p 127.0.0.1:7777:7777 \
-p 127.0.0.1:61090:61090 \
-v /data/cfg:/app/cfg \
ety001/lazycat-cli:latest \
/app/hclient-cli \
-api-addr "172.20.0.66:7777" \
-http-addr "172.20.0.66:61090"
  1. 请注意不要暴露你的 7777 管理端口和 61090 代理端口给全局网络
  2. 容器启动后,所有 lnmp 网络中的容器可以通过 172.20.0.66:61090 代理访问懒猫资源。
  3. 宿主机可以通过 127.0.0.1:7777 管理,通过 127.0.0.1:61090 代理访问。

在宿主机中,调用 cmd.sh (在我的那个 PR 中)完成懒猫网络的登陆。

1
2
3
./cmd.sh add_box
./cmd.sh add_tfa
./cmd.sh client_info

登陆成功后,打开远端的青龙面板,安装依赖 axioshttps-proxy-agent。之后增加环境变量配置如下:

1
2
3
LAZYCAT_PROXY=http://172.20.0.66:61090
INFLUXDB_TOKEN=xxxx
INFLUXDB_URL=http://influxdb.ecat.heiyu.space:8086

创建一个新的测试 js 脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');

const proxy = process.env.LAZYCAT_PROXY;
const agent = new HttpsProxyAgent(proxy);

// 自定义 HTTP 客户端,使用 axios 和代理
const customHttpClient = axios.create({
httpAgent: agent,
httpsAgent: agent,
});

const token = process.env.INFLUXDB_TOKEN;
const url = process.env.INFLUXDB_URL;
const org = 'default';
const bucket = 'steem';


// 定义 Flux 查询
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "price")
|> filter(fn: (r) => r._field == "lowest_ask")
`;

// 发送查询请求
async function queryData() {
try {
const response = await customHttpClient.post(
`${url}/api/v2/query?org=${org}`,
{
query: fluxQuery,
type: 'flux',
},
{
headers: {
Authorization: `Token ${token}`,
'Content-Type': 'application/json',
},
}
);
console.log('查询结果:', response.data);
} catch (error) {
console.error('查询数据时出错:', error.response ? error.response.data : error.message);
}
}

queryData();

调试运行,成功获取到懒猫微服上的 Influxdb 中的数据。

image.png

今天发现了一个有意思的工具 —— 青龙面板

平时经常会有一些零零碎碎的脚本需要运行,放在宿主机运行,就要安装 nodejs 的环境,如果放在 docker 里运行,就要写 Dockerfile ,太繁琐。

使用这个青龙面板,相当于直接拥有一个容器空间,自带 python, nodejs 环境。可以理解为是云服务商的那种 server less 服务。

有这些特性很方便

1.按计划拉取指定的 repo 的指定分支。

image.png

这样我们可以把零碎的脚本放在一个独立的代码库里面,进行版本管理。

当然,如果你想要立即执行拉取任务,也提供了单独的运行按钮可以立即执行。

2.共享依赖。

06bbe3ec9a2257829a8383ed4c432d88.jpg

独立的依赖管理面板,可以只需要安装一次依赖,就可以所有脚本都使用。

3.在线编辑/调试代码。

image.png

这是拉取下来的代码,可以在线编辑。

image.png

这是调试界面,可以实时调试。

4.核心功能——计划任务

image.png

编辑好的脚本,可以在这里设置计划任务,让脚本按计划运行。

在 React 中,useEffect 的依赖项决定了其什么时候执行。

基本语法

useEffect 接受两个参数:

  • 副作用函数:在组件渲染后或依赖项发生变化时执行的函数。
  • 依赖项数组(可选):决定副作用函数何时重新执行。
1
2
3
useEffect(() => {
// 副作用逻辑
}, [dependency1, dependency2, ...]);

依赖项的常见场景与解释

1.无依赖项数组(每次渲染都会执行): 如果不传入依赖项数组,useEffect 中的副作用函数将在每次组件渲染后都执行。

1
2
3
useEffect(() => {
console.log('This runs after every render');
});

场景:这种用法不常见,通常用于希望在每次渲染时执行某些逻辑,但要注意性能开销。

2.空依赖项数组(只在组件挂载和卸载时执行): 如果传入一个空数组 [],则 useEffect 只会在组件挂载时运行一次,并在组件卸载时运行清理函数(如果提供了)。

1
2
3
4
5
6
useEffect(() => {
console.log('This runs only on mount');
return () => {
console.log('This runs on unmount');
};
}, []);

场景:通常用于初始化数据(例如组件挂载时的 API 请求),或在组件卸载时执行清理操作(例如清理定时器、取消订阅等)。

3.具有依赖项的数组(依赖项变化时执行): 当传入特定的依赖项时,useEffect 只有在这些依赖项的值发生变化时才会重新执行。

1
2
3
useEffect(() => {
console.log('This runs when count changes');
}, [count]); // 当 count 变化时副作用重新执行

场景:用于根据某个特定状态或 prop 变化来执行副作用逻辑。例如,当 count 变化时,可能需要重新获取某些数据或触发其他副作用。

4.多个依赖项: 可以在依赖项数组中传入多个依赖项,useEffect 将会在其中任何一个依赖发生变化时重新执行。

1
2
3
useEffect(() => {
console.log('This runs when count or user changes');
}, [count, user]);

场景:用于当多个状态或 prop 变化时,重新执行某个逻辑。例如,可能需要在 count 或 user 变化时重新发起某个 API 请求。

如何选择依赖项

1.依赖项的选择要遵循以下原则:

  • 副作用函数中使用的所有外部变量:任何在 useEffect 中使用的外部变量(函数组件中的状态、props 等)都应该作为依赖项传入。这是因为这些变量在每次渲染时都可能更新,你需要确保 useEffect 在依赖的值发生变化时执行。
  • dispatch 和其他稳定函数:通常像 dispatch(来自 useDispatch)这样的函数引用是稳定的(即在多次渲染之间不会改变),所以可以安全地放入依赖项数组中。如果某个函数引用在不同渲染周期中保持不变,就可以把它作为依赖项。

2.常见的误区与优化:

  • 忘记依赖项:如果 useEffect 中依赖的某些值没有包含在依赖项数组中,可能会导致副作用函数使用了过时的值(也称为“闭包陷阱”)。例如,依赖于某个 state 却没有将其加入依赖项数组,这样的结果是 useEffect 中的逻辑不会随着 state 的变化重新执行。
  • 过度依赖不必要的变量:有时候不小心将不必要的变量放入依赖项数组,导致 useEffect 过于频繁地执行,浪费性能。例如,不必要地将某些稳定的变量放入依赖项。

ET碎碎念,每周更新,欢迎订阅,点赞,转发!


好用不贵的VPS推荐

https://1hour.win

0%