知识广场

按学科筛选:计算机科学
清除筛选

«计算机科学» 分类下共 256 篇帖子

pre-commit 框架:跨语言项目的 git hook 统一管理

## 起因 团队仓库混 Python + Go + TypeScript + YAML,每种语言一套 linter / formatter。新人 clone 后 IDE 不一定配齐 → 推上来的 PR 各种格式不一致。 review 时纠结 "tab 还是空格" 很烦。 `pre-commit` 是 Python 写的跨语言 git hook 框架,几行 YAML 配齐 所有语言的 lint / format,commit 时自动跑。改不合规直接拒绝。 ## 解决方案 ### 1. 装 ```bash pipx install pre-commit # 或 uv tool install pre-commit ``` ### 2. `.pre-commit-config.yaml` 放仓库根: ```yaml default_install_hook_types: [pre-commit, commit-msg] default_stages: [pre-commit] repos: # 通用:尾空格 / 文件末尾换行 / 大文件 / merge conflict - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-json - id: check-added-large-files args: ['--maxkb=500'] - id: check-merge-conflict - id: detect-private-key # Python: ruff 一统 (lint + format + import sort) - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.9 hooks: - id: ruff # lint + autofix args: [--fix] - id: ruff-format # 替代 black # Go: gofmt / golangci-lint - repo: https://github.com/golangci/golangci-lint rev: v1.61.0 hooks: - id: golangci-lint # TypeScript / JavaScript / CSS / Markdown: Prettier - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier types_or: [javascript, jsx, ts, tsx, css, scss, json, yaml, markdown] # ESLint - repo: https://github.com/pre-commit/mirrors-eslint rev: v9.10.0 hooks: - id: eslint files: \.(js|jsx|ts|tsx)$ additional_dependencies: - [email protected] - [email protected] # Shell scripts - repo: https://github.com/shellcheck-py/shellcheck-py rev: v0.10.0.1 hooks: - id: shellcheck # Dockerfile - repo: https://github.com/hadolint/hadolint rev: v2.13.0 hooks: - id: hadolint-docker # commit message: conventional commits - repo: https://github.com/compilerla/conventional-pre-commit rev: v3.4.0 hooks: - id: conventional-pre-commit stages: [commit-msg] args: [feat, fix, docs, style, refactor, perf, test, chore] ``` ### 3. install ```bash pre-commit install # .git/hooks/pre-commit 被注入 # 之后 git commit 自动触发 ``` ### 4. 试一下 ```bash echo 'x ' > test.txt # 故意尾空格 git add test.txt git commit -m 'test' # trim trailing whitespace.............................Failed # - hook id: trailing-whitespace # - exit code: 1 # - files were modified by this hook # # Fixing test.txt ``` hook 自动修了(去掉尾空格),但本次 commit 失败。再 `git add` + `git commit`: ```bash git add test.txt git commit -m 'fix: trim whitespace' # all passed! ``` ### 5. 给整个仓库初始跑一遍(修复存量问题) ```bash pre-commit run --all-files # 跑所有 hook 在所有文件上 # 第一次可能有大量改动;review 后 commit ``` ### 6. CI 也跑(防绕过) ```yaml # .github/workflows/lint.yml - uses: actions/checkout@v4 - uses: pre-commit/[email protected] ``` 开发者 commit 时绕过(`git commit --no-verify`)的情况,CI 兜底。 ### 7. 局部跳过某个 hook ```bash SKIP=ruff git commit -m 'wip: temp' ``` 不推荐常态化,应急用。 ### 8. 排除某些文件 ```yaml exclude: | (?x)^( vendor/.*| generated/.*| migrations/.*| \.min\.js$ )$ ``` 或者单 hook 排除: ```yaml - id: prettier exclude: ^public/build/ ``` ### 9. 自动更新 hook 版本 ```bash pre-commit autoupdate # 把 yaml 里所有 rev 改成各 repo 最新 release tag ``` 每月跑一次保持 hooks 最新。结果 commit 进 git。 ### 10. 自动加进 commit ```yaml - id: ruff args: [--fix] always_run: true ``` `--fix` 让 ruff 自动改。改完文件后 hook fail(让你重新 add)。 更激进的"hook 失败也不阻拦 commit": ```bash pre-commit run --hook-stage manual ``` 把 `stages: [manual]` 的 hook 设为"手动跑",commit 时不阻拦。 ## 与 husky / lefthook 对比 | | pre-commit | husky | lefthook | |---|---|---|---| | 语言无关 | ✅ | 半 (Node 项目优先) | ✅ | | 装 / 配 | Python | npm | 二进制 | | hook 仓库化 | ✅ pre-commit-hooks 大量 | 自己写 | 自己写 | | 性能 | 中(Python 启动) | 快 | 极快 (Go) | | 易用 | 高 | 高 | 中 | 混语言仓库 / Python 项目 → pre-commit;纯 Node 项目 → husky 也行; 极致性能 → lefthook。 ## monorepo 多服务 ```yaml # 给每个子目录的 hook 限制路径 - id: ruff files: ^backend/ - id: eslint files: ^frontend/ ``` 避免在 backend 改 Python 时跑 frontend eslint。 ## 效果 我们仓库接入 pre-commit 后: - PR 里 "形式修改" commit 消失(已经在本地修了) - review 只关注业务逻辑 - 新人 clone 后 `pre-commit install` 一次,之后所有规范自动 enforce - CI lint 时间从 5 分钟 → 1 分钟(pre-commit 已经修好的不重检) ## 踩过的坑 1. **pre-commit 第一次跑超慢**:每个 hook 创建 isolated env 装依赖。 `.pre-commit-cache` 缓存几个 GB 之后才稳定。CI 里 cache 该目录。 2. **hook 改了文件 → commit 失败**:合理设计,但新人会困惑。 告诉团队"hook fail 看下面输出 + 重新 git add + commit"流程。 3. **`SKIP=` 滥用**:成员养成习惯跳所有 hook。在 PR 模板里加"是否跑了 pre-commit"checkbox。CI 一定要跑兜底。 4. **autoupdate 引入 breaking change**:新版 ruff 突然报 50 个新警告。 autoupdate 后单独 PR 修问题再合主。 5. **hook 内拉 docker image**:`hadolint-docker` hook 第一次拉 image 慢。可以换成本地 `hadolint`(先 `brew install hadolint`)+ `repo: local`。

git rebase -i:把零散提交压成一条 + 改提交信息 + 拆提交

写代码时 commit 颗粒度往往很碎:"修了个 typo"、"WIP"、"忘了 import"。 推到主分支前用 `rebase -i` 重写历史,让 PR 干净。 ## 1. 启动 ```bash git rebase -i HEAD~5 # 重写最近 5 个提交 # 或: git rebase -i origin/main # 重写从 main 分叉以来的所有提交 ``` 打开编辑器: ``` pick a1b2c3d Add user model pick e4f5g6h Add tests pick i7j8k9l fix typo pick m1n2o3p more tests pick q5r6s7t WIP # Rebase 1234567..q5r6s7t onto 1234567 (5 commands) # # Commands: # p, pick = use commit # r, reword = use commit, but edit the message # e, edit = use commit, but stop for amending # s, squash = use commit, but meld into previous commit # f, fixup = like "squash", but discard this commit's log # d, drop = remove commit ``` ## 2. 最常用:squash + 改 message ``` pick a1b2c3d Add user model fixup e4f5g6h Add tests # 合到上一条,丢弃 message fixup i7j8k9l fix typo pick m1n2o3p more tests fixup q5r6s7t WIP ``` 保存退出。Git 把这 5 个提交重写成 2 个: - 第 1 个:a1b2c3d 的 message + 包含 a/e/i 的代码 - 第 2 个:m1n2o3p 的 message + 包含 m/q 的代码 `fixup` vs `squash`:squash 让你重新写合并后的 message,fixup 直接丢弃。 有改 message 需求用 squash,没需求用 fixup。 ## 3. 调整顺序 直接把行换位置即可: ``` pick m1n2o3p more tests # 原本第 4 个 pick a1b2c3d Add user model # 原本第 1 个 ... ``` Git 会按新顺序重新 cherry-pick。换序时如果两提交改动了同样的文件, 可能 conflict —— 解决然后 `git rebase --continue`。 ## 4. 拆一个提交 想把一个"什么都改了"的大提交拆成几个小的: ``` edit a1b2c3d Big mess of changes ``` 保存后 git 停在那个提交,你可以: ```bash git reset HEAD~ # 把改动放回工作区 git add file1.py git commit -m 'add file1' git add file2.py git commit -m 'add file2' git rebase --continue ``` ## 5. 改某个提交的内容 发现某个旧提交少改一行: ``` edit e4f5g6h Add tests ``` 停下来后: ```bash # 改你要改的文件 vim test_user.py git add test_user.py git commit --amend --no-edit git rebase --continue ``` ## 6. autosquash:标记 fixup 提交 写代码时直接打 fixup 标签: ```bash git commit --fixup=a1b2c3d # 标记这是给 a1b2c3d 的修补 # 后续可以多个 fixup 提交 git commit --fixup=a1b2c3d git commit --fixup=m1n2o3p ``` 然后: ```bash git rebase -i --autosquash origin/main ``` `--autosquash` 自动把每个 `fixup!` 提交移到对应原提交后面,并标 `fixup`。 你只需要确认 + 保存。这是我个人 99% 的 rebase 用法。 可以让 autosquash 默认开: ```bash git config --global rebase.autosquash true ``` ## 7. 安全推 rebase 后本地历史和远端不一致,普通 push 会被拒绝。要 force push: ```bash git push --force-with-lease origin feature-branch ``` `--force-with-lease` 比 `--force` 安全:如果远端有别人新推的提交(你不知道), 会拒绝。`--force` 会盲目覆盖,可能毁掉同事的工作。 ## 8. 出错回滚 rebase 把事情搞乱了? ```bash git rebase --abort # 进行中的 rebase 直接放弃 git reflog # 看历史所有 HEAD 移动 git reset --hard HEAD@{5} # 回到 5 步前的状态 ``` reflog 是 Git 的 undo 神器,rebase 出错 90% 都能从这里救回来。 默认保留 90 天。 ## 9. 不要 rebase 的场景 - **已经推到共享分支**(main / develop)的提交:rebase 后 force push 会让其他人 pull 时混乱 - **多人共同开发的 feature 分支**:同理 - 安全的规则:"只 rebase 自己的本地分支或单人 feature 分支" ## 10. merge --squash 的替代方案 如果 feature 分支提交特别乱,懒得 rebase -i,直接: ```bash git checkout main git merge --squash feature-branch git commit -m 'feat: add user system' ``` 会把整个 feature 分支压成一个 staged 改动,由你写一条 commit message。 这是"合并时清整历史"的快捷做法,但失去了原始提交的颗粒度。 ## 踩过的坑 - rebase 时 conflict 改完忘记 `git add` → `git rebase --continue` 报错 "no changes"。 - 用 GUI 工具做 rebase:很多 GUI 让操作太容易,新手秒搞砸主分支。 建议命令行做 rebase,至少有一道心理门槛。 - `git push --force` 主分支:CI 测试基于旧 SHA 的 deploy 全失效, 其他 dev 拉不下来。绝对禁止,用 GitHub branch protection 锁住。 - rebase 改 message 时含中文,OS / 编辑器编码不一致会变乱码。 git config `i18n.commitEncoding utf-8`。

