知识广场

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

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

全文搜索后端:Elasticsearch vs Meilisearch vs Quickwit

## 起因 要给 web app 加"搜索文章" 功能。SQL `LIKE '%word%'` 慢 + 不能模糊。 PG 的 `tsvector` / FTS5 能用但中文分词 + 高级 query 较弱。 专用搜索引擎选哪个? 三个主流选项: - **Elasticsearch**:业界事实标准,功能最全 + 最重 - **Meilisearch**:Rust 写的"现代轻量",开箱即用 - **Quickwit**:Rust 写的"日志搜索专用",对象存储友好 下面对比。 ## Elasticsearch (8.x) 老牌强者。基于 Lucene。 ### 装 ```bash docker run -d -p 9200:9200 \ -e "discovery.type=single-node" \ -e "xpack.security.enabled=false" \ -m 2g \ docker.elastic.co/elasticsearch/elasticsearch:8.15.0 ``` `-m 2g` 必须(ES Java 默认 1 GB heap + JVM overhead,最低 2 GB RAM)。 ### 索引 + 查询 ```python from elasticsearch import Elasticsearch es = Elasticsearch('http://localhost:9200') # 索引文档 es.index(index='articles', id='1', document={ 'title': 'PostgreSQL 全文搜索', 'body': 'tsvector ts_rank ...', 'tags': ['db', 'pg'], 'created_at': '2024-05-24', }) # 搜索 res = es.search(index='articles', query={ 'bool': { 'must': [ {'match': {'body': 'tsvector 全文'}}, ], 'filter': [ {'term': {'tags': 'pg'}}, {'range': {'created_at': {'gte': '2024-01-01'}}}, ], }, }) for hit in res['hits']['hits']: print(hit['_score'], hit['_source']['title']) ``` ### 优势 - 极完整:聚合 / 复杂 query / geo / vector - 大规模成熟(PB 级集群) - 生态广(Logstash / Kibana / Beats) - 中文分词通过 IK plugin ### 劣势 - 资源吃货(最少 2 GB RAM;生产 8-32 GB / node) - 集群运维复杂 - API 庞大学习曲线陡 - Elastic 公司协议 2021 改为 SSPL(云厂商不爽 fork AWS OpenSearch) 适合:日志 + 复杂搜索 + 已有 ES 经验 / 团队。 ## Meilisearch (1.x) 2018 年起的新晋。Rust 写。专为"产品内搜索"优化。 ### 装 ```bash docker run -d -p 7700:7700 \ -e MEILI_MASTER_KEY=your-key \ -v meili-data:/meili_data \ getmeili/meilisearch:v1.10 ``` 200 MB 镜像,启动几秒。 ### 索引 + 查询 ```python import meilisearch client = meilisearch.Client('http://localhost:7700', 'your-key') index = client.index('articles') # 索引 index.add_documents([ {'id': 1, 'title': 'PostgreSQL 全文搜索', 'body': '...', 'tags': ['db']}, {'id': 2, 'title': 'Elasticsearch 入门', 'body': '...', 'tags': ['search']}, ]) # 默认配置:所有字段都搜 results = index.search('全文搜索') # { # "hits": [{"id": 1, "title": "...", ...}], # "processingTimeMs": 3 # } # 配 filter / sort(需要先标记 filterable / sortable) index.update_filterable_attributes(['tags']) index.update_sortable_attributes(['created_at']) results = index.search('搜索', { 'filter': 'tags = db', 'sort': ['created_at:desc'], 'limit': 20, }) ``` ### 优势 - **零配置开箱即用**(typo-tolerance / instant search 默认开) - 极快:百万级文档 P99 < 50ms - API 极简 - 资源占用低(150 MB RAM 跑 10w 文档) - 内置 admin UI(http://localhost:7700) - 支持中文 / 日文等亚洲语言(自动 tokenize) ### 劣势 - 不像 ES 那么强大的复杂 query - 集群在 v1 是 Cloud-only feature(自托管单节点) - 生态相对小 - 不适合大日志(无 time-series 优化) 适合:电商 / 文档 / 博客 / 内容站的"搜索框"。 对比 ES:90% 用户的"产品内搜索"用 Meilisearch 更省心。 ### TypoTolerance + Synonyms ```python index.update_typo_tolerance({ 'enabled': True, 'minWordSizeForTypos': {'oneTypo': 4, 'twoTypos': 8}, }) index.update_synonyms({ 'js': ['javascript'], 'k8s': ['kubernetes'], }) ``` 打 "javasrcipt" 也能找到 "javascript";搜 "js" 同时匹配 "javascript"。 ## Quickwit 针对**日志搜索** 优化的现代后端,基于 tantivy(Rust Lucene)。 ### 装 ```bash docker run -d -p 7280:7280 -p 7281:7281 \ quickwit/quickwit:v0.8 run ``` ### 设计哲学 - 索引存对象存储(S3 / GCS / local),不依赖本地 SSD - 计算 / 存储分离,scale-to-zero - log search 优化:append-only,按时间分片,老数据冷存 ### 用法 ```bash # 创建索引 curl -X POST http://localhost:7280/api/v1/indexes -H 'Content-Type: application/yaml' --data ' version: 0.7 index_id: logs doc_mapping: field_mappings: - name: timestamp type: datetime input_formats: ['unix_timestamp'] fast: true - name: level type: text tokenizer: raw - name: message type: text tokenizer: default timestamp_field: timestamp search_settings: default_search_fields: [message] ' # Ingest logs curl -X POST http://localhost:7280/api/v1/logs/ingest \ -H 'Content-Type: application/json' \ -d '{"timestamp": 1716543210, "level": "ERROR", "message": "DB connection failed"}' # 查 curl 'http://localhost:7280/api/v1/logs/search?query=ERROR+database&start_timestamp=...&end_timestamp=...' ``` ### 优势 - 极便宜(对象存储几 $0.02/GB/月 vs SSD 10-50x) - "无限"保留(S3 不限容量) - 查询历史日志快(带时间过滤的 query) ### 劣势 - 仅 log / time-series 场景 - 不适合"产品内搜索" - 复杂 query 不如 ES 适合:日志聚合 / 审计 / 任何"时间序列 + 文本搜索"。 ## 决策矩阵 | | Elasticsearch | Meilisearch | Quickwit | PG FTS / SQLite FTS5 | |---|---|---|---|---| | 入门门槛 | 高 | 极低 | 中 | 低 | | 资源占用 | 2 GB+ | 150 MB | 1 GB | 共享 DB | | 文档规模 | PB 级 | 千万级 | EB 级 (log) | 千万级 | | Query 复杂度 | 极高 | 中 | 中(log 限定) | 中 | | 集群 / HA | 复杂 | 单 | 计算存储分离 | 跟 DB | | 实时性 | 秒 | 秒 | 秒 | 实时 | | 价格 | 资源贵 | 便宜 | 极便宜 | 0 | ## 推荐 - **数据规模小(< 10w 文档)+ 已经在用 PG / SQLite** → PG FTS / FTS5 - **产品内搜索(电商 / 文档 / 内容)** → Meilisearch - **日志聚合** → Quickwit(替代 ES + Loki 也行) - **大规模 + 复杂 query + 已有 ES 经验** → Elasticsearch - **企业偏好** → OpenSearch(ES 的 Apache fork) ## 实战:Meilisearch 集成 Django ```python # requirements.txt # meilisearch import meilisearch client = meilisearch.Client('http://localhost:7700', settings.MEILI_KEY) index = client.index('posts') # Django signal: 文档变更同步到 Meili from django.db.models.signals import post_save, post_delete @receiver(post_save, sender=Post) def index_post(sender, instance, **kwargs): index.add_documents([{ 'id': instance.id, 'title': instance.title, 'body': instance.body, 'tags': list(instance.tags.values_list('name', flat=True)), 'author': instance.author.name, 'created_at': instance.created_at.timestamp(), }]) @receiver(post_delete, sender=Post) def delete_post(sender, instance, **kwargs): index.delete_document(instance.id) # 搜索 view def search(request): q = request.GET.get('q', '') results = index.search(q, {'limit': 20}) return render(request, 'search.html', {'hits': results['hits']}) ``` 部署:Meilisearch 容器 + Django 配 MEILI_URL。 效果: - 搜索从 PG `ILIKE` 几秒 → Meili 50ms - 支持 typo / 高亮 / 相关性排序 - 资源占用比 PG 多 200 MB ## 踩过的坑 1. **Meilisearch 没 master key**:API 任何人能读写。生产必设。 2. **ES heap size**:默认 1 GB 不够大数据。Java options 设 `-Xms4g -Xmx4g`。 3. **重索引慢**:document 多了 reindex 几小时。设计时考虑增量 sync。 4. **filterable 字段没声明** → filter 不生效报错。Meilisearch 每加 filter 字段要先 `update_filterable_attributes`。 5. **数据一致性**:DB write success + Meili sync failed → 数据漂移。 重要场景用 outbox pattern:写 DB + outbox table → background worker 把 outbox sync 到 Meili。

用 systemd timer 替代 cron(精确到秒 + 失败重试 + 日志归档)

cron 用了二十多年但有几个老问题:失败没有 retry、错过的任务(机器关机) 不补跑、日志要自己重定向、表达式只到分钟、找不到任务跑慢了的根因。 systemd timer 把这些一站式解决。 ## 一个完整例子 需求:每小时把 PostgreSQL 备份压缩传到远端,失败重试 3 次。 ### service 单元 ```ini # /etc/systemd/system/pgdump.service [Unit] Description=Hourly pg_dump + ship to backup host Wants=network-online.target After=network-online.target [Service] Type=oneshot User=postgres Group=postgres # 失败重试:5s, 15s, 45s 三次 Restart=on-failure RestartSec=5s StartLimitIntervalSec=120 StartLimitBurst=4 # 运行约束 Nice=10 IOSchedulingClass=idle TimeoutStartSec=30m # 命令本身 ExecStart=/usr/local/sbin/pgdump-ship.sh # 资源限制(避免 OOM 把别的进程踩死) MemoryMax=2G TasksMax=64 ``` `Restart=on-failure` 是关键 —— 脚本非零退出就触发重启。`StartLimitBurst=4` 意味着 120s 内最多触发 4 次(即 3 次重试),超过就 systemd 放弃并把单元 标记为 `failed`,配合 `OnFailure=` 还能进一步触发告警。 ### timer 单元 ```ini # /etc/systemd/system/pgdump.timer [Unit] Description=Hourly pg_dump [Timer] # 每小时第 5 分钟(避开 nginx logrotate 等整点任务) OnCalendar=*:05:00 # 错过的执行(如机器关机时)启动时补一次 Persistent=true # 多机器错峰 RandomizedDelaySec=2m # 防止上一次还没跑完又起新的 Unit=pgdump.service [Install] WantedBy=timers.target ``` ### 启用 ```bash sudo systemctl daemon-reload sudo systemctl enable --now pgdump.timer systemctl list-timers pgdump.timer # NEXT LEFT LAST PASSED UNIT ACTIVATES # Sat 2026-05-23 14:05:00 CST 22min left Sat 2026-05-23 13:05:00 CST 38min pgdump.timer pgdump.service ``` ## OnCalendar 表达式速查 | 表达式 | 含义 | |---|---| | `*-*-* 03:00:00` | 每天 03:00 | | `Mon..Fri 09:00` | 工作日 09:00 | | `*:0/15` | 每 15 分钟 | | `*:0/30:00` | 每 30 分钟(秒级精度) | | `2026-05-23 12:00` | 一次性 | | `weekly` | 每周一 00:00(别名) | 校验表达式: ```bash systemd-analyze calendar 'Mon..Fri 09:30' # Next elapse: Mon 2026-05-26 09:30:00 CST ``` ## OnUnitActiveSec / OnBootSec:相对触发 不基于墙钟,基于上次激活时间: ```ini [Timer] OnBootSec=15min # 开机后 15 分钟触发首次 OnUnitActiveSec=1h # 之后每隔 1 小时 ``` 适合"开机后 N 分钟做一次健康检查 + 之后周期跑"的场景。 ## 失败时告警 ```ini # 在 pgdump.service 里加 [Unit] [email protected] ``` ```ini # /etc/systemd/system/[email protected] [Service] Type=oneshot ExecStart=/usr/local/sbin/notify.sh "%i failed: $(journalctl -u %i -n 30 --no-pager)" ``` `%i` 是模板实例名,这里就是 `pgdump`。`notify.sh` 调用 Slack webhook / 邮件 / 任意。 ## 日志:journal 自动归档 不需要 `>> /var/log/...`。所有 stdout / stderr 自动进 journal: ```bash journalctl -u pgdump.service --since '1 day ago' journalctl -u pgdump.service -p err # 只看 error 级 ``` ## 一次性手动触发 ```bash sudo systemctl start pgdump.service # 立刻跑一次,不影响 timer ``` ## 踩过的坑 - `Type=oneshot` 漏写:systemd 默认 simple,脚本退出后认为"挂了"会触发 restart 死循环。所有 cron-style 任务必须 `Type=oneshot`。 - `Wants=network-online.target` 但脚本仍然连不上:很多 ISP 给 IPv6 默认路由 比 IPv4 慢;加 `ExecStartPre=/bin/sleep 5` 简单粗暴。 - `Persistent=true` 在频繁开关机的笔记本上会变成"开机就跑一堆任务"风暴, 可以加 `RandomizedDelaySec=10m` 散开。 - 两个 timer 同时触发同一个 service 时只跑一次 —— 这是 feature 不是 bug (服务的"活跃中"状态不会被重复启动)。

