知识广场

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

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

scikit-learn Pipeline + ColumnTransformer 把"训练泄漏"杀掉

## 起因 ML 新手最容易写出"data leakage"——把测试集的统计信息(均值 / 编码 / 缺失值填补)混到训练里。常见错法: ```python # ❌ 整个数据集上算均值标准差,然后切分 from sklearn.preprocessing import StandardScaler scaler = StandardScaler().fit(X) X = scaler.transform(X) X_train, X_test, y_train, y_test = train_test_split(X, y) ``` 测试集的均值"漏"进了 scaler,cross-validation 分数偏高,部署到真实 新数据立刻拉胯。 `Pipeline` + `ColumnTransformer` 强制把所有 preprocessing 关在 cv split 内部,自动避免泄漏。 ## 解决方案 ### 1. 简单 Pipeline ```python from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score pipe = Pipeline([ ('scaler', StandardScaler()), ('clf', LogisticRegression()), ]) scores = cross_val_score(pipe, X, y, cv=5) print(f'mean acc: {scores.mean():.3f} ± {scores.std():.3f}') ``` 每折 cv 时 pipeline 重新 `fit` scaler,只在那一折的训练数据上算 mean / std。没有泄漏。 ### 2. 混合类型特征:ColumnTransformer 数据集常常包含 numerical + categorical 两类: ```python import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer df = pd.read_csv('users.csv') y = df.pop('churn') X = df num_cols = ['age', 'days_active', 'monthly_revenue'] cat_cols = ['country', 'plan', 'referral_source'] preprocessor = ColumnTransformer([ ('num', Pipeline([ ('imp', SimpleImputer(strategy='median')), ('sc', StandardScaler()), ]), num_cols), ('cat', Pipeline([ ('imp', SimpleImputer(strategy='constant', fill_value='missing')), ('oh', OneHotEncoder(handle_unknown='ignore', sparse_output=False)), ]), cat_cols), ], remainder='drop') full_pipe = Pipeline([ ('prep', preprocessor), ('clf', GradientBoostingClassifier()), ]) scores = cross_val_score(full_pipe, X, y, cv=5, scoring='roc_auc') ``` 要点: - 每列类型独立配 imputer + encoder/scaler - `handle_unknown='ignore'`:测试集里出现训练集没见过的 category 值不报错 - `remainder='drop'` 显式:未声明的列扔掉(safer than `passthrough`) ### 3. 超参搜索:所有阶段一起搜 ```python from sklearn.model_selection import GridSearchCV param_grid = { 'prep__num__imp__strategy': ['mean', 'median'], 'clf__n_estimators': [100, 200, 500], 'clf__max_depth': [3, 5, 7], } search = GridSearchCV(full_pipe, param_grid, cv=5, scoring='roc_auc', n_jobs=-1, verbose=1) search.fit(X, y) print(search.best_params_, search.best_score_) ``` 注意命名:`stage_name__sub_stage__param`,双下划线层级。 `n_jobs=-1` 用所有 CPU 核并行 fit 各组合。 ### 4. 保存 / 加载整个 pipeline ```python import joblib joblib.dump(search.best_estimator_, 'model.pkl') # 部署侧 model = joblib.load('model.pkl') y_pred = model.predict(new_X) # 自动跑 preprocessor → estimator,新数据原始 DataFrame 进去 ``` 整套 preprocessing + 模型在一个文件里,不需要"先 scale 再 predict" 两步走,部署更安全。 ### 5. 自定义 transformer 业务相关的 feature engineering 包成 transformer: ```python from sklearn.base import BaseEstimator, TransformerMixin import numpy as np class LogRatio(BaseEstimator, TransformerMixin): """两列做 log(a/b) 派生""" def __init__(self, num_col, denom_col): self.num_col, self.denom_col = num_col, denom_col def fit(self, X, y=None): return self def transform(self, X): ratio = (X[self.num_col] + 1) / (X[self.denom_col] + 1) return np.log(ratio).to_frame('log_ratio') # 加进 ColumnTransformer preprocessor = ColumnTransformer([ ('lr', LogRatio('revenue', 'days_active'), ['revenue', 'days_active']), ('num', ..., num_cols), ('cat', ..., cat_cols), ]) ``` 业务知识沉淀进 pipeline,每次实验都用。 ## 效果 - cross-validation 分数与真实 holdout / 线上 A/B 偏差 < 1% (之前漏了泄漏时偏差 5-10%) - 部署只 `joblib.load + predict`,前后端不再重复实现 preprocessing - 改特征工程只动 pipeline 定义,下游所有实验自动跟着 - 团队新同事接手时一个 .pkl 文件即可,无须读 100 行 prep 脚本 ## 踩过的坑 1. **`drop='first'` 在 OneHotEncoder**:drop 第一个 category 用于 linear model 避免共线性,但 tree-based model 不需要。GBT / RF 用 `drop=None` 信息更全。 2. **categorical 列只出现在测试集的新值**:`handle_unknown='error'` 默认 会报错。生产用 `handle_unknown='ignore'` 当全 0 处理;或 `'infrequent_if_exist'` 归入 "rare" 桶。 3. **SimpleImputer 把整数列变浮点**:填了 median 后 int → float。 下游模型可能内存翻倍。pandas 2.0+ 用 nullable Int 类型避开。 4. **大 categorical 高基数(user_id)做 OneHot**:维度爆炸。 用 target encoding / hashing trick / embeddings。 5. **`fit_transform(X_train)` + `transform(X_test)` 写错顺序**: 测试集 fit_transform = 灾难。强迫自己用 Pipeline 就规避了 —— 你只 `pipe.fit(X_train)` + `pipe.predict(X_test)`,永远不会写 到 transform 这个层。

Prefect vs Airflow:现代数据流编排选哪个

## 起因 数据 pipeline 需要: - 定时 / 触发跑(cron / event) - 任务依赖 DAG - 重试 / 失败告警 - UI 看历史 / 调试 `Airflow` 是 2014 起的事实标准。`Prefect`(2018+)是现代挑战者。 最近从 Airflow 迁了一半 pipeline 到 Prefect,下面对比。 ## Airflow ```python from airflow import DAG from airflow.operators.python import PythonOperator from datetime import datetime def extract(): print('extract') def transform(): print('transform') def load(): print('load') with DAG('etl', start_date=datetime(2025, 1, 1), schedule='@daily') as dag: e = PythonOperator(task_id='extract', python_callable=extract) t = PythonOperator(task_id='transform', python_callable=transform) l = PythonOperator(task_id='load', python_callable=load) e >> t >> l ``` DAG 是静态文件,scheduler 周期扫描 + render。 ### 优势 - 业界事实标准(10 年沉淀) - 巨大 operator 生态(数百个 connector) - 大规模成熟(Airbnb 几万 DAG) - Kubernetes / Celery executor 久经考验 ### 劣势 - DAG 必须静态(运行时 branch / 动态 task 难) - 调试本地难(要起 scheduler / webserver / DB / executor) - Python function 跟 task framework 耦合(PythonOperator wrapper) - 升级痛苦(2.x vs 1.x 大变;插件兼容差) - 巨型部署(PG + scheduler + worker + webserver 几组件) ## Prefect 2/3 ```python from prefect import flow, task @task(retries=3) def extract(): return [1, 2, 3] @task def transform(x): return x * 2 @task def load(items): print(f'loaded {items}') @flow def etl(): data = extract() transformed = [transform(x) for x in data] load(transformed) if __name__ == '__main__': etl() ``` `@flow` / `@task` 装饰器即编排。普通 Python 函数 + decorator。 ### 优势 - **DAG 动态生成**(运行时根据数据决定 task) - 本地 dev 简单:`python etl.py` 跑 - 装饰器轻,function 仍是 function - API + UI 现代 - Prefect Cloud free tier 个人 OK ### 劣势 - 生态比 Airflow 小(operator / connector 少) - 还在快速演化(Prefect 3 vs 2 部分 API 变) - 大规模 production 沉淀少 ## 调度模型对比 **Airflow**: ``` Scheduler 每分钟扫 DAG → 决定哪 task 该跑 → 推到 worker ``` 集中式,scheduler 是瓶颈。 **Prefect**: ``` Flow run 由 trigger(schedule / API / event)启动 Worker pull pending run 跑 ``` 更松散,worker 任意机器都能 pull。 ## 动态 task ```python # Airflow(静态 → 用 mapped task 模拟动态) @task def process(item): ... @task def get_items(): return [1, 2, 3, 4] @dag def my_dag(): items = get_items() process.expand(item=items) ``` Airflow 2.3+ 加了 dynamic task mapping,写起来扭。 ```python # Prefect:原生 @flow def my_flow(): items = get_items() for item in items: # 普通 Python process(item) ``` Prefect 直接用 Python loop,运行时决定 task 数。 ## subflow Prefect 鼓励 flow 嵌套: ```python @flow def daily_etl(): for region in ['us', 'eu', 'asia']: region_flow(region) # subflow 单独 run,独立 retry @flow def region_flow(region): extract(region) transform(region) load(region) ``` UI 里嵌套展开。复杂 pipeline 模块化。 ## 部署模型 **Airflow**: ``` - PG(metadata) - Redis / Celery(queue) - Webserver - Scheduler - N × Worker - 自动化 deploy DAG file → /dags 文件夹 ``` K8s 部署 6+ 容器。 **Prefect**: ``` - Server(API + UI)(Prefect Cloud 替代) - Worker(pull flow run) - Code 存哪都行(git / S3 / docker) ``` 简单很多。可以全 serverless(Cloud + ECS / Lambda worker)。 ## 本地开发 Airflow 本地: ```bash docker compose up # airflow-init + scheduler + webserver + worker # 改 DAG → 等 60s scheduler 扫 ``` Prefect 本地: ```bash prefect server start # 起 server python my_flow.py # 直接跑 ``` iter loop 快 5-10x。 ## 与 Dagster 对比 Dagster 是第三玩家,asset-based 编排(pipeline 是 "asset 之间的 graph")。 更声明式,更适合数据 platform 团队。 | | Airflow | Prefect | Dagster | |---|---|---|---| | 模型 | task DAG | flow + task | asset graph | | 上手 | 中 | 低 | 中高 | | 动态 | 弱 | 强 | 中 | | 生态 | 最大 | 中 | 中 | | 适合 | 大企业 / 复杂 ETL | 中小项目 / Pythonic | 数据 platform | ## 迁移 case 我们 30 个 Airflow DAG,三类: 1. 简单 ETL(SQL → SQL):保留 Airflow(operator 现成,省事) 2. 复杂 Python pipeline(动态 logic):迁 Prefect 3. ML 训练 pipeline:迁 Prefect(model artifact + dynamic 强) 混合架构:Airflow 跑标准 ETL,Prefect 跑 dynamic / Pythonic 流程。 ## 与 dbt 集成 两者都能跑 dbt: ```python # Prefect from prefect_dbt.cli.commands import DbtCoreOperation @flow def dbt_flow(): DbtCoreOperation(commands=['dbt run --select tag:hourly']).run() ``` ```python # Airflow from airflow.providers.dbt.cloud.operators.dbt import DbtCloudRunJobOperator # 或者 BashOperator('dbt run ...') ``` 差不多。Prefect block 概念让 dbt cli 复用简单。 ## API trigger ```python # Prefect:HTTP trigger flow run from prefect.client.orchestration import get_client async with get_client() as client: await client.create_flow_run_from_deployment( deployment_id='abc', parameters={'date': '2025-01-01'}, ) ``` 外部系统(webhook / event)触发简单。Airflow 也有 REST API 但繁琐。 ## 监控 / 告警 两者都有 UI + email / Slack 通知 + Prometheus metrics。 Prefect Cloud 免费 tier 告警直接配。 Airflow self-host 要自己拼 alertmanager。 ## 选型决策 - **大企业 + 复杂 ETL + 团队熟 Airflow** → Airflow - **新项目 + Python pipeline** → Prefect - **数据 platform 团队 + asset 思维** → Dagster - **简单 cron + 几个 job** → 别上编排框架,systemd timer + GitHub Actions 够 ## 踩过的坑(迁移) 1. **Airflow XCom → Prefect return value**:XCom 传数据有限(< 48KB metadata)。Prefect 直接 return Python object,但 cluster 间要序 列化 → 用 storage block。 2. **schedule timezone**:Airflow `start_date` UTC;Prefect 默认 UTC 但 cron string 解释要明确。 3. **retries 默认 0**:忘配 → 失败不重试。生产 retries=3 + backoff 默认。 4. **flow concurrency limit**:同 flow 多 run 并行 → DB 锁。 `concurrency_limit` 控制。 5. **Prefect 3 升级**:从 2.x 迁 3.x 有 breaking change。读 migration guide。