用 systemd 写一个 oneshot 服务清理 /tmp(带每日 timer)

`tmpfiles.d(5)` 能解决大多数自动清理需求,但偶尔我们需要更"剧本化"的清理逻辑: 按业务规则保留某些目录、清完后通知 Prometheus pushgateway,或者顺便压缩归档。 这时候手写一个 `oneshot` 服务比改 `tmpfiles.d` 干净得多。 ## 1. 单元文件 ```ini # /etc/systemd/system/tmp-purge.service [Unit] Description=Purge stale /tmp entries older than 24h After=network.target [Service] Type=oneshot ExecStart=/usr/local/sbin/tmp-purge.sh Nice=10 IOSchedulingClass=idle ProtectSystem=strict ProtectHome=true ReadWritePaths=/tmp NoNewPrivileges=true ``` `ReadWritePaths=/tmp` 是关键 —— `ProtectSystem=strict` 之后整个 `/` 默认只读, 不显式开白名单会让脚本写不了任何东西。 ## 2. 脚本 ```bash #!/usr/bin/env bash # /usr/local/sbin/tmp-purge.sh set -euo pipefail PROTECTED='^/tmp/(systemd-private-|\.X11-unix|\.ICE-unix)' find /tmp -mindepth 1 -maxdepth 1 \ -mmin +1440 \ ! -regex "$PROTECTED" \ -exec rm -rf -- {} + logger -t tmp-purge "completed at $(date -Iseconds)" ``` `-mmin +1440` 是 24 小时;用 `+10080` 改为一周。`-regex` 那条把 `systemd-private-*` 和 X session 套接字目录排除掉,否则容易踩坑。 ## 3. timer ```ini # /etc/systemd/system/tmp-purge.timer [Unit] Description=Daily /tmp purge [Timer] OnCalendar=*-*-* 03:30:00 Persistent=true RandomizedDelaySec=15m [Install] WantedBy=timers.target ``` `Persistent=true` 在机器关机过那一时刻后会补跑一次。`RandomizedDelaySec` 让多 台机器错峰,避免 NFS / 备份目标在同一时刻被打爆。 ## 4. 启用 + 校验 ```bash chmod +x /usr/local/sbin/tmp-purge.sh systemctl daemon-reload systemctl enable --now tmp-purge.timer # 手动跑一次确认 systemctl start tmp-purge.service journalctl -u tmp-purge.service -n 20 --no-pager # 看下一次触发时间 systemctl list-timers tmp-purge.timer ``` ## 踩过的坑 - 早期版本忘记 `Type=oneshot`,systemd 默认 simple 模式会把"脚本退出"判为 "服务挂了",反复重启把磁盘 I/O 拉爆。一定要写 oneshot。 - 在很老的 systemd(< 235)上 `ReadWritePaths=/tmp` 会被 `PrivateTmp=true` 覆盖;如果你的 base unit 继承了 `PrivateTmp`,记得显式写 `PrivateTmp=false`。 - `find -delete` 不会递归删除非空目录,所以这里用 `-exec rm -rf -- {} +`。

PyTorch Lightning 把训练循环写成 50 行(多卡 / 混合精度 / checkpoint 全免费)

## 起因 裸 PyTorch 写一个像样的训练循环要处理:device 切换 / `.train()/.eval()` / gradient zero / loss accumulation / lr scheduler step / checkpoint 保存 / early stopping / 多 GPU DDP 启动 / 混合精度 / logging。 一个研究 notebook 反复抄这些代码很烦,还容易写错(忘 `optimizer.zero_grad()` 或者 `.eval()` 是经典的)。 PyTorch Lightning 把"工程脚手架"和"模型逻辑"分开:你只写 `training_step` / `validation_step` / `configure_optimizers`,其它由 framework 处理。 ## 解决方案 ```bash uv add lightning torchvision ``` ```python import lightning as L import torch import torch.nn.functional as F from torch.utils.data import DataLoader from torchvision import datasets, transforms class MNIST(L.LightningModule): def __init__(self, lr=1e-3): super().__init__() self.save_hyperparameters() self.conv = torch.nn.Sequential( torch.nn.Conv2d(1, 32, 3, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(2), torch.nn.Conv2d(32, 64, 3, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(2), torch.nn.Flatten(), torch.nn.Linear(64*7*7, 128), torch.nn.ReLU(), torch.nn.Linear(128, 10), ) def forward(self, x): return self.conv(x) def training_step(self, batch, batch_idx): x, y = batch logits = self(x) loss = F.cross_entropy(logits, y) acc = (logits.argmax(1) == y).float().mean() self.log_dict({'train/loss': loss, 'train/acc': acc}, prog_bar=True) return loss def validation_step(self, batch, batch_idx): x, y = batch logits = self(x) loss = F.cross_entropy(logits, y) acc = (logits.argmax(1) == y).float().mean() self.log_dict({'val/loss': loss, 'val/acc': acc}, prog_bar=True) def configure_optimizers(self): opt = torch.optim.Adam(self.parameters(), lr=self.hparams.lr) sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=10) return [opt], [sched] def main(): transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]) train = DataLoader(datasets.MNIST('./data', train=True, download=True, transform=transform), batch_size=128, num_workers=4, shuffle=True) val = DataLoader(datasets.MNIST('./data', train=False, transform=transform), batch_size=512, num_workers=4) trainer = L.Trainer( max_epochs=5, accelerator='auto', # cuda / mps / cpu 自动选 devices='auto', # 多卡时自动用全部 precision='16-mixed', # 混合精度 callbacks=[ L.pytorch.callbacks.EarlyStopping(monitor='val/loss', patience=3), L.pytorch.callbacks.ModelCheckpoint(monitor='val/acc', mode='max', save_top_k=2), ], logger=L.pytorch.loggers.TensorBoardLogger('logs', name='mnist'), ) trainer.fit(MNIST(), train, val) if __name__ == '__main__': main() ``` 50 行包含训练 + 验证 + 多卡 + 混合精度 + 早停 + checkpoint + TensorBoard。 ## 效果 - 单 GPU vs 4 GPU DDP 只改 `devices=4`,无需写 init_process_group 之类 - 混合精度只改 `precision='16-mixed'`,速度 ~1.5-2x,显存减 30% - TensorBoard `tensorboard --logdir logs` 看 loss / metric 曲线 - ModelCheckpoint 自动保存 val/acc 最高的 2 个 checkpoint - 中断后 `Trainer(resume_from_checkpoint=...)` 恢复 - 切换 wandb logger 一行:`L.pytorch.loggers.WandbLogger(project=...)` ## 踩过的坑 1. **`self.log` 不能放在 `forward` 里**:只能在 step 方法里。否则 batch_size 信息不对。 2. **DDP 时 `validation_step` 写了 print** → 多进程刷屏。用 `self.log` 或 `rank_zero_only` 装饰。 3. **`num_workers > 0` 时 macOS / Windows 死锁**:DataLoader 用 fork 策略。Mac 上设 `num_workers=0` 或 `persistent_workers=True`。 4. **混合精度 NaN**:loss scale 不对。`precision='bf16-mixed'`(A100+) 比 `16-mixed` 更稳,不需要 grad scaler。 5. **保存的 checkpoint 太大**:默认保存 optimizer state。要 inference-only: `ModelCheckpoint(save_weights_only=True)`。

Redis Streams vs NATS JetStream:轻量消息队列怎么选