Go errgroup + 信号量做 worker pool(不引入第三方)

## 起因 要并发处理 10 万个 URL 抓取。原生 goroutine + channel 写起来麻烦: - 限制并发数(不能开 10 万 goroutine 抓) - 错误传播(一个失败要么 cancel 其它要么继续) - 等所有任务完成 - 收集结果 `sync/errgroup` + `golang.org/x/sync/semaphore` 标准库组合解决, 不需要 ants / workerpool 第三方。 ## 解决方案 ### 1. 简单 errgroup ```go package main import ( "context" "fmt" "net/http" "time" "golang.org/x/sync/errgroup" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() g, ctx := errgroup.WithContext(ctx) urls := []string{ "https://example.com", "https://golang.org", "https://github.com", } results := make([]int, len(urls)) for i, u := range urls { i, u := i, u // capture range vars g.Go(func() error { req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() results[i] = resp.StatusCode return nil }) } if err := g.Wait(); err != nil { fmt.Println("error:", err) } fmt.Println(results) } ``` 关键点: - `g.Go(func() error)` 启动一个 goroutine - 任一 goroutine 返回 error → `errgroup` 自动 cancel ctx → 其它 goroutine 收到 cancel signal 退出 - `g.Wait()` 等所有完成,返回第一个 error ### 2. 限并发:SetLimit (Go 1.20+) ```go g, ctx := errgroup.WithContext(ctx) g.SetLimit(50) // 最多 50 个 goroutine 并发 for _, u := range urls { u := u g.Go(func() error { return fetch(ctx, u) }) } g.Wait() ``` `SetLimit(50)` + `Go()` 会阻塞 caller 直到有空位。 10 万 URL 排队,永远不超过 50 个并发。 ### 3. 老 Go 版本:semaphore ```go import "golang.org/x/sync/semaphore" sem := semaphore.NewWeighted(50) g, ctx := errgroup.WithContext(ctx) for _, u := range urls { u := u if err := sem.Acquire(ctx, 1); err != nil { break } g.Go(func() error { defer sem.Release(1) return fetch(ctx, u) }) } g.Wait() ``` `semaphore` 是计数信号量,权重可以不是 1(如内存敏感任务每个占 4)。 ### 4. 结果收集:channel ```go type result struct { url string code int err error } results := make(chan result, len(urls)) g, ctx := errgroup.WithContext(ctx) g.SetLimit(50) for _, u := range urls { u := u g.Go(func() error { code, err := fetch(ctx, u) results <- result{u, code, err} return nil // 不让 errgroup 因单个 fetch 失败 cancel 其它 }) } go func() { g.Wait() close(results) }() for r := range results { fmt.Println(r) } ``` 注意: - 用 buffered channel 避免阻塞 - 单个 fetch 错误不让 errgroup cancel(包成 result 一起传) - 单独 goroutine wait + close channel ### 5. 真正的 worker pool 如果 task 是 stream 进来(不预知数量),用 channel + worker: ```go type Task struct{ URL string } func RunPool(ctx context.Context, tasks <-chan Task, workers int) <-chan error { out := make(chan error, workers) var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() for t := range tasks { select { case <-ctx.Done(): return default: } if err := process(ctx, t); err != nil { out <- err } } }() } go func() { wg.Wait() close(out) }() return out } // 用 tasks := make(chan Task, 100) go func() { defer close(tasks) for _, u := range urls { tasks <- Task{URL: u} } }() errors := RunPool(ctx, tasks, 50) for err := range errors { log.Printf("err: %v", err) } ``` stream 处理 + back-pressure(producer 阻塞在 tasks 满时)。 ### 6. context cancel 在 worker 里 worker 函数必须**响应 ctx.Done()**,否则 cancel 没用: ```go func fetch(ctx context.Context, url string) error { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) // 关键 if err != nil { return err } resp, err := http.DefaultClient.Do(req) // ctx cancel 时立刻断 // ... } ``` `http.NewRequestWithContext` / `db.QueryContext` / `redis.Get(ctx)` 等 所有 IO 都用 ctx 版本。 ### 7. 实际 benchmark 10 万 URL,50 并发: | 写法 | 总时长 | 内存峰值 | |---|---|---| | 串行 | 30 min+ | 50 MB | | 无限制 goroutine | 1.5 min(多数失败) | 800 MB | | errgroup SetLimit(50) | 4 min(全成功) | 80 MB | | worker pool channel | 4 min | 70 MB | 无限制 goroutine 看似快但实际:DNS 解析失败、连接被 ban、 socket fd 耗尽。**永远要限并发**。 ## 效果 - 10 万 task 在 5 分钟内可控完成 - 任意 task 失败 ctx cancel,剩余资源不浪费 - 内存控制在 50-100 MB,不会 OOM - 代码 50 行,比引第三方库少依赖 ## 何时还是要第三方库 - **panicking worker 自动 recover**:errgroup 不 recover panic。 生产建议每个 worker 函数 `defer recover()`。 - **复杂 retry / backoff 策略**:用 `github.com/cenkalti/backoff/v4` - **分布式(跨机器)worker pool**:用 Asynq / Machinery - **动态 worker 数**:errgroup SetLimit 启动后不能改 ## 踩过的坑 1. **range var 没 capture**: ```go for _, u := range urls { g.Go(func() error { return fetch(u) // ❌ 所有 goroutine 看到 last u }) } ``` Go 1.22 才修了 range var 默认 scope。1.21- 必须 `u := u` 显式 capture。 2. **g.Wait 不调**:goroutine leak。 ```go if err := g.Wait(); err != nil { ... } ``` 永远要 Wait。 3. **`g.Go` 里 panic** → 整个程序 crash(errgroup 不 recover)。 生产 worker 函数 wrap: ```go g.Go(func() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() return fetch(ctx, u) }) ``` 4. **共享 slice 写**:上面 `results[i] = ...` 不需要锁(每个 worker 写 不同 index),但如果是 `results = append(results, ...)` 则要 sync.Mutex 或 channel。 5. **HTTP client default reuse**:`http.DefaultClient` 全局共享 + connection pool。多个 goroutine 用 OK。但默认无超时——务必传 ctx 或自己 `Client{ Timeout: 30 * time.Second }`。

etcd 备份 + 恢复:K8s 灾难前的最后一道防线

## 起因 K8s 整个 cluster 状态存在 etcd: - 所有 deployment / service / configmap / secret - node 状态 - 所有 namespace etcd 挂 → cluster 整体瘫。 更恐怖:etcd 数据损坏(disk 错 / 误操作)→ 数据丢失。 定期 etcd backup + 演练 restore 是 cluster 运维基础功。 没做过 restore 演练等于没 backup。 ## 看 etcd 状态 ```bash # kubeadm 部署的 cluster sudo etcdctl --endpoints=https://127.0.0.1:2379 \ --cacert=/etc/kubernetes/pki/etcd/ca.crt \ --cert=/etc/kubernetes/pki/etcd/server.crt \ --key=/etc/kubernetes/pki/etcd/server.key \ endpoint status --write-out=table ``` ``` +------------------------+------------+---------+---------+--------+ | ENDPOINT | DB SIZE | LEADER | RAFT IDX| ALARMS | +------------------------+------------+---------+---------+--------+ | 10.0.1.10:2379 | 250 MB | true | 123456 | | | 10.0.1.11:2379 | 250 MB | false | 123456 | | | 10.0.1.12:2379 | 250 MB | false | 123456 | | +------------------------+------------+---------+---------+--------+ ``` 3 个 endpoint 应一致 + 1 个 leader + 无 alarms。 ## 创建 snapshot ```bash sudo ETCDCTL_API=3 etcdctl \ --endpoints=https://127.0.0.1:2379 \ --cacert=/etc/kubernetes/pki/etcd/ca.crt \ --cert=/etc/kubernetes/pki/etcd/server.crt \ --key=/etc/kubernetes/pki/etcd/server.key \ snapshot save /backup/etcd-$(date +%Y%m%d-%H%M%S).db ``` `Snapshot saved at /backup/etcd-...db` snapshot 是单 binary 文件,几百 MB 量级(小 cluster)。 ## 自动化备份 (cronjob) ```bash # /etc/cron.d/etcd-backup 0 */6 * * * root /usr/local/bin/etcd-backup.sh ``` ```bash #!/bin/bash # /usr/local/bin/etcd-backup.sh set -euo pipefail BACKUP_DIR=/backup RETENTION_DAYS=7 TIMESTAMP=$(date +%Y%m%d-%H%M%S) ETCDCTL_API=3 etcdctl \ --endpoints=https://127.0.0.1:2379 \ --cacert=/etc/kubernetes/pki/etcd/ca.crt \ --cert=/etc/kubernetes/pki/etcd/server.crt \ --key=/etc/kubernetes/pki/etcd/server.key \ snapshot save "$BACKUP_DIR/etcd-$TIMESTAMP.db" # 上传到 S3 aws s3 cp "$BACKUP_DIR/etcd-$TIMESTAMP.db" s3://my-backups/etcd/ # 删本地老备份 find $BACKUP_DIR -name 'etcd-*.db' -mtime +$RETENTION_DAYS -delete # 删 S3 老备份(lifecycle policy 也行) aws s3 ls s3://my-backups/etcd/ | awk '{print $4}' | while read f; do days_old=$((($(date +%s) - $(date -d $(echo $f | sed 's/etcd-\(....\)\(..\)\(..\)-.*/\1-\2-\3/') +%s)) / 86400)) if [ $days_old -gt 30 ]; then aws s3 rm s3://my-backups/etcd/$f fi done ``` 每 6 小时备份 + 7 天本地 + 30 天 S3。 ## restore (灾难恢复) **演练步骤**(请在测试环境跑过几次): ```bash # 1. 停所有 etcd 节点的 etcd(kubeadm 用 static pod 控制) sudo mv /etc/kubernetes/manifests/etcd.yaml /tmp/ # 等几秒确认 etcd 停了 sudo crictl ps | grep etcd # 2. 备份当前 data dir sudo mv /var/lib/etcd /var/lib/etcd.bak # 3. restore snapshot sudo ETCDCTL_API=3 etcdctl snapshot restore /backup/etcd-xxx.db \ --data-dir /var/lib/etcd \ --name etcd-node-1 \ --initial-cluster etcd-node-1=https://10.0.1.10:2380,etcd-node-2=https://10.0.1.11:2380,etcd-node-3=https://10.0.1.12:2380 \ --initial-cluster-token etcd-cluster-1 \ --initial-advertise-peer-urls https://10.0.1.10:2380 # 4. 在每节点上做 3 / restore # 5. 启动 etcd sudo mv /tmp/etcd.yaml /etc/kubernetes/manifests/ # 6. 验证 kubectl get nodes kubectl get pods -A ``` cluster 回到 snapshot 时点状态。 snapshot 之后创建的资源(pod / deployment 等)丢失(除非应用 IaC / GitOps 能重 apply)。 ## velero (更高层备份) etcd snapshot 是 raw K8s API state。 **Velero**:备份 + restore K8s 资源 + PV 数据: ```bash helm install velero vmware-tanzu/velero \ --namespace velero --create-namespace \ --set configuration.provider=aws \ --set configuration.backupStorageLocation.bucket=my-backups \ ... ``` ```bash # 备份 namespace velero backup create myapp-backup --include-namespaces myapp # 备份全集群(除 system) velero backup create cluster-backup --exclude-namespaces kube-system,kube-public # 列出 velero backup get # restore velero restore create --from-backup myapp-backup --namespace-mappings myapp:myapp-restore ``` Velero 优势 vs etcd snapshot: - 选择性备份(按 namespace / label) - restore 到不同 namespace / cluster - PV 数据快照(CSI snapshot 支持) - 跨 cluster migration etcd snapshot 是最后兜底(整 cluster 灾难); Velero 是日常运维(误删 namespace / 迁移 / 测试 restore)。 两者都做。 ## 测试 restore 至关重要 经验:**没演练过的 backup = 没 backup**。 每季度跑一次: 1. spin up 临时 cluster 2. 从 backup restore 3. 验证应用能跑 4. 文档化每步 第一次跑会发现: - 漏备份某 resource - restore script 步骤错 - 某 PV 数据没快照 修完 → real disaster 时不慌。 ## etcd 调优 defrag (DB 碎片整理): ```bash etcdctl defrag --endpoints=... ``` DB size 大但 key 少 → 碎片多。定期 defrag。 每 leader change / 大批量改后跑。 quota: ```bash etcd --quota-backend-bytes=8589934592 # 8 GB ``` 默认 2 GB → 大 cluster 不够。 monitor: ```promql etcd_mvcc_db_total_size_in_bytes etcd_disk_wal_fsync_duration_seconds etcd_server_leader_changes_seen_total ``` leader changes 频繁 → 网络 / disk 问题。 ## 多 cluster: 跨 region backup ```bash # velero schedule velero schedule create daily-backup --schedule="0 2 * * *" --ttl 720h \ --include-namespaces myapp \ --storage-location aws-prod-east # 跨 region replication(备份不放同 region 防 region-wide 灾) aws s3 cp s3://my-backups-east/... s3://my-backups-west/... ``` ## 与 cloud managed (EKS / GKE / AKS) cloud managed K8s 的 etcd 是 cloud provider 维护。 - 自动备份 / failover - 但你不能直接 etcdctl snapshot 仍要用 Velero 备份 resource 层(防 namespace 误删 / restore 到其它 cluster)。 ## 真实 incident case 某客户误执行 `kubectl delete namespace prod`。 恢复: 1. 看最近 velero backup(4 小时前) 2. `velero restore create --from-backup ... --include-namespaces prod` 3. 30 分钟后 deployment / service / configmap / secret 全回 4. PV 数据完整(CSI snapshot) 无 backup → cluster 完蛋。有 backup + 演练 → 30 分钟恢复。 ## 踩过的坑 1. **backup 没测**:以为有 backup → 真灾难时发现 backup file 损坏 / 不完整。定期 restore 测试。 2. **etcd snapshot 不包括 secret in etcd-encryption-config 外**: K8s 1.13+ 支持 etcd 加密。restore 后 encryption config 不存在 → 解密失败。备份时同时 cp encryption config。 3. **velero 不备 PV 数据 by default**:要装 CSI plugin 或者 restic integration。 4. **restore 后 IP 变**:service 重新创建 ClusterIP 可能变 → DNS 改 传播延迟。 5. **etcd full**:DB size 超 quota → cluster read-only。`defrag` + `alarm disarm`。