ClickHouse:百亿行表秒级聚合查询的列存 OLAP 数据库

PostgreSQL 适合 OLTP(事务),千万行查询还行,几十亿行后聚合就力不从心。 ClickHouse 是俄罗斯 Yandex 开源的列存 OLAP 数据库,专为"几十亿行表 + 复杂 SELECT 聚合"设计。日志分析 / BI / 指标存储常用。 ## 安装 ```bash # Debian / Ubuntu sudo apt install -y apt-transport-https ca-certificates dirmngr GNUPGHOME=$(mktemp -d) sudo GNUPGHOME="$GNUPGHOME" gpg --no-default-keyring --keyring /usr/share/keyrings/clickhouse-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 8919F6BD2B48D754 echo "deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg] https://packages.clickhouse.com/deb stable main" | sudo tee /etc/apt/sources.list.d/clickhouse.list sudo apt update sudo apt install -y clickhouse-server clickhouse-client sudo systemctl enable --now clickhouse-server clickhouse-client ``` 或 docker: ```bash docker run -d --name ch \ -p 8123:8123 -p 9000:9000 \ -v $(pwd)/ch-data:/var/lib/clickhouse \ clickhouse/clickhouse-server ``` ## 建表(关键:选对引擎 + 排序键) ```sql CREATE TABLE events ( event_date Date, event_time DateTime, user_id UInt64, type LowCardinality(String), country LowCardinality(String), amount Float64, payload String, ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, type, user_id) SETTINGS index_granularity = 8192; ``` 关键设计: - **ENGINE = MergeTree**:最常用的引擎,类似 LSM tree,写快读快 - **PARTITION BY**:按月分区,删旧数据 DROP PARTITION 即可 - **ORDER BY**("主键"):决定数据物理顺序 + 稀疏索引;按最常 WHERE 的列排序 - **LowCardinality(String)**:枚举类字段(status / country)的压缩 + 加速 ## 插入 ```sql INSERT INTO events VALUES ('2026-05-23', '2026-05-23 10:00:00', 42, 'login', 'CN', 0.0, ''), ('2026-05-23', '2026-05-23 10:01:00', 43, 'purchase', 'US', 99.50, '{...}'); ``` 或批量从 CSV: ```sql INSERT INTO events FROM INFILE 'events.csv.gz' FORMAT CSVWithNames; ``` 或从 JDBC / Python: ```python from clickhouse_driver import Client client = Client('localhost') client.execute( 'INSERT INTO events VALUES', [(d, t, uid, type, country, amt, payload) for ... in batch] ) ``` ClickHouse 偏好 **批量大插入**(每次 > 1000 行),单条 INSERT 性能差。 ## 聚合查询 ```sql -- 国家维度统计昨日金额 SELECT country, count() AS events, sum(amount) AS total, avg(amount) AS avg_amt FROM events WHERE event_date = today() - 1 GROUP BY country ORDER BY total DESC LIMIT 10; ``` 10 亿行 + 单机 16 cores:通常 < 1 秒。同样的查询在 PostgreSQL 几十秒到几分钟。 ```sql -- 时间序列:每小时按 type 的趋势 SELECT toStartOfHour(event_time) AS hour, type, count() FROM events WHERE event_date BETWEEN today() - 7 AND today() GROUP BY hour, type ORDER BY hour, type; ``` ## 函数:ClickHouse 100s 内置 - `uniq(col)` / `uniqExact(col)`:基数(HyperLogLog 估算 / 精确) - `quantile(0.95)(col)`:分位数 - `groupArray(col)` / `groupUniqArray(col)`:聚合成数组 - `topK(10)(col)`:Top N - `argMax(col, by)`:返回 by 最大时 col 的值 ```sql SELECT country, uniq(user_id) AS dau, quantile(0.95)(amount) AS p95, topK(3)(type) AS top_types FROM events WHERE event_date = today() GROUP BY country; ``` ## 物化视图(pre-aggregated) 百亿行表也希望仪表盘秒级响应?预聚合: ```sql CREATE MATERIALIZED VIEW events_daily_mv ENGINE = SummingMergeTree PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, country, type) AS SELECT event_date, country, type, count() AS events, sum(amount) AS total FROM events GROUP BY event_date, country, type; ``` 之后插 `events` 表的同时自动累加 daily 表。 仪表盘查 `events_daily_mv` 而不是原表,几毫秒返回。 ## 用 PostgreSQL 表 ```sql -- 从 PG 拉数据(postgres engine) CREATE TABLE pg_users ( id UInt64, name String, email String ) ENGINE = PostgreSQL( 'localhost:5432', 'mydb', 'users', 'user', 'pass' ); SELECT count() FROM pg_users; -- 实时去 PG 查 ``` 或者 ClickHouse 作为 PG 的 OLAP 副本(通过 Kafka / debezium 同步)。 ## 读 Parquet / S3 ```sql SELECT count() FROM s3('s3://bucket/data/*.parquet', 'access_key', 'secret_key', 'Parquet'); -- 不导入,直接查 S3 上的 Parquet ``` ```sql SELECT * FROM file('events.parquet', 'Parquet') LIMIT 10; ``` 让 ClickHouse 当"S3 上 SQL 查询引擎",类似 DuckDB / Athena。 ## 副本 + 集群 单机能跑 TB 级。继续增长: - **副本(Replica)**:高可用 + 读扩展 - **分片(Shard)**:水平扩容 ```xml <remote_servers> <my_cluster> <shard> <replica><host>ch01</host><port>9000</port></replica> <replica><host>ch02</host><port>9000</port></replica> </shard> <shard> <replica><host>ch03</host><port>9000</port></replica> <replica><host>ch04</host><port>9000</port></replica> </shard> </my_cluster> </remote_servers> ``` ```sql CREATE TABLE events_dist ON CLUSTER my_cluster AS events ENGINE = Distributed(my_cluster, default, events, rand()); ``` 查 `events_dist` 自动并行到所有 shard。 ## 性能 tip - **避免 SELECT ***:列存的核心优势——只读你要的列 - **WHERE 用 ORDER BY 的前缀列**:才能命中稀疏索引 - **批量 INSERT** > 1000 行/次 - **LowCardinality(String)** 给低基数字符串字段 - **`final` 修饰** 用在 ReplacingMergeTree 上,性能差,少用 ## 何时选 ClickHouse - 日志 / 事件 / metric > 1 亿行 - 聚合密集 / OLAP 类查询 - 不需要事务 不适合: - 事务 / 强一致(用 PG) - 频繁 UPDATE / DELETE(ClickHouse 这两个是异步 ALTER) - 小数据(< 1 GB)—— 杀鸡用牛刀 ## 工具 - **clickhouse-client**:CLI(很强) - **clickhouse-cli**(rust 版):交互体验更好 - **Tabix / DBeaver / Metabase / Superset**:可视化 - **Grafana** 内置 ClickHouse data source ## 踩过的坑 - 单条 INSERT 性能极差(百行/秒):批量插入百倍速度差距。 - ORDER BY 选错列:所有查询的 WHERE 都不能用上索引,全表扫。 按"最常被 WHERE filter 的列"排。 - UPDATE / DELETE:在 ClickHouse 是 `ALTER TABLE ... UPDATE`, 异步、慢、影响压缩。设计阶段尽量避免。 - 分区数 > 1000 性能下降。partition 颗粒度通常按月。

ZFS ARC 内存吃光的诊断 + 调优(家庭 NAS 16GB 内存够用)

## 起因 家用 NAS 16GB RAM,跑 ZFS + 几个 Docker 容器(Plex / Photoview / Syncthing)。`free -h` 显示 `used 15.2G`,容器频繁因 OOM 被重启。 但 `htop` 加起来所有进程才用 5GB。剩下 10GB 是谁吃的? 答案:ZFS ARC(Adaptive Replacement Cache,磁盘 cache)。 ZFS 默认抢半数物理内存做 cache,且不像 page cache 那样有 application 请求时立刻让出来。 ## 诊断 ### 看 ARC 实际占用 ```bash arc_summary | head -30 # 或: cat /proc/spl/kstat/zfs/arcstats | grep -E '^(c|c_max|c_min|size|hits|misses)' ``` 输出例: ``` size = 9.8 GB c = 10.4 GB c_max = 10.4 GB # arc 上限(默认 RAM 一半) c_min = 660 MB hits = 24351234 misses = 234567 # hit rate ~ 99% ``` `size` 是当前 ARC 占用。`c_max` 是上限——ZFS 不会让 ARC 超过这个值, 但会贪婪地用到这个值。 ### 看分类 ```bash arc_summary | grep -A 20 'ARC size' # ARC size (current): # MFU: 6.2 GB # MRU: 3.5 GB # Anon: 50 MB ``` MFU = most frequently used;MRU = most recently used。 ### `free` 看 ARC 在哪一栏 ```bash free -h # total used free shared buff/cache available # Mem: 15Gi 5.0Gi 0.2Gi 320Mi 9.8Gi 4.5Gi ``` ARC 主要算在 `used` 而非 `buff/cache`(与一般 page cache 不同)。 `available` 列才反映"应用真正可拿到多少内存"——这个 4.5GB 还行, 但 ZFS 释放 ARC 给 application 不是瞬间的,高内存压力时仍可能 OOM。 ## 解决方案 ### 1. 调小 ARC 上限 临时(重启失效): ```bash # 限到 4GB echo 4294967296 | sudo tee /sys/module/zfs/parameters/zfs_arc_max ``` 持久(modprobe 配置 + 重新生成 initramfs): ```bash echo 'options zfs zfs_arc_max=4294967296' | sudo tee /etc/modprobe.d/zfs.conf echo 'options zfs zfs_arc_min=1073741824' | sudo tee -a /etc/modprobe.d/zfs.conf sudo update-initramfs -u sudo reboot ``` `zfs_arc_max` 单位 byte。4GB = `4 * 1024^3 = 4294967296`。 ### 2. 设 ARC 收缩门槛(让 ARC 更快让位) ```bash # 系统 free memory < 256MB 时立刻收缩 ARC echo 268435456 | sudo tee /sys/module/zfs/parameters/zfs_arc_sys_free ``` 默认 ZFS 收缩很慢,对突发负载不友好。这条让 ARC 在内存紧张时更积极 释放。 ### 3. 实际数据评估"够不够" ```bash arc_summary | grep -A 5 'Cache hits' # Cache hits: # Total hits: 24.3M # Cache miss ratio: 1.0% ``` > 99% hit rate 说明现有 ARC 大小 OK,缩小一点不会明显影响性能。 < 90% 说明 ARC 缺,缩小会让磁盘 IO 增加。 ### 4. 应用专门的 prefetch 调优 ```bash # 预读(顺序读多 sample) echo 0 | sudo tee /sys/module/zfs/parameters/zfs_prefetch_disable # = 0 启用 prefetch;某些场景关掉省内存 # L2ARC:用 SSD 作二级缓存(家用通常没必要) sudo zpool add tank cache /dev/nvme0n1p3 ``` L2ARC 把"放不下 RAM 的 ARC 内容"扩展到 SSD。代价是 RAM 里要存 L2ARC 的元数据(约每 1GB L2ARC 占 25MB RAM)。 ### 5. 给容器 / 重要服务设 cgroup 内存保证 ```ini # /etc/systemd/system/docker.service.d/memory.conf [Service] MemoryHigh=10G MemoryLow=2G # 内存紧张时优先保住 docker 这 2GB ``` `MemoryLow` 是"低水位保护"——内核回收内存时不会减这个 cgroup 低于 2GB(除非全系统真没内存)。 ```bash sudo systemctl daemon-reload sudo systemctl restart docker ``` ## 效果 我家 NAS 调整后: - 设 `zfs_arc_max=4G`,ARC 从 10GB 缩到 4GB - 释放 6GB 给 Docker 容器,OOM 消失 - ZFS cache hit rate 从 99.2% → 96.5%(轻微下降) - 磁盘 IO 从 5 MB/s 平均 → 12 MB/s(更频繁回原盘读) - 但容器响应延迟稳定,体验明显改善 **结论**:家用 NAS 16GB / 32GB 内存,ARC 给 1/4 物理内存合适。 ZFS 默认 1/2 是为"纯 NAS"设计的,混合负载机器要调。 ## 何时不调 ARC 服务器是 dedicated ZFS file server(不跑别的)→ ARC 越大越好, hit rate 决定性能。 服务器跑 PostgreSQL / 任何强 I/O DB → DB 自己也想要 RAM 做 buffer pool。 两者抢内存。建议给 DB 60% RAM、ARC 30% RAM,剩 10% OS。 ## 看 cache 效果 ```bash # arcstat 实时 sudo apt install zfsutils-linux arcstat 5 # time read miss miss% dmis dm% pmis pm% mmis mm% arcsz c # 10:00 1.5K 2 0 2 0 0 0 1 0 4.0G 4.0G # ... # 第一列 read 是每秒读次数;miss% 越低越好 ``` ## 踩过的坑 1. **改 zfs.conf 后没 update-initramfs**:ZFS 模块在 initramfs 里 被加载,外面 conf 不生效。`update-initramfs -u` + reboot。 2. **`free` 误判**:很多人看到 ARC 占内存以为是 leak,去 kill 进程 也没用。`arc_summary` 才说清楚。 3. **L2ARC 放 HDD 上**:完全没意义,L2 比主磁盘还慢。L2ARC 必须 SSD 或 NVMe。 4. **ZIL(SLOG)vs L2ARC 混淆**:ZIL 是同步写日志加速(SSD 推荐), L2ARC 是读缓存扩展。不一样。 5. **swap 不要放 zvol 上**:ZFS 的写时复制 + 内存压力时需要 swap → 死锁。如果用 ZFS root,swap 单独建普通分区。