## 起因 需要异步消息队列处理: - 用户上传图片 → 后台压缩 - 发邮件 / 短信 - 跑数据 export Kafka 太重(要 ZooKeeper 时代 / 现在 KRaft 也复杂); RabbitMQ 装着 OK 但对小项目仍偏重; Redis Streams 和 NATS JetStream 是轻量替代。 下面对比两者 + 用法。 ## Redis Streams Redis 5.0+ 内置的 stream 数据结构,类似 Kafka topic 但单实例。 ### 装 Redis 7.x 默认带: ```bash sudo apt install -y redis redis-cli --version ``` ### Producer ```python import redis r = redis.Redis() # 向 stream "tasks" 加一条消息 r.xadd('tasks', {'type': 'send_email', 'to': '[email protected]', 'subject': 'hi'}) # 返回 message id:'1716543210000-0' ``` ID 是 timestamp-seq 自动生成。 ### Consumer Group ```python # 创建 consumer group(每个 worker 进程一份消费状态) r.xgroup_create('tasks', 'email-workers', id='0', mkstream=True) # Worker 拉消息 while True: msgs = r.xreadgroup( groupname='email-workers', consumername='worker-1', streams={'tasks': '>'}, # > 表示新消息 count=10, block=5000, # 5s 没消息阻塞 ) for stream_name, messages in msgs: for msg_id, data in messages: try: send_email(data[b'to'], data[b'subject']) # ack:确认处理完 r.xack('tasks', 'email-workers', msg_id) except Exception as e: # 不 ack:消息留在 pending list,可被 reclaim 或 retry log.exception('failed') ``` 特性: - **at-least-once delivery**(ack 确认;不 ack 会被 reclaim) - **多 consumer 共享 group**:消息分配给空闲 worker - **持久化**:写盘(开 AOF 时) + 全消息历史可重读 ### 维护:MAXLEN 防爆 ```python r.xadd('tasks', {...}, maxlen=100000, approximate=True) # 超过 10w 条自动删最老的(近似,性能好) ``` 否则 stream 无限增长。 ### Pending list(处理失败 / worker 挂) ```python # 看哪些消息被 worker-1 取了但没 ack r.xpending_range('tasks', 'email-workers', min='-', max='+', count=100, consumer='worker-1') # Worker 挂掉超过 60s 没 ack 的消息 → reclaim 给 worker-2 r.xclaim('tasks', 'email-workers', 'worker-2', min_idle_time=60000, message_ids=stuck_ids) ``` 成熟 retry / dead letter 都要自己 wrap。 ## NATS JetStream NATS 是 Go 写的轻量消息中间件,JetStream 是它的持久化层(v2.2+)。 ### 装 ```bash curl -L https://github.com/nats-io/nats-server/releases/latest/download/nats-server-linux-amd64.tar.gz \ | sudo tar xz -C /usr/local/bin --strip-components=1 nats-server-*/nats-server # 启动 + JetStream nats-server -js ``` Docker: ```bash docker run -d -p 4222:4222 nats:latest -js ``` ### 装 client ```bash uv add nats-py ``` ### Producer ```python import asyncio import nats async def main(): nc = await nats.connect('nats://localhost:4222') js = nc.jetstream() # 创建 stream(一次性) await js.add_stream( name='TASKS', subjects=['tasks.>'], # 接受 tasks.X 各 subject retention='workqueue', # work queue 模式(被消费就删) max_msgs=100000, ) # 发消息 await js.publish('tasks.email', b'{"to":"[email protected]"}') await nc.close() asyncio.run(main()) ``` ### Consumer ```python async def worker(): nc = await nats.connect('nats://localhost:4222') js = nc.jetstream() # 创建 durable consumer psub = await js.pull_subscribe( subject='tasks.email', durable='email-workers', config=ConsumerConfig( ack_policy='explicit', max_deliver=3, # 最多重试 3 次 ack_wait=30, # 30s 内 ack ), ) while True: try: msgs = await psub.fetch(batch=10, timeout=5) for msg in msgs: try: await send_email(json.loads(msg.data)) await msg.ack() except Exception: await msg.nak() # 立刻重投 except nats.errors.TimeoutError: continue asyncio.run(worker()) ``` 特性: - **at-least-once / exactly-once (with dedup)** - **多 consumer 模式**:work queue / fanout / replay - **subject 多级 routing**:`tasks.>` / `orders.created.*` - **集群 / 副本**:3 节点 raft 内置 ### 多副本 + HA ```yaml # nats.conf jetstream { store_dir: /var/lib/nats } cluster { name: my-cluster listen: 0.0.0.0:6222 routes: [ nats-route://node1:6222, nats-route://node2:6222, nats-route://node3:6222, ] } ``` ```python await js.add_stream(name='TASKS', subjects=['tasks.>'], num_replicas=3) ``` 3 节点 raft 自动同步。一节点挂剩 2 节点继续工作。 **Redis Streams 没有这个能力**(要 sentinel + 第三方扩展实现)。 ## 对比表 | | Redis Streams | NATS JetStream | |---|---|---| | 学习曲线 | 低(Redis 老熟人) | 中 | | 集群 / HA | 弱(需 Sentinel) | 内置 raft | | 吞吐 | 中(10k-50k msg/s) | 高(100k+ msg/s) | | 持久化 | AOF / RDB | log 文件 | | 多语言 client | 极多 | 较多 | | 资源占用 | 中(Redis 进程) | 低(Go 单二进制) | | Subject routing | 无(按 stream 名) | 多级 wildcard | | 跨集群联邦 | 无 | leaf node | | 复杂场景 | 简单 work queue | 复杂 routing + replay | ### Redis Streams 适合 - 已经在用 Redis 不想加新组件 - 单实例 OK,QPS < 10k - 简单 work queue / event log - 团队熟 Redis ### JetStream 适合 - 需要 HA / 多副本 - 多消费模式(同一消息多 group 各取) - subject-based routing 复杂 - 高吞吐(> 10k msg/s) - 不想运营 Kafka 但需要类似能力 ## 跟 Kafka 对比 Kafka 适合超大规模(数百万 msg/s)+ 长期存储 + 多 consumer group + stream processing(Kafka Streams / Flink)。 JetStream / Redis Streams 都是 "Kafka 简化版",但 80% 场景够。 ## 实战 case 我们小创业公司: - 1k-10k msg/s - 工作队列:图片处理 / 邮件 / API 调用 / data ETL - 不想运营 Kafka 选 **NATS JetStream**: - 单二进制部署(5 分钟搞定) - 3 节点集群跑 6 个月 0 down - subject routing 让"按业务类型分流"自然(`tasks.email.*`、`tasks.image.*`) - Go client 性能极好(业务端 Go 服务直连) 对比之前用 Celery + RabbitMQ: - 资源占用 1/3 - 吞吐 5x - 维护成本明显降 但 Celery 的"task discovery / chain / chord / scheduled task" 生态 更丰富,Python-heavy 项目仍是合理选择。 ## 与 Celery / Sidekiq / dramatiq 比 这些是"任务队列 framework"(带 retry / scheduling / monitoring), 底层 broker 是 Redis / RabbitMQ。 Redis Streams / JetStream 是底层 broker。 要"task 框架体验" 在它们上套一层(如 dramatiq + Redis)。 ```bash uv add dramatiq[redis] ``` ```python import dramatiq @dramatiq.actor(max_retries=3, queue_name='email') def send_email(to, subject, body): smtp.send(to, subject, body) # 业务 send_email.send('[email protected]', 'Welcome', '...') ``` 跟 Celery 用法类似但代码 1/3 + Redis Streams 后端。 ## 监控 ### Redis ```bash redis-cli xinfo stream tasks # length / first-entry / last-entry / consumer group 详情 # 持续监控积压 watch -n 5 'redis-cli xlen tasks' ``` Prometheus redis_exporter 暴露 stream length / lag。 ### NATS ```bash nats stream info TASKS nats consumer info TASKS email-workers # 看 pending / ack pending / lag ``` `nats` CLI + prometheus-nats-exporter。 ## 踩过的坑 ### Redis Streams 1. **没 MAXLEN → 无限增长**:1 小时几十万消息后 Redis 内存爆。 永远 `xadd ... MAXLEN ~ 100000`。 2. **consumer name 重复**:两个进程用 `consumer-1` → pending list 混乱。每 worker 独立 name(`worker-${hostname}-${pid}`)。 3. **claim 没设 min_idle_time**:抢走还没超时的消息 → 重复处理。 生产 30-60s。 ### JetStream 4. **stream 创建后改 config**:某些 config(subject / retention)改了 要 delete + recreate,丢消息。一开始想清楚。 5. **pull vs push subscription**:pull 简单可控;push 需要 client 一直 连。新手用 pull。 6. **JetStream domain / account 隔离**:多 tenant 时配错权限互通。 小项目用 default 就好。 ## 总结 简单"事件流" → Redis Streams(已经在用 Redis 的话)。 "想要类 Kafka 但更轻" → NATS JetStream。 极致简单 + Python → Celery + Redis broker。 真大规模 → Kafka。

团队 commit 信息一团乱?conventional commits + commitlint + husky 强制规范

## 起因 我们小团队 5 个人维护一个 monorepo,半年下来 git log 一塌糊涂: `fix bug` / `WIP` / `更新一下` / `aaaaa`。每周一次的 release notes 得人工梳理,找哪个 commit 是新功能、哪个是 bug fix,每次半小时起步。 ## 解决方案:conventional commits + 工具链强制 约定 commit 必须以类型开头: ``` feat: 新功能 fix: bug 修复 docs: 文档 style: 格式化(不影响功能) refactor: 重构 perf: 性能优化 test: 测试 chore: 构建 / CI / 依赖 revert: 回滚 ``` 可选 scope:`feat(auth): add OAuth`、`fix(api): correct status code`。 breaking change 加 `!`:`feat!: drop Node 16 support`。 ### 装 commitlint + husky ```bash npm i -D @commitlint/cli @commitlint/config-conventional husky # 初始化 husky npx husky init # commitlint 配置 echo "export default { extends: ['@commitlint/config-conventional'] }" \ > commitlint.config.js # git hook echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg chmod +x .husky/commit-msg ``` 之后任何不合规范的 commit 直接被拒: ``` $ git commit -m "update stuff" ✖ subject may not be empty ✖ type may not be empty ✖ found 2 problems, 0 warnings ``` ### 配合 commitizen 让团队上手 ```bash npm i -D commitizen cz-conventional-changelog ``` `package.json` 加: ```json { "scripts": { "commit": "cz" }, "config": { "commitizen": { "path": "cz-conventional-changelog" } } } ``` `npm run commit` → 交互式选 type / scope / subject,新人 30 秒上手。 ### 自动生成 changelog ```bash npm i -D conventional-changelog-cli npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0 ``` 把所有 commit 按 type 分类、按 scope 分组、生成 markdown changelog。 更进一步用 semantic-release:基于 commit type 自动 bump 版本号 + 生成 release notes + 发到 npm + 创建 GitHub release: ```bash npm i -D semantic-release ``` `.github/workflows/release.yml`: ```yaml - run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` merge 到 main → CI 看到 `feat:` 自动发 minor 版本,`fix:` 发 patch, `feat!:` 发 major。完全无人值守。 ## 效果 - 一周后所有人习惯了;commit log 整洁可读 - 每月 release notes 从 30 分钟人工梳理 → 0 分钟自动生成 - PR review 时只看 commit 标题就知道改动性质 - 半年后回头看 git log,能复盘"上次的 OAuth feature 是哪天合的" ## 踩过的坑 1. **第一次接入老仓库 husky 不工作**:`.husky/` 目录权限不对,hook 文件 要可执行(`chmod +x`)。CI 里 `npm ci` 后 husky install 有时不触发, `prepare` script 设 `husky` 保险。 2. **commitizen 卡在 monorepo**:subpath 项目里 commitizen 找不到配置。 根目录 .czrc 用绝对路径或者全局装 commitizen-cli。 3. **rebase 时 commit-msg hook 反复触发**:rebase 多个老 commit 都 过一遍 lint,老的破格式都 fail。一次性 `HUSKY=0 git rebase ...` 绕过,整理完后再开。 4. **scope 不要太多**:定义 5-7 个核心 scope 就好(如 `auth` / `api` / `ui` / `db` / `infra`)。每个目录一个 scope 反而没用,commit 信息 被 scope 撑长。 5. **breaking change 容易忘**:仅靠 `!` 易遗漏。配合 PR template 让 提 PR 时勾"是否含 breaking" 列表,CI lint 时校验 commit footer 有 `BREAKING CHANGE:` 描述。