shadcn/ui:不是 npm 装的 UI 库(拷贝 component 模式)

## 起因 React UI 库选择困难: - **Material UI**:很全 + 重 + 偏 google look - **Chakra UI**:现代 + 灵活但 bundle 大 - **Ant Design**:业务向,复杂表单强 - **Radix UI**:unstyled headless - **Tailwind UI**:付费 component 每个都是"npm install 进项目,按 props 用"。 痛点: - 改 design 难(要覆盖默认 style) - 升级 lib 版本可能 break style - bundle 总是吃满(你只用 button 但 import 全 lib) **shadcn/ui** 提出不同模式:**copy component code 进项目**。 不是 library,是 component template + Radix 底层。 ## 加 component ```bash npx shadcn@latest init # 配置 tailwind / 颜色主题 npx shadcn@latest add button # 把 Button 源码 copy 到 components/ui/button.tsx ``` ```tsx import { Button } from '@/components/ui/button'; <Button variant="default">Click</Button> <Button variant="destructive">Delete</Button> <Button variant="outline" size="sm">Cancel</Button> ``` UI 跟 Tailwind UI 类似(基于 Tailwind class)。 ## 改 component 是 yours ```bash npx shadcn@latest add card ``` 文件 `components/ui/card.tsx` 现在是你的。 要改 padding / 圆角 / 加 prop → 直接改源码。 没 lib 升级问题(没 lib)。 ## 模式核心 ``` Radix UI (headless, accessible) + Tailwind CSS (styling) + 你 own 的 code = shadcn/ui ``` - Radix 提供 a11y / behavior(focus trap / aria 等) - Tailwind 提供 styling - 你拥有源码 + 修改自由 ## 优势 - **零 lock-in**:lib 没了你 code 不挂 - 改 design 直接改 file - bundle 只含你用的 component(tree-shake 友好) - TS first-class - 跟现代 stack(Next.js / Vite / Astro)天然 fit ## 劣势 - 不是 install 即用(每 component 要 add + 看 code) - 升级要手动(lib 出新版要 copy 新 source) - 设计语言比 Material 单调(neutral 风格,要自己丰富) ## 完整 setup ```bash # Next.js + Tailwind 项目 npx shadcn@latest init # 装常用 npx shadcn@latest add button card input label dialog dropdown-menu sheet ``` 生成: ``` components/ui/ button.tsx card.tsx dialog.tsx ... lib/utils.ts # cn() helper ``` `cn()` 是 clsx + tailwind-merge 包装,让 className 智能合并: ```tsx <Button className={cn('w-full', isLoading && 'opacity-50', className)}> ``` ## form 模式 shadcn + react-hook-form + zod: ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; const schema = z.object({ email: z.string().email(), }); function MyForm() { const form = useForm({ resolver: zodResolver(schema) }); return ( <Form {...form}> <form onSubmit={form.handleSubmit(console.log)}> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </form> </Form> ); } ``` template 嗦但极灵活 + a11y 完整 + validation 自动。 ## theme `globals.css`: ```css @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; /* ... */ } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... */ } } ``` CSS variable 驱动主题。dark mode 加 `class="dark"` on root。 改 brand color:改 `--primary`。 ## sonner / vaul 等 shadcn 推荐组合的几个: - **sonner**:toast notification - **vaul**:bottom sheet (mobile-style drawer) - **cmdk**:command palette - **react-day-picker**:date picker 都是高质量 headless lib,shadcn add 帮你 scaffold 集成。 ## v0 / 自动生成 UI `v0.dev`(Vercel)用 LLM 生成 shadcn/ui-based React component: ``` prompt: "user profile card with avatar, name, bio, and follow button" → 生成 component code 用 shadcn pieces ``` 新 component prototype 1 分钟出。 之后人 review + 调整。 ## 与 Material / Chakra 对比 | | shadcn | Material UI | Chakra | |---|---|---|---| | 装 | copy code | npm install | npm install | | 改 design | 改 code | override theme / sx | override theme | | 学习曲线 | 中(要懂 Tailwind) | 高(API 大) | 中 | | bundle | tree-shake 极好 | 大 | 中 | | 设计语言 | minimal neutral | google material | 现代 | | 适合 | custom design / 中型项目 | 企业产品 | 通用 | 我 2024+ 项目 100% shadcn。 老 Material 项目维持。 ## 跟 Tailwind UI 对比 Tailwind UI(付费): - 设计精良,付费许可 - 复制 HTML 进项目(不分 component) shadcn: - 免费 - React component(不只是 HTML) - 跟 Radix 集成(a11y) 如果钱不是问题且要顶级设计 → Tailwind UI 块 + shadcn 组件 混用。 ## 何时不用 - 不用 React → shadcn 是 React-only(但 Svelte/Vue 有 community port) - 不用 Tailwind → 不适合(核心是 Tailwind class) - 极简项目(landing page)→ Tailwind 直接写 HTML 够 ## 真实 case 新项目 admin dashboard: - 1 周 setup shadcn + 10+ component - 0 设计稿,直接 v0 生成 + 微调 - bundle 200 KB(vs Material 类似项目 600 KB+) - 想改 button radius 全局 → 改 `--radius` CSS var 迭代速度 + 灵活性大幅提升。 ## 踩过的坑 1. **不识别相对路径**:shadcn 默认用 `@/components/ui/...` alias。 要 tsconfig + vite config 配 alias。 2. **冲突 className**:`<Button className="w-full bg-red-500">` 跟内 建 variant 冲突。`cn()` + tailwind-merge 解决。 3. **暗色模式切换闪烁**:SSR 时 server 不知道用户 prefer。Next.js 用 next-themes 处理 hydration。 4. **lib 更新没拉新**:shadcn 出新 version Button → 你的没自动升。 手动 `npx shadcn add button --overwrite` 或者 diff merge。 5. **设计简陋**:default 风格中性 → 看起来朴素。要加品牌色 / 图标 / 插图才"鲜活"。

DuckDB 在本地跑 SQL 分析 Parquet(无服务器、零安装、列存极速)