Framer Motion:React 里几行写出生产级动画

CSS transition / keyframes 能解决简单动画,复杂的(列表重排、组件进入/离开、 gesture、SVG morph)就力不从心。Framer Motion 是 React 生态的事实标准动画库, API 简洁,自动 GPU 加速。 ## 安装 ```bash npm i framer-motion ``` ## 1. 最简单的进入动画 ```tsx import { motion } from 'framer-motion' <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} > Hello </motion.div> ``` 任何 HTML 元素加 `motion.` 前缀即可。 ## 2. 进入 + 离开(AnimatePresence) ```tsx import { motion, AnimatePresence } from 'framer-motion' <AnimatePresence> {visible && ( <motion.div key="modal" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} > Modal </motion.div> )} </AnimatePresence> ``` `AnimatePresence` 在子元素被 React 卸载时延迟卸载,让 `exit` 动画完成。 必须给每个直接子元素显式 `key`。 ## 3. 列表动画(重排 / 添加 / 删除) ```tsx <AnimatePresence> {items.map(item => ( <motion.li key={item.id} layout // 自动 FLIP 动画 initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} > {item.text} </motion.li> ))} </AnimatePresence> ``` `layout` 让元素位置变化时自动 morph(FLIP 算法)。 增删 / 排序列表时无需手动算位移。 ## 4. variants:复用动画状态 ```tsx const variants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 }, } <motion.div variants={variants} initial="hidden" animate="visible"> ... </motion.div> // 父子联动 const parent = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } }, } const child = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 }, } <motion.ul variants={parent} initial="hidden" animate="visible"> {items.map(i => <motion.li key={i.id} variants={child}>{i.text}</motion.li>)} </motion.ul> ``` `staggerChildren: 0.1` 让子元素一个接一个延迟 0.1s 进入,列表波浪效果。 ## 5. 手势:hover / tap / drag ```tsx <motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > Click </motion.button> <motion.div drag dragConstraints={{ left: -100, right: 100, top: 0, bottom: 0 }} dragElastic={0.5} whileDrag={{ scale: 1.1 }} > 拖我 </motion.div> ``` `drag` 自动处理触摸 / 鼠标拖动。`dragConstraints` 限制范围。 ## 6. transition 选项 ```tsx <motion.div animate={{ x: 100 }} transition={{ duration: 0.5, ease: 'easeOut', // 'linear' | 'easeIn' | 'easeInOut' | 'circIn' | ... // 或弹簧物理 type: 'spring', stiffness: 100, damping: 10, }} /> ``` 弹簧(spring)参数比 duration 更自然,是 Framer Motion 默认。 ## 7. scroll-triggered 动画 ```tsx import { motion, useScroll, useTransform } from 'framer-motion' function Hero() { const { scrollYProgress } = useScroll() const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]) const y = useTransform(scrollYProgress, [0, 1], [0, -200]) return <motion.h1 style={{ opacity, y }}>Title</motion.h1> } ``` `useTransform` 把 0-1 的 scrollYProgress 映射到任意值范围,做视差效果。 ## 8. layout 高级:跨组件共享 ```tsx import { motion } from 'framer-motion' // 小卡片 <motion.div layoutId="card-1" onClick={open}> <img src="thumb.jpg" /> </motion.div> // 弹出全屏 {open && ( <motion.div layoutId="card-1" className="fullscreen"> <img src="full.jpg" /> </motion.div> )} ``` 两个不同位置的元素 `layoutId` 相同 → Framer 自动 morph 从一个位置到另一个。 "卡片展开"效果一行写完。 ## 9. SVG morph ```tsx <motion.path d={pathData} initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 2 }} /> ``` `pathLength: 0 → 1` 让 SVG 路径"画出来"效果。 ## 10. 性能注意 Framer Motion 默认用 transform / opacity(GPU 友好)。不要动 width / height / top / left(CPU 重排)。 ```tsx // ❌ width 变化触发 layout <motion.div animate={{ width: 200 }} /> // ✅ transform scale <motion.div animate={{ scaleX: 2 }} /> ``` ## 11. 与 Tailwind / CSS-in-JS `motion.div` 接受所有 HTML props,class / style 正常写: ```tsx <motion.div className="bg-blue-500 rounded p-4" whileHover={{ scale: 1.05 }} /> ``` ## 12. 替代方案 - **react-spring**:物理为主,API 不同 - **gsap**:传统 JS 动画,强大但非 React-first - **Motion One**:framer-motion 作者的"无 React 依赖"版 React 项目就用 Framer Motion,最省事。 ## 踩过的坑 - AnimatePresence 子元素必须有 `key`:忘了 key 看不到 exit 动画。 - 直接给 `<div>` 加 motion 属性不工作:必须用 `motion.div`。 - 大量元素同时 layout 动画 → 卡。`layout` 只给真正需要 morph 的元素加。 - prefers-reduced-motion:尊重用户系统设置,给敏感动画加: ```tsx const reduce = useReducedMotion() <motion.div animate={{ x: reduce ? 0 : 100 }} /> ```

写一个自己的 Vite plugin:build 时自动生成 sitemap.xml

## 起因 每次 `npm run build` 后要手动跑 `node generate-sitemap.js` 生成 sitemap.xml 然后放进 dist/。容易忘 → 部署的 sitemap 是过期的。 把它做成 Vite plugin,build 时自动跑。学一次 plugin 编写 = 解锁 "任意 build-time 自动化"。 ## Vite plugin 基础 Vite plugin 是基于 Rollup plugin API + Vite 扩展。最小例子: ```ts // vite-plugin-hello.ts import type { Plugin } from 'vite' export default function helloPlugin(): Plugin { return { name: 'hello', buildStart() { console.log('build started') }, closeBundle() { console.log('build finished') }, } } ``` 用: ```ts // vite.config.ts import { defineConfig } from 'vite' import hello from './vite-plugin-hello' export default defineConfig({ plugins: [hello()], }) ``` `buildStart` / `closeBundle` 等是 Rollup hook。Vite 加了一些自己的 (`configResolved` / `transformIndexHtml` 等)。 ## 实战:自动生成 sitemap 需求:build 后扫描 `src/routes/**/*` 提取所有路由 → 生成 `dist/sitemap.xml`。 ```ts // vite-plugin-sitemap.ts import type { Plugin } from 'vite' import { glob } from 'glob' import path from 'node:path' import fs from 'node:fs/promises' interface Options { baseUrl: string outputDir?: string routesGlob?: string } export default function sitemapPlugin(opts: Options): Plugin { return { name: 'sitemap', apply: 'build', // 只在 build 时跑(dev 不需要) async closeBundle() { const outDir = opts.outputDir ?? 'dist' const routes = await scanRoutes(opts.routesGlob ?? 'src/routes/**/*.{tsx,ts}') const xml = generateXml(opts.baseUrl, routes) const outPath = path.resolve(outDir, 'sitemap.xml') await fs.writeFile(outPath, xml, 'utf-8') console.log(`[sitemap] wrote ${routes.length} URLs to ${outPath}`) }, } } async function scanRoutes(globPattern: string): Promise<string[]> { const files = await glob(globPattern) return files.map(file => { // src/routes/about.tsx → /about // src/routes/blog/[slug].tsx → /blog/:slug (skip dynamic) let p = file.replace(/^src\/routes/, '').replace(/\.(tsx|ts)$/, '') if (p.endsWith('/index')) p = p.replace(/\/index$/, '/') if (p.includes('[')) return null // dynamic route skip return p || '/' }).filter(Boolean) as string[] } function generateXml(baseUrl: string, urls: string[]): string { const now = new Date().toISOString() const urlEntries = urls.map(u => ` <url> <loc>${baseUrl}${u}</loc> <lastmod>${now}</lastmod> <changefreq>weekly</changefreq> </url>`).join('') return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urlEntries} </urlset>` } ``` 用: ```ts import sitemap from './vite-plugin-sitemap' export default defineConfig({ plugins: [ sitemap({ baseUrl: 'https://example.com' }), ], }) ``` `npm run build` 完自动看到 `dist/sitemap.xml`。 ## 关键 Vite hooks ### apply ```ts apply: 'build' // 只 build 时跑 apply: 'serve' // 只 dev server 跑 // 默认两者都跑 ``` ### configResolved ```ts configResolved(config) { console.log('mode:', config.mode) console.log('outDir:', config.build.outDir) } ``` 读完整 config 后调。常用于读 outDir / base / 模式自适应。 ### transformIndexHtml ```ts transformIndexHtml: { order: 'pre', handler(html, ctx) { return html.replace('<head>', `<head>\n <link rel="prefetch" href="/critical.js">`) }, } ``` 修改 `index.html` 模板。常用于 inject 第三方 script / meta tag。 ### transform(修改 source 文件) ```ts transform(code, id) { if (id.endsWith('.md')) { return { code: `export default ${JSON.stringify(parseMarkdown(code))}`, map: null, } } } ``` 把 `.md` 文件转成 JS module。`?raw` `?url` 等 query 处理也在这里。 ### load / resolveId ```ts resolveId(id) { if (id === 'virtual:my-module') return id } load(id) { if (id === 'virtual:my-module') { return `export const value = 42` } } ``` 虚拟模块:`import { value } from 'virtual:my-module'`。 不存在的文件但 plugin 生成内容。 ### configureServer (dev only) ```ts configureServer(server) { server.middlewares.use('/api/_health', (req, res) => { res.end('ok') }) } ``` dev server 加 middleware。例如 mock API endpoint。 ### handleHotUpdate ```ts handleHotUpdate(ctx) { if (ctx.file.endsWith('.md')) { ctx.server.ws.send({ type: 'full-reload' }) return [] } } ``` 自定义 HMR 行为。`.md` 改了让浏览器 full reload。 ## 更复杂:内容 transformer 把所有 `.svg` import 转成 React component: ```ts import { transform } from 'esbuild' export default function svgrPlugin(): Plugin { return { name: 'svgr-light', async transform(code, id) { if (!id.endsWith('.svg')) return const svg = await fs.readFile(id, 'utf-8') const component = ` import React from 'react' export default function Svg(props) { return ${svg.replace('<svg', '<svg {...props}')} } ` const result = await transform(component, { loader: 'jsx' }) return result.code }, } } ``` ```tsx import Logo from './logo.svg' <Logo width="40" /> ``` 实际生产用 `@svgr/rollup` plugin 即可,自己写说明原理。 ## generateBundle ```ts generateBundle(opts, bundle) { // 修改最终 bundle 中的 chunk / asset bundle['manifest.json'] = { type: 'asset', fileName: 'manifest.json', source: JSON.stringify({ ... }), } } ``` 往 `dist/` 加额外文件(不一定是 sitemap,可以是 license 文件 / manifest / 报告)。 ## Plugin 开发流程 ```bash mkdir vite-plugin-mine && cd vite-plugin-mine npm init -y npm i -D vite typescript # src/index.ts # tests/ npm publish ``` 发到 npm 任何人能用。 或者 monorepo 里 `packages/vite-plugin-mine` 本地引用。 ## 注意 ### Plugin 顺序 ```ts plugins: [ pluginA(), // 先跑 pluginB(), pluginC(), // 后跑 ] ``` 某些 hook(transform)按顺序串行。明确 plugin 间依赖。 `order: 'pre'` / `'post'` 调整单 hook 顺序: ```ts { name: 'mine', transform: { order: 'pre', handler(code, id) { ... } } } ``` ### 性能 每个文件 transform 都跑你的 plugin → 大项目 build 慢。 filter 早 return: ```ts transform(code, id) { if (!id.endsWith('.special')) return // ... } ``` 或者更优雅用 `filter`: ```ts transform: { filter: { id: /\.special$/ }, handler(code, id) { ... } } ``` ### dev vs build 某些 hook 只在 build 跑(generateBundle / writeBundle / closeBundle)。 dev 模式没 bundle 概念。 要 dev 也响应文件改动用 `configureServer` + watcher。 ## 实战 plugin 库参考 读 source 学到更多: - `@vitejs/plugin-react`:JSX transform + HMR - `vite-plugin-pwa`:service worker 注入 - `vite-plugin-svgr`:SVG → React component - `unocss/vite`:原子 CSS transform 都是几百-千行代码,看完心里有数怎么写复杂 plugin。 ## 我们的实际 plugin 例子 ```ts // 收集 build 时所有 import "use server" 函数 → 生成 server actions 清单 export default function serverActionsPlugin(): Plugin { const actions: string[] = [] return { name: 'collect-server-actions', transform(code, id) { if (code.includes("'use server'")) { actions.push(id) } }, generateBundle() { this.emitFile({ type: 'asset', fileName: 'server-actions.json', source: JSON.stringify(actions, null, 2), }) }, } } ``` build 后 `dist/server-actions.json` 给后端读 → 注册 RPC 端点。 ## 踩过的坑 1. **`transform` 返回 string 不带 source map**:sourcemap 丢失 → 调试 时 stack trace 指向编译后代码。永远 return `{ code, map }`。 2. **path.resolve 在 Windows 反斜杠**:跨 OS 用 `path.posix` 或 `path.normalize`。 3. **hook 异步未 await**:plugin 完成前 build 已经结束 → 文件没生成。 `async` hook 必须 await 完。 4. **dev 模式 plugin 改动 cache 没失效**:vite hot reload 时改 plugin 要重启 dev server。或者 `--force` 让 vite 不用 cache。 5. **plugin 间冲突**:A 改 .md 转 JS,B 也改 .md 加 frontmatter parser, 顺序错就乱。明确 order + 测试。