5 分钟用 Vite 起一个 React + TypeScript 项目(可上生产的最小配置)

CRA 已经废弃,Next.js 太重——纯 SPA 现在的标准答案是 Vite。 下面建立一个开箱即用的最小项目,包含路由、CSS 模块化、ESLint、 开发 + 生产构建。 ## 1. 脚手架 ```bash npm create vite@latest my-app -- --template react-ts cd my-app npm install ``` `--template react-ts` 直接给 TypeScript。 ## 2. 装常用依赖 ```bash npm i react-router-dom npm i -D @types/node prettier eslint-config-prettier ``` ## 3. 给 `tsconfig.json` 加 path alias ```jsonc { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } } ``` 让 Vite 也认 alias: ```ts // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, server: { port: 5173, host: true }, build: { sourcemap: true, rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], }, }, }, }, }) ``` `manualChunks` 把 React 切成独立 chunk,业务代码变化不会重新拉这部分。 ## 4. 主入口 ```tsx // src/main.tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from '@/App' import '@/index.css' createRoot(document.getElementById('root')!).render( <StrictMode> <BrowserRouter> <App /> </BrowserRouter> </StrictMode>, ) ``` ## 5. App + 路由 ```tsx // src/App.tsx import { Routes, Route, Link } from 'react-router-dom' import Home from '@/pages/Home' import About from '@/pages/About' export default function App() { return ( <> <nav> <Link to="/">Home</Link> | <Link to="/about">About</Link> </nav> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> </Routes> </> ) } ``` ```tsx // src/pages/Home.tsx export default function Home() { return <h1>Hello</h1> } ``` ## 6. CSS Modules ```css /* src/pages/Home.module.css */ .title { font-size: 2rem; color: #2563eb; } ``` ```tsx import s from './Home.module.css' export default function Home() { return <h1 className={s.title}>Hello</h1> } ``` TypeScript 默认认得 `.module.css` —— vite/client 类型定义里包含了。 ## 7. ESLint + Prettier ```bash npm i -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks \ eslint-plugin-react-refresh prettier ``` `eslint.config.js`: ```js import js from '@eslint/js' import ts from 'typescript-eslint' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' export default ts.config( { ignores: ['dist'] }, js.configs.recommended, ...ts.configs.recommended, { plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': 'warn', }, }, ) ``` `package.json` 加 script: ```json { "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint .", "format": "prettier -w ." } } ``` ## 8. 跑 ```bash npm run dev # 开发,HMR # http://localhost:5173 npm run build # tsc 类型检查 + Vite 打包,输出到 dist/ npm run preview # 本地预览生产构建 ``` ## 9. 部署(静态托管) ```bash npm run build # dist/ 直接 rsync 到任何静态服务器: rsync -avz --delete ./dist/ user@server:/var/www/myapp/ ``` 服务端 nginx: ```nginx server { listen 80; server_name myapp.example.com; root /var/www/myapp; index index.html; # SPA 路由:所有未命中文件的请求都 fallback 到 index.html location / { try_files $uri $uri/ /index.html; } # 资源缓存 1 年(文件名带 hash 所以安全) location ~* \.(css|js|png|jpg|svg|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } } ``` ## 踩过的坑 - 用 alias `@/` 后 IDE 不识别 → 重启 TypeScript Server(VSCode:Cmd+Shift+P → "TypeScript: Restart TS Server")。 - HMR 突然不工作:检查文件名首字母是否大写。`Home.tsx` 默认 export 必须是 `Home`(首字母大写),否则 react-refresh 不接管。 - 生产构建 hash 文件名后部署时新文件先到,旧 index.html 还在引用, 瞬间 404。解决:先部署 `assets/` 再部署 `index.html`,并保留旧 hash 文件 2-3 个版本。 - `vite preview` 不能完全替代真实生产 nginx,特别是 try_files fallback —— preview 自带 SPA fallback,nginx 没配就 404。

给 nginx 启用 Brotli 压缩(含验证步骤)

Brotli 是 Google 开源的压缩算法,对 HTML / CSS / JS 的压缩率通常比 gzip 高 15-25%。现代浏览器(Chrome 50+ / Firefox 44+ / Safari 11+)全支持, 所以现在没有理由不开。 需要源里有 `libnginx-mod-brotli`(Debian 12+ / Ubuntu 22.04+ 已带)。 官方仓库的 nginx 不带,要么从 nginx.org 仓库装 `nginx-mod-brotli` 要么编译时加 `--add-dynamic-module=ngx_brotli`。 ## 启用 `/etc/nginx/nginx.conf` 顶部 `events {}` 之前加: ```nginx load_module modules/ngx_http_brotli_filter_module.so; load_module modules/ngx_http_brotli_static_module.so; ``` `http {}` 里: ```nginx # Brotli for dynamic responses brotli on; brotli_comp_level 5; brotli_min_length 1024; brotli_types application/atom+xml application/javascript application/json application/rss+xml application/xml application/xml+rss application/x-font-ttf application/vnd.ms-fontobject application/x-web-app-manifest+json font/opentype image/svg+xml image/x-icon text/css text/javascript text/plain text/x-component text/xml; # Pre-compressed .br files (built by your asset pipeline) brotli_static on; # gzip still on for legacy clients gzip on; gzip_comp_level 5; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css application/json application/javascript application/xml text/xml application/xml+rss image/svg+xml; ``` 为什么 level 5:测试下来 5 是 CPU / 压缩比的甜点。9 会把 CPU 拉满但压缩 比只多 2-3%。 ## 校验 ```bash sudo nginx -t && sudo systemctl reload nginx # 客户端模拟 Brotli 支持 curl -H 'Accept-Encoding: br' -I https://example.com/main.css # 看返回头里有 content-encoding: br ``` 完整对比: ```bash echo '--- raw ---' curl -s -H 'Accept-Encoding: identity' https://example.com/main.css | wc -c echo '--- gzip ---' curl -s -H 'Accept-Encoding: gzip' --compressed https://example.com/main.css | wc -c # wc -c 是解压后的字节,看不出区别。改用 -w 看下载字节: curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w 'gzip: %{size_download}\n' https://example.com/main.css curl -s -H 'Accept-Encoding: br' -o /dev/null -w 'br: %{size_download}\n' https://example.com/main.css ``` ## Brotli static —— 预压缩资源 构建期生成 `.br` 文件,nginx 命中后直接发文件,省运行时 CPU: ```bash # 给所有 css/js 生成 .br find /var/www/static -type f \( -name '*.css' -o -name '*.js' \) -print0 \ | xargs -0 -n 1 -P 4 brotli --keep --best ``` 然后 `brotli_static on;` 会优先发 `foo.css.br`。 ## 踩过的坑 - 别忘了 gzip 留着,Bot / 旧浏览器不发 `br` 头时 fallback 用。 - 如果前面有 Cloudflare 等 CDN,**它已经做了 Brotli**,源站没必要再做。 反而源站 Brotli + CDN gzip 链路上会多两次解压压缩。可以关掉源站的 brotli 只留 brotli_static 节省 CPU。 - 静态预压缩别忘了同步部署:CI 在压缩 `app.js` 后没把 `app.js.br` 推到 目录,nginx 走的还是动态压缩,毫无收益。

给纯 IPv4 服务器开 IPv6(含 nginx / Docker / 防火墙的几个坑)

