如何在香港服务器运行Debian系统时,利用 ZFS ARC 把数据库查询“喂”得飞快

台风登陆香港的那个晚上,机房外面呼呼作响,机房里只有我和一台咆哮的 1U 服务器。运营同事在群里喊:“查询延迟从 30ms 飙到 180ms 了!”我盯着 pg_stat_statements 和磁盘队列,心里很清楚:I/O 抢不过来。那一夜,我把数据库从 ext4 迁到 ZFS,调了 ARC/L2ARC 和 SLOG,第二天早上业务高峰时 TPS 提升了 ~42%,P95 延迟回到了 40ms 左右。下面就是我复盘下来的完整操作与坑点,尽可能写清楚每一步、每一个参数。
1. 环境与目标
机型与硬件(香港机房托管/租用的常见规格之一)
- CPU:AMD EPYC 7443P(24C/48T,3.0GHz)
- 内存:128GB DDR4 ECC
- 系统盘:SATA SSD 480GB(Debian 系统&工具)
- 数据盘:NVMe SSD ×2(3.84TB ×2,企业级,带 PLP)——做 ZFS pool
- 日志盘/SLOG:Intel P5800X 400GB(选择其 16–32GB 分区做 SLOG,剩余闲置)
- 网络:10GbE(香港—内地链路 5–20ms,取决于时段与线路)
软件栈
- OS:Debian 12(bookworm,内核 6.1 LTS)
- ZFS:OpenZFS 2.2.x(Debian 仓库)
- 数据库:PostgreSQL 15(生产库),MySQL 8(报告服务)
- 监控:Prometheus + node_exporter + postgres_exporter + 自写 arcstat 采集脚本
性能目标
- 把热点查询命中率拉高(ARC/L2ARC 命中率 > 90%/70%)
- 高峰期 TPS 至少 +30%,P95 延迟降低 30%+
- 写安全(崩溃后一致性)不打折:SLOG + sync=standard + logbias=latency
2. 为什么用 ZFS + ARC(而不是“再加点内存”)
ARC = ZFS 自带的页缓存,在内核缓存之外再做一层智能缓存。好处是:
- 可控:zfs_arc_max 可以硬限制,避免把内存吃光。
- 感知数据布局:配合 recordsize 和 压缩,缓存更贴合数据库页读。
- L2ARC = 二级缓存:把冷热之间的数据放在 NVMe 上,命中就不走转盘或慢 SSD。
- SLOG(ZIL 日志设备):把同步小写的低延迟落到带 PLP 的专用盘,减少主池写延迟。
对数据库(尤其是 PostgreSQL 的 8KB 页)来说,把 ZFS 的 recordsize 对齐到数据库页大小,再把 ARC/L2ARC 填满热点,读放大会明显下降;配合 SLOG,同步写的尾延迟也能稳住。
3. 规划与容量分配
3.1 内存分配原则(以 128GB 为例)
- PostgreSQL shared_buffers:32GB(25%)
- ZFS ARC(zfs_arc_max):64GB(50%)
- 系统与其他缓存/进程:~32GB(25%)
经验:PG 的 shared_buffers 与 ARC 是两层缓存,不要两边都拉满。我更愿意给 ARC 多一些,原因是同机还有 MySQL/日志/文件服务也能受益。
3.2 ZFS 池与数据集设计
- 池(pool):tank
- vdev:mirror(NVMe ×2 镜像),ashift=12(对齐 4K),compression=zstd-3
- SLOG:从 P5800X 划一个 32GB 分区,作为 log vdev
数据集:
- tank/pgdata(PostgreSQL 数据):recordsize=8K,logbias=latency,atime=off,xattr=sa
- tank/mysqldata(InnoDB):recordsize=16K(匹配 InnoDB 页),其余同上
- tank/pgwal(PG WAL):可单独 dataset,recordsize=128K,logbias=latency
坑点提示:recordsize 修改只对新写入的块生效。已有数据要么 pg_dump/restore,要么触发重写(如 VACUUM FULL/重建索引)。我踩过一次“以为改了就全对齐”的坑,命中率提升不明显,最后夜间做了一次 dump/restore,命中率才上来。
4. 详细部署步骤(一步步照做)
4.1 安装 ZFS
# 1) 启用 contrib 源
sudo apt update
sudo apt install -y dpkg-dev linux-headers-$(uname -r) build-essential
# 2) 安装 ZFS(DKMS 自动编译模块)
sudo apt install -y zfs-dkms zfsutils-linux
sudo modprobe zfs
确认:
zfs version
zpool version
lsmod | grep zfs
4.2 创建 ZFS 池与 SLOG
假设数据 NVMe 分别为 /dev/nvme0n1、/dev/nvme1n1,SLOG 盘为 /dev/nvme2n1。
# SLOG 盘划分 32GB 分区
sgdisk -n 1:0:+32G -t 1:bf01 /dev/nvme2n1
partprobe
# 创建主池(镜像)
zpool create -o ashift=12 \
-O compression=zstd \
-O atime=off \
-O xattr=sa \
-O acltype=posixacl \
tank mirror /dev/nvme0n1 /dev/nvme1n1
# 加入 SLOG
zpool add tank log /dev/nvme2n1p1
zpool status -v
- ashift=12:4K 对齐,不要偷懒。NVMe 基本都是 4K 物理扇区。
- SLOG 强烈建议 企业盘 + 断电保护(PLP),否则崩溃/断电风险更高。
4.3 数据集与属性
# PostgreSQL 数据目录
zfs create tank/pgdata
zfs set recordsize=8K tank/pgdata
zfs set logbias=latency tank/pgdata
zfs set primarycache=all tank/pgdata # 既缓存数据也缓存元数据
zfs set relatime=on tank/pgdata # 兼顾一致性与性能
# PostgreSQL WAL 可单独
zfs create tank/pgwal
zfs set recordsize=128K tank/pgwal
zfs set logbias=latency tank/pgwal
# MySQL 数据目录(如需)
zfs create tank/mysqldata
zfs set recordsize=16K tank/mysqldata
zfs set logbias=latency tank/mysqldata
挂载点:
zfs set mountpoint=/var/lib/postgresql/15/main tank/pgdata
zfs set mountpoint=/var/lib/postgresql/15/wal tank/pgwal
# /etc/postgresql/15/main/postgresql.conf 中把 wal_dir 指向 /var/lib/postgresql/15/wal (或使用符号链接)
4.4 ARC 与 L2ARC 设置
ARC 上限(64GB):
编辑 /etc/modprobe.d/zfs.conf:
options zfs zfs_arc_max=68719476736 zfs_arc_min=17179869184
# 64G & 16G,按需调整
立即生效(重启最稳妥;临时也可):
echo 68719476736 | sudo tee /sys/module/zfs/parameters/zfs_arc_max
echo 17179869184 | sudo tee /sys/module/zfs/parameters/zfs_arc_min
L2ARC(可选,但我强烈推荐)
如果还有一块空闲 NVMe(或同一 P5800X 划出另一个分区):
# 准备 200~800GB 的 L2ARC 分区,根据热点量估算
sgdisk -n 2:0:+400G -t 2:bf01 /dev/nvme2n1
partprobe
# 加入 L2ARC
zpool add tank cache /dev/nvme2n1p2
zpool status
L2ARC 参数:默认就很好用。数据库负载通常 关闭预取到 L2ARC 更稳(避免顺序流量污染),可以在 /etc/modprobe.d/zfs.conf 里加:
options zfs l2arc_noprefetch=1
我在压测里看到 l2arc_noprefetch=1 后,二级缓存命中更“干净”,P95 起伏更小。
4.5 PostgreSQL 配置(关键点)
/etc/postgresql/15/main/postgresql.conf 里重点:
shared_buffers = 32GB
effective_cache_size = 96GB # shared_buffers + ARC + OS 估算
random_page_cost = 1.1 # 有高命中缓存时可调低,默认 4 太保守
maintenance_work_mem = 4GB
work_mem = 64MB
wal_compression = on
synchronous_commit = on # 保持写安全,配合 SLOG
checkpoint_timeout = '15min'
max_wal_size = '8GB'
双缓存的取舍:shared_buffers 我通常给 25% 左右;ARC 给 50%。如果你的查询几乎全在 PG 的缓存里完成,适当把 random_page_cost 调到 1.1–1.3,能让规划器更愿意走索引。
4.6 MySQL(InnoDB)要点(如你也跑它)
# /etc/mysql/mysql.conf.d/mysqld.cnf
innodb_buffer_pool_size = 24G
innodb_flush_log_at_trx_commit = 1 # 数据安全
sync_binlog = 1
innodb_io_capacity = 4000 # 视 NVMe 能力调
innodb_flush_method = O_DIRECT # 避免双缓存(ZFS 上 O_DIRECT 由内核&ZFS处理,观测有效果更依赖版本)
InnoDB 页 16KB,所以 dataset recordsize=16K。同时建议单独数据集给 binlog/WAL,logbias=latency。
5. 压测与观测
5.1 压测方法(PostgreSQL)
# 初始化 pgbench(在一个单独的 schema/db)
sudo -u postgres pgbench -i -s 200 mydb
# 读为主(简单场景)
sudo -u postgres pgbench -c 64 -j 64 -T 300 -S mydb
# 混合读写
sudo -u postgres pgbench -c 64 -j 64 -T 300 mydb
ARC/L2ARC 观测(简易脚本,读 /proc):
#!/usr/bin/env bash
# arcstat_min.sh
f=/proc/spl/kstat/zfs/arcstats
while true; do
hits=$(awk '/hits/{print $3}' $f)
misses=$(awk '/misses/{print $3}' $f)
if [[ -n "$hits" && -n "$misses" ]]; then
total=$((hits+misses))
pct=$(( 100*hits/ (total==0?1:total) ))
echo "$(date '+%H:%M:%S') ARC hit: $pct% ($hits/$total)"
fi
sleep 5
done
L2ARC 命中(同文件里有 l2_hits/l2_misses 指标):
awk '/l2_hits/{h=$3} /l2_misses/{m=$3} END{t=h+m; if(t>0) printf "L2ARC hit: %.2f%%\n", 100*h/t;}' /proc/spl/kstat/zfs/arcstats
5.2 压测结果(真实一线场景的量级示例)
读为主(-S),64 并发,5 分钟跑 3 轮取中位:
| 场景 | ARC 上限 | L2ARC | TPS(中位) | P95 延迟 | ARC 命中 | L2ARC 命中 |
|---|---|---|---|---|---|---|
| A:ext4 基线 | — | — | 101k | 180ms | — | — |
| B:ZFS,ARC=16G | 16G | 关 | 118k | 120ms | 78% | — |
| C:ZFS,ARC=64G | 64G | 关 | 139k | 85ms | 92% | — |
| D:ZFS,ARC=64G + L2ARC=400G | 64G | 开 | 144k | 72ms | 90% | 68% |
混合读写(默认脚本),64 并发:
| 场景 | SLOG | TPS(中位) | P95 延迟 | Checkpoint 偏移 |
|---|---|---|---|---|
| E:无 SLOG(sync=standard) | × | 18.2k | 420ms | 偏大 |
| F:有 SLOG(P5800X 32G 分区) | ✔ | 26.0k | 220ms | 明显平滑 |
解读:
- 把 ARC 从 16G 提到 64G,热点命中明显上升,读延迟下降最明显。
- 加 L2ARC 后,进一步守住“冷一点的热数据”,峰值波动更小。
- 写场景里,SLOG 对同步小写的改善非常直观——尤其是 WAL/小事务。
6. 生产化细节与常见坑
6.1 内核更新 & DKMS
Debian 更新内核后,ZFS 模块需要重编译。
我在变更窗口外坚决不动内核;若必须更新,提前 apt install -yq --download-only,维护窗内做升级并验证 zpool import、zfs list 正常。
6.2 记录大小(recordsize)陷阱
改属性不回写历史数据。要全量生效需 dump/restore 或触发重写。
我最终选了夜间只读窗口:pg_dump → zfs set recordsize=8K → pg_restore,并顺手重建索引。
6.3 ARC 抢内存
观察 kswapd 和 OOM。若看到系统压力高,先降 zfs_arc_max;
也可以给 PostgreSQL 适当减 shared_buffers,把空间还给 ARC(或反之)。
6.4 SLOG 选型
必须选带断电保护(PLP)的企业 NVMe。我用 P5800X 的一个小分区做 SLOG,不要拿普通消费级 NVMe 冒险。
logbias=latency 用在 WAL/小事务数据集;顺序吞吐型用 throughput。
6.5 压缩与 CPU
compression=zstd 很香,但 CPU 忙时也会顶。一般设 zstd-3 或 zstd-5,别太激进。
观测 CPU iowait 与 system time 的变化,平衡后我停在 zstd-3。
6.6 L2ARC 热身
L2ARC 需要时间“预热”。不要压测 5 分钟就下结论。
生产里观察 24 小时曲线;命中率一般会逐步走高并稳定。
6.7 备份与快照
ZFS 快照太好用了:
zfs snapshot tank/pgdata@pre_migration_2025_08_27
zfs send -R tank/pgdata@pre_migration_2025_08_27 | zfs receive backup/pgdata
但 数据库一致性 仍需配合:PG 用 pg_basebackup/pgbackrest 更稳;ZFS 快照可作为额外保险与快速回滚手段。
7. 我的“上线”步骤清单(可直接当 Runbook)
变更申请与回滚方案(含老卷只读保留 48h)。
创建 ZFS 池(镜像、ashift=12、compression=zstd、SLOG)。
数据集:pgdata 设 recordsize=8K、logbias=latency、atime=off。
ARC/L2ARC:zfs_arc_max=64G、l2arc_noprefetch=1、加入 400G L2ARC。
PG 配置:shared_buffers=32G、effective_cache_size=96G、random_page_cost=1.1。
导入数据:pg_dump/pg_restore(或停机 rsync + VACUUM FULL 重写)。
冷启动预热:回放一部分热点查询脚本,让 ARC/L2ARC 预热。
上线观察:
- arcstats 命中率、zpool iostat -v 1、PG 延迟与 TPS;
- dmesg/journalctl -k 观察 ZFS 是否有错误;
- 业务 P95/P99 曲线是否回落并稳定。
一周复盘:根据峰值内存压力微调 zfs_arc_max 与 PG 参数。
8. 附:常用命令速查
# ZFS 健康与 I/O
zpool status -v
zpool iostat -v 1
# ARC 概览
cat /proc/spl/kstat/zfs/arcstats | head -n 50
# 实时查看某数据集属性
zfs get all tank/pgdata | egrep 'recordsize|logbias|primarycache|compression'
# 调整 ARC(临时)
echo $((64*1024*1024*1024)) | sudo tee /sys/module/zfs/parameters/zfs_arc_max
# 移除/替换 SLOG(维护窗口做)
zpool remove tank /dev/nvme2n1p1
zpool add tank log /dev/nvme2n1p1
9. 成本与收益(肉眼可见)
| 项 | 调整前 | 调整后 |
|---|---|---|
| 峰值 TPS | 18–20k(混合) | 26–28k |
| 读为主 TPS | ~101k | 144k |
| P95 延迟 | 180–420ms | 72–220ms(场景不同) |
| 灰度回滚 | 费时 | ZFS 快照 1 行回退 |
| 维护窗口 | 常规 | 内核升级需计划(ZFS DKMS) |
10.复盘与启示:缓存命中率带来的确定性
第二天清晨,我回到机房,把空调口结的水珠抹了一把。运营同事发来消息:“今天峰值稳得离谱。”我看着 arcstats 的曲线,ARC 命中在 90% 左右轻轻抖动,L2ARC 也默默兜底。后来每次业务上新、热点切换,我都先翻翻 recordsize、logbias、ARC/L2ARC 的状态。ZFS 不是银弹,但在香港这台忙碌的服务器上,它给了我可预期的性能与可控的复杂度——这就够了。
TL;DR(给赶时间的你)
- 对齐页:PG 用 recordsize=8K,InnoDB 用 16K。
- 喂饱缓存:shared_buffers≈25%,zfs_arc_max≈50%。
- 加速同步写:用 企业级 SLOG + logbias=latency。
- 二级缓存:有 NVMe 就上 L2ARC,命中率更稳。
- 监控先行:arcstats、zpool iostat、PG 导出器一起看。
- 别忘了坑:recordsize 对老数据不生效、内核升级要计划、SLOG 必须 PLP。