Redis 高可用:sentinel vs cluster 怎么选

## 起因 Redis 单实例够用阶段过了: - 数据量逼近单机 RAM - 单点故障 = 整服务挂 - 写入 / QPS 接近单机极限 两个官方 HA 方案: - **Sentinel**:master-replica + 自动 failover(数据仍单 master) - **Cluster**:sharded,多 master,自动分片 + failover 下面对比哪种适合哪种场景。 ## Sentinel ``` [client] ↓ (asks sentinel) [sentinel × 3] → 监控 master / replica ↓ [master] ←── async replication ──→ [replica × 2] ``` - 1 master 服务读写 - N replica 异步复制 - 3+ sentinel 监控,master 挂自动选 replica 升 master ### 部署 `sentinel.conf`: ``` port 26379 sentinel monitor mymaster 127.0.0.1 6379 2 # 2 票 = 多数 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 60000 sentinel parallel-syncs mymaster 1 ``` 3 个 sentinel 跑在不同机器 → 任意 2 个同意才能 failover。 ### 客户端连接 ```python from redis.sentinel import Sentinel sentinel = Sentinel([ ('sentinel1', 26379), ('sentinel2', 26379), ('sentinel3', 26379), ]) master = sentinel.master_for('mymaster') replica = sentinel.slave_for('mymaster') master.set('key', 'val') # 写 replica.get('key') # 读(可能略 stale) ``` client 问 sentinel 当前 master 是谁 → 直连。 failover 时 sentinel 推新 master → client 重连。 ### 优势 - 简单(仍是单 master 模型) - 数据完整性强(无分片复杂性) - 支持所有 Redis command(包括 MULTI/EXEC、Lua、cluster 不支持的) ### 劣势 - 单 master 写 QPS 上限(10w/s 量级) - 单机 RAM 上限(200 GB 算极限) - failover 期间(10-60s)短暂不可写 ## Cluster ``` [client] ↓ (knows slot → node mapping) [node1: slot 0-5460] ←→ [node2: slot 5461-10922] ←→ [node3: slot 10923-16383] ↓ ↓ ↓ [replica1] [replica2] [replica3] ``` - 16384 个 hash slot 分配到 master 节点 - key 哈希到 slot → slot 在哪个节点 - 每 master 自带 replica 做 failover - 无 sentinel(cluster 节点之间 gossip) ### 部署 需要至少 3 master + 3 replica = 6 节点: ```bash redis-cli --cluster create \ 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \ 127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \ --cluster-replicas 1 ``` `redis.conf`: ``` cluster-enabled yes cluster-config-file nodes.conf cluster-node-timeout 5000 appendonly yes ``` ### 客户端 ```python from redis.cluster import RedisCluster rc = RedisCluster(host='node1', port=7001, decode_responses=True) rc.set('key', 'val') rc.get('key') ``` client 知道 slot 映射 → 直接连到对应节点(无 sentinel proxy)。 MOVED redirect 让 client 学新映射。 ### 优势 - 水平扩展:6 节点撑 60w QPS,3 TB RAM - 自动 sharding(hash slot) - failover 同样自动 ### 劣势 - multi-key 操作受限(必须同 slot,hash tag `{...}`) - pub/sub 不跨节点(cluster 模式下 pub/sub 是 broadcast 到所有节点) - 客户端要支持 cluster protocol - 运维复杂(add/remove node 时 reshard) ## 关键区别 | | Sentinel | Cluster | |---|---|---| | 数据分片 | 否(全在 master) | 是(16384 slot) | | 容量上限 | 单机 RAM | 累加 | | 写 QPS 上限 | 单 master | 累加 | | 节点数 | 1m + N replica + 3 sentinel | 3+ master + 3+ replica | | failover 控制 | sentinel 投票 | gossip + 选举 | | multi-key | 完全支持 | 必须同 slot (`{tag}`) | | pub/sub | 正常 | 节点间不传 | | 复杂度 | 中 | 高 | ## 怎么选 - **数据 < 50 GB + 中等 QPS(< 50k)** → Sentinel - **数据 > 100 GB + 高 QPS** → Cluster - **多 key transaction / Lua 复杂** → Sentinel - **缓存场景(多数 key 独立)** → Cluster - **AWS / GCP / managed**:用 ElastiCache / MemoryStore(背后就是这两个) 我们的实际:90% 项目 Sentinel 够。极少几个超大缓存项目用 Cluster。 ## hash tag (cluster multi-key) cluster 默认每 key hash 到不同 slot → MGET/MSET 跨 slot 失败。 `{...}` 强制 key 同 slot: ```python rc.set('{user:42}:profile', '...') rc.set('{user:42}:settings', '...') rc.mget(['{user:42}:profile', '{user:42}:settings']) # OK,同 slot ``` `{user:42}` 部分用来 hash。所有 `{user:42}:*` 在同节点。 设计 key 时考虑:相关 key 用同 tag → 减少跨节点操作。 ## failover 实测 Sentinel:master kill → 5-10 秒 detect + 选新 master + 客户端重连。 期间写失败 + 读 replica 可用。 Cluster:master kill → 类似 5-15 秒 detect + replica 提升。 该 slot 短暂不可写,其它 slot 正常。 两者都不是"零停机",但都是"短暂不可写"级别。 ## ProxySQL-like proxy? Redis 没像 ProxySQL 那么常见的官方 proxy。 第三方: - **twemproxy**(Twitter,老) - **codis**(豌豆荚,老) - **predixy**(Cluster proxy,仍维护) 加 proxy 让 client 不需要懂 sentinel/cluster,但多一跳 + 单点。 非必要不引入。 ## 监控 key 指标: - `connected_clients` - `used_memory` / `maxmemory` - `instantaneous_ops_per_sec` - `evicted_keys`(开 maxmemory-policy 时) - `master_link_status`(replica) - `cluster_state`(cluster) Prometheus redis_exporter 一行装。 ## 持久化 `appendonly yes` + `appendfsync everysec` → 最多丢 1 秒数据。 `save 900 1` RDB 备份基础。 混合(aof + rdb)是默认推荐。 HA 也不替代持久化(脑裂 / 整集群挂)。 ## 踩过的坑 1. **Sentinel quorum 配错**:2 sentinel + quorum 2 → 任意 sentinel 挂 → 没法 failover。最少 3 sentinel。 2. **cluster reshard 慢**:迁移大 slot 几小时。期间客户端 MOVED redirect 多 → latency 抖。计划好低峰期。 3. **client 不支持 cluster**:老版本 jedis / lettuce / redis-py 没 cluster 支持。升级 client。 4. **pub/sub in cluster**:消息只在该 channel 所在节点发布。redis 7+ 引入 sharded pub/sub `SPUBLISH` 改善但兼容性问题。 5. **跨 DC**:Redis 不为跨数据中心同步设计。延迟 > 几十 ms 时 replica lag 严重。跨 DC 用应用层方案(Kafka mirror / 应用双写)。

tmux 配置 prefix + 鼠标 + 状态栏(终端复用的最小可用版)

tmux 让你在一个终端窗口里挂多个 session / window / pane,断开 SSH 后 进程继续跑。任何远程开发都该用 tmux 或 screen。 ## 安装 ```bash # Debian / Ubuntu sudo apt install -y tmux # macOS brew install tmux tmux -V # 确认 >= 3.0 ``` ## 最小配置 `~/.tmux.conf` ```bash # prefix 从 Ctrl-B 改成 Ctrl-A(更顺手;和 GNU readline 冲突的时候 set-key -r 解决) unbind C-b set -g prefix C-a bind C-a send-prefix # 256 色 + 真彩 set -g default-terminal 'tmux-256color' set -ga terminal-overrides ',xterm-256color:Tc' # 鼠标(点击切 pane、滚轮翻历史、拖边界改大小) set -g mouse on # 窗口编号从 1 开始(数字键近一点) set -g base-index 1 set -g pane-base-index 1 set -g renumber-windows on # 历史滚动行数 set -g history-limit 100000 # 切 pane 用 vim 风格 hjkl bind h select-pane -L bind j select-pane -D bind k select-pane -U bind l select-pane -R # 切窗口用 Alt-数字 bind -n M-1 select-window -t :1 bind -n M-2 select-window -t :2 bind -n M-3 select-window -t :3 bind -n M-4 select-window -t :4 bind -n M-5 select-window -t :5 # split 用 | 和 -(更直观) bind | split-window -h -c "#{pane_current_path}" bind - split-window -v -c "#{pane_current_path}" unbind '"' unbind % # 重载配置 bind r source-file ~/.tmux.conf \; display "Config reloaded" # 拷贝模式用 vi 键位 setw -g mode-keys vi bind -T copy-mode-vi v send-keys -X begin-selection bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'xclip -in -selection clipboard' # escape 时延(vim 模式必备,否则 ESC 后等半秒才响应) set -sg escape-time 10 # 状态栏:左边 session 名,右边时间 + 主机 set -g status-style 'bg=#2b2d3a fg=#cdd6f4' set -g status-left '#[fg=#a6e3a1,bold] #S #[default]' set -g status-right '#[fg=#89b4fa]%H:%M #[fg=#fab387]%Y-%m-%d #[fg=#cba6f7]#h ' set -g status-left-length 30 set -g status-right-length 50 # 当前窗口高亮 setw -g window-status-current-style 'fg=#1e1e2e bg=#a6e3a1 bold' setw -g window-status-current-format ' #I:#W ' setw -g window-status-format ' #I:#W ' ``` ## 日常用法 | 按键 | 动作 | |---|---| | `tmux` | 新建一个 session | | `tmux new -s mywork` | 新建命名 session | | `tmux ls` | 列所有 session | | `tmux a -t mywork` | attach 到指定 session | | **Prefix + c** | 新 window | | **Prefix + ,** | 重命名当前 window | | **Prefix + n / p** | 下一个 / 上一个 window | | **Prefix + 1..9** | 跳到第 N 个 window | | **Prefix + w** | 列 window(可选) | | **Prefix + \|** | 横分 pane(按配置) | | **Prefix + -** | 纵分 pane | | **Prefix + hjkl** | 切 pane | | **Prefix + x** | 关掉当前 pane | | **Prefix + d** | detach(session 继续跑) | | **Prefix + [** | 进入滚动 / copy 模式 | | **q** | 退出 copy 模式 | | **Prefix + ?** | 当前所有快捷键 | ## SSH + tmux 远程机器: ```bash ssh server tmux new -s work # 干活... # Ctrl-a d (detach),断开 SSH ``` 下次: ```bash ssh server tmux a -t work # 之前的所有进程还在 ``` 更进一步——SSH 自动 attach: ```bash # ~/.bashrc on server if [[ -z "$TMUX" && -n "$SSH_TTY" ]]; then tmux a -t main 2>/dev/null || tmux new -s main fi ``` ## tmux + 项目 每个项目一个 session,每个 session 多个 window(如 "vim"、"shell"、"logs"): ```bash tmux new -s myproj -d # 后台建 tmux rename-window -t myproj:0 'vim' tmux send-keys -t myproj:0 'nvim .' Enter tmux new-window -t myproj:1 -n 'shell' tmux new-window -t myproj:2 -n 'logs' tmux send-keys -t myproj:2 'tail -f log/app.log' Enter tmux a -t myproj ``` 这套脚本化的 session 启动用 `tmuxinator` / `tmuxp` 包管,写一份 YAML 配 所有 project layout。 ## 拷贝粘贴 进入 copy 模式(Prefix + `[`),用 vi 键位 hjkl 移光标,`v` 开始选择, `y` 拷贝。拷贝目标是 X11 clipboard(macOS 上 `pbcopy`、Wayland 上 `wl-copy`): ```bash # Wayland 版本: bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'wl-copy' # macOS 版本: bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'pbcopy' ``` OSC 52 escape sequence 让 tmux 通过终端把内容传到本地剪贴板, 不需要 X11 forward: ```bash set -g set-clipboard on ``` iTerm2 / Alacritty / WezTerm / kitty / 现代 Windows Terminal 都支持。 ## 重命名 / 整理 ```bash tmux rename-session -t old new tmux move-window -s myproj:3 -t myproj:1 tmux kill-session -t old tmux kill-window -t myproj:3 ``` ## 踩过的坑 - 修改 `.tmux.conf` 后没 reload:`tmux source-file ~/.tmux.conf` 或者 prefix-r(如果配置了)。 - 配置中颜色不显示:终端不支持真彩色(macOS 自带 Terminal); 换 iTerm2 / Alacritty / WezTerm。 - `escape-time` 不调,vim 里按 ESC 进 normal 模式有半秒延迟, 极其难用。设 10ms。 - pane 拖动会触发 mouse;想用鼠标选文字复制到本地反而困难。 按住 Shift 拖能临时绕过 tmux 的 mouse capture,让终端模拟器原生处理。