## 起因 VPS 厂商给的 IP 默认是 IPv4。但越来越多用户网络只走 IPv6(移动 5G、 家宽 IPv6-only),网站不支持 IPv6 会被这些用户判为"无法访问"。 另外 SEO(Google)也把 IPv6 当作 ranking signal。 许多 VPS 厂商免费送 IPv6 /64 段,不开白不开。 ## 解决方案 ### 1. 拿到 IPv6 配置 VPS 厂商控制台 → Network → 看 IPv6 段(如 `2001:db8::/64`)+ gateway。 有些厂商默认开了,`ip -6 addr` 能看到;有些要在控制台手动启用。 ### 2. 配置接口 `/etc/netplan/01-netcfg.yaml`(Ubuntu): ```yaml network: version: 2 ethernets: eth0: addresses: - 192.0.2.10/24 - 2001:db8::a/64 gateway6: 2001:db8::1 nameservers: addresses: - 1.1.1.1 - 2606:4700:4700::1111 # 同时配 v4 + v6 DNS ``` ```bash sudo netplan apply ip -6 addr show eth0 # 应该看到 2001:db8::a/64 ``` 老 Debian 用 `/etc/network/interfaces`: ``` iface eth0 inet6 static address 2001:db8::a/64 gateway 2001:db8::1 ``` ### 3. 校验 ```bash ping6 ipv6.google.com # PING ipv6.google.com(... 2607:f8b0:...) ... curl -6 https://ifconfig.co # 你的 IPv6 地址 ``` ### 4. DNS 加 AAAA 记录 DNS 控制面板加: ``` example.com. AAAA 2001:db8::a www.example.com. AAAA 2001:db8::a ``` A 记录保留给 v4;AAAA 加给 v6。客户端按需选。 ```bash dig AAAA example.com # example.com. 300 IN AAAA 2001:db8::a ``` Cloudflare 用户:A + AAAA 都加上让 Cloudflare 处理 dual stack。 ### 5. nginx listen on IPv6 ```nginx server { listen 80; listen [::]:80; # IPv6 listen 443 ssl http2; listen [::]:443 ssl http2; server_name example.com; ... } ``` `[::]` 是 IPv6 通配地址。重启: ```bash sudo systemctl reload nginx ss -tlnp | grep nginx # 同时看到 *:80 和 :::80 ``` ### 6. 防火墙 IPv6 规则 `ufw` 默认同时管 v4 + v6(看 `/etc/default/ufw` 里 `IPV6=yes`): ```bash sudo ufw allow 22 sudo ufw allow 80 sudo ufw allow 443 # 自动同时管 v4 + v6 ``` `iptables` / `nftables` 要分别管 v4 / v6: ```bash # nftables(推荐:table inet 一管两种) table inet filter { chain input { type filter hook input priority filter; policy drop; ct state established,related accept iif lo accept ip protocol icmp accept meta nfproto ipv6 icmpv6 accept # IPv6 ICMP(必须开,PMTU 用) tcp dport { 22, 80, 443 } accept } } ``` **v6 ICMP 必须放行**——v6 路径 MTU 发现完全依赖 ICMP。封了 → 大包不通 → 间歇性页面打不开。 ### 7. systemd / 应用监听 v6 很多 service 默认只听 v4: ```nginx listen 0.0.0.0:8080; # 只 v4 # vs listen [::]:8080; # 既 v6 也 v4(Linux 默认开 IPV6_V6ONLY=0) listen [::]:8080 ipv6only=on; # 仅 v6 ``` Python: ```python # 单一 socket 同时 v4 + v6 import socket s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) s.bind(('::', 8080)) # accept v4 client 时收到 ::ffff:1.2.3.4 形式 ``` Node: ```js server.listen({ host: '::', port: 8080 }) ``` Go: ```go http.ListenAndServe(":8080", nil) // Go 默认双栈 ``` ### 8. Docker 容器开 v6 默认 Docker 容器只有 v4 网络。开 v6: `/etc/docker/daemon.json`: ```json { "ipv6": true, "fixed-cidr-v6": "fd00::/80", "experimental": true, "ip6tables": true } ``` ```bash sudo systemctl restart docker docker run --rm alpine ip -6 addr # 看到 inet6 ``` 或者用 host network 模式: ```bash docker run --network host nginx # 容器直接用 host 的网络栈 + v6 ``` Docker Compose: ```yaml networks: default: enable_ipv6: true ipam: config: - subnet: 'fd00:dead:beef::/64' ``` ### 9. systemd-resolved 配 v6 ```bash sudo nano /etc/systemd/resolved.conf # DNS=1.1.1.1 1.0.0.1 2606:4700:4700::1111 # FallbackDNS=8.8.8.8 8.8.4.4 2001:4860:4860::8888 sudo systemctl restart systemd-resolved ``` 或者直接 `/etc/resolv.conf` 加 v6 nameserver。 ### 10. 监控两栈连通 写一个 cron: ```bash #!/usr/bin/env bash # /usr/local/sbin/dual-stack-check.sh set -e curl -sf -4 https://example.com/health || echo "v4 down" | mail -s alert ops@ curl -sf -6 https://example.com/health || echo "v6 down" | mail -s alert ops@ ``` 或者用 Uptime Kuma 配两个 monitor(v4 + v6)。 ## 实测一些坑 ### A. `ping6` ICMP 被防火墙挡 ```bash ping6 ipv6.google.com # ping: connect: Network is unreachable ``` 99% 是 ICMPv6 被防火墙误封。`nft list ruleset` 看 `meta nfproto ipv6 icmpv6 accept` 是否在。 ### B. v4 通 v6 不通 ```bash curl example.com # OK curl -6 example.com # timeout ``` 检查: 1. AAAA 记录有没有:`dig AAAA example.com` 2. nginx 是否 listen :::80:`ss -tlnp | grep nginx` 3. 防火墙 v6 允许 80/443:`ip6tables -L` / `nft list` ### C. 部分客户端"happy eyeballs" 走 v6 失败 浏览器有 happy eyeballs 算法:v4/v6 都试,谁先连上用谁。 如果 v6 路径慢(你 ISP 链路差),happy eyeballs 会让浏览器走 v4, 看起来一切正常。但 v6-only 用户仍打不开 → 监控里抓不到。 专门做 v6-only 测试机定期 check。 ### D. CDN / WAF v6 支持 Cloudflare / Fastly 自动双栈。 某些小 CDN 不支持 v6 → 源站直连 v6 用户慢。问清楚。 ### E. SSH brute-force 在 v6 上 v6 地址空间大,扫不动。所以 v6 上 SSH 攻击罕见。 不过 fail2ban 默认只管 v4,要专门启 v6 支持: ```ini [sshd] banaction = ufw # 或 nftables-multiport ipv6 friendly ``` ### F. AAAA 记录加错让网站慢 如果 AAAA 指向不可达 v6(你机器没真开 v6),浏览器会先试 v6 失败 + timeout + 再试 v4。**首屏慢 1-3 秒**。 要么真开 v6,要么删 AAAA。 ## 效果 我们站点开 IPv6 后: - 移动 5G 用户访问稳定(之前部分运营商 v4 NAT 偶尔丢包) - Google PageSpeed Insights "Modern web" 那条满分 - 全球访问质量轻微改善(v6 路径在某些链路上更直) - 部分 API 请求量上升(v6-only 客户端被 unblock) ## 测试工具 - [https://test-ipv6.com](https://test-ipv6.com):浏览器跑测试看双栈情况 - [https://ipv6-test.com/validate.php?url=example.com](https://ipv6-test.com):你的网站 v6 评分 ## 踩过的坑(再总结) 1. **AAAA 记录加了但服务器没真 listen v6** → 用户访问慢 2. **ICMPv6 防火墙封死** → MTU 探测失败 → 大包不通 3. **systemd service 只 bind 0.0.0.0** → v6 客户端连不到 4. **Docker 容器忘开 v6** → 容器内服务对 v6 client 不响应 5. **fail2ban 只防 v4** → v6 上 attacker 不受限(虽然少见但仍要防)

用 syncoid 把 ZFS 数据集自动增量同步到异地(家用 NAS → VPS)

## 起因 家里 NAS 上有 4TB 照片 + 项目数据,本地做了 ZFS RAID1(同一台机器 两块盘镜像)。但火灾 / 水患 / 误删 ZFS 仓库 → 一晚上全没。 "异地备份"是真灾备。 需求:每天把 NAS 上某个 dataset 增量同步到一台远程 VPS(10GB 起价 便宜的 storage VPS)。要求:增量、加密传输、自动管理 snapshot 链、 失败告警。 手写 `zfs send | ssh ... | zfs receive` 能行,但要自己管"上一次成功 的 base snapshot 是哪个"非常烦。`syncoid`(sanoid 项目的一部分)自动 处理整套流程。 ## 解决方案 ### 装 NAS + VPS 都要装: ```bash # Debian / Ubuntu sudo apt install sanoid syncoid --version ``` ### 配 SSH 密钥(NAS → VPS 单向) NAS: ```bash sudo ssh-keygen -t ed25519 -f /root/.ssh/syncoid -C 'syncoid' sudo cat /root/.ssh/syncoid.pub ``` VPS: ```bash sudo useradd -m -s /bin/bash zfsbackup sudo mkdir -p /home/zfsbackup/.ssh sudo tee -a /home/zfsbackup/.ssh/authorized_keys <<'EOF' command="zfs receive *",no-pty,no-port-forwarding ssh-ed25519 AAAA... EOF sudo chown -R zfsbackup:zfsbackup /home/zfsbackup/.ssh sudo chmod 700 /home/zfsbackup/.ssh sudo chmod 600 /home/zfsbackup/.ssh/authorized_keys ``` `command="zfs receive *"` 限定这把 key 只能跑 zfs receive, 即使泄露也不能登 shell。 ### 远程仓库 VPS 建 ZFS pool(数据盘,比如 `/dev/sdb`): ```bash sudo zpool create -O compression=zstd backup-pool /dev/sdb sudo zfs create backup-pool/nas sudo zfs allow zfsbackup create,mount,receive,destroy,snapshot \ backup-pool/nas ``` `zfs allow` 让 zfsbackup 用户能 receive 到这个 dataset 下。 ### 测试一次同步 NAS: ```bash sudo syncoid -i /root/.ssh/syncoid \ tank/photos [email protected]:backup-pool/nas/photos ``` 第一次跑会全量传 4TB(取决带宽,几小时-几天)。后续增量只传差异。 `syncoid` 自动: - 在源端创建一个 `syncoid_*` 快照 - `zfs send -i <last-common> <new>` 增量发到远端 - 远端 `zfs receive` - 删除源端不再需要的老 syncoid 快照(保留最近两个用于下次增量) ### sanoid 管本地 snapshot retention NAS `/etc/sanoid/sanoid.conf`: ```ini [tank/photos] use_template = production recursive = yes [template_production] hourly = 36 daily = 30 monthly = 12 yearly = 3 autosnap = yes autoprune = yes ``` cron / timer 跑: ```bash sudo systemctl enable --now sanoid.timer # 默认每 15 分钟检查 ``` 每小时打 snapshot,保留 36 小时;每天保留 30 天;每月保留 12 个月。 本地 snapshot 给"刚才误删了文件,立刻回来"用;syncoid 异地同步给"机器 没了"用。 ### 自动化 syncoid `/etc/systemd/system/syncoid-nas.service`: ```ini [Unit] Description=ZFS replicate tank/photos to vps After=network-online.target [Service] Type=oneshot ExecStart=/usr/sbin/syncoid \ --quiet \ -i /root/.ssh/syncoid \ tank/photos \ [email protected]:backup-pool/nas/photos ExecStartPost=-/usr/local/sbin/notify-success.sh syncoid [email protected] ``` `/etc/systemd/system/syncoid-nas.timer`: ```ini [Timer] OnCalendar=*-*-* 02:30:00 RandomizedDelaySec=30m Persistent=true [Install] WantedBy=timers.target ``` ```bash sudo systemctl enable --now syncoid-nas.timer ``` ### 校验异地数据 VPS: ```bash sudo zfs list -t snapshot backup-pool/nas/photos | tail -5 # 应当看到最近 syncoid_* snapshot sudo zpool scrub backup-pool # 每月一次校验 ``` ### 还原演练(重要) 最佳实践:每季度做一次"模拟 NAS 没了"演练。 从 VPS 拉回最近 snapshot: ```bash ssh nas sudo zfs send -R zfsbackup@vps:backup-pool/nas/photos@syncoid_2026_05_20 \ | sudo zfs receive -F tank/photos-restored ``` 确认能用 + 文件完整 + 权限对。**演练过的备份才是真备份**。 ## 效果 - 4TB 数据每天增量同步,平均增量 100-500 MB(取决于使用量) - 上传跑半小时-1 小时(看带宽),完全在低峰期 02:30 - 本地误删 → ZFS snapshot 秒级恢复 - NAS 全挂 → VPS 拉回完整数据,零数据丢失 - VPS 端 zstd 压缩让 4TB 实际占 ~2.4 TB(10 美元 / 月的 storage VPS 够用) ## 踩过的坑 1. **第一次全量同步卡 ssh 限速**:default OpenSSH 在某些 distro 上有 ChaCha20 加密的 CPU 瓶颈。改 `[email protected]` 加密算法 能快 2-3 倍:`syncoid --sshcipher [email protected]`。 2. **远端 dataset 没存在**:syncoid 不会自动建 parent dataset。 先手动 `zfs create backup-pool/nas` 建好。 3. **snapshot 过多**:sanoid 没配 retention 时 snapshot 几千个, `zfs list` 都慢。一定配 `autoprune = yes`。 4. **clock skew**:源端 / 目标端时间差大于 1 分钟,syncoid 偶尔报 "common ancestor" 找错。两端配 chrony 同 NTP。 5. **加密 dataset 同步**:源是 encrypted dataset,要加 `--sendoptions=w` 送 raw encrypted stream,远端不需要密码就能存。