DuckDB 是嵌入式分析数据库("SQLite for analytics"),单二进制, 能直接读 Parquet / CSV / JSON 文件,复杂分析查询比 pandas / Spark 本地模式快得多。 适合:本地数据探索、报表生成、数据科学家"我有 50GB Parquet 在 S3 想跑几条 SQL"。 ## 安装 ```bash uv add duckdb # 或独立 CLI: curl https://install.duckdb.org | sh duckdb --version ``` ## CLI 入门 ```bash duckdb my.db # duckdb shell D SELECT 1 + 1; ┌───────┐ │ ?col0 │ ├───────┤ │ 2 │ └───────┘ D .help D .quit ``` ## 直接查 Parquet(不需要导入) ```sql -- 单文件 SELECT * FROM 'data/sales.parquet' LIMIT 10; -- 多文件 SELECT * FROM 'data/year=*/month=*/*.parquet' LIMIT 10; -- S3 直接读 SET s3_region='us-east-1'; SET s3_access_key_id='...'; SET s3_secret_access_key='...'; SELECT count(*) FROM 's3://bucket/data/*.parquet'; ``` `year=*/month=*` 是 Hive 风格分区路径,DuckDB 自动识别并 prune 不需要 的分区。 ## 与 polars / pandas 互通 ```python import duckdb import polars as pl # 直接查 polars DataFrame(不复制数据,Arrow zero-copy) df = pl.read_csv('users.csv') result = duckdb.sql("SELECT country, COUNT(*) FROM df GROUP BY 1").pl() # 同理 pandas import pandas as pd pdf = pd.DataFrame({...}) result = duckdb.sql("SELECT ... FROM pdf").df() ``` `duckdb.sql(...)` 返回 DuckDBPyRelation,可以 `.pl()` `.df()` `.fetchall()` 转换。 ## 典型用例 ### 1. 探索一份大 CSV ```python import duckdb duckdb.sql(""" SELECT country, COUNT(*) AS rows, AVG(amount) AS avg_amt, QUANTILE_CONT(amount, [0.5, 0.95, 0.99]) AS p50_p95_p99 FROM 'sales.csv' WHERE date >= '2024-01-01' GROUP BY country ORDER BY rows DESC LIMIT 20 """).show() ``` 整个 CSV 在内存里只读一次,DuckDB 自动用所有 CPU 核心。 ### 2. CSV → Parquet 转换 ```python duckdb.sql(""" COPY (SELECT * FROM 'sales.csv') TO 'sales.parquet' (FORMAT PARQUET) """) # 同样一份数据,Parquet 通常 1/3 大小 + 列查询快 10x ``` 按字段分区: ```python duckdb.sql(""" COPY (SELECT * FROM 'sales.csv') TO 'partitioned/' (FORMAT PARQUET, PARTITION_BY (year, month)) """) # 输出:partitioned/year=2024/month=01/data_0.parquet ... ``` ### 3. JOIN 多个文件 ```sql SELECT u.name, COUNT(*) AS n FROM 'users.parquet' u JOIN 'orders.parquet' o ON o.user_id = u.id WHERE o.date >= '2024-01-01' GROUP BY u.name ORDER BY n DESC LIMIT 10; ``` DuckDB 优化器自己决定 hash join / merge join;不需要建索引。 ### 4. 窗口函数 ```sql SELECT user_id, date, amount, SUM(amount) OVER (PARTITION BY user_id ORDER BY date) AS cum_amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn FROM 'orders.parquet' WHERE rn = 1; ``` 完整 SQL:2003 窗口函数。 ### 5. 写回数据库 ```python # 写到 Postgres duckdb.sql("INSTALL postgres; LOAD postgres;") duckdb.sql(""" ATTACH 'host=localhost dbname=mydb' AS pg (TYPE POSTGRES); COPY (SELECT * FROM 'data.parquet') TO pg.public.my_table; """) ``` ## DuckDB 与 SQLite 对比 | 维度 | SQLite | DuckDB | |---|---|---| | 设计 | OLTP(事务) | OLAP(分析) | | 存储 | 行存 | 列存 | | 多核 | 单线程 | 自动并行 | | Parquet | 不直接支持 | 一等公民 | | 文件 | .db 单文件 | .db 单文件,也能完全无文件 | 不要替代——它们解决不同问题。事务用 SQLite,分析用 DuckDB。 ## 持久化 vs 内存 ```python import duckdb # 内存(默认) con = duckdb.connect(':memory:') # 文件 con = duckdb.connect('analytics.db') ``` 文件模式可以 `CREATE TABLE` 持久化数据。内存模式纯查询。 ## 大数据场景:spilling to disk DuckDB 内存不够时自动 spill 到 `tmp/`: ```python duckdb.sql("PRAGMA temp_directory='./tmp_duckdb';") duckdb.sql("PRAGMA memory_limit='4GB';") ``` 跑大 query 不会 OOM,但慢点。比起 Spark 集群部署成本,单机能跑通是 胜利。 ## 与 BI 工具集成 ```bash # Tableau / DBeaver / Metabase 都有 DuckDB connector # JDBC: org.duckdb.DuckDBDriver # ODBC: 官方提供 ``` 数据科学家用 DuckDB 算分析,BI 工具直接连展示。 ## CLI 高级技巧 ```bash # 用 fzf 选 .parquet 文件查 duckdb -c "SELECT * FROM '$(fd -e parquet | fzf)' LIMIT 100" # 输出 JSON duckdb -json -c "SELECT * FROM 'data.parquet' LIMIT 5" # 输出 CSV duckdb -c "COPY (SELECT * FROM 'data.parquet' WHERE x > 0) TO STDOUT WITH (HEADER, FORMAT CSV)" ``` ## 踩过的坑 - 写 Parquet 时 string column 含 NULL → 默认 NULL handling 可能不对。 `COPY ... TO ... (FORMAT PARQUET, COMPRESSION 'zstd')` 显式指定参数。 - S3 大量小文件(< 10MB / 文件)→ 元数据请求开销大。聚合成少量大文件 再扫。 - DuckDB 跨大版本(0.x → 1.x)数据库文件格式有时不兼容;升级前 `EXPORT DATABASE '...'` 备份。 - python 包默认装的是 prebuilt wheel,没装某些 extension(spatial / excel)。`INSTALL spatial; LOAD spatial;` 运行时装。

SSH config 写好 jump host 跳板(一行 ProxyJump 干掉所有手动 tunnel)

很多生产环境的机器不直接对外,要通过堡垒机(jump host)跳进去。 没配置 SSH config 时每次都要: ```bash ssh -L 2222:internal:22 user@bastion # 另开终端 ssh -p 2222 user@localhost ``` 或者: ```bash ssh -t user@bastion ssh user@internal ``` `~/.ssh/config` 里写好 `ProxyJump`,之后直接: ```bash ssh internal # 就完事了 ``` ## 最小可用 `~/.ssh/config` ``` # 公开堡垒机 Host bastion HostName bastion.example.com User opsdude Port 22 IdentityFile ~/.ssh/id_ed25519_company IdentitiesOnly yes # 内网机器,通过 bastion 跳 Host db1 HostName 10.0.0.11 User dbadmin ProxyJump bastion IdentityFile ~/.ssh/id_ed25519_company Host app1 app2 app3 HostName %h.internal.example.com User app ProxyJump bastion IdentityFile ~/.ssh/id_ed25519_company ``` `%h` 是当前 `Host` 名字(app1 / app2 / app3)。整段不需要重复 3 遍。 之后: ```bash ssh bastion # 直接进堡垒 ssh db1 # 自动通过 bastion 进 db1 ssh app1 # 同上 scp file.tar app1:/tmp/ # scp / rsync 同样自动跳板 rsync -avz ./dist/ app1:/srv/myapp/ ``` ## 多层跳板 ``` Host very-deep HostName 192.168.99.5 User admin ProxyJump bastion,intermediate # 等价于: # bastion → intermediate → very-deep ``` ## 常用配置项速查 ``` Host name HostName actual.host # 真实主机名 / IP User myname # 登录用户 Port 22 # 端口 IdentityFile ~/.ssh/key # 指定密钥 IdentitiesOnly yes # 只用上面这个 key,不用 ssh-agent 里的其它 ProxyJump bastion # 跳板 ProxyCommand cmd %h %p # 自定义代理命令(少用了,被 ProxyJump 替代) LocalForward 9000 localhost:9000 # ssh -L 等价 RemoteForward 9001 localhost:80 # ssh -R 等价 ServerAliveInterval 60 # 60 秒发一个 keepalive 心跳 ServerAliveCountMax 3 # 3 次没回应就断 ControlMaster auto # 复用 TCP 连接 ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 10m StrictHostKeyChecking accept-new UserKnownHostsFile ~/.ssh/known_hosts ``` ## 关键技巧:ControlMaster 复用连接 ``` Host * ControlMaster auto ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 10m ``` 意思是:每个 (user, host, port) 组合的第一次 ssh 建立真实 TCP; 后续 ssh / scp / rsync / git 全部复用这个 TCP, 不再重新握手 / 不再输密码(如果原先要的话)。 效果: - 第一次 `ssh server` 慢一点 - 第二次 / scp / rsync / git push 几乎瞬开 `/tmp/` 可能不够安全(多用户系统),放 `~/.ssh/cm-...` 私有。 ## 跳板的端口转发 ``` Host db-via-bastion HostName 10.0.0.11 User dbadmin ProxyJump bastion LocalForward 5432 localhost:5432 ``` ```bash ssh -fN db-via-bastion # 后台开 tunnel psql -h localhost -p 5432 -U user ``` `-fN` 表示后台 + 不开 shell,纯做 tunnel。 ## SSH agent + agent forwarding 跳到中间机后再要去第三跳,不想把 key 拷到中间机: ``` Host bastion ForwardAgent yes ``` ```bash eval $(ssh-agent) ssh-add ~/.ssh/id_ed25519 ssh bastion # 在 bastion 上 ssh other —— 用的是你本地的 key ``` agent forwarding 有 root 风险(堡垒机 root 能用你的 key), 所以 **只对自己完全信任的机器开**。生产堡垒机用 ProxyJump 替代, 不让 key 真的暴露给堡垒机。 ## 别名 + 子目录配置 ``` # ~/.ssh/config Include ~/.ssh/config.d/*.conf ``` ``` # ~/.ssh/config.d/work.conf Host work-* User worker IdentityFile ~/.ssh/work_key ``` 把不同项目 / 客户的配置拆开,避免单个文件几百行。 ## known_hosts + 自动接受新主机 在脚本里第一次连陌生机器: ```bash ssh -o StrictHostKeyChecking=accept-new server ``` `accept-new` 比 `no` 安全——只接受新主机;已存在 host key 改变了仍报错 (中间人攻击信号)。 ## 密钥类型 ```bash # 推荐:Ed25519,短、快、安全 ssh-keygen -t ed25519 -C '[email protected]' # 老兼容:RSA 4096 ssh-keygen -t rsa -b 4096 ``` 服务端要支持 Ed25519(OpenSSH 6.5+ / 2014 后基本都行)。 ## 安全:禁用密码登录 服务端 `/etc/ssh/sshd_config`: ``` PasswordAuthentication no PubkeyAuthentication yes PermitRootLogin prohibit-password ``` ```bash sudo systemctl reload ssh ``` 之后任何密码登录都被拒;只能凭密钥。结合 fail2ban / 改端口效果叠加。 ## 踩过的坑 - `IdentityFile` 没设 `IdentitiesOnly yes` → ssh-agent 里的其它 key 会被一一尝试,可能因连续失败把你 ban 了。配同事的 git host 时尤其 容易踩。 - ProxyJump 在很老的 OpenSSH(< 7.3)不支持,要用 ProxyCommand: `ProxyCommand ssh -W %h:%p bastion`。 - ControlPersist 时长不要无限("yes"),机器一直长连接,重启后状态 乱。10m / 1h 比较合理。 - known_hosts 里的旧 host key 在机器重装后失效,连接报 MITM 警告。 `ssh-keygen -R hostname` 清掉那条。

Vitest vs Jest:2026 看 JS 测试框架