写一个真正有用的 /healthz 和 /readyz(不是返回 200 那么简单)

K8s / 反代 / 监控系统都会查应用的健康状态。很多人把它们写成 `return {"ok": true}` 然后觉得搞定了——这种 endpoint 没区分**进程活着** 和**真的能服务请求**,到时候监控告警和实际故障对不上。 正确做法是分两个端点: - `/healthz` (liveness):**进程是否活着**。失败 → 重启容器 - `/readyz` (readiness):**能否接收新请求**。失败 → 从 LB 后端摘掉但不重启 ## liveness:尽量薄 ```python @app.get('/healthz') def liveness(): return {'status': 'alive'} ``` 就这么薄。原则:**不能查任何外部依赖**。因为: - DB 暂时不通 → 不应该重启 Web 进程 - Redis 慢 → 重启不能解决 - liveness 失败的语义是"进程已经损坏,没法自愈",只有 OOM / 死循环 / panic 这种才该 fail 加点点缀(确认 process 没死锁): ```python import time @app.get('/healthz') def liveness(): # 检查事件循环 / 主线程没卡住 return {'status': 'alive', 'ts': time.time()} ``` ## readiness:检查所有 hard dependency ```python import asyncio from sqlalchemy import text @app.get('/readyz') async def readiness(): checks = {} overall_ok = True # DB try: async with db_session() as s: await asyncio.wait_for( s.execute(text('SELECT 1')), timeout=2.0) checks['db'] = 'ok' except Exception as e: checks['db'] = f'fail: {e!r}' overall_ok = False # Redis try: await asyncio.wait_for(redis.ping(), timeout=1.0) checks['redis'] = 'ok' except Exception as e: checks['redis'] = f'fail: {e!r}' overall_ok = False # 关键外部 API(可选)—— 通常 readiness 不查第三方 API, # 因为他们挂了你也没办法 # checks['stripe'] = ... status = 200 if overall_ok else 503 return JSONResponse( status_code=status, content={'ok': overall_ok, 'checks': checks}, ) ``` 注意: - **wait_for + 超时**:依赖卡死时 readiness 自己别卡死 - **失败返回 503**,K8s 才会把这个 pod 从 service endpoints 里摘掉 - **同时返回详情**:人工排查时一眼看见哪个依赖挂了 ## startup probe(K8s 1.16+) 应用启动慢的(如加载大模型),需要第三种 probe:startup。 启动期间 readiness 还没就绪也别立刻杀,给它时间: ```yaml # K8s 部署 YAML 示例 livenessProbe: httpGet: { path: /healthz, port: 8000 } periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: { path: /readyz, port: 8000 } periodSeconds: 5 failureThreshold: 2 startupProbe: httpGet: { path: /readyz, port: 8000 } periodSeconds: 5 failureThreshold: 60 # 给 60 * 5 = 300 秒启动时间 ``` `startupProbe` 没通过前 liveness / readiness 都不算。通过后切到正常 probe。 ## 不要写成的反模式 ```python # 错: 把 health 和 ready 写一起 @app.get('/health') def health(): db_ok = db.check() return {'ok': db_ok} # 问题:DB 抖一下整个进程被重启 → 雪崩 ``` ```python # 错: liveness 检查外部依赖 @app.get('/healthz') def liveness(): requests.get('https://api.example.com/ping', timeout=5) return {'ok': True} # 问题:第三方 API 慢 → liveness 慢 → K8s 觉得进程挂了 → 反复重启 ``` ```python # 错: 不区分 ok / 503 @app.get('/readyz') def ready(): return {'db': 'fail'} # status=200! LB 仍认为这个 instance 健康 ``` ## 给 readiness 加"我自己降级中"标志 有时候你想主动让某 pod 不接新请求(比如准备 deploy / drain): ```python ready_flag = True @app.get('/readyz') def readiness(): if not ready_flag: return JSONResponse(503, {'ok': False, 'reason': 'draining'}) return ... @app.post('/admin/drain') def drain(): global ready_flag ready_flag = False return {'ok': True, 'state': 'draining'} ``` 收到 SIGTERM 时先把 `ready_flag=False`、等 LB 摘掉、再退出: ```python import signal, asyncio async def graceful_shutdown(): global ready_flag ready_flag = False await asyncio.sleep(15) # 等 LB 注意到 # 然后退出 sys.exit(0) signal.signal(signal.SIGTERM, lambda *_: asyncio.create_task(graceful_shutdown())) ``` ## Metric 一起暴露 ```python ready_counter = Counter('readyz_total', 'readyz checks', ['result']) @app.get('/readyz') def readiness(): result = 'ok' if all_ok else 'fail' ready_counter.labels(result=result).inc() ... ``` Prometheus 上能看 readiness 通过率随时间变化。 ## 踩过的坑 - 用 `requests` 同步查依赖 → 阻塞事件循环 → readiness 用了几秒, 健康的 pod 也被错杀。所有依赖检查必须超时 + async。 - 检查 DB 用 `SELECT 1` 是基本健康但不能验证可写。如果你的服务必须能写, 检查 `SELECT 1` 同时 `INSERT ... ON CONFLICT DO NOTHING` 一条特殊行。 - 把 `/healthz` 和 `/readyz` 暴露在公网:让攻击者用慢请求 DoS 你的检查 端点。挂内网,或者加简单 IP 白名单。 - K8s 没配 `terminationGracePeriodSeconds` → SIGTERM 后 30 秒就 SIGKILL, graceful shutdown 没时间完成。把这个值调到至少 60 秒。

让网页可"添加到主屏幕":PWA manifest + 离线支持的最小路径

## 起因 做了一个工具站,用户反馈"每次都要打开浏览器 → 输 URL,能不能像 app 一样固定在桌面?" PWA(Progressive Web App)让任何网站能被 "添加到主屏幕"作为独立 app 启动,而不需要写 React Native / Flutter。 满足 PWA 安装条件: 1. HTTPS(localhost 例外) 2. valid `manifest.webmanifest` 3. service worker(至少注册) 4. 192×192 + 512×512 PNG icon ## 最小步骤 ### 1. icons 用 [Real Favicon Generator](https://realfavicongenerator.net/) 或者 imagemagick 自己生成: ```bash convert source.png -resize 192x192 icon-192.png convert source.png -resize 512x512 icon-512.png convert source.png -resize 192x192 -background none -gravity center \ -extent 192x192 icon-192-maskable.png # maskable 留 padding ``` 放 `public/icons/`。 ### 2. manifest.webmanifest `public/manifest.webmanifest`: ```json { "name": "My Tool App", "short_name": "MyTool", "description": "在线小工具", "start_url": "/?source=pwa", "display": "standalone", "orientation": "portrait", "theme_color": "#2563eb", "background_color": "#ffffff", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" } ], "shortcuts": [ { "name": "新建", "url": "/new", "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }] } ] } ``` 要点: - `display: standalone` 让安装后启动是无浏览器 UI 的独立窗口 - `start_url: /?source=pwa` 加 source 参数便于分析 PWA 启动量 - `maskable` icon:Android 自适应图标,让系统裁圆角不丢内容 - `shortcuts`:长按 app 图标显示快捷菜单(如"新建文档") ### 3. 链接到 HTML ```html <head> <link rel="manifest" href="/manifest.webmanifest"> <meta name="theme-color" content="#2563eb"> <link rel="apple-touch-icon" href="/icons/icon-192.png"> <!-- iOS --> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="MyTool"> </head> ``` iOS Safari 不完全遵循 web manifest,需要 `apple-*` meta 兼容。 ### 4. Service Worker(让"可安装"条件满足) 最简版: ```js // public/sw.js self.addEventListener('install', e => { self.skipWaiting() }) self.addEventListener('activate', e => { e.waitUntil(self.clients.claim()) }) self.addEventListener('fetch', e => { // 不做任何拦截,纯透传 }) ``` ```js // main.js if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') }) } ``` 这就够 Chrome 把"安装"图标显示在地址栏。 ### 5. 加离线兜底页(推荐) ```js // public/sw.js const CACHE = 'v1' const OFFLINE_URL = '/offline.html' self.addEventListener('install', e => { e.waitUntil( caches.open(CACHE).then(c => c.addAll(['/', OFFLINE_URL, '/icons/icon-192.png'])) ) self.skipWaiting() }) self.addEventListener('activate', e => { e.waitUntil(self.clients.claim()) }) self.addEventListener('fetch', e => { if (e.request.mode === 'navigate') { e.respondWith( fetch(e.request).catch(() => caches.match(OFFLINE_URL)) ) } }) ``` `public/offline.html`: ```html <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>离线</title></head> <body> <h1>当前没有网络</h1> <p>请检查网络连接后重试。</p> </body> </html> ``` 断网时用户打开 PWA 看到"离线"页而不是浏览器报 ERR_INTERNET_DISCONNECTED。 ### 6. 自定义安装按钮 ```js let deferredPrompt = null window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault() deferredPrompt = e document.getElementById('install-btn').style.display = 'block' }) document.getElementById('install-btn').addEventListener('click', async () => { if (!deferredPrompt) return deferredPrompt.prompt() const { outcome } = await deferredPrompt.userChoice console.log('user choice:', outcome) deferredPrompt = null document.getElementById('install-btn').style.display = 'none' }) ``` 控制安装提示的时机(如用户用了 5 分钟后才显示),比浏览器默认弹的 体验好。 ## 配合 Vite ```bash npm i -D vite-plugin-pwa ``` ```ts // vite.config.ts import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ plugins: [ VitePWA({ registerType: 'autoUpdate', manifest: { name: 'My Tool', short_name: 'MyTool', theme_color: '#2563eb', icons: [/* ... */], }, workbox: { globPatterns: ['**/*.{js,css,html,png,svg,woff2}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\./, handler: 'NetworkFirst', options: { cacheName: 'api', expiration: { maxAgeSeconds: 60*60*24 }}, }, ], }, }), ], }) ``` vite-plugin-pwa 用 Workbox 自动生成 manifest + service worker, 含 precache / runtime cache / 更新策略。 ## 效果 - 用户打开后 Chrome 地址栏显示 ⊕ 安装按钮 - 安装后桌面 / 应用列表里有 app icon - 启动 app 是独立窗口(无浏览器地址栏 / 标签) - 断网时显示"离线页"而不是浏览器错误 - iOS / Android / 桌面 Chrome / Edge 都支持 - 不需要 App Store 审核 ## 验证 Chrome DevTools → Application → Manifest: - 看 manifest 是否被正确解析 - icon 预览 - "Installability" 一栏告诉你不满足哪些条件 Lighthouse → PWA 类别也可以跑评分。 ## 推送通知(可选) ```js // 申请权限 const perm = await Notification.requestPermission() if (perm === 'granted') { const sub = await navigator.serviceWorker.ready .then(reg => reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidPublicKey, })) // 把 sub 发给后端,后端用 web-push 库发送 } ``` iOS Safari 16.4+ 才支持 web push(且要求"添加到主屏幕"后)。 ## 踩过的坑 1. **manifest URL 写错**:`<link rel="manifest" href="/manifest.json">` 但文件叫 `manifest.webmanifest`。404 没声音,PWA 静默不可安装。 DevTools → Application 检查。 2. **HTTP**:service worker 不工作,安装条件不满足。localhost 是例外, 生产必须 HTTPS。 3. **icon 路径错**:相对路径 vs 绝对路径混乱。manifest 里推荐绝对 路径 `/icons/...`。 4. **iOS 安装后地址栏没了,分享 / 复制 URL 不便**:iOS PWA 没"分享 到浏览器"按钮。给 app 内自己加分享按钮(`navigator.share()`)。 5. **service worker cache 把新版本卡住**:用户装了 PWA 后总用老版本。 `registerType: 'autoUpdate'` + 检测到新版后提示用户刷新。