用 uv 起一个最小 Django 5 项目(替代 pip + venv)

`uv` 是 Astral(Ruff 团队)的 Python 项目管理器,Rust 写的, 比 `pip + venv + pip-tools` 快 10-100 倍。2024 之后基本是新项目的默认。 下面 5 分钟起一个 Django 5 项目。 ## 1. 装 uv ```bash curl -LsSf https://astral.sh/uv/install.sh | sh # 或 macOS: brew install uv # 或 Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex" uv --version ``` ## 2. 起项目 ```bash uv init myapp --python 3.12 cd myapp ``` `uv init` 生成 `pyproject.toml` + `.python-version` + `hello.py`, 不创建 venv 直到需要。 ## 3. 加依赖 ```bash uv add django gunicorn psycopg[binary] python-dotenv uv add --dev ruff pytest pytest-django ``` `uv add` 会: 1. 解析依赖(极快) 2. 写入 `pyproject.toml` 3. 锁定到 `uv.lock` 4. 装到 `.venv/`(自动创建) `psycopg[binary]` 是 psycopg3 的预编译版(生产建议 `psycopg[c]` 自己编)。 ## 4. Django 项目结构 ```bash uv run django-admin startproject myapp . uv run python manage.py startapp blog ``` `uv run` 等价于"在项目 venv 里跑"。比 `source .venv/bin/activate && python ...` 直接,跨 shell / CI 都一样。 ## 5. 跑 ```bash uv run python manage.py migrate uv run python manage.py createsuperuser uv run python manage.py runserver ``` ## 6. CI / Docker 中使用 `Dockerfile`: ```dockerfile FROM python:3.12-slim # 装 uv 二进制 COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app COPY pyproject.toml uv.lock ./ # --frozen 严格按 lock 安装,不解析 RUN uv sync --frozen --no-install-project COPY . . RUN uv sync --frozen EXPOSE 8000 CMD ["uv", "run", "gunicorn", "myapp.wsgi:application", "-b", "0.0.0.0:8000"] ``` GitHub Actions: ```yaml - uses: astral-sh/setup-uv@v3 with: enable-cache: true - run: uv sync --frozen - run: uv run pytest ``` `enable-cache: true` 跨 job 缓存 wheel,CI 飞快。 ## 7. 升级 / 锁定 ```bash uv lock --upgrade-package django # 只升 django uv lock --upgrade # 全部升到最新允许的范围 uv add 'django>=5.1,<6' # 改约束 uv pip compile pyproject.toml > requirements.txt # 兼容 pip 导出 ``` ## 8. 与 pip / poetry 对比 | 操作 | pip / pip-tools | uv | |---|---|---| | 装 100 个依赖 | 30-60s | 1-3s | | 解析 lock | 几秒到几十秒 | < 1s | | 创建 venv | `python -m venv .venv` (慢) | 自动且快 | | 跨平台 lock | 麻烦 | 内置 | | Python 多版本 | pyenv 配合 | `uv python install 3.12` 内置 | ## 踩过的坑 - `uv.lock` 必须进 git。它包含跨平台依赖锁,删了会让 `uv sync --frozen` 失败。 - `uv add` 默认只在主 group;要 dev 依赖加 `--dev`;要 optional group 加 `--optional groupname`。 - `psycopg[binary]` 在 musl-libc(Alpine)镜像上没预编译 wheel,会回退到 源码编译——慢。改用 `python:3.12-slim`(glibc)镜像。 - VSCode 自动识别 `.venv/` —— 但要把 Python 解释器手动选为 `.venv/bin/python`, 否则 import 检查走系统 Python。

用 uv 装 PyTorch + 正确 CUDA 版本(一条命令搞定)

PyTorch 装错 CUDA 版本(CPU-only / 不匹配显卡驱动)是新手最常踩的 第一个坑。`uv` + PyTorch 官方提供的 index URL 能在一行命令里搞定。 ## 1. 看清你需要的版本 ```bash nvidia-smi # 关注右上角 "CUDA Version: 12.4" —— 这是 driver 支持的最高 CUDA 版本 # PyTorch 实际使用的 runtime 可以等于或更低,但不能更高 ``` PyTorch 现在主要 ship 三种轮子: - **cu124** (CUDA 12.4 runtime) - **cu121** (CUDA 12.1) - **cu118** (CUDA 11.8) — 老显卡 / 老驱动 - **cpu** (无 GPU) 你的驱动支持 12.x 就装 cu121 或 cu124;不确定就 cu121(兼容性最好)。 ## 2. 一条命令装 ```bash # uv 新项目 uv init mlproject --python 3.12 cd mlproject # 直接装最新稳定版 uv add torch torchvision torchaudio --index https://download.pytorch.org/whl/cu124 # 或指定版本 uv add 'torch==2.4.1' 'torchvision==0.19.1' \ --index https://download.pytorch.org/whl/cu124 ``` `--index` 让 uv 从 PyTorch 自己的 CDN 拉轮子(PyPI 上的轮子默认是 CPU 版的)。 ## 3. 校验 ```python # check.py import torch print(f'torch: {torch.__version__}') print(f'cuda built: {torch.version.cuda}') print(f'cuda avail: {torch.cuda.is_available()}') print(f'device count: {torch.cuda.device_count()}') if torch.cuda.is_available(): print(f'device name: {torch.cuda.get_device_name(0)}') print(f'capability: {torch.cuda.get_device_capability(0)}') # 做一次实际计算 x = torch.randn(1000, 1000) if torch.cuda.is_available(): x = x.cuda() print(f'matmul on GPU: {(x @ x).sum().item():.2f}') ``` ```bash uv run python check.py ``` 期望输出: ``` torch: 2.4.1+cu124 cuda built: 12.4 cuda avail: True device count: 1 device name: NVIDIA GeForce RTX 4090 capability: (8, 9) matmul on GPU: ... ``` `+cu124` 后缀确认装的是 CUDA wheel;`is_available()` False 说明 wheel 匹配了但驱动 / NVIDIA 库有问题。 ## 4. 锁版本 ```bash ls uv.lock # 已经写入 ``` CI / 同事直接: ```bash uv sync --frozen --index https://download.pytorch.org/whl/cu124 ``` 得到完全一致的环境。 ## 5. 配置 pyproject.toml 让 index 持久化 每次都加 `--index` 很烦。`pyproject.toml`: ```toml [project] name = "mlproject" version = "0.1.0" requires-python = ">=3.12" dependencies = [ "torch>=2.4", "torchvision>=0.19", ] [[tool.uv.index]] name = "pytorch-cu124" url = "https://download.pytorch.org/whl/cu124" explicit = true [tool.uv.sources] torch = { index = "pytorch-cu124" } torchvision = { index = "pytorch-cu124" } ``` 之后 `uv add ...` 不再需要 `--index`。 ## 6. Apple Silicon (M1/M2/M3) 用 MPS ```bash uv add torch torchvision torchaudio # Mac 上 PyPI 默认轮子已经带 MPS(Metal Performance Shaders)后端 ``` ```python device = 'mps' if torch.backends.mps.is_available() else 'cpu' x = torch.randn(1000, 1000, device=device) ``` MPS 不如 CUDA 快但比 CPU 快几倍,免费的不错。 ## 7. CPU-only(开发 / 测试 / CI) ```bash uv add torch --index https://download.pytorch.org/whl/cpu ``` CI 跑测试一般用 CPU wheel,节省 wheel 大小 + 启动时间。 ## 8. 多 GPU 看见 ```python torch.cuda.device_count() # 几张卡 # 显式选择 device = torch.device('cuda:0') model = model.to(device) ``` 多卡训练用 `torch.nn.DataParallel` 简单粗暴 / `DistributedDataParallel` (DDP)才是生产选择。 ## 9. PyTorch 升级 ```bash uv lock --upgrade-package torch --index https://download.pytorch.org/whl/cu124 ``` 不同 CUDA 版本的 PyTorch 是不同 package(torch+cu121 vs torch+cu124), 直接 lock 升级最稳。 ## 10. Docker 镜像 ```dockerfile FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04 # uv 二进制 COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv # Python 3.12 RUN apt update && apt install -y python3.12 python3-pip git WORKDIR /app COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --index https://download.pytorch.org/whl/cu124 COPY . . CMD ["uv", "run", "python", "train.py"] ``` 镜像底层用 NVIDIA 官方 `cuda:*-devel`,宿主装 `nvidia-container-toolkit`: ```bash docker run --gpus all -v $(pwd):/app mlproject ``` ## 踩过的坑 - 装了 CPU wheel 但代码里 `.cuda()`:报 "no CUDA-capable device"。 pip / uv 默认从 PyPI 拉 = CPU wheel。必须 `--index pytorch`。 - 驱动 CUDA 12.0 但装了 cu124:装得上但 `is_available()=False` 或运行 时 segfault。升驱动或者降 wheel。 - 多版本 Python 共存时 wheel 不匹配(cp310 wheel 装到 cp312 venv): uv 会自动挑对的,但手动 pip 时容易选错。 - 想用 `torch.compile()` 但 wheel 没带:cu118 上没 compile 支持。 cu121+ / cu124 才有。 - jupyter notebook 启动时不见 CUDA:notebook 是另一个进程, 确认是用 `uv run jupyter` 启动(继承了 venv 的 path)。