## 起因 JS 项目测试框架历史: - mocha + chai + sinon(老) - jest(Facebook,2014+,事实标准) - vitest(Vite 团队,2021+,Vite-native) 新项目选谁?老 jest 项目要不要迁?下面经验。 ## jest ```js // my.test.js const { add } = require('./math'); describe('math', () => { test('add', () => { expect(add(1, 2)).toBe(3); }); }); ``` ```bash npx jest ``` 10 年生态,全 JS 圈子默认。 ### 优势 - 教程 / 答案最多 - 巨大插件生态(jest-dom / jest-axe / ...) - snapshot testing 成熟 - mock 系统强(auto-mock / manual mock) ### 劣势 - 启动慢(2-5 秒只为跑 1 个测试) - ESM 支持差(CJS 优先,ESM 配置复杂) - TS 要 ts-jest / @swc/jest 等 - 慢(百 test 几十秒) ## vitest ```js // my.test.js import { test, expect, describe } from 'vitest'; import { add } from './math'; describe('math', () => { test('add', () => { expect(add(1, 2)).toBe(3); }); }); ``` ```bash npx vitest ``` API jest-compat(`expect` / `describe` 大部分一致)。 基于 Vite,原生 ESM + esbuild + TS 直接。 ### 优势 - **极快**:watch mode HMR-like,改文件即重跑相关 test - ESM 原生 + TS 原生 - Vite 项目同 config 共享 - jest API 兼容(容易迁移) - 内置 coverage / UI / browser mode ### 劣势 - 生态比 jest 小(但每年增长快) - 某些 jest 插件没等价 - snapshot 跟 jest 略不同(导致迁移微调) ## 性能对比 中型项目 500 test: | | jest | vitest | |---|---|---| | cold start | 8s | 1.5s | | 全跑 | 25s | 6s | | watch(改 1 file) | 5s | 0.3s | | coverage | 35s | 10s | vitest 普遍 3-5x 快。开发循环 watch mode 差距更大。 ## 写法对比 写法 99% 一致: ```js // 通用 describe('x', () => { beforeEach(() => { ... }); test('does y', async () => { expect(...).toBe(...); }); }); ``` vitest 加: ```js import { vi } from 'vitest'; // jest 是全局 vi → jest.fn / mock ``` `jest.fn()` → `vi.fn()`,`jest.spyOn` → `vi.spyOn`,`jest.mock` → `vi.mock`。 config 注入全局可让 jest 写法直接跑: ```ts // vitest.config.ts export default defineConfig({ test: { globals: true }, // 启用 describe / test / expect 全局 }); ``` ## mock ```js // vitest import { vi } from 'vitest'; vi.mock('./api', () => ({ fetchUser: vi.fn(() => Promise.resolve({ name: 'mock' })), })); const spy = vi.spyOn(console, 'log'); ``` 跟 jest 几乎一样。 ## snapshot ```js expect(rendered).toMatchSnapshot(); ``` 生成 `__snapshots__/my.test.js.snap`。 vitest 用 jest 相同格式。 inline snapshot: ```js expect(rendered).toMatchInlineSnapshot(`"<div>hello</div>"`); ``` review 时 inline 直观。 ## React Testing Library 集成 ```ts // vitest.config.ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: './test/setup.ts', }, }); ``` ```ts // test/setup.ts import '@testing-library/jest-dom/vitest'; // matchers ``` ```tsx import { render, screen } from '@testing-library/react'; import { test, expect } from 'vitest'; import App from './App'; test('renders title', () => { render(<App />); expect(screen.getByText('Hello')).toBeInTheDocument(); }); ``` 跟 jest 配 RTL 几乎一样。 ## browser mode(vitest 1.0+) ```ts test: { browser: { enabled: true, name: 'chromium' }, } ``` 测试在真实浏览器跑(替代 jsdom)。 适合:测 component 在真实环境(layout / CSS / fetch)。 jest 不能直接跑浏览器(要 jest-playwright 等组合)。 ## 迁移 jest → vitest ```bash npm install -D vitest @vitest/ui ``` `package.json`: ```json "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage" } ``` 代码层 99% 测试直接跑: ```ts // 全局 import 替代 - import { describe, test, expect } from '@jest/globals'; + // (with globals: true 不需要) + import { describe, test, expect } from 'vitest'; - jest.fn() + vi.fn() - jest.mock('./api') + vi.mock('./api') ``` 一般 sed 批量替换 + 个别手动调。 中型项目几小时迁移完。 ## CI 集成 ```yaml - run: pnpm test --coverage - uses: codecov/codecov-action@v4 ``` 不变。GitHub Actions / GitLab / CircleCI 都 vitest 友好。 ## 真实迁移 case 我们一个 React 项目,jest + RTL + 200 test: - ci 时间从 90s → 25s - watch mode 改文件秒返馈 - ts-jest 配置删了(vite 自带 TS) - 配置文件简化 但迁完一周才完全稳: - 几个 mock 行为微差 - snapshot whitespace 略不同需 regenerate - 某些 jest plugin(如 jest-axe)没 vitest 版本(找替代) ## 何时不必迁 - 老项目大 jest,团队稳定 → 不动 - 用极偏 jest 插件 → 维持 ## 决策 - **新项目** → vitest(无脑选) - **大老项目 + 团队稳** → jest,可不迁 - **小老项目** → 半天迁 ## 与 node:test 对比 Node 20+ 内置 `node --test`: ```js import { test } from 'node:test'; import assert from 'node:assert'; test('add', () => { assert.strictEqual(add(1, 2), 3); }); ``` 无依赖。简单 unit test 够。 但 mock / snapshot / coverage 弱于 jest / vitest,复杂项目仍 vitest。 ## 踩过的坑 1. **vi.mock 提升**:vite 把 mock 提升到 file 顶部 → 跟 import 顺序 交互奇怪。简单场景 OK,复杂用 mock factory + lazy。 2. **`globals: true` 没设**:jest 写法报 `describe is not defined`。 3. **CSS import 报错**:vitest 不像 jest 自动 mock CSS。配 `vitest.config.ts` 加 `css: true` 或 `css.modules.classNameStrategy`。 4. **timeout 默认 5s**:复杂 e2e 测试超时。`testTimeout: 30000`。 5. **watch 没 trigger**:file change 但 test 没重跑 → vitest cache bug。`vitest --no-cache` 或 restart。

用 Cloudflare Tunnel 把本地服务暴露到公网(不开任何端口)

家宽 / NAT 后面的开发机想暴露一个服务出去,传统方案是路由器端口转发 + 动态 DNS。Cloudflare Tunnel(前身 Argo Tunnel)走反向通道:本地 `cloudflared` 守护进程主动连出,所有入站流量走 Cloudflare 边缘转发回来。 好处: - 公网完全看不到你的源 IP - 不需要在路由器 / 防火墙打洞 - 免费层支持自定义域名 + HTTPS 自动签发 下面把本地 `http://127.0.0.1:8080` 暴露成 `myapp.example.com`。 前提:`example.com` 已经托管在 Cloudflare。 ## 1. 安装 cloudflared ```bash # Debian/Ubuntu curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | sudo tee /etc/apt/trusted.gpg.d/cloudflare-main.gpg >/dev/null echo "deb https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \ | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt update && sudo apt install -y cloudflared ``` ## 2. 登录 + 创建隧道 ```bash cloudflared tunnel login # 浏览器里选择 example.com,授权后会下载 cert.pem 到 ~/.cloudflared/ cloudflared tunnel create myapp # 输出隧道 UUID 和凭据文件路径 ~/.cloudflared/<uuid>.json ``` ## 3. 写配置 ```yaml # ~/.cloudflared/config.yml tunnel: <这里粘上面的 UUID> credentials-file: /home/yourname/.cloudflared/<uuid>.json ingress: - hostname: myapp.example.com service: http://127.0.0.1:8080 - service: http_status:404 ``` 最后一条 `http_status:404` 是兜底必填项,否则 cloudflared 拒绝启动。 ## 4. 配 DNS ```bash cloudflared tunnel route dns myapp myapp.example.com # 在 Cloudflare DNS 表里自动写入一条 CNAME 指向 <uuid>.cfargotunnel.com ``` ## 5. 跑起来(先前台测试) ```bash cloudflared tunnel run myapp ``` 另开终端 `curl https://myapp.example.com/`,能拿到本地服务的响应就成功。 ## 6. 转为 systemd 服务 ```bash sudo cloudflared service install sudo systemctl enable --now cloudflared sudo journalctl -u cloudflared -f ``` `service install` 会把 `~/.cloudflared/` 整个复制到 `/etc/cloudflared/`, 并生成 unit。后续改配置改 `/etc/cloudflared/config.yml` 然后 `restart`。 ## 多服务复用同一隧道 `ingress:` 是个列表,可以挂多个: ```yaml ingress: - hostname: app.example.com service: http://127.0.0.1:8080 - hostname: api.example.com service: http://127.0.0.1:3000 - hostname: ssh.example.com service: ssh://127.0.0.1:22 # 配合 cloudflared access ssh 用 - service: http_status:404 ``` ## 踩过的坑 - 配置里 `credentials-file` 必须是 **绝对** 路径,相对路径在 systemd 下找不到。 - DNS 那条 CNAME 是 "proxied" 状态(橙云)才会走 tunnel;如果手动改成 灰云(DNS only),整个 tunnel 立刻 404。 - 隧道凭据 `.json` 文件等于密码,泄露后任何人都能冒充你的隧道。 `/etc/cloudflared/` 权限默认 700,不要随便改。

PgBouncer:3 种 pool_mode 实操,跟 Django/Rails 的坑

## 起因 PostgreSQL 每个 client connection 是一个进程,几 MB RAM。 应用每 worker 一个 connection × 几十 worker × 几个服务 → 几千连接 → PG 默认 `max_connections=100` 撑不住,加到 1000 也吃几 GB RAM。 **PgBouncer** 在应用和 PG 之间做 connection pool: ``` [app worker × 100] ←──── 100 cheap connection ────→ [PgBouncer] ↓ [10 actual PG conn] ↓ [Postgres] ``` PG 真正打开 10 connection 即可,PgBouncer 复用给 100 个 app worker。 ## pool_mode 三选 ```ini # pgbouncer.ini pool_mode = session | transaction | statement ``` ### session mode client 一直占有 PG conn 直到断开。 跟没 pgbouncer 几乎一样(只是 connection 数仍上限)。 基本无意义,跳过。 ### transaction mode(默认 + 最常用) client 一个 transaction 内独占一个 PG conn,transaction 结束归还。 ``` client A: BEGIN → 拿 conn1 client A: SELECT client A: UPDATE client A: COMMIT → 还 conn1 client B: BEGIN → 拿 conn1 ... ``` 效率高,但有限制: - ❌ session 状态(SET、prepared statement、cursor 等)不跨 transaction - ❌ LISTEN/NOTIFY(pub/sub) - ❌ 临时表(声明周期是 session) ### statement mode 每个 SQL 语句独立 conn。 最高复用,但禁止 transaction(不能 BEGIN/COMMIT)。 极少用,特殊只读 / 简单 OLTP 场景。 ## 典型配置 ```ini # pgbouncer.ini [databases] mydb = host=pg.example.com port=5432 dbname=mydb pool_size=20 [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 auth_type = scram-sha-256 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction default_pool_size = 20 # 每 db × user 组合 20 个 PG conn max_client_conn = 1000 # 接受最多 1000 个 client conn reserve_pool_size = 5 server_idle_timeout = 600 # 重要:让 prepared statement 在 transaction mode 工作 max_prepared_statements = 100 # pgbouncer 1.21+ ``` ## userlist.txt ``` "appuser" "SCRAM-SHA-256$4096:..." ``` `scram-sha-256` hash,从 PG 查: ```sql SELECT rolname, rolpassword FROM pg_authid; ``` ## 客户端连接 应用连 pgbouncer 地址,不是 PG: ```python # Django settings DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'HOST': 'pgbouncer.example.com', 'PORT': 6432, 'NAME': 'mydb', 'USER': 'appuser', 'PASSWORD': '...', 'CONN_MAX_AGE': 0, # 不用应用层 connection pool(让 pgbouncer 管) } } ``` `CONN_MAX_AGE=0` 是关键:让 Django 每请求重新拿 conn(实际是从 pgbouncer 池)。 `CONN_MAX_AGE > 0` 会让 app worker 长期持 connection → pgbouncer 无用。 ## Django / Rails 与 transaction mode 的坑 ### prepared statement ```python # psycopg2 默认 prepared statement queryset = MyModel.objects.filter(x=1) ``` psycopg2 用 server-side prepared statement 提速。 但 transaction mode 跨 transaction conn 切换 → prepared statement 在 另一 conn 没准备 → 报错。 解决: **option A**:禁用 prepared statement ```python # Django + psycopg2 DATABASES['default']['OPTIONS'] = { 'options': '-c default_transaction_isolation=read_committed', } # psycopg3: DATABASES['default']['OPTIONS'] = { 'prepare_threshold': None, } ``` **option B**:pgbouncer 1.21+ 支持 prepared statement caching: ```ini max_prepared_statements = 100 ``` 我建议 option B(pgbouncer 新版本 + 不改应用)。 ### SET / 临时表 / cursor 业务代码避免: ```python # bad(transaction mode 不持久) cursor.execute("SET application_name = 'myapp'") # 下个 query 可能换 conn → SET 丢 # bad cursor.execute("CREATE TEMP TABLE ...") # 临时表跨 transaction 没了 # bad LISTEN / NOTIFY → 直接不能用 ``` 需要 SET / 临时表 → 包在同 transaction 内,或者直连 PG 不走 pgbouncer。 ## pool_size 怎么定 经验: - PG 总 `max_connections`:保留给所有 client 之和 - pgbouncer `default_pool_size`:少于 PG max_connections / N(N = client app 数) 例:PG max_connections=100,2 app server,每 app 配 30 pool_size → 2×30=60 < 100,留 40 给 admin / monitoring。 pool_size 太小 → app 等 conn timeout。 pool_size 太大 → PG 进程多 RAM 涨。 经验值:CPU 核数 × 2-4。32 vCPU PG → pool_size 64-128 / app。 ## 监控 `SHOW POOLS;`(连 pgbouncer 跑): ``` database | user | cl_active | cl_waiting | sv_active | sv_idle mydb | appuser | 50 | 0 | 18 | 2 ``` - `cl_waiting > 0` → 客户端在等 conn,pool_size 不够 - `sv_active / pool_size 接近 1` → 长期满负载 prometheus pgbouncer_exporter 抓 → Grafana panel。 ## 透明部署 vs sidecar 部署方式: 1. **每 app server 一个 pgbouncer**:sidecar,本地连 2. **集中 pgbouncer cluster**:多 app 共用 3. **PG container 内嵌** 我倾向 1(sidecar): - app 连 localhost:6432 几乎 0 网络开销 - pgbouncer 进程独立 crash 不影响 PG - 配置分散但简单 ## 与 pgcat / odyssey 对比 - **pgcat**(Rust):现代 PG pooler,原生支持 read replica 路由 + sharding - **odyssey**(Yandex):高性能 multi-threaded pooler - **pgbouncer**:单线程 C,事实标准,稳 90% 场景 pgbouncer 够。需要 read replica 路由 / sharding → pgcat。 ## 实战:解决 too many connections 我们一个 Django app + Celery + 30 worker × 4 instance = 120 conn 需求。 PG max_connections=200 撑得住但内存吃紧。 加 pgbouncer: ```ini pool_size = 25 max_client_conn = 200 pool_mode = transaction ``` 实际 PG conn 数:25 × 1(pgbouncer)= 25。 app 仍能并发 200 个 logical conn(pgbouncer 排队复用)。 PG RAM 从 8 GB → 2 GB。预算节省。 ## 踩过的坑 1. **CONN_MAX_AGE > 0**:以为加 pgbouncer 就万事大吉,结果 app 仍持 长 conn → pgbouncer 无效。改 0。 2. **scram-sha-256 vs md5**:PG 14+ 默认 scram,pgbouncer 1.20+ 才好 支持。老版本配 md5。 3. **prepared statement 误报**:奇怪的 `prepared statement "_pg_q1" does not exist` → transaction mode + prepared 不兼容。pgbouncer 1.21 + prepare cache 或者禁 prepared。 4. **transaction 中调存储过程**:某些 PG 存过程内 `COMMIT` 触发 → pgbouncer 状态错乱。pool_mode statement 或者重构。 5. **pgbouncer 重启 = 断所有 client**:升级 pgbouncer 谨慎。优雅做法 多个 pgbouncer + LB rolling restart。