浏览器 WebSocket:心跳 + 自动重连 + 指数退避(生产级封装)

原生 `WebSocket` 没有自动重连、没有心跳检测、断网时 send 直接丢。 生产用都得自己包一层。下面是一个 50 行的实用实现。 ## 1. 用法目标 ```ts const ws = new ResilientWS('wss://api.example.com/ws') ws.on('message', (msg) => console.log(msg)) ws.on('open', () => console.log('connected')) ws.on('close', () => console.log('disconnected')) ws.send({ type: 'subscribe', channel: 'orders' }) // 如果当前断开,先排队;连上后自动发出去 ``` ## 2. 实现 ```ts type Listener<T> = (data: T) => void interface ResilientWSOptions { reconnectMaxDelay?: number // 默认 30s heartbeatInterval?: number // 默认 25s heartbeatMessage?: string // 默认 'ping' } export class ResilientWS { private url: string private ws: WebSocket | null = null private listeners = new Map<string, Set<Listener<any>>>() private queue: any[] = [] private retries = 0 private heartbeatTimer?: number private reconnectTimer?: number private opts: Required<ResilientWSOptions> private alive = true constructor(url: string, opts: ResilientWSOptions = {}) { this.url = url this.opts = { reconnectMaxDelay: opts.reconnectMaxDelay ?? 30000, heartbeatInterval: opts.heartbeatInterval ?? 25000, heartbeatMessage: opts.heartbeatMessage ?? 'ping', } this.connect() } private connect() { if (!this.alive) return this.ws = new WebSocket(this.url) this.ws.onopen = () => { this.retries = 0 this.emit('open') this.startHeartbeat() // 重发排队的消息 this.queue.forEach(m => this.ws!.send(m)) this.queue = [] } this.ws.onmessage = (e) => { // 服务端 pong 不要传给用户 if (e.data === 'pong') return try { this.emit('message', JSON.parse(e.data)) } catch { this.emit('message', e.data) } } this.ws.onclose = () => { this.stopHeartbeat() this.emit('close') this.scheduleReconnect() } this.ws.onerror = (e) => { this.emit('error', e) // 让 onclose 触发重连 } } private scheduleReconnect() { if (!this.alive) return // 指数退避:1s, 2s, 4s, 8s, ... 上限 reconnectMaxDelay const delay = Math.min( 1000 * Math.pow(2, this.retries), this.opts.reconnectMaxDelay ) // 加 ±20% 抖动,避免多客户端同时重连打爆服务端 const jittered = delay * (0.8 + Math.random() * 0.4) this.retries++ this.reconnectTimer = window.setTimeout(() => this.connect(), jittered) } private startHeartbeat() { this.heartbeatTimer = window.setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(this.opts.heartbeatMessage) } }, this.opts.heartbeatInterval) } private stopHeartbeat() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) } send(data: any) { const text = typeof data === 'string' ? data : JSON.stringify(data) if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(text) } else { this.queue.push(text) } } on(event: string, fn: Listener<any>) { if (!this.listeners.has(event)) this.listeners.set(event, new Set()) this.listeners.get(event)!.add(fn) return () => this.listeners.get(event)?.delete(fn) } private emit(event: string, data?: any) { this.listeners.get(event)?.forEach(fn => fn(data)) } close() { this.alive = false this.stopHeartbeat() if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.ws?.close() } } ``` ## 3. 心跳的作用 许多防火墙 / 反代会在空闲连接 60-300 秒后悄悄断(不发 close 帧)。 客户端 ws 的 readyState 还是 OPEN,但 send 出去服务端永远收不到。 每 25 秒主动发个 `ping` 字符串,服务端回 `pong`,强制流量保持连接活跃。 服务端配合: ```python # FastAPI 示例 @app.websocket('/ws') async def ws(websocket: WebSocket): await websocket.accept() while True: msg = await websocket.receive_text() if msg == 'ping': await websocket.send_text('pong') continue # 业务处理 ``` ## 4. 指数退避 + 抖动 重连立即重试会把服务端打爆: - 1 秒后,2 秒后,4 秒后,8 秒后... - 上限 30 秒,避免太久不重连 加随机抖动(jitter)避免"惊群"——大量客户端同时断线后同时重连。 ## 5. 离线 / 在线监听 浏览器有 `online` / `offline` 事件: ```ts window.addEventListener('online', () => { // 用户网络恢复,立刻重连(不必等当前 backoff timer) ws.forceReconnect() }) window.addEventListener('offline', () => { console.log('network offline') }) ``` 加到 ResilientWS 里: ```ts constructor(...) { ... window.addEventListener('online', () => { if (this.ws?.readyState !== WebSocket.OPEN) { this.retries = 0 if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.connect() } }) } ``` ## 6. visibilitychange:tab 被切到后台 ```ts document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { // tab 重新可见,确认连接还活着 if (this.ws?.readyState !== WebSocket.OPEN) ws.forceReconnect() } }) ``` ## 7. React Hook 封装 ```tsx import { useEffect, useState } from 'react' export function useWebSocket<T>(url: string) { const [messages, setMessages] = useState<T[]>([]) const [connected, setConnected] = useState(false) const [send, setSend] = useState<(d: any) => void>(() => () => {}) useEffect(() => { const ws = new ResilientWS(url) ws.on('open', () => setConnected(true)) ws.on('close', () => setConnected(false)) ws.on('message', (m) => setMessages(prev => [...prev, m])) setSend(() => (d: any) => ws.send(d)) return () => ws.close() }, [url]) return { messages, connected, send } } ``` ## 8. 选择:原生 WebSocket vs Socket.IO - **原生 + ResilientWS(上)**:协议轻量,控制完整 - **Socket.IO**:自带 fallback (polling) + room / namespace / ack, 但流量大,跨语言客户端少 - **SockJS**:纯 ws fallback - **Centrifugo**:高性能 ws 服务端 + 多语言客户端 后端 Python 用 FastAPI / Django Channels / Sanic 都好; Node 用 `ws` 库 / `socket.io`。 ## 9. 鉴权 WebSocket 没有标准 Authorization header(浏览器 API 不让设)。常见解: 1. **Token 作为 query**:`wss://...?token=xxx`。简单但 token 进 URL log。 2. **首条消息发 token**:连上后立刻 `ws.send({ auth: token })`, 服务端验证后才允许其它消息。 3. **Cookie**:浏览器自动带 Cookie,服务端从 Cookie 取 session。 跨域要 CORS 配。 ## 10. 调试 Chrome DevTools → Network → WS 标签: - 看每条消息的方向 / 内容 / 时间 - 看连接打开 / 关闭事件 - 看 ping / pong 是否正常 ## 踩过的坑 - 旧的 React Strict Mode 双调用 useEffect → 连两个 ws。要么用 ref 保存实例,要么 cleanup 函数里 close。 - 关掉 tab 时浏览器不会等 ws.close() 完成 → 服务端看到的是异常断开。 服务端不要 assume 客户端会优雅退出。 - 重连 storm:服务端挂了 → 1k 客户端同时重连 → 服务端起来又被打挂。 加抖动 + 上限 + 服务端限流。 - 心跳间隔 < 反代超时;nginx 默认 60s,心跳 25s 安全。

CLIP + Faiss 做"用文字搜图"的图片搜索引擎(自家相册版)