Vite bundle 分析:找出"为什么我的 JS 包是 1.2 MB"

## 起因 一个 React app build 完 `main.js` 1.2 MB(gzipped 320 KB),首屏卡顿。 "哪些包是大头?删得掉吗?"——光看 `dist/` 看不出。 `rollup-plugin-visualizer` 给 vite build 加可视化报告: treemap 看每个 dependency 的大小占比,10 分钟能砍 30-60%。 ## 解决方案 ### 装 ```bash npm i -D rollup-plugin-visualizer ``` `vite.config.ts`: ```ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ react(), visualizer({ open: true, // build 后自动开浏览器 gzipSize: true, brotliSize: true, filename: 'dist/stats.html', }), ], }) ``` ```bash npm run build # 浏览器自动开 dist/stats.html ``` 显示一张 treemap:每个矩形大小 = 该 module 占 bundle 的比例。 hover 看具体 KB(raw / gzip / brotli)。 ## 常见"巨包" + 处理 ### A. moment.js 占 200 KB+ ``` node_modules/moment/ → 200 KB(含全部 locale) ``` 修复:换成 day.js(2 KB,API 兼容)或 date-fns(tree-shakeable)。 ```diff - import moment from 'moment' + import dayjs from 'dayjs' - moment().format('YYYY-MM-DD') + dayjs().format('YYYY-MM-DD') ``` 如果坚持用 moment,至少剔除 locale: ```js // vite.config.ts build: { rollupOptions: { plugins: [ // 忽略 moment locale { name: 'remove-moment-locale', resolveId(id) { if (id.includes('moment/locale')) return false }, }, ], }, } ``` 省 150 KB+。 ### B. lodash 全 import 占 80 KB ```js import _ from 'lodash' // ❌ 整个 lodash _.debounce(...) ``` → ```js import debounce from 'lodash/debounce' // ✅ 只 import 用到的 debounce(...) ``` 或者用 `lodash-es` + ESM tree-shaking: ```diff - "lodash": "^4.17.21" + "lodash-es": "^4.17.21" ``` ```js import { debounce, throttle } from 'lodash-es' // Vite tree-shake 后只 bundle debounce + throttle ``` ### C. icon 库一次进 1000+ icon ```js import * as Icons from 'react-icons/fa' // ❌ 全部 ``` → ```js import { FaUser, FaBell } from 'react-icons/fa' // ✅ 只用到的 ``` 或换成 lucide-react(更轻 + tree-shakeable): ```js import { User, Bell } from 'lucide-react' ``` ### D. chart 库(chart.js / echarts)整套 import ```js import * as echarts from 'echarts' // ❌ 1MB+ ``` → 按需 import: ```js import * as echarts from 'echarts/core' import { BarChart } from 'echarts/charts' import { GridComponent, TooltipComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]) ``` 只 bundle 用到的 chart 类型 + components。1MB → 200 KB。 ### E. 重复包 stats.html 里看到 `react` 出现两次(不同版本)→ 包冲突。 ```bash npm dedup # 或: npm ls react # 看依赖树哪几条用了不同 react 版本 ``` resolutions / overrides 强制单版本: ```json { "overrides": { "react": "18.3.1", "react-dom": "18.3.1" } } ``` ### F. 大 polyfill ``` core-js → 150 KB ``` target 现代浏览器后大多不需要: ```js // vite.config.ts build: { target: 'es2020', // 现代浏览器 } ``` 砍掉一堆 polyfill。 ## 代码分割:route-level lazy ```tsx import { lazy, Suspense } from 'react' const Dashboard = lazy(() => import('./pages/Dashboard')) const Settings = lazy(() => import('./pages/Settings')) <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={ <Suspense fallback={<Spinner />}> <Dashboard /> </Suspense> } /> </Routes> ``` 每个路由独立 chunk,访问到才下载。 首屏 bundle 只含 Home + 公共代码。 ```bash npm run build # 看到 dist/assets/Dashboard-abc123.js + dist/assets/Settings-def456.js ``` ## 第三方分割:manualChunks 把 React 等"很少变" 的 dep 抽独立 chunk,业务代码改不影响 cache: ```ts build: { rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'], charts: ['echarts'], }, }, }, } ``` 业务代码更新 → 用户重下 main.js(小)+ 命中 react.js / ui.js cache。 ## 动态 import 大 dep ```tsx async function exportPDF() { const { jsPDF } = await import('jspdf') // 用到才下载 const doc = new jsPDF() // ... } ``` PDF 导出按钮 click 才 fetch jspdf chunk。 ## 监控 bundle size on PR ```yaml # .github/workflows/size.yml - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run build - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} ``` PR 显示 "bundle 增加 +12 KB",超 budget 就 fail。 防止某天某人引一个巨包没注意。 `.size-limit.json`: ```json [ { "name": "main bundle", "path": "dist/assets/index-*.js", "limit": "200 KB" } ] ``` ## 实测:从 1.2 MB → 380 KB 我们一个真实 React app: | 阶段 | bundle (raw) | gzip | |---|---|---| | 初始 | 1.2 MB | 320 KB | | 删 moment → dayjs | 950 KB | 260 KB | | lodash 按需 import | 850 KB | 235 KB | | icon 改 lucide | 730 KB | 200 KB | | echarts 按需 | 480 KB | 145 KB | | route lazy split (main) | 380 KB | 115 KB | | 总改动 | -68% | -64% | LCP 从 4.5s → 1.8s。改动总共半天。 ## 其它分析工具 ### webpack-bundle-analyzer 风格 `source-map-explorer` 也支持 Vite: ```bash npm i -D source-map-explorer npm run build npx source-map-explorer dist/assets/index-*.js ``` 需要 source map 开(vite 默认开发开 / production 关; build sourcemap: true)。 ### bundlephobia 写代码前查:"如果我加这个包,bundle 变多大": ``` https://bundlephobia.com/package/moment # Bundle size: 290.7 KB # Minified + Gzipped: 71.2 KB ``` 知道代价再决定要不要装。 ### Vite Bundle Visualizer 升级 Vite 5 + `--report` 选项: ```bash vite build --report ``` 内置 visualizer。无需额外 plugin。 ## CDN 拆解 如果 React 等大库走 CDN: ```html <script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script> ``` ```ts build: { rollupOptions: { external: ['react', 'react-dom'], output: { globals: { react: 'React', 'react-dom': 'ReactDOM' }, }, }, } ``` bundle 不含 React → 共享 CDN cache 跨站。但 CDN 多次 DNS / 信任问题, 现代项目少用。 ## 踩过的坑 1. **chunk 太碎**:每页一个 chunk + 每包一个 chunk → 总文件数几百。 HTTP/1.1 client 多次连接慢。HTTP/2 没事;HTTP/1 视乎服务端。 2. **manual chunks 改后 hash 变** → cache 失效。`manualChunks` 改一次 全 dependency cache 一次性失效,所有用户重下。慎重。 3. **dynamic import 路径动态化** → vite 静态分析不到 → 不切 chunk: ```ts const m = await import(`./pages/${name}`) // ❌ vite 不知道哪些 page ``` 要给 vite 提示: ```ts const m = await import(`./pages/${name}.tsx`) // 静态可分析 // 或:vite-ignore 注释 + 自己管 ``` 4. **bundle 在 dev 看不出问题**:dev 模式 vite 不打包,每个 module 独立 HTTP。production 才看真实大小。永远以 production build 衡量。 5. **react 包很小但 hydrate 慢**:bundle size 不是 LCP 唯一决定因素。 React 启动 / hydrate 也耗时。RSC + 减少 client component 是补充 优化方向。

litestream 把 SQLite 实时复制到 S3 / Backblaze(秒级 RPO 备份)