MLflow:本地自托管的实验跟踪 + 模型注册 + 部署 4-in-1

## 起因 公司数据不能上 wandb / Comet 这种 cloud SaaS。要本地自托管的实验 跟踪 + 模型版本控制 + 一键部署。 MLflow 是 Databricks 出的开源套件,4 个组件覆盖 ML lifecycle: - **Tracking**:记录实验(params / metrics / artifacts) - **Projects**:可复现的 ML 包格式 - **Models**:模型版本 + 多 framework 统一接口 - **Registry**:模型生命周期(staging / production / archived) ## 装 + 启服务 ```bash uv add mlflow # 启动 tracking server(默认 SQLite 后端 + 本地文件 artifact) mlflow server \ --host 0.0.0.0 --port 5000 \ --backend-store-uri sqlite:///mlflow.db \ --default-artifact-root ./mlruns ``` 或更"生产"配置(PostgreSQL + S3): ```bash mlflow server \ --host 0.0.0.0 --port 5000 \ --backend-store-uri postgresql://user:pass@db/mlflow \ --default-artifact-root s3://my-bucket/mlruns \ --workers 4 ``` systemd unit + nginx 套一下就是企业级服务。 ## Tracking:训练时记录 ```python import mlflow import mlflow.pytorch mlflow.set_tracking_uri('http://localhost:5000') mlflow.set_experiment('churn-prediction') with mlflow.start_run(run_name='lgbm-baseline'): mlflow.log_params({ 'model': 'lgbm', 'lr': 0.05, 'n_estimators': 200, 'max_depth': 7, }) mlflow.set_tag('dataset_version', 'v2024-05-01') # 训练 model = train(...) eval_metrics = evaluate(model, X_val, y_val) mlflow.log_metrics(eval_metrics) # {'auc': 0.84, 'precision': 0.71, 'recall': 0.66} # 多 step:每 epoch log for epoch in range(20): train_one_epoch() mlflow.log_metric('train/loss', loss, step=epoch) mlflow.log_metric('val/auc', auc, step=epoch) # 保存模型(mlflow 自动 capture 依赖 env) mlflow.lightgbm.log_model(model, 'model') # 任意 artifact mlflow.log_artifact('confusion_matrix.png') mlflow.log_artifact('feature_importance.csv') ``` 跑完 → MLflow UI 看到 run,有 params / metrics 表 + 曲线 + artifacts 下载。 ## 实验对比 UI 选多个 run → Compare → 表格 + parallel coordinates + scatter plot。 一眼看出"哪几个超参组合 auc 高"。 ## Model Registry ```python # 训练完成后注册到 registry mlflow.lightgbm.log_model( lgb_model=model, artifact_path='model', registered_model_name='ChurnPredictor', ) ``` UI 里看到 `ChurnPredictor v1`。 版本管理 + 状态机: ```python client = mlflow.MlflowClient() # 升级到 staging client.transition_model_version_stage( name='ChurnPredictor', version=1, stage='Staging', ) # 验证后升级到 production client.transition_model_version_stage( name='ChurnPredictor', version=1, stage='Production', archive_existing_versions=True, # 老 production 自动 archive ) ``` 线上代码总是拿 production 版本: ```python model = mlflow.pyfunc.load_model( model_uri='models:/ChurnPredictor/Production' ) prediction = model.predict(X) ``` 回滚?把老版本 transition 回 Production 即可。 ## Models:统一 framework 接口 ```python # sklearn mlflow.sklearn.log_model(model, 'm') # PyTorch mlflow.pytorch.log_model(model, 'm') # Transformers mlflow.transformers.log_model(pipeline, 'm') # 自定义(Pyfunc) class MyModel(mlflow.pyfunc.PythonModel): def load_context(self, context): self.artifact = load(context.artifacts['my_file']) def predict(self, context, input_df): return my_inference(input_df, self.artifact) mlflow.pyfunc.log_model('m', python_model=MyModel(), artifacts={'my_file': 'data.pkl'}) ``` 加载时**不需要知道 framework**: ```python model = mlflow.pyfunc.load_model('models:/X/Production') model.predict(df) ``` 业务代码不再 hardcode "import torch / sklearn / xgboost"。 ## 部署:一行 serve ```bash mlflow models serve -m models:/ChurnPredictor/Production -p 5001 # 起一个 HTTP API on :5001 ``` ```bash curl -X POST http://localhost:5001/invocations \ -H 'Content-Type: application/json' \ -d '{"dataframe_records": [{"age": 30, "balance": 1000}]}' # {"predictions": [0.72]} ``` 或 build Docker image: ```bash mlflow models build-docker -m models:/X/Production -n my-model:latest docker run -p 5001:8080 my-model:latest ``` 或部署到 Sagemaker / Azure ML / K8s: ```bash mlflow sagemaker deploy ... mlflow azureml deploy ... ``` framework 抽象一直延伸到部署。 ## Autologging ```python mlflow.sklearn.autolog() # 之后所有 sklearn fit() 自动 log model + params + metrics model = RandomForestClassifier(n_estimators=100) model.fit(X, y) # 自动 log: n_estimators / max_depth / mean_score / training_time / ... ``` 支持 sklearn / PyTorch / Lightning / TensorFlow / XGBoost / LightGBM。 适合"快速 baseline 跑 N 个 algorithm 选最好的"。 ## Projects:可复现 ML 包 `MLproject` 文件: ```yaml name: churn-prediction python_env: python_env.yaml entry_points: main: parameters: data_path: {type: string, default: 'data/train.parquet'} lr: {type: float, default: 0.05} n_estimators: {type: int, default: 200} command: 'python train.py --data {data_path} --lr {lr} --n {n_estimators}' ``` 跑: ```bash mlflow run . -P lr=0.1 -P n_estimators=500 # 或 git URL: mlflow run https://github.com/me/churn-prediction.git -P lr=0.1 ``` 自动建 virtualenv + 装依赖 + 跑训练。同事任何人能复现。 ## 与替代品对比 | | MLflow | Weights & Biases | Neptune | DVC | |---|---|---|---|---| | 自托管 | ✅ 简单 | 企业版 | ✅ | ✅ | | 实验跟踪 | ✅ | ✅ 最强 | ✅ | 较弱 | | 模型注册 | ✅ | ✅ | ✅ | ❌ | | 部署 | ✅ 内置 | ❌ | ❌ | ❌ | | Pipeline | ❌(外部 Airflow) | weave | ❌ | ✅ | | 价格 | 免费 | 付费(个人免费) | 付费 | 免费 | 数据合规要本地的 → MLflow。 体验最好 + 不担心数据外发 → wandb。 ## 我们的实战配置 `docker-compose.yml`: ```yaml services: mlflow: image: ghcr.io/mlflow/mlflow:v2.16.2 ports: ["5000:5000"] environment: MLFLOW_S3_ENDPOINT_URL: http://minio:9000 AWS_ACCESS_KEY_ID: minio AWS_SECRET_ACCESS_KEY: ... command: > mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri postgresql://mlflow:pw@pg/mlflow --default-artifact-root s3://mlflow-artifacts/ pg: image: postgres:16 environment: POSTGRES_DB: mlflow POSTGRES_USER: mlflow POSTGRES_PASSWORD: pw minio: image: minio/minio command: server /data --console-address ":9001" ports: ["9000:9000", "9001:9001"] environment: MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: ... ``` 三个 service:MLflow + Postgres (metadata) + Minio (S3 兼容 artifact)。 全本地,数据不出公司。 ## 效果 - 30+ 个实验跑下来"哪个 lr × dataset 最优"清晰 - 上线模型有版本号 + git commit 关联 + 训练数据版本 traceable - 回滚到上版本:UI 点 button + 30 秒 redeploy - 数据科学家 / ML 工程师 / 部署运维各看 UI 不同部分 ## 踩过的坑 1. **artifact 上传慢**:local file backend OK;S3 时大模型几 GB 上传 几分钟。`mlflow.log_artifact` 阻塞 train script。后台 thread 异步。 2. **autolog 误 log 整张 dataframe**:默认 sklearn autolog 会 log X_train shape + 部分 sample。私密数据可能进 MLflow → 安全隐患。 `mlflow.sklearn.autolog(log_input_examples=False)`。 3. **Model registry 没强制 stage gating**:任何人能把 dev model 推 Production。生产建 ACL + reviewer 流程。 4. **跨 Python 版本 model 加载失败**:在 3.10 训练的 sklearn model 在 3.12 load 时 unpickle 错。MLflow log model 时 capture 了 python_env.yaml,确认部署机器装对版本。 5. **UI 慢**:实验数量 > 几万后 list 慢。定期 archive 老 experiment 到 `s3://archived/`。

LangSmith 调试 LLM agent:把每个 prompt / 工具调用都看清楚