## 起因 手机里 10 万张照片,找"去年在日本拍的樱花" 要翻几天。 Google Photos 能做语义搜索但隐私 → 想本地。 OpenAI 的 CLIP 模型把图片和文字编码到同一个语义向量空间。 "樱花" 的文字向量 ≈ 樱花图片的视觉向量。 本地跑 CLIP + Faiss 索引 + 几行 Python = 自己的 Google Photos。 ## 解决方案 ### 装 ```bash uv add open-clip-torch faiss-cpu torch pillow tqdm # GPU 加速: uv add faiss-gpu torch --index https://download.pytorch.org/whl/cu124 ``` `open-clip` 是 LAION 训的 CLIP 系列(性能比官方 CLIP 好)。 ### Step 1: 提取图片特征 ```python import torch import open_clip from PIL import Image from pathlib import Path import numpy as np from tqdm import tqdm DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu' # 中等大小 + 性能 + 多语言:xlm-roberta-large 支持中文 model, _, preprocess = open_clip.create_model_and_transforms( 'xlm-roberta-large-ViT-H-14', pretrained='frozen_laion5b_s13b_b90k', ) model = model.to(DEVICE).eval() tokenizer = open_clip.get_tokenizer('xlm-roberta-large-ViT-H-14') def encode_image(path): img = Image.open(path).convert('RGB') x = preprocess(img).unsqueeze(0).to(DEVICE) with torch.no_grad(): feat = model.encode_image(x) feat = feat / feat.norm(dim=-1, keepdim=True) return feat.cpu().numpy().squeeze().astype('float32') # 跑全相册 photos_dir = Path('~/Pictures/Photos').expanduser() paths = list(photos_dir.rglob('*.jpg')) + list(photos_dir.rglob('*.heic')) features = [] valid_paths = [] for p in tqdm(paths): try: features.append(encode_image(p)) valid_paths.append(str(p)) except Exception as e: print(f'skip {p}: {e}') features = np.stack(features) # shape (N, 1024) np.save('image_features.npy', features) with open('image_paths.txt', 'w') as f: f.write('\n'.join(valid_paths)) ``` GPU 上 10 万张 ~ 2-4 小时。CPU 慢 5-10 倍。一次性事,后续只索引新增。 ### Step 2: Faiss 索引 ```python import faiss import numpy as np features = np.load('image_features.npy') N, D = features.shape # 100000, 1024 # 小数据集(< 1M)用 flat:精确 + 简单 index = faiss.IndexFlatIP(D) # IP = inner product = cosine(features 已 normalize) index.add(features) faiss.write_index(index, 'images.index') # 大数据集(百万级)用 IVF + PQ 压缩 # index = faiss.index_factory(D, 'IVF1024,PQ32', faiss.METRIC_INNER_PRODUCT) # index.train(features) # index.add(features) ``` 10 万 × 1024 维 flat 索引约 400 MB。搜一次 < 10ms。 ### Step 3: 文字 → 找图 ```python paths = open('image_paths.txt').read().splitlines() index = faiss.read_index('images.index') def search(query: str, k=12): tokens = tokenizer([query]).to(DEVICE) with torch.no_grad(): feat = model.encode_text(tokens) feat = feat / feat.norm(dim=-1, keepdim=True) feat = feat.cpu().numpy().astype('float32') scores, indices = index.search(feat, k) return [(paths[i], scores[0][rank]) for rank, i in enumerate(indices[0])] # 用! for path, score in search('cherry blossoms in Japan'): print(f'{score:.3f} {path}') # 中文 for path, score in search('一只在沙滩上奔跑的金毛狗'): print(f'{score:.3f} {path}') ``` 返回 top-12 最相似的图,按 cosine similarity 排序。 ### Step 4: Web UI(10 行 Streamlit) ```python # app.py import streamlit as st from PIL import Image # ... 上面的 search 函数 ... st.title('我的照片搜索') query = st.text_input('描述你要找的图:') if query: results = search(query, k=12) cols = st.columns(4) for i, (path, score) in enumerate(results): with cols[i % 4]: st.image(Image.open(path), caption=f'{score:.2f}') ``` ```bash streamlit run app.py # 浏览器自动打开 ``` 输入"日落沙滩"→ 3 秒内显示所有匹配照片。 ## 进阶 ### 1. 图搜图(以图找图) ```python def search_by_image(image_path, k=12): feat = encode_image(image_path) feat = feat.reshape(1, -1) scores, indices = index.search(feat, k) return [(paths[i], scores[0][rank]) for rank, i in enumerate(indices[0])] ``` "找跟这张图相似的所有照片"。适合"找出所有该旅行的照片"。 ### 2. 增量索引 新增图片时不要重建整个 index: ```python new_features = [] for path in new_paths: new_features.append(encode_image(path)) new_features = np.stack(new_features) index.add(new_features) faiss.write_index(index, 'images.index') # paths 文件追加 with open('image_paths.txt', 'a') as f: f.write('\n' + '\n'.join(new_paths)) ``` Flat index 支持 add;如果是 IVF + PQ 需要 reuse trained index + add。 ### 3. 过滤:按 metadata ```python # EXIF 信息读取拍摄时间 / GPS / 相机 from PIL.ExifTags import TAGS def get_exif(path): img = Image.open(path) exif = {TAGS.get(k, k): v for k, v in (img._getexif() or {}).items()} return exif # 搜结果加 metadata filter results = search('cherry blossoms') filtered = [(p, s) for p, s in results if get_year(p) == 2023] ``` 更高级:把 metadata 存 SQLite 一起 join。 ### 4. CLIP 模型选择 | 模型 | 大小 | 速度 | 质量 | 多语言 | |---|---|---|---|---| | ViT-B/32 | 150 MB | 快 | 中 | 仅英 | | ViT-L/14 | 430 MB | 中 | 高 | 仅英 | | ViT-H/14 | 1.1 GB | 慢 | 极高 | 仅英 | | xlm-roberta + ViT-H | 4 GB | 慢 | 极高 | **多语言** | | siglip-large | 1 GB | 中 | 极高 | 看版本 | 中文场景一定用支持多语言的(xlm-roberta CLIP 或 chinese-clip)。 ### 5. faiss 大数据集 百万 - 千万级照片: ```python # IVF: 把 vectors 分桶,搜时只查最近的 N 个桶 nlist = int(np.sqrt(N)) # 桶数 quantizer = faiss.IndexFlatIP(D) index = faiss.IndexIVFFlat(quantizer, D, nlist, faiss.METRIC_INNER_PRODUCT) index.train(features) index.add(features) index.nprobe = 8 # 搜时查 8 个桶(增加 recall) ``` 千万级用 IVF + PQ 压缩(牺牲一点精度换 50x 内存压缩)。 ### 6. 部署到手机 CLIP 模型导出 ONNX / CoreML 后能在手机端跑: ```python torch.onnx.export(model.visual, dummy_image, 'clip_vision.onnx', opset_version=14) ``` Apple CoreML 工具更直接。手机端单图 encode < 200ms。 ## 完整效果 我的真实相册(4 万张照片): - index 大小:160 MB - 文字搜索单 query:~30ms - "去年在京都的樱花" → 95% 召回率(漏的是被树枝挡住的) - "戴墨镜的人" → 90% - "一群人合影" → 85% - "蓝色的天空" → 100% 但太多匹配 - "我爸" → 0%(没人脸识别能力) CLIP 强在"语义概念",弱在"特定人物 / 文字 OCR"。 后两者需要专门的人脸识别 + OCR pipeline 配合。 ## 与替代品对比 | | 自托管 CLIP | Google Photos | Apple Photos | immich (开源) | |---|---|---|---|---| | 隐私 | ✅ | ❌ | 部分(设备端) | ✅ | | 自由度 | 高 | 低 | 中 | 中 | | 人脸识别 | 没(自己加) | ✅ | ✅ | ✅ | | 语义搜索 | ✅(CLIP) | ✅ | 一定 | ✅(CLIP) | | 多设备 | 自己搭 | ✅ | ✅ | ✅ | 如果不想从零搭:**immich** 是开源 Google Photos 替代, 内置 CLIP 搜索 + 人脸识别 + 多设备同步。 ## 踩过的坑 1. **HEIC 格式**:iPhone 拍的 .heic 默认 Pillow 读不了。 `pip install pillow-heif` + `register_heif_opener()`。 2. **GPU memory 不够**:H/14 model + 高分辨率图 batch=1 仍 OOM。 `feat = model.encode_image(x.half())` half precision 减半显存。 3. **路径有中文**:Windows 上 `Path` 偶尔编码乱。统一 UTF-8 + 转 绝对路径。 4. **加新图后忘 update**:每次 sync 跑增量 encode + add to index。 写个 cron。 5. **face matching is poor**:CLIP 不擅长"区分两张人脸是否同人"。 要加 face recognition 用 ArcFace / FaceNet 等专用模型。

cloud-init + Ubuntu cloud image:30 秒起一台开发 VM

## 起因 经常需要"起一台干净 Ubuntu 试个东西"。从 ISO 装 + 改源 + 加 user + 配 SSH 要十几分钟。如果用 cloud-init 配合官方 cloud image,30 秒 能起一台已经配好用户 / 密钥 / 时区 / 软件的 VM,全自动。 ## 解决方案 ### 1. 拿官方 cloud image ```bash # Ubuntu 22.04 LTS cloud image wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img # 几百 MB,已预装 cloud-init ``` cloud-init 是个开机时跑的脚本框架,能读 metadata 自动配置: - 用户 + SSH key - hostname / 时区 / locale - 软件包安装 - 自定义脚本 ### 2. 写 user-data `user-data` 是 cloud-init 读的 YAML 配置: ```yaml #cloud-config hostname: devbox timezone: Asia/Shanghai locale: zh_CN.UTF-8 users: - name: alice sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash ssh_authorized_keys: - ssh-ed25519 AAAA... alice@laptop ssh_pwauth: false disable_root: true package_update: true package_upgrade: true packages: - vim - git - curl - htop - tmux - python3-pip - build-essential write_files: - path: /etc/motd content: | Welcome to devbox - provisioned by cloud-init runcmd: - curl -fsSL https://get.docker.com | sh - usermod -aG docker alice - systemctl enable --now docker power_state: mode: reboot delay: 'now' message: 'rebooting after initial setup' ``` 第一行 `#cloud-config` 必须,告诉 cloud-init 这是它能读的格式。 ### 3. 也写 meta-data ```yaml # meta-data instance-id: devbox-001 local-hostname: devbox ``` ### 4. 打成 ISO(NoCloud datasource) cloud-init 启动时找 `cidata` label 的盘读 user-data + meta-data: ```bash mkdir cloud mv user-data meta-data cloud/ genisoimage -output cloud-init.iso -volid cidata -joliet -rock cloud/ # 或 cloud-localds(更直接) cloud-localds cloud-init.iso user-data meta-data ``` ### 5. 用 qemu / libvirt 起 VM ```bash # 拷贝 image(避免污染原始) cp jammy-server-cloudimg-amd64.img devbox.qcow2 # 扩容到 30G qemu-img resize devbox.qcow2 30G # 启动(无图形,串口输出) qemu-system-x86_64 \ -enable-kvm \ -m 4G -smp 4 \ -drive file=devbox.qcow2,if=virtio \ -drive file=cloud-init.iso,if=virtio,format=raw \ -nic user,hostfwd=tcp::2222-:22 \ -nographic ``` 30 秒后 cloud-init 跑完 + reboot,外部 SSH: ```bash ssh -p 2222 alice@localhost # 进去就是配好的环境,docker 可用,时区对 ``` ### 6. 用 virt-install / virsh(更正式) ```bash sudo virt-install \ --name devbox \ --memory 4096 --vcpus 4 \ --disk path=/var/lib/libvirt/images/devbox.qcow2,size=30 \ --disk path=/var/lib/libvirt/images/cloud-init.iso,device=cdrom \ --os-variant ubuntu22.04 \ --network bridge=virbr0 \ --import \ --noautoconsole virsh list --all virsh console devbox # 串口 virsh shutdown devbox virsh start devbox ``` ### 7. 在 LXD / Multipass / Proxmox 用 **LXD**(前面章节有提到): ```bash lxc launch ubuntu:22.04 devbox \ --config user.user-data="$(cat user-data)" ``` **Multipass**(macOS / Windows 上跑 Ubuntu VM 最简单): ```bash multipass launch jammy --name devbox \ --cloud-init user-data --cpus 4 --memory 4G --disk 30G ``` **Proxmox**:直接在 UI 给 VM 挂 cloud-init drive,在 web 表单填用户 名 / 密钥 / IP。 ### 8. 公有云 AWS / GCP / Azure 创建实例时 user-data 字段贴上面的 YAML, 开机自动跑。整套 IaC 用 Terraform: ```hcl resource "aws_instance" "web" { ami = "ami-..." # Ubuntu official AMI instance_type = "t3.small" user_data = file("cloud-config.yaml") } ``` ## 效果 - 实验室常备一个 user-data 模板 + Makefile,`make vm name=test` 30 秒 起一台 - 新员工 onboarding 给个 user-data 文件让他在自己机器上起,零摩擦 - 部署模板化:dev / staging / prod 共用 user-data,只改少量变量 - 真实灾备:服务器挂了 cloud-init + 备份恢复,重新 provision 半小时 ## 调试 cloud-init 机器跑起来发现配置没生效: ```bash sudo cloud-init status # done / running / error sudo cloud-init query userdata # 当前 user-data 内容 sudo cat /var/log/cloud-init.log sudo cat /var/log/cloud-init-output.log # runcmd 等的 stdout/stderr ``` `/var/log/cloud-init-output.log` 是最常看的——所有 runcmd 输出在这里。 强制重新跑(**测试用,会改 instance-id**): ```bash sudo cloud-init clean --logs --seed --machine-id sudo reboot ``` ## 踩过的坑 1. **YAML 缩进错**:cloud-init 静默跳过出错段,配置部分生效部分不生效。 `cloud-init schema --config-file user-data` 校验语法。 2. **第二次启动不再跑 user-data**:cloud-init 默认只在首次启动跑。 要重跑改 instance-id:`/var/lib/cloud/data/instance-id` 或者 `cloud-init clean`。 3. **package_upgrade 卡 grub menu prompt**:apt 升级遇到内核配置 选择卡住。`debconf-show grub-pc` 或者 user-data 加: ```yaml bootcmd: - DEBIAN_FRONTEND=noninteractive apt-get update ``` 4. **runcmd 提早 exit**:`runcmd:` 里某条失败不影响后面(默认 set +e)。 要按顺序 fail-fast 用 `bash -e` 包: ```yaml runcmd: - bash -e -c 'curl ... && systemctl restart ...' ``` 5. **私钥不在 user-data**:user-data 是明文。永远只写 public key, secret 通过其它机制注入(vault / env / fetch from S3)。

polars vs pandas(2026 视角)