## 起因 我们的小型生产服务用 SQLite(部署简单 + 性能够 + 单文件备份)。 但默认备份方案最多每天 cron `sqlite3 .backup` + scp 异地。两次备份之间 有 24 小时 RPO(最坏丢一天数据)。 `litestream` 是开源工具,把 SQLite 的 WAL 日志实时增量复制到 S3 兼容 存储。RPO 降到秒级,无需改 application 代码。 ## 解决方案 ### 装 ```bash # 二进制安装 LITESTREAM_VERSION=0.3.13 curl -fsSL https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.tar.gz \ | sudo tar xz -C /usr/local/bin litestream litestream version ``` 或 Docker:`docker run litestream/litestream`。 ### 配置 `/etc/litestream.yml`: ```yaml dbs: - path: /srv/knowledge/db.sqlite3 replicas: - type: s3 bucket: my-backups path: knowledge endpoint: https://s3.us-east-005.backblazeb2.com region: us-east-005 access-key-id: ${B2_KEY_ID} secret-access-key: ${B2_APP_KEY} retention: 720h # 保留 30 天 snapshot-interval: 24h # 每天全量 snapshot ``` litestream 工作原理: 1. application 写 `db.sqlite3` → SQLite 写 WAL 2. litestream 后台读 WAL 增量帧 3. 每个 WAL 帧立刻上传到 S3(默认 10s 内) 4. 定期(默认 24h)做一次全量 snapshot 5. retention 期外的 snapshot + WAL 自动清理 ### systemd service ```ini # /etc/systemd/system/litestream.service [Unit] Description=Litestream Requires=network.target After=network.target [Service] User=trio Group=trio EnvironmentFile=/etc/litestream.env ExecStart=/usr/local/bin/litestream replicate -config /etc/litestream.yml Restart=on-failure RestartSec=5s StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ``` `/etc/litestream.env`: ```bash B2_KEY_ID=00abc... B2_APP_KEY=K00... ``` ```bash sudo systemctl enable --now litestream journalctl -u litestream -f # 看到 "replicating wal frames..." ``` ### 验证副本 ```bash litestream snapshots /srv/knowledge/db.sqlite3 # replica generation index size created # s3 abc123 5 12345678 2026-05-24T03:30:00Z litestream wal /srv/knowledge/db.sqlite3 | head # replica generation index offset size ``` ### 灾难还原 服务器全没了,新机器恢复: ```bash # 装 litestream + 复制 /etc/litestream.yml + .env 到新机器 litestream restore -o /srv/knowledge/db.sqlite3 \ s3://my-backups/knowledge # 从最新 snapshot + 重放 WAL 到最新状态 # 几秒-几分钟(取决数据量) # 启动 application sudo systemctl start knowledge ``` 时间点恢复: ```bash litestream restore -timestamp 2026-05-20T10:00:00Z \ -o /tmp/db-at-may-20.sqlite3 \ s3://my-backups/knowledge ``` 恢复到任意 30 天内的时间点。 ## 效果 - RPO 从 24h → 10s - 灾难还原 = 一行 `litestream restore` - B2 存储费用:30 天保留 ~2 GB DB 约 $0.10/月 - application 零改动:litestream 在后台独立进程跑 ## 与其它方案对比 | | cron `.backup` + scp | litestream | PG streaming replica | |---|---|---|---| | RPO | 24h | ~10s | ~ms | | 单机部署 | ✅ | ✅ | 需主备 | | 时间点恢复 | ❌ | ✅ | ✅ | | 同步开销 | 0(备份时刻才有) | 极低(WAL 增量) | 中(流复制) | | 成本 | 几乎 0 | 存储费 + 几乎免费 | 备机 + 网络 | SQLite + litestream 在中小规模生产是性价比最高方案之一。 ## 与之前装的 daily backup 共存 之前章节装的 `knowledge-backup.timer` 是每日全量本机存档;litestream 是实时异地复制。两者互补: - 本地 daily 用于"刚才误删一条记录" 快速回滚 - litestream 用于"机器全没了" 灾难恢复 不冲突,都开就好。 ## 性能影响 litestream 读 WAL 是只读操作,对 application 几乎无影响。 WAL 上传是后台 + 异步,application 写延迟不变。 CPU 占用通常 < 1%,内存 < 50 MB。 ## 踩过的坑 1. **SQLite 必须 WAL 模式**:litestream 依赖 WAL。 ```sql PRAGMA journal_mode = WAL; ``` 不是 WAL 时 litestream 报错。Django 我们已经在 settings 里开了。 2. **不能两个 litestream 同时复制同一个 DB**:会写坏。一个机器一个 实例。 3. **同时开 cron `.backup`**:`.backup` 命令本身不影响 WAL,安全; 但 `VACUUM` / `VACUUM INTO` 会重写 DB 让 litestream 失效,需要 重新 init。 4. **B2 / R2 等 S3-兼容 endpoint URL 易写错**:每个区域 endpoint 不同。出错时 `litestream replicate` 报 "no such bucket"。 curl 测试一下访问。 5. **首次 restore 时 generation 不匹配**:DB 被本地写过新数据, litestream WAL 链断了。生产里数据始终从备份 restore,本地不要 手动改。

FastAPI + Pydantic v2 严格校验请求与响应(含自定义错误格式)

FastAPI 的核心卖点是"用 Python 类型注解定义 API schema,自动校验 + 生成 OpenAPI 文档"。Pydantic v2 是其背后的校验引擎,比 v1 快 5-50 倍。 ## 1. 最小例子 ```python from fastapi import FastAPI from pydantic import BaseModel, EmailStr, Field app = FastAPI() class CreateUser(BaseModel): email: EmailStr nickname: str = Field(min_length=2, max_length=30) age: int = Field(ge=0, le=150) class UserOut(BaseModel): id: int email: EmailStr nickname: str @app.post('/users', response_model=UserOut, status_code=201) def create_user(payload: CreateUser) -> UserOut: # ... 写入 DB ... return UserOut(id=42, email=payload.email, nickname=payload.nickname) ``` 发请求时任何字段不合法都返回 422 + 详细错误。打开 `/docs` 看自动文档。 ## 2. 自定义 validator ```python from pydantic import field_validator class CreateUser(BaseModel): nickname: str @field_validator('nickname') @classmethod def no_whitespace(cls, v: str) -> str: if v != v.strip() or ' ' in v: raise ValueError('昵称不能含首尾或连续空格') return v ``` V2 必须加 `@classmethod`。 ## 3. 计算字段(动态) ```python from pydantic import computed_field class UserOut(BaseModel): nickname: str username: str @computed_field @property def display_name(self) -> str: return f'{self.nickname}@{self.username}' ``` ## 4. 加载 / 序列化别名 ```python class UserIn(BaseModel): email_address: str = Field(alias='email') # 接收的 JSON 里是 "email",Python 属性是 email_address ``` ## 5. 严格模式:拒绝多余字段 V2 默认允许额外字段被忽略。生产里建议严格: ```python class CreateUser(BaseModel): model_config = {'extra': 'forbid'} email: EmailStr # 客户端发 {"email": ..., "foo": ...} 会 422 ``` ## 6. 统一错误格式 FastAPI 默认 422 响应是 Pydantic 原始结构。给前端友好点: ```python from fastapi import Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @app.exception_handler(RequestValidationError) async def validation_handler(request: Request, exc: RequestValidationError): errors = [ { 'field': '.'.join(str(x) for x in e['loc']), 'message': e['msg'], 'type': e['type'], } for e in exc.errors() ] return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={'detail': '请求数据校验失败', 'errors': errors}, ) ``` ## 7. 路径 / 查询 / Body / Header / Cookie ```python from fastapi import Path, Query, Header, Cookie @app.get('/items/{item_id}') def get_item( item_id: int = Path(ge=1), fields: list[str] | None = Query(None, max_length=10), user_agent: str | None = Header(None), session: str | None = Cookie(None), ): ... ``` ## 8. Depends 注入 ```python from fastapi import Depends def get_db(): db = SessionLocal() try: yield db finally: db.close() @app.get('/users/{uid}') def read_user(uid: int, db = Depends(get_db)): return db.query(User).get(uid) ``` `Depends` 是 FastAPI 的 DI 系统;可以套娃(依赖里又用 Depends)。 ## 9. 鉴权依赖 ```python from fastapi import HTTPException, status from fastapi.security import OAuth2PasswordBearer oauth2 = OAuth2PasswordBearer(tokenUrl='token') def current_user(token: str = Depends(oauth2)): user = decode_jwt(token) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, headers={'WWW-Authenticate': 'Bearer'}) return user @app.get('/me') def me(user = Depends(current_user)): return user ``` ## 10. 后台任务(轻量) ```python from fastapi import BackgroundTasks def send_welcome_email(email: str): # ... 同步发邮件 ... ... @app.post('/users') def create_user(payload: CreateUser, tasks: BackgroundTasks): user = save(payload) tasks.add_task(send_welcome_email, user.email) return user ``` 请求立即返回,邮件在响应发出后异步执行。注意:失败没有重试。 要可靠就上 Celery / RQ / Arq。 ## 11. CORS ```python from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=['https://example.com'], allow_credentials=True, allow_methods=['*'], allow_headers=['*'], ) ``` ## 12. 运行 ```bash uv add fastapi 'uvicorn[standard]' uv run uvicorn main:app --reload # 开发 uv run uvicorn main:app --workers 4 --host 0.0.0.0 --port 8000 # 生产 # 生产更稳的是用 gunicorn 启 uvicorn worker: uv run gunicorn -k uvicorn.workers.UvicornWorker -w 4 main:app ``` ## 踩过的坑 - Pydantic v1 / v2 在同一项目混用:v1 model 的 `.dict()`、`.json()` 在 v2 是 `.model_dump()` 和 `.model_dump_json()`。代码改名后老的方法 调用会静默返回不正确的格式。 - 用 dataclass 替代 BaseModel:dataclass 在 FastAPI 路径参数处不会被校验, 只在 response_model 处校验。混着用很容易出问题。 - `response_model` 会**过滤**响应字段(不在 model 里的字段被丢掉), 这是 feature 不是 bug。想全输出就别设 response_model 或用 `dict`。 - `BackgroundTasks` 是同一进程里的协程,长任务会撑住 worker; 超过 30 秒的任务就该上 Celery。