## 起因 写了一个 LangChain agent 帮用户查数据库 + 写 SQL + 解释结果, 跑起来时不时给出乱七八糟的答案。问题可能出在: - 哪一步的 prompt 让 LLM 跑偏? - 调了什么 tool、tool 返回了什么? - 重试了几次? - 哪段花了最多 token / 最长时间? `print(intermediate_steps)` 看不出来。LangSmith 是 LangChain 团队的可观测平台, 自动把 chain / agent 的每一次执行都记下来,UI 时间线展开看。 ## 解决方案 ### 注册 + 装 注册 [smith.langchain.com](https://smith.langchain.com)(免费层个人项目够), 拿 API key。 ```bash uv add langsmith export LANGCHAIN_TRACING_V2=true export LANGCHAIN_API_KEY=ls__xxxxxxxx export LANGCHAIN_PROJECT=my-agent-debug ``` **就这样**。LangChain 自动把所有 chain / agent 执行 trace 上报到 LangSmith。 不需要改任何代码。 ### 看 trace 进 LangSmith UI → 选 project → 看 trace 列表: ``` Run name Duration Tokens Status sql_agent.invoke 5.3s 2,341 success sql_agent.invoke 12.1s 8,902 error ... ``` 点进任意 run: ``` └─ sql_agent (5.3s, 2341 tokens) ├─ planner (1.2s, 540 tokens) │ └─ ChatOpenAI (1.1s, 540 tokens) │ ▶ prompt: "You are a SQL planner. Decide..." │ ◀ output: {"action": "run_sql", "sql": "SELECT ..."} ├─ tool: run_sql (0.3s) │ ▶ input: SELECT count(*) FROM users WHERE ... │ ◀ output: [{"count": 142}] └─ responder (3.8s, 1801 tokens) └─ ChatOpenAI (3.7s, 1801 tokens) ▶ prompt: "Given the query result, answer..." ◀ output: "总共有 142 个符合条件的用户。" ``` 每一层展开看完整 prompt + completion + tokens + latency。 ### 找出"为什么这次出错" filter "status=error" 看所有失败 run。点进去: ``` └─ sql_agent (failed at responder) ├─ planner ✓ ├─ run_sql ✗ (ERROR: column "user_typ" does not exist) └─ ... ``` 清楚看到 SQL agent 拼错列名(应该是 user_type)。回头改 prompt 加 schema 提示。 ### A/B 对比 prompt LangSmith 有 "Datasets" + "Compare experiments": 1. 创建 dataset:10-20 个典型 query + 期望答案 2. 跑 prompt v1:`run_on_dataset(dataset_name, prompt_v1_chain)` 3. 跑 prompt v2:同上 4. UI 对比每个 input 上 v1 vs v2 的输出 + 自动 eval 分数 ```python from langsmith import Client client = Client() dataset = client.create_dataset('sql_agent_eval') client.create_example( inputs={'query': '过去 7 天注册的用户数'}, outputs={'expected': '~150'}, dataset_id=dataset.id, ) from langchain.smith import RunEvalConfig client.run_on_dataset( dataset_name='sql_agent_eval', llm_or_chain_factory=lambda: sql_agent_v2, evaluation=RunEvalConfig(evaluators=['qa', 'context_qa']), ) ``` LangSmith 自动用 GPT-4 当 judge 评分。 ### prompt hub LangChain Hub 集成在 LangSmith: ```python from langchain import hub prompt = hub.pull('rlm/rag-prompt') ``` 社区 prompt 模板,pull + 改自己版本 + push 回去(你的私有 namespace)。 ### 用于 production 监控 不止 dev 用: ```python import os os.environ['LANGCHAIN_TRACING_V2'] = 'true' os.environ['LANGCHAIN_PROJECT'] = 'prod' # 区分环境 ``` production 上跑的每个 trace 都收集。看: - 每天调用量 - 每个 chain 的 P50 / P95 延迟 - token 消耗趋势 - error 率 + 错误类型分布 可以接 alert:当 error rate > 5% 邮件通知。 ## 与替代品对比 | | LangSmith | Langfuse | Phoenix (Arize) | |---|---|---|---| | 开源 / 自托管 | ❌(cloud 为主,自托管要 enterprise) | ✅ 全开源 | ✅ | | 与 LangChain 集成 | 最原生 | 中 | 中 | | 与 LlamaIndex | 中 | 中 | 强 | | eval 框架 | ✅ | ✅ | ✅ | | 价格 | 免费 5k traces/月 | 完全免费(自托管) | 免费层 | LangChain 项目用 LangSmith 最方便;不想绑定平台用 Langfuse 自托管。 ## 效果 - agent 失败率 12% → 4%,靠看 trace 改 prompt 一周搞定 - "为什么这次跑出 X" 的问题从"猜 + 加 print"变成 "去 LangSmith 看一下" - 找到一个 prompt 让 token 消耗减半(trace 里看到 LLM 反复重复同样 上下文) - 团队 review prompt 改动有了客观依据(运行某 dataset 对比 v1/v2 分数) ## 踩过的坑 1. **traces 含敏感数据上 cloud**:用户邮箱 / 手机号都进 prompt 时 隐私问题。开 `LANGCHAIN_TRACING_V2=false` 临时关,或者 enterprise 自托管。 2. **大批量 run 上报慢**:默认同步上报,每个 run 加 50-100ms。设 `LANGCHAIN_CALLBACKS_BACKGROUND=true` 异步上报。 3. **trace 嵌套太深**:复杂 agent 数十层调用,UI 加载慢。用 tag / metadata 标记关键步骤再筛选。 4. **eval 用 GPT-4 评分**:成本可能比被评模型还高。先小 dataset 验证 eval 设置对,再扩规模。 5. **本地 LLM trace**:用 Ollama / vLLM 跑本地模型时,把 ChatOpenAI 的 base_url 改本地即可,trace 一样上报。注意 token 计数对本地 模型不准。

Jotai:把 React state 拆成原子(比 Zustand 更细粒度)

## 起因 复杂表单 / 多级嵌套组件共享 state 时,Zustand 的"一个 store 多字段" 还是会让"用 fieldA 的组件" 因为 fieldB 改而 re-render(除非每字段 单独 selector)。 Jotai 思路完全不同:**每个状态是一个独立的 atom**。组件只订阅它真用到 的 atom,atom 变才重渲。天然细粒度。 ## 解决方案 ### 装 ```bash npm i jotai ``` ### 第一个 atom ```tsx import { atom, useAtom } from 'jotai' const countAtom = atom(0) function Counter() { const [count, setCount] = useAtom(countAtom) return <button onClick={() => setCount(c => c + 1)}>{count}</button> } function Display() { const [count] = useAtom(countAtom) return <span>{count}</span> } ``` `useAtom` 跟 `useState` 几乎一样的 API。 **多个组件共享同一 atom** 时自动同步。 不需要 Provider(默认全局): ```tsx function App() { return ( <> <Counter /> <Display /> </> ) } ``` 两个组件用同一 atom,互相同步。 ## Derived atom(计算属性) ```tsx const countAtom = atom(0) const doubledAtom = atom((get) => get(countAtom) * 2) function Doubled() { const [doubled] = useAtom(doubledAtom) return <span>{doubled}</span> } ``` `doubledAtom` 自动跟 `countAtom` 关联:count 变 → doubled 重算 → 订阅 doubled 的组件重渲。 ## Async atom ```tsx const userIdAtom = atom('alice') const userAtom = atom(async (get) => { const id = get(userIdAtom) const r = await fetch(`/api/users/${id}`) return r.json() }) function UserCard() { const [user] = useAtom(userAtom) return <div>{user.name}</div> } function App() { return ( <Suspense fallback={<Spinner />}> <UserCard /> </Suspense> ) } ``` 异步 atom 自动用 React Suspense。改 userIdAtom 触发 refetch。 ## Atom family(动态创建 atoms) 每个 user id 一个 atom: ```tsx import { atomFamily } from 'jotai/utils' const userAtomFamily = atomFamily((id: string) => atom(async () => fetch(`/api/users/${id}`).then(r => r.json())) ) function UserCard({ id }: { id: string }) { const [user] = useAtom(userAtomFamily(id)) return <div>{user.name}</div> } ``` 每个 `id` 独立 atom;不同 UserCard 不会互相影响。 ## 写组合:写一个 atom 触发多个修改 ```tsx const firstNameAtom = atom('') const lastNameAtom = atom('') const updateNameAtom = atom( null, // read function(这里没读) (get, set, fullName: string) => { const [f, l] = fullName.split(' ') set(firstNameAtom, f) set(lastNameAtom, l) } ) function Form() { const [, setName] = useAtom(updateNameAtom) return <input onChange={e => setName(e.target.value)} /> } ``` action / mutation 风格。 ## 持久化 ```tsx import { atomWithStorage } from 'jotai/utils' const themeAtom = atomWithStorage('theme', 'light') function ThemeToggle() { const [theme, setTheme] = useAtom(themeAtom) return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}> {theme} </button> } ``` 自动 localStorage。 ## 跟 Zustand 比 | | Jotai | Zustand | |---|---|---| | 粒度 | atom(极细) | store(粗,需 selector) | | API 风格 | 函数式 hook | mutate / function | | 学习曲线 | 中(atom 思维转换) | 低 | | 大组件树 | 优秀(自动只更新订阅者) | 需精确 selector | | 异步 | 一等公民 + Suspense | 手写 | | 复杂派生 | 自然 | 需 useMemo 等 | | Devtools | 有 | 有 | 适合 Jotai 的场景: - 复杂表单(每字段独立 atom) - 大量派生状态 - 需要 Suspense 异步 适合 Zustand: - 简单全局 store(用户 auth / theme) - 倾向 imperative API ## 实战:表单 with field-level state ```tsx import { atom, useAtom } from 'jotai' const emailAtom = atom('') const emailErrorAtom = atom((get) => { const e = get(emailAtom) if (!e) return null if (!/@/.test(e)) return '邮箱格式错' return null }) const passwordAtom = atom('') const passwordErrorAtom = atom((get) => { const p = get(passwordAtom) if (p.length < 8) return '密码至少 8 位' return null }) const canSubmitAtom = atom((get) => get(emailErrorAtom) === null && get(passwordErrorAtom) === null && get(emailAtom) && get(passwordAtom) ) function EmailField() { const [email, setEmail] = useAtom(emailAtom) const [error] = useAtom(emailErrorAtom) return ( <div> <input value={email} onChange={e => setEmail(e.target.value)} /> {error && <p>{error}</p>} </div> ) } // PasswordField 类似 function SubmitButton() { const [canSubmit] = useAtom(canSubmitAtom) return <button disabled={!canSubmit}>提交</button> } ``` 每个字段独立 atom + 独立 error atom + 派生 canSubmit。 只有"我用到的" 重渲。 对比 react-hook-form: - RHF 更成熟 + 更多功能(registration / validation chain) - Jotai 概念上更轻 + 不限于表单 复杂表单仍推荐 RHF;中等表单 + 跨组件共享 Jotai 更灵活。 ## 与 React Query 集成 ```tsx import { atomWithQuery } from 'jotai-tanstack-query' const userAtom = atomWithQuery((get) => ({ queryKey: ['user', get(userIdAtom)], queryFn: async ({ queryKey: [, id] }) => fetch(`/api/users/${id}`).then(r => r.json()), })) function UserCard() { const [{ data, isPending }] = useAtom(userAtom) if (isPending) return <Spinner /> return <div>{data.name}</div> } ``` React Query 的 cache + Jotai 的 atom 组合 = 服务端状态 + 客户端 state 一致管理。 ## 调试:Jotai Devtools ```tsx import { useAtomDevtools } from 'jotai-devtools' function DebugAll() { useAtomDevtools(countAtom, { name: 'count' }) useAtomDevtools(userAtom, { name: 'user' }) return null } ``` Redux DevTools 显示所有 atom 变化时间线 + 当前值。 ## 性能注意 Jotai 极细粒度 = 每个 atom 一份 React subscription。 **几千个 atom + 大量更新** 时 React scheduler 压力大。 > 千级 atom 场景考虑: - 用 `atomFamily` 而非手写百份 atom - 把不需要响应的数据放普通对象 / Map - 必要时用 Jotai 内部 store 做 batched update ## 何时不用 Jotai - 全局只有几个简单 state(auth + theme)→ Zustand 足够 - 已经深度用 Redux Toolkit → 切换成本大 - 团队不熟函数式 → 学习曲线一道槛 ## 踩过的坑 1. **atom 在组件外创建**:放函数体内每次 render 新 atom → state 重置。 atom 一定在 module top-level 创建。 2. **派生 atom 依赖循环**:A 依赖 B,B 依赖 A → 无限循环 stack overflow。 设计时检查依赖图无环。 3. **async atom + Suspense**:默认 fetch 失败抛 ErrorBoundary。 不想用 Suspense 写 `loadable(asyncAtom)` 退化到 loading state pattern。 4. **atom 大对象**:整个对象一变所有订阅者重渲。改为多 atom 细分 + selector 派生。 5. **SSR / Next.js**:Jotai SSR 需要 `Provider` 隔离 request-level state,不能全局。注意配 hydration。

TanStack Query (React Query):把"fetch + loading + error + cache"一站搞定