## 起因 pandas 是 Python 数据界 15 年的事实标准。但: - 单线程(GIL),大数据慢 - 内存膨胀(同一列多份 copy) - API 设计累赘(SettingWithCopyWarning、index 烦) `polars` 是 Rust 写的 DataFrame,2020+ 起飞。Apache Arrow 内存格式 + 多线程 + lazy 执行。 2026 视角看,polars 在多个维度全面超越 pandas。 ## 装 ```bash pip install polars # 或者 uv add polars ``` ## 句法对比 ```python import polars as pl import pandas as pd # pandas df = pd.read_csv('orders.csv') result = ( df[df['country'] == 'US'] .groupby('product') .agg({'amount': 'sum', 'qty': 'count'}) .reset_index() .sort_values('amount', ascending=False) .head(10) ) # polars df = pl.read_csv('orders.csv') result = ( df.filter(pl.col('country') == 'US') .group_by('product') .agg([ pl.col('amount').sum(), pl.col('qty').count(), ]) .sort('amount', descending=True) .head(10) ) ``` polars 句法 method chain 顺。 明确的 `pl.col(...)` 比 pandas `df['x']` 在复杂 expression 里清晰。 ## 性能 我们一个 10 GB CSV / 80 列: | 操作 | pandas | polars | polars-lazy | |---|---|---|---| | read_csv | 95s | 22s | 22s | | filter + groupby + agg | 38s | 5s | 3s | | join 两 10 GB | 90s (OOM 风险) | 18s | 12s | | sort by 3 列 | 25s | 4s | 3s | 5-10x 快。32 核机器更明显(pandas 单核)。 内存:pandas 30 GB peak,polars 12 GB peak(Arrow columnar + zero-copy)。 ## lazy 执行 polars 杀手 feature: ```python # eager(每步实际跑) df = pl.read_csv('big.csv') result = df.filter(...).group_by(...).agg(...) # lazy(构建 query plan,scan 时才执行) result = ( pl.scan_csv('big.csv') # 注意 scan_ 而不是 read_ .filter(pl.col('x') > 0) .group_by('y') .agg(pl.col('z').sum()) .collect() # 触发执行 ) ``` lazy 优势: - **predicate pushdown**:filter 推到 CSV 读取阶段,只读符合行 - **projection pushdown**:只读用到的列 - **CSE**:重复 expression 算一次 - **streaming**:> 内存数据流式处理 ```python result = ( pl.scan_csv('100GB.csv') .filter(pl.col('date') > '2025-01-01') .select(['user_id', 'amount']) # 只读这俩列 .group_by('user_id') .agg(pl.col('amount').sum()) .collect(streaming=True) # 流式,不全加载 ) ``` 100 GB CSV 在 16 GB 机器跑得动。pandas 没 streaming 直接 OOM。 ## SQL interface ```python ctx = pl.SQLContext() ctx.register('orders', df) result = ctx.execute(""" SELECT country, SUM(amount) FROM orders WHERE qty > 5 GROUP BY country """).collect() ``` 熟 SQL 但不熟 polars expression → 写 SQL。 ## 跟 pandas 互转 ```python df_pd = pl.DataFrame(...).to_pandas() df_pl = pl.from_pandas(df_pd) ``` 零拷贝(用 Arrow buffer 共享)。混用方便。 ## 与 pandas 2.x(Arrow backend)对比 pandas 2.x 加了 pyarrow backend: ```python df = pd.read_csv('data.csv', dtype_backend='pyarrow') ``` 性能改善但**仍单线程**。 比 polars 还差一截(polars 多核 + lazy + native rust)。 ## 与 spark / dask 对比 | | pandas | polars | dask | spark | |---|---|---|---|---| | 内存模型 | row | columnar (Arrow) | partition | columnar | | 并行 | 单线程 | 多线程 | 多进程/集群 | 集群 | | 数据规模 | < RAM | > RAM (streaming) | TB | PB | | 学习曲线 | 低 | 中 | 中 | 高 | | 启动 | 0.1s | 0.1s | 1s | 30s+ | - < 10 GB → polars - 10 GB - 1 TB → polars streaming / dask - > 1 TB → spark / dask 集群 ## 实际项目迁移 我们 ETL pipeline 30 个 script,pandas → polars: ``` 1. read_csv → scan_csv:1 行换 2. df[df.x > 5] → df.filter(pl.col('x') > 5):手动改 3. groupby().agg({}) → group_by().agg([]):手动改 4. .reset_index() → 删(polars 无 index 概念) 5. lambda apply → 改成 polars expression ``` 大约 30 - 50% 行需要改。但跑速从 2 小时 → 12 分钟,值得。 LLM 辅助迁移很方便,pandas 到 polars 是 well-defined 转换。 ## API 缺点 / 注意 - 没 index(这是 feature 不是 bug,但 pandas 老用户要适应) - merge → join(语义稍不同,pandas merge 默认 inner,polars join 默认 inner,OK) - pivot / melt 等也有 + 语义略不同 - 没 multi-index column 90% workflow polars OK。某些特殊 transformation(时间序列 resample 加 multi-index)pandas 仍胜。 ## 用什么场景 - **新 ETL** → polars 默认 - **现有 pandas codebase** → 看痛点决定,不必全迁 - **notebook 探索性分析** → 二选一都行,polars 性能优势更大 - **DataFrame for ML 输入** → sklearn 仍 pandas 友好;polars 转 numpy 传 sklearn ## 我的工作流 - 数据 ingestion / heavy ETL:polars - ML feature engineering:polars - 给 sklearn / pytorch 时:`.to_numpy()` 或 `.to_pandas()` - 临时小数据:pandas(生态广) ## 踩过的坑 1. **expression 错位**:`pl.col('x') + 5 - pl.col('y')` vs `pl.col('x') + (5 - pl.col('y'))`。运算符优先级跟 Python 一致, 但容易看走眼。 2. **lazy collect 慢**:忘了 `.collect()` 一直 lazy。debug 时 `.head(10).collect()` 看数据。 3. **datetime 时区**:polars 严格 timezone aware / naive 区分。 pandas 经常混。从 pandas 来的 dataframe `pl.from_pandas` 时 timezone 信息可能丢。 4. **null 处理**:polars 用 Arrow null bit,跟 pandas NaN 不同。 `pl.col('x').is_null()` 不是 `x != x`。 5. **groupby 后默认按 key 排序**:pandas 默认排,polars 默认不排。 要 sort 显式 `.sort()`。

Vue 3 Composition API:从 Options API 迁移的实际感受

## 起因 Vue 2 用 Options API 多年: ```vue <script> export default { data() { return { count: 0 }; }, computed: { doubled() { return this.count * 2; }, }, methods: { inc() { this.count++; }, }, mounted() { console.log('mounted'); }, }; </script> ``` 清晰简单。但复杂组件痛点: - 同 feature 的 data / method / watcher 分散在不同 section - 跨组件复用 logic 难(mixin 有命名冲突) - TS 类型推导弱 Vue 3 Composition API + `<script setup>`: ```vue <script setup> import { ref, computed, onMounted } from 'vue'; const count = ref(0); const doubled = computed(() => count.value * 2); function inc() { count.value++; } onMounted(() => console.log('mounted')); </script> ``` 更接近 React Hook 思路。重构 6 个月经验: ## 主要变化 ```vue <!-- Options API --> <template><button @click="inc">{{ count }}</button></template> <script> export default { data() { return { count: 0 }; }, methods: { inc() { this.count++; } }, }; </script> <!-- Composition API --> <template><button @click="inc">{{ count }}</button></template> <script setup> import { ref } from 'vue'; const count = ref(0); function inc() { count.value++; } </script> ``` - `data()` → `ref()` / `reactive()` - `methods` → 普通 function - `computed` → `computed()` - `watch` → `watch()` / `watchEffect()` - lifecycle → `onMounted` / `onUnmounted` 等 - props → `defineProps()` - emit → `defineEmits()` ## ref vs reactive ```ts const count = ref(0); // primitive count.value++; console.log(count.value); const state = reactive({ count: 0, name: '' }); // object state.count++; state.name = 'bob'; ``` `ref` 需 `.value` 访问(template 中自动 unwrap)。 `reactive` 用 Proxy 包对象,访问无 .value。 主流偏好:**全用 ref**(一致性,避免混乱)。 对 object 用 ref({ ... })。 ## composable (Hook 等价) 跨组件复用 logic: ```ts // composables/useCounter.ts import { ref } from 'vue'; export function useCounter(initial = 0) { const count = ref(initial); const inc = () => count.value++; const dec = () => count.value--; return { count, inc, dec }; } // 任意组件 <script setup> import { useCounter } from '@/composables/useCounter'; const { count, inc } = useCounter(); </script> ``` 跟 React custom hook 概念一致,命名 `useXxx`。 替代 Vue 2 mixin(没命名冲突 + 类型推导好)。 ## TS 类型自动 ```vue <script setup lang="ts"> const props = defineProps<{ title: string; count?: number; }>(); const emit = defineEmits<{ (e: 'update', value: number): void; }>(); // props.title typed string // emit('update', 42) typed </script> ``` vs Options API: ```vue <script> export default { props: { title: { type: String, required: true }, count: Number, }, emits: ['update'], }; </script> ``` TS-style 简洁 + 类型自动。 ## reactivity 注意 ```ts const state = ref({ count: 0 }); // ❌ destructure 失 reactive const { count } = state.value; count++; // 不更新 view // ✅ toRefs const stateRefs = toRefs(state.value); const { count } = stateRefs; count.value++; ``` destructure reactive object 是常见坑。解决用 `toRefs`。 ## watch vs watchEffect ```ts // watch(显式 source) watch(count, (newVal, oldVal) => { console.log(`count changed: ${oldVal} → ${newVal}`); }); // watchEffect(auto-track deps) watchEffect(() => { console.log(`count: ${count.value}`); }); ``` watch 像 React useEffect with explicit dep。 watchEffect 自动追踪用到的 ref。 ## lifecycle ```ts import { onMounted, onUnmounted, onUpdated, nextTick } from 'vue'; onMounted(async () => { await fetchData(); }); onUnmounted(() => { cleanup(); }); ``` 直观跟 Options API mapping。 ## provide / inject (Context) ```ts // 父 import { provide, ref } from 'vue'; const theme = ref('dark'); provide('theme', theme); // 子(任意深度) import { inject } from 'vue'; const theme = inject('theme'); ``` 跟 React Context 类似。typed key: ```ts import { InjectionKey } from 'vue'; const themeKey: InjectionKey<Ref<string>> = Symbol(); provide(themeKey, theme); const theme = inject(themeKey); // typed Ref<string> ``` ## 项目迁移经验 Vue 2 → Vue 3 大型项目: 1. Vue Compat(Vue 2/3 兼容层)渐进迁移 2. component 一个一个改 Composition API(同 file 内可以 Options 跟 Composition 共存暂时) 3. mixin → composable 4. global API(Vue.use → app.use) 我们 100 component 项目 1 个月迁完。 ROI: - 代码量降 20-30%(去掉 boilerplate) - 复杂组件可读性大幅提升 - TS 类型推导显著强 ## 仍能用 Options API Vue 3 兼容 Options API(permanent)。 没必要强迫迁。新 component 用 Composition,老的稳定就放。 ## 与 React Hook 对比 | | Vue Composition | React Hook | |---|---|---| | reactivity | Proxy (auto) | manual setState | | effect deps | 自动 track | 手动 dep array | | 模板 | SFC (`<template>`) | JSX | | 性能 | 默认细粒度 | 默认 re-render component | | 学习 | 中 | 中(dep array 陡) | Vue reactivity 更"魔法" + 不用记 dep。 React 更显式但 boilerplate 多。 ## script setup 是关键 只用 Composition API 但没 `<script setup>` → 仍要 `setup()` function + return。冗余。 `<script setup>`(SFC 顶部)让所有 top-level binding 自动可用 template。 减少 50%+ boilerplate。 强烈推荐每 Vue 3 component 都用。 ## 与 Nuxt 3 Nuxt 3 是 Vue 3 的 meta-framework(Next.js 类比)。 auto-import composable / page-based routing / SSR 等。 新 Vue 项目几乎都 Nuxt 3。 ## 真实 case:复杂 form 老 Vue 2 form 组件(800 行 Options API): - 30+ field,复杂校验 - 跨 step state - watcher 一堆 迁 Composition + Pinia + Zod: - 300 行(-60%) - 校验集中 schema 内 - step 切换 logic 用 composable 复用 - TS 类型保证 最大改进:思路清晰。一个 file 内"这 feature 的所有相关 code 在一起" (不是 data / methods / computed / watch 跳跃读)。 ## 踩过的坑 1. **forgot .value**:`if (count > 5)` 永远 truthy(ref 对象 truthy)。 `count.value > 5`。template 内不需要 .value 容易混。 2. **reactive deep limit**:reactive 是浅 → 嵌套 plain object 不 trigger update。`ref(deepObject)` 或者 `reactive` 递归。 3. **lifecycle order**:onMounted 内调子组件 ref → 子还没 mount。 await nextTick() 或 onMounted nested。 4. **props 解构丢 reactivity**:`const { title } = defineProps()` title 是 snapshot。`const props = defineProps(); props.title` 保 reactive。Vue 3.5+ 支持解构保留 reactivity。 5. **TS 推导慢**:复杂 generic component 编译变慢。简化 generic 或 拆分 component。