## 起因 React 里每次写"组件 mount 时 fetch 数据,loading 状态显示 spinner, error 显示 toast,组件 unmount 时 abort"——这一套 boilerplate 在 useState + useEffect 里写起来 30 行: ```tsx function UserCard({ id }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let alive = true setLoading(true) fetch(`/api/users/${id}`) .then(r => r.json()) .then(data => { if (alive) { setUser(data); setLoading(false) } }) .catch(e => { if (alive) { setError(e); setLoading(false) } }) return () => { alive = false } }, [id]) if (loading) return <Spinner /> if (error) return <ErrorBlock /> return <div>{user.name}</div> } ``` 而且这里还没处理:去重(同时多个组件查同一 id)、refetch 策略、 后台静默更新、optimistic update、stale-while-revalidate…… `TanStack Query`(前身 React Query)把这套抽象成 hook,代码减少 80%。 ## 解决方案 ### 装 ```bash npm i @tanstack/react-query ``` ### 顶层 Provider ```tsx // main.tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 分钟内复用 cache,不 refetch gcTime: 5 * 60 * 1000, // 5 分钟没人订阅就 gc retry: 2, refetchOnWindowFocus: true, }, }, }) <QueryClientProvider client={queryClient}> <App /> <ReactQueryDevtools /> {/* dev 用 */} </QueryClientProvider> ``` ### useQuery:上面那 30 行变成 ```tsx import { useQuery } from '@tanstack/react-query' function UserCard({ id }) { const { data: user, isPending, error } = useQuery({ queryKey: ['user', id], queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()), }) if (isPending) return <Spinner /> if (error) return <ErrorBlock message={error.message} /> return <div>{user.name}</div> } ``` 5 行。自动: - mount 时 fetch - unmount abort(如果 queryFn 支持 signal) - 同 queryKey 多组件去重(只一次请求) - staleTime 内复用 cache,不重 fetch - focus tab / 网络恢复时 background refetch - error 时自动重试 ### queryKey 是核心 ```tsx useQuery({ queryKey: ['user', id] }) // GET /api/users/:id useQuery({ queryKey: ['users', { tag: 'admin', page: 2 }] }) useQuery({ queryKey: ['posts', userId, 'comments'] }) ``` queryKey 像 cache 字典的 key。同 key 共享一份 cache。 对象 / 数组按值比较(deep equality)。 ### 失效 cache ```tsx import { useQueryClient } from '@tanstack/react-query' function CreatePostForm() { const qc = useQueryClient() const mutate = async (data) => { await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }) // 让所有 posts 相关查询失效,触发自动 refetch qc.invalidateQueries({ queryKey: ['posts'] }) } // ... } ``` `invalidateQueries({ queryKey: ['posts'] })` 让所有以 `['posts', ...]` 开头的 cache 标记 stale,触发 refetch。 ### useMutation:POST / PUT / DELETE ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' function DeleteButton({ id }) { const qc = useQueryClient() const mutation = useMutation({ mutationFn: (postId) => fetch(`/api/posts/${postId}`, { method: 'DELETE' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['posts'] }) }, }) return ( <button onClick={() => mutation.mutate(id)} disabled={mutation.isPending} > {mutation.isPending ? '删除中...' : '删除'} </button> ) } ``` ### Optimistic update(界面立刻反映,再发请求) ```tsx const mutation = useMutation({ mutationFn: (newPost) => fetch('/api/posts', ...).then(r => r.json()), onMutate: async (newPost) => { // 取消正在跑的 query await qc.cancelQueries({ queryKey: ['posts'] }) // 保存 snapshot const prev = qc.getQueryData(['posts']) // 乐观更新 cache qc.setQueryData(['posts'], (old) => [...old, newPost]) return { prev } }, onError: (err, newPost, ctx) => { // 回滚 qc.setQueryData(['posts'], ctx.prev) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['posts'] }) }, }) ``` UI 立刻看到新 post,服务端失败自动回滚。用户体验大幅改善。 ### 分页 / 无限滚动 ```tsx import { useInfiniteQuery } from '@tanstack/react-query' const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetch(`/api/posts?page=${pageParam}`).then(r => r.json()), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, }) return ( <> {data?.pages.map(page => page.items.map(p => <PostCard key={p.id} {...p} />))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> 加载更多 </button> )} </> ) ``` ### prefetch 页面将进入前先预加载下个页面: ```tsx function PostLink({ id }) { const qc = useQueryClient() return ( <a href={`/posts/${id}`} onMouseEnter={() => qc.prefetchQuery({ queryKey: ['post', id], queryFn: () => fetch(`/api/posts/${id}`).then(r => r.json()), })} > 详情 </a> ) } ``` 鼠标 hover 时预 fetch,用户点进去秒开。 ### Devtools 极爽 `<ReactQueryDevtools />` 在 dev 模式右下角浮窗: - 所有 query 状态(fresh / fetching / stale / inactive) - cache 内容 - 手动 invalidate / refetch 测试 调试缓存问题神器。 ### SSR / Next.js 集成 ```tsx // app/posts/page.tsx import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query' export default async function PostsPage() { const qc = new QueryClient() await qc.prefetchQuery({ queryKey: ['posts'], queryFn: () => fetchPosts(), }) return ( <HydrationBoundary state={dehydrate(qc)}> <PostList /> </HydrationBoundary> ) } ``` 服务端预先取数据塞进 client cache,hydrate 后客户端立刻有数据。 ## 效果 我们的 SPA 改造后: - 网络请求量降 40%(多组件共享 cache + staleTime 复用) - 用户感知到的"loading"次数下降 60%(cache 命中秒显示) - bug 数下降明显(边界情况 framework 处理而非自己写) - 代码量净减少 30% ## 替代品 - **SWR**(Vercel 出品):API 更简洁,能力少一些 - **Apollo Client**(GraphQL):GraphQL 项目优秀 - **RTK Query**(Redux Toolkit):已经用 Redux 的话 - **TanStack Query**:通用、最强大、生态最大 新项目无 GraphQL → TanStack Query 默认选。 ## 踩过的坑 1. **queryKey 不稳定**:每次 render 新建对象 / 数组 → query 永远 stale。 `useMemo(() => ['user', id], [id])` 或者扁平 `['user', id]`。 2. **mutation 后忘 invalidate**:UI 不刷新。永远在 `onSuccess` invalidate 相关 query。 3. **staleTime: 0 + refetchOnWindowFocus**:tab 切回来都 refetch, API 调用爆炸。生产 staleTime 至少 30s-1min。 4. **`useQuery` 在条件渲染分支里**:违反 hooks 规则。用 `enabled: !!id`: ```tsx useQuery({ queryKey: ['user', id], queryFn: ..., enabled: !!id }) ``` 5. **`useQuery` 直接 throw error**:导致 React error boundary 接管。 用 `error` 字段做条件渲染,或 `useErrorBoundary: true` 显式启用。

atuin:跨机器同步 + 加密的 shell 历史

## 起因 shell 历史的痛点: - 几台机器(笔记本 / 台式 / 远程 server)历史不互通 - Ctrl-R 反搜慢 + 一次显一条 - 历史没 metadata(哪个目录跑的?exit code?耗时?) - 关闭终端 history 偶尔丢 `atuin` 是 Rust 写的 shell history 替代: - 历史存 SQLite,带元数据(cwd、exit、duration、host、session) - E2E 加密同步到 server(自己 host 或官方) - Ctrl-R 替换成全屏 fuzzy search UI ## 装 ```bash brew install atuin curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh # shell 集成 atuin init zsh >> ~/.zshrc atuin init bash >> ~/.bashrc atuin init fish | source ``` import 现有历史: ```bash atuin import auto # 检测 shell 自动 import ``` ## 第一次用 Ctrl-R 启动 atuin 全屏 search: ``` atuin search ~/proj/myapp ──────────────────────────────────────── > docker 10s /home/u/proj docker compose up -d ✓ 2m /home/u/other-proj docker logs api -f ✓ 1h /home/u/proj docker compose ps ✓ yesterday /home/u/proj docker compose build ✓ ──────────────────────────────────────────────────────────────────── filter <ctrl-r> | invert <ctrl-a> | exit <esc> ``` - 实时 fuzzy 过滤 - 显示运行时间 / cwd / 是否成功(✓/✗) - enter 执行 / tab 编辑 `Ctrl-R` 一秒查 10 年前在哪台机器跑过啥命令。 ## sync 同步 注册账号: ```bash atuin register -u me -e [email protected] atuin login -u me # import 上来的 + 之后的命令都加密同步到 cloud ``` E2E 加密:服务端只存 ciphertext,端上解密。 默认用 atuin.sh 官方服务(免费);介意 → 自部署: ```bash docker run -d -p 8888:8888 ghcr.io/atuinsh/atuin server start ``` `~/.config/atuin/config.toml`: ```toml sync_address = "https://your-server.com" ``` ## 跨机器登录 新机器: ```bash brew install atuin atuin login -u me -p <password> -k <encryption-key> atuin sync ``` `-k` 是首次注册时 atuin 显示的加密 key,**自己存好**(atuin server 无法恢复 key)。 `atuin sync` 把云端历史拉下来 + 解密 → 立刻有所有机器的命令。 ## 配置过滤 不想同步某些命令: ```toml # ~/.config/atuin/config.toml # 不记录 secret 命令 history_filter = [ "^secret-cmd", "^aws.*--secret", ] # 不在公共 wifi 同步 sync_address = "..." auto_sync = true sync_frequency = "5m" ``` ## stats ```bash $ atuin stats Top commands: 1. git status (1234) 2. ls (987) 3. cd .. (876) ... Total commands: 45678 First command: 2022-03-14 ``` 或者: ```bash $ atuin stats day $ atuin stats week $ atuin stats --since '1 month ago' ``` 看一个月你跑啥最多。会启发优化 alias / 工作流。 ## 各机器 history 分别看 ```bash atuin search --cwd . # 当前目录历史 atuin search --hostname laptop # 在 laptop 跑的 atuin search --exit 0 # 只成功的 atuin search 'docker' --before '1 week ago' ``` ## 内置 vs atuin | | shell 内置 history | atuin | |---|---|---| | 存储 | ~/.zsh_history (text) | SQLite | | 同步 | 无 | 跨机器加密 | | 元数据 | 无 | cwd / exit / duration | | 搜索 | 反搜(单行) | TUI fuzzy | | 容量 | 几千-几万行 | 无限 | | 性能 | 文件 grep | 索引 query | 不会用 atuin 后就回不去了。 ## 性能 `atuin search` 上百万 history entry sub-50ms 响应。SQLite 索引 + Rust 解析快。 启动开销:shell 集成 ~5-10ms(vs no atuin ~0ms)。基本无感。 ## 替代品 - **mcfly**(rust):类似 atuin,没 sync - **hstr**:传统 TUI,无元数据 - **fzf + fc**:fzf 模糊搜历史(无元数据) atuin 是目前最完整方案。 ## 退出策略 万一不喜欢: ```bash # 取消 shell 集成(从 .zshrc 删 atuin init) # 导出回 plain history atuin export csv > history.csv # 删 SQLite rm -rf ~/.local/share/atuin ``` history 不会"绑定"住你。 ## 隐私 / 安全 - 数据库本地:`~/.local/share/atuin/history.db`,未加密 → 笔记本被偷 对方能读历史 - 云端:E2E 加密,server 看不到内容 - 命令含 secret:`history_filter` 排除 担心本地泄密 → FileVault / LUKS 全盘加密 + atuin filter sensitive 命令。 ## 一个 case:debug 老问题 3 个月后客户报"上次配的某个 nginx 设置不工作了"。 我不记得当时改了啥。 ```bash $ atuin search --since '4 months ago' --before '2 months ago' nginx ``` 10 个命令列出来:当时 edit 啥配置、reload、看 log。 2 分钟还原情境。比"翻 commit / 看 wiki"快多了。 shell history 是被低估的 personal knowledge base。 ## 踩过的坑 1. **首次同步慢**:百万 entry 上传几分钟。耐心等。 2. **重复 history**:bash + zsh 都用 atuin → 同命令记两次。每 shell 只用一个。 3. **导入 fish 历史 format 不对**:fish 用 yaml 历史,老版本 atuin import 解析坑。新版本修了。 4. **`Up` 键被吃**:atuin 默认绑 `Up` 显示历史 → 跟 vi mode 冲突。 `disable_up_arrow = true` 关。 5. **加密 key 丢了**:服务端有数据但解密不了。**`atuin register` 时 立刻把 key 存密码管理器**。