知识广场

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

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

Crossplane vs Terraform:用 K8s 管 cloud 资源?

## 起因 cloud 资源(RDS / S3 / VPC)管理工具: - **Terraform**:HCL,最广泛,但状态文件 / drift 问题多 - **Pulumi**:用 TS/Python 写 - **Crossplane**:K8s controller,cloud 资源当 CR 管 最近用 Crossplane 一个项目,下面对比经验。 ## Terraform 简单回顾 ```hcl resource "aws_db_instance" "main" { identifier = "myapp-db" engine = "postgres" instance_class = "db.t3.medium" allocated_storage = 100 username = "admin" password = var.db_password } ``` ```bash terraform plan terraform apply ``` - `.tfstate` 文件存当前资源映射 - 多人协作要 remote state(S3 + DynamoDB lock) - drift 检测靠 `terraform plan` ## Crossplane 思路 cloud 资源是 K8s CRD。`kubectl apply` 创建 cloud 资源。 ```bash # 装 Crossplane helm install crossplane crossplane-stable/crossplane -n crossplane-system # 装 AWS provider kubectl apply -f - <<EOF apiVersion: pkg.crossplane.io/v1 kind: Provider metadata: name: provider-aws spec: package: xpkg.upbound.io/upbound/provider-aws:v1.0.0 EOF ``` ## 创建 RDS ```yaml apiVersion: rds.aws.upbound.io/v1beta1 kind: Instance metadata: name: myapp-db spec: forProvider: region: us-east-1 engine: postgres engineVersion: "16" instanceClass: db.t3.medium allocatedStorage: 100 username: admin passwordSecretRef: name: db-pass key: password namespace: default ``` ```bash kubectl apply -f rds.yaml # Crossplane controller 调用 AWS API 创建 RDS ``` cluster 里看: ```bash kubectl get instance.rds.aws.upbound.io NAME SYNCED READY ... myapp-db True True ``` ## composition (高层抽象) 写一个 "AppDatabase" abstraction,dev 不用写 50 行 RDS yaml: ```yaml apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: name: appdatabases.example.com spec: group: example.com names: kind: AppDatabase plural: appdatabases versions: - name: v1 served: true referenceable: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string enum: [small, medium, large] env: type: string ``` ```yaml apiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: name: appdatabase-aws spec: compositeTypeRef: apiVersion: example.com/v1 kind: AppDatabase resources: - name: rds-instance base: apiVersion: rds.aws.upbound.io/v1beta1 kind: Instance spec: forProvider: engine: postgres engineVersion: "16" patches: - fromFieldPath: spec.size toFieldPath: spec.forProvider.instanceClass transforms: - type: map map: small: db.t3.small medium: db.t3.medium large: db.r6g.xlarge ``` dev 写: ```yaml apiVersion: example.com/v1 kind: AppDatabase metadata: name: my-app-db spec: size: medium env: prod ``` → Crossplane 自动创建 RDS + 关联资源。 平台团队 control 实际 cloud impl,dev 用简单接口。 ## 对比 | | Terraform | Crossplane | |---|---|---| | 状态存储 | tfstate file | k8s etcd | | 语言 | HCL | YAML + composition | | Drift 检测 | terraform plan 主动 | controller 持续 reconcile | | 自愈 | 不 | 是(detect drift 自动修) | | 多人协作 | state lock | k8s 原生 RBAC | | 学习曲线 | 中 | 高(要懂 K8s) | | 生态 | 极大 | 中 | | GitOps | 需 Atlantis 等 | 原生(kubectl apply) | ## 优势:自愈 Crossplane controller 每 N 分钟比 cluster CR 与实际 cloud 资源: ``` desired (yaml): RDS storage = 100 GB actual (AWS): storage = 200 GB (手动改了) ↓ Crossplane: 调 API 改回 100 GB ``` terraform 不主动 reconcile,要手动 `apply` 才修。 不想 reconcile 某字段: ```yaml spec: managementPolicies: [Observe] # 只读不改 ``` 或者 `ignoreFields`。 ## 优势:GitOps 一致 ArgoCD 同步 Crossplane CR → cluster apply → Crossplane 调 cloud API。 整条链 git → cluster → cloud 都声明式。 Terraform 配 ArgoCD 不容易(Terraform 是命令式 apply)。 ## 缺点:复杂 - composition 写起来累(patches / transforms) - debug 调试链长(CR → composition → managed resource → cloud API) - provider 质量参差(aws 强,azure / gcp 较新) - 没 terraform 那种 module 生态 ## 缺点:要 K8s Crossplane 跑在 K8s cluster 里。 要管 cloud 资源 → 必须先有 K8s cluster(鸡生蛋问题)。 我们方案:一个 "management cluster"(小 EKS)跑 Crossplane → 它管 其它 cluster + cloud 资源。 ## 适合的场景 - 已经深度 K8s - 多团队 self-service(dev 写 yaml,平台控制 composition) - ArgoCD 已用 → 想统一 GitOps - 容忍 K8s 复杂度 不适合: - 单 cloud 资源 / 简单项目 → Terraform 够 - 团队不会 K8s - 需要快速 prototype ## 与 Pulumi 对比 | | Terraform | Pulumi | Crossplane | |---|---|---|---| | 语言 | HCL | TS/Python/Go | YAML | | 真正编程 | 弱 | 强 | 弱 | | state | tfstate | own backend | k8s etcd | | 多人 | locking | locking | k8s | | 学习 | 易 | 中 (语言+SDK) | 高 | Pulumi 是"真编程语言写基础设施"。 Crossplane 是"K8s 思维管基础设施"。 Terraform 是"DSL 描述基础设施"。 ## 真实 case 某客户多 cluster + 多 cloud 资源: - Terraform:3 个团队各自有 tfstate,conflict / drift 频发 - 改 Crossplane: - 平台团队建 AppDatabase / AppQueue / AppBucket 等 abstraction - 业务团队 yaml 申请:`kind: AppDatabase, size: medium` - Crossplane 创建 + 自愈 - ArgoCD 跟踪 效果: - 业务 dev 不学 RDS 细节 - 资源命名 / tag 强制规范(composition 模板控制) - drift 自动修 - 一切都在 ArgoCD UI 可见 挑战: - composition 写起来痛苦(YAML 模板表达力差) - provider bug 偶尔(aws provider 几个版本 ec2 有问题) ## 与 ACK (AWS Controllers for K8s) ACK 是 AWS 官方出的 K8s controller for AWS 资源。 跟 Crossplane provider-aws 重叠。 | | Crossplane | ACK | |---|---|---| | Composition | ✅ | ❌ | | 多 cloud | ✅ | AWS only | | 抽象 | 强 | 弱 | | 稳定 | 中 | 高(AWS 维护) | 如果只 AWS + 不要 composition → ACK。 多 cloud / 要 abstraction → Crossplane。 ## 踩过的坑 1. **YAML 长**:1 个 RDS 的 yaml 100 行。composition 抽象后 dev 写 5 行, 但 platform team 写 composition 累。 2. **provider 升级 breaking**:provider-aws v1 → v2 schema 变 → 所有 CR 要改。锁 provider 版本 + 计划升级。 3. **delete 危险**:删 CR 默认 delete cloud 资源。`deletionPolicy: Orphan` 保留 cloud 资源仅删 CR。 4. **多 region**:每 region 一个 ProviderConfig。CR 用 `providerConfigRef` 指。 5. **state recovery**:万一 etcd 丢 → cloud 资源仍在但 cluster 不知道。 `crossplane beta` 有 import 功能。

atuin:让 shell 历史跨机同步 + 模糊搜索 + 上下文感知

## 起因 我有 3 台机器(笔记本 + 两台服务器),每台 bash 历史互相不通。 "那条神秘的 ffmpeg 命令我半年前在哪台机器上跑过…" 永远找不回来。 而且原生 `Ctrl-R` 只能子串匹配,搜索体验差。 atuin 把 shell 历史存 SQLite + 加密同步 + 替换 `Ctrl-R` 为模糊搜索 TUI。 ## 安装 ```bash # 一行装 bash <(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh) # 或包管理器 brew install atuin cargo install atuin # 注册到 shell atuin init bash >> ~/.bashrc atuin init zsh >> ~/.zshrc atuin init fish | source >> ~/.config/fish/config.fish # 重启 shell ``` ## 第一次跑 `Ctrl-R` 不再是 bash 原生 reverse-i-search,而是 atuin 的全屏 TUI: ``` > ffmpeg 2026-05-20 14:32 ffmpeg -i input.mp4 -vcodec libx264 -crf 23 out.mp4 2025-12-01 09:15 ffmpeg -i src.mov -ss 00:00:05 -t 30 clip.mp4 2025-11-10 21:00 ffmpeg -i in.wav -ab 128k out.mp3 ↑↓ 选 | Enter 执行 | Tab 编辑后执行 | Ctrl-D 退出 ``` 模糊匹配(不需要前缀完整)、按时间最近排序、显示具体执行时间。 ## 同步到云 atuin 提供免费托管: ```bash atuin register -u myuser -e [email protected] -p 'long-password' # 服务端发邮件确认 atuin login -u myuser -k <密钥> # 加密 key,重要! atuin sync ``` 之后每台机器: ```bash atuin login -u myuser -k <密钥> atuin sync ``` 历史立刻同步过去(端到端加密 —— 服务端只能存 ciphertext)。 **密钥(key)丢了等于所有历史丢了**——保存到 password manager。 ## 自托管 server 不想用官方 host: ```bash # server 端 docker run -d \ -e ATUIN_DB_URI=postgres://user:pass@host/db \ -p 8888:8888 \ ghcr.io/atuinsh/atuin server start # 客户端 ~/.config/atuin/config.toml sync_address = "https://atuin.your-server.com" ``` ## 配置 `~/.config/atuin/config.toml`: ```toml # 搜索模式:prefix / fulltext / fuzzy(推荐 fuzzy) search_mode = "fuzzy" # 启动时显示历史的 filter filter_mode = "global" # global | session | host | directory # 模式:自动按上下文切(按 Tab 切) filter_mode_shell_up_key_binding = "directory" # 默认搜索结果数 show_help = true inline_height = 20 # 不记录某些命令(密码、敏感的) history_filter = [ "^secret-cmd", "^export.*PASSWORD", ] # 不记录这些目录 cwd_filter = [ "/tmp", ] ``` ## context-aware 搜索 最强的 feature:按 Tab 切换 filter scope: - `global`:所有机器所有目录 - `host`:仅本机 - `session`:仅本 shell session - `directory`:仅本目录 ``` > docker [filter: directory] ``` 进 `~/projects/myapp` 目录搜 `docker` 只显示在这个目录跑过的 docker 命令。这比 bash `Ctrl-R` 强 10 倍——找上次"在哪个项目里跑的什么命令" 秒级。 ## 时间过滤 ```bash atuin search --before '2 days ago' docker atuin search --after '1 week ago' kubectl ``` ## 统计 ```bash atuin stats # Total commands: 12847 # Unique commands: 4321 # Top commands: # 1. ls (1234) # 2. cd (987) # 3. git status (654) # ... atuin stats --period 7d # 最近 7 天 ``` 发现自己 80% 时间在 cd 和 ls,提示我多用 z + 文件管理器。 ## 导入老历史 ```bash atuin import auto # 自动检测 bash/zsh/fish/atuin ``` 之前几年的 `~/.bash_history` / `~/.zsh_history` 全导入 atuin DB。 ## 不想被记录的命令 ```bash # 单条命令前面加空格(bash HISTCONTROL=ignorespace 兼容) secret-command --token xyz ``` 或者用 atuin 的 history_filter regex。 ## CLI 用法 ```bash atuin history list --limit 50 atuin history list --cwd ~/projects/myapp atuin search 'docker run' # 把某条历史拿出来重新跑 atuin search 'ffmpeg' --format '{{ .command }}' --limit 1 | bash ``` ## 跟 fzf 配合 `Ctrl-R` 默认是 atuin TUI。如果你更喜欢 fzf: ```bash # atuin 输出 + fzf 渲染 fh() { atuin history list --format '{{ .timestamp }} | {{ .command }}' \ | fzf --tac --no-sort --tiebreak=index \ --bind 'enter:become(echo {3..})' \ | xargs -I {} bash -c '{}' } ``` 但 atuin 自带的 TUI 其实 fzf-like,绝大多数场景不需要替换。 ## 性能 10 万条历史的 atuin DB ~ 30 MB;查询 < 50ms。本地纯 Rust + SQLite, 无网络请求。 ## 效果 - 3 台机器历史共享,"半年前那条命令" 永远找得回来 - 搜索体验从 grep-字符串 升到 fuzzy + context-aware - 在项目目录 `Ctrl-R` 自动过滤到本项目历史,省去大量"误中其它项目命令" - 跨机迁移 / 重装系统 / 新员工 onboarding 都瞬间继承"老司机经验" ## 踩过的坑 1. **加密密钥丢失** = 所有同步历史丢失。注册后立刻 `atuin key` 看 key,存进 password manager 三份(云 + 本地 + 打印)。 2. **某些 ZSH 主题与 atuin 冲突**:oh-my-zsh 的 `Ctrl-R` 被绑给其它 插件。`bindkey '^R' atuin-search` 强制覆盖。 3. **同步把敏感命令也带走**:包含 `export PASSWORD=xxx` / `mysql -p xxx` 的命令也加密同步。提前用 `history_filter` 过滤。 4. **服务端不可达时 `atuin sync` 卡**:网络问题导致同步 hang。 `atuin sync --force` 或者 `kill` 后下次再 sync。 5. **新 shell session 启动慢**:atuin init 加了几个 hook。如果发现 shell 启动 > 100ms,看看是不是 atuin 同步在做(关 `auto_sync`,手动 `atuin sync`)。

Git 子项目共享代码:submodule / subtree / monorepo 怎么选

## 起因 我们有 5 个微服务都用一份"共享 utility 代码"(如 protobuf 定义 / 通用 model / shared logger)。三种主流方式: A. 各项目 copy-paste(同步靠人工,最差) B. 抽成 npm/pip 包发包(增加发版流程) C. Git submodule 共享一个 repo D. Git subtree 把共享 repo 嵌入主 repo E. monorepo 把所有项目放一起 各自适用场景不同。 ## A. Git submodule ```bash cd myservice git submodule add https://github.com/myorg/shared-libs.git lib/shared git commit -m 'add shared submodule' ``` `.gitmodules` 文件 + lib/shared 目录(指向 shared-libs 的某个 commit)。 ```bash # clone 主 repo 时要 init submodule git clone --recurse-submodules https://github.com/myorg/myservice.git # 或事后 cd myservice git submodule update --init --recursive ``` 更新 submodule: ```bash cd lib/shared git pull origin main cd ../.. git add lib/shared git commit -m 'bump shared submodule to abc123' ``` 主 repo 记录 "我指向 shared 的哪个 commit"。 **优点**: - 共享 repo 独立版本控制 - 每个项目可固定某个版本(不同步升级) **缺点**: - 命令繁琐(`git submodule update` 经常忘) - 新成员第一次 clone 总踩坑(忘 `--recurse-submodules`) - 改 submodule 内代码后要 push 共享 repo + 更新主 repo 引用 = 两个 PR ## B. Git subtree ```bash cd myservice git subtree add --prefix=lib/shared https://github.com/myorg/shared-libs.git main --squash ``` 效果:把 shared-libs 整个内容 copy 到 lib/shared/ 目录,作为本 repo 的真实 commit(不是引用)。 ```bash # 拉 shared 上游更新 git subtree pull --prefix=lib/shared shared-remote main --squash # 把本地 shared 改动推回上游 git subtree push --prefix=lib/shared shared-remote feature-x ``` **优点**: - 普通 clone 拿到所有文件(无 submodule 双步骤) - 本地直接改 shared 代码 + commit(无需先 push 上游) - 简单:只是普通 git 文件 **缺点**: - `git log` 看到 shared 的所有历史(噪音) - subtree push 偶尔合并冲突复杂 - 大多数开发者不熟,命令易记错 ## C. monorepo 把所有服务和 shared 放一个 repo: ``` myorg-monorepo/ ├── services/ │ ├── api/ │ ├── worker/ │ └── frontend/ ├── packages/ │ ├── shared-models/ │ └── shared-utils/ └── tools/ ``` shared 代码就是 packages/ 下的子目录,import 直接相对路径或 workspace (pnpm / yarn workspace / npm workspace / Cargo workspace / Go workspace / etc)。 **优点**: - 改 shared + 改用它的 service 一个 commit + 一个 PR - 所有服务的 history / branch / CI 在同处 - 重构跨服务时方便(IDE refactor 全 monorepo 生效) **缺点**: - repo 越来越大(clone 慢;shallow clone 缓解) - CI 要 path-based filter(不能"改 README 跑全部测试") - 工具支持差异(Bazel / Nx / Turborepo 等 monorepo build 工具学习成本) ## D. 单独发包 shared 抽成 npm / pip 包发到内部 registry: ```bash # shared 项目 npm publish --registry https://npm.internal.example.com # 使用方 npm i @myorg/shared ``` **优点**: - 标准化语言生态 - 严格版本管理 - 不同服务可用不同 shared 版本 **缺点**: - 改 shared 要发版 → 更新 → 测试 → 多步骤 - 内部 registry 要维护 - 不适合 quick iteration ## 决策矩阵 | 场景 | 推荐 | |---|---| | 5-10 个服务 + 1 个公司 + 同一团队 | **monorepo** | | 开源项目想用别人的 OSS lib | submodule(少改) | | 想 vendor 别人代码 + 偶尔修改 | subtree | | 多公司 / 跨组织共享 lib | 单独发包 | | 多团队 + 服务有独立发版周期 | 单独发包 | | 想保持简单 + 改动频繁 | monorepo | 实际我的经验:**绝大多数场景 monorepo 是最佳选择**。submodule 是 "避免 monorepo 的 hack",长期维护成本高。 ## monorepo 工具 JavaScript / TypeScript: ```bash # package.json { "private": true, "workspaces": ["packages/*", "services/*"] } # 用 npm workspaces / pnpm workspace / yarn workspaces ``` ```bash pnpm install # 装所有 pnpm -r build # 在所有包里跑 build pnpm --filter api dev # 只对 api 包 ``` 更高级:Turborepo / Nx,加 task caching + 增量 build。 Python:用 uv workspace: ```toml # pyproject.toml [tool.uv.workspace] members = ["packages/*", "services/*"] ``` Go:go workspace: ```bash go work init go work use ./service-a ./service-b ./pkg/shared ``` Rust:Cargo workspace 在 `Cargo.toml` `[workspace]` 里。 ## CI 优化(monorepo 必备) GitHub Actions path filter: ```yaml on: pull_request: paths: - 'services/api/**' - 'packages/shared-models/**' - '.github/workflows/api.yml' jobs: test-api: ... ``` 只在 api 或它依赖的 shared 改动时跑 api 测试。 或者用 `dorny/paths-filter` action 做动态: ```yaml - uses: dorny/paths-filter@v3 id: changes with: filters: | api: 'services/api/**' web: 'services/web/**' shared: 'packages/shared-*/**' - if: steps.changes.outputs.api == 'true' || steps.changes.outputs.shared == 'true' run: pnpm --filter api test ``` 避免改一行触发全部 CI。 ## 我们的真实迁移 从 6 个独立 repo + Git submodule 到 monorepo: | | submodule before | monorepo after | |---|---|---| | 新人 clone 步骤 | 5 步 | 1 步 | | 跨 service 改动 | 6 个 PR | 1 个 PR | | build 时间(CI 全跑) | 18min | 12min(with cache: 4min) | | repo 大小 | 6 × 50MB | 280MB | | 全部签新版本 | 几小时 | 单 PR + tag | monorepo 显著简化。 ## 踩过的坑 ### submodule 类 1. **改 submodule 内代码忘 push**:在 lib/shared 改了 + 主 repo commit 更新引用 + 推主 repo。同事 clone 后 lib/shared 拿到的 commit 在 origin 不存在 → fail。**先 push submodule,再 push 主 repo**。 2. **`git checkout` 切分支 submodule 不变**:默认 git 不自动更新 submodule 到目标分支记录的版本。`git config submodule.recurse true` 让所有命令自动 recurse。 ### subtree 类 3. **subtree push 偶尔失败**:主 repo 内 commit 太散,subtree 算 patch 出错。先 squash 合并相关 commit 再 push。 ### monorepo 类 4. **某 service 重 build 全 monorepo**:必须做 path-based filter + build cache。否则 monorepo 反而比独立 repo 慢。 5. **依赖循环**:service A 用 shared B,shared B 用 service A 的某 helper(不该)→ 循环。monorepo workspace tool 应该报错;强制依赖 方向 packages/* → services/*,不反向。

CSS variables 做运行时主题:用户自选色彩 + 实时切换

## 起因 设计师要"用户能自选主色"——蓝色 / 紫色 / 红色 / 绿色等 5 个选项, 切换立刻全 UI 跟着变。 传统做法:每个主题独立 stylesheet,user 选 → 切换 `<link>` href。 重 + 慢 + 全 UI 闪一下。 CSS 变量(custom properties)让"颜色变量化",运行时改 root 上的变量值 → 所有引用立刻更新,无闪烁、无重新 load。 ## 基础 ```css :root { --color-primary: #2563eb; --color-primary-hover: #1d4ed8; --color-text: #1f2937; --color-bg: #ffffff; } button.primary { background: var(--color-primary); color: white; } button.primary:hover { background: var(--color-primary-hover); } ``` JS 改变量: ```js document.documentElement.style.setProperty('--color-primary', '#9333ea') // 所有用了 var(--color-primary) 的元素瞬间变紫 ``` ## 多主题 preset ```css :root { --color-primary: #2563eb; /* default 蓝 */ --color-primary-hover: #1d4ed8; } [data-theme="purple"] { --color-primary: #9333ea; --color-primary-hover: #7e22ce; } [data-theme="red"] { --color-primary: #dc2626; --color-primary-hover: #b91c1c; } [data-theme="green"] { --color-primary: #16a34a; --color-primary-hover: #15803d; } ``` 切换: ```js document.documentElement.setAttribute('data-theme', 'purple') ``` 零延迟切换。 ## 派生:HSL + alpha 灵活 把 primary 拆 H/S/L 分量存: ```css :root { --color-primary-h: 220; --color-primary-s: 90%; --color-primary-l: 56%; } button { background: hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l)); border: 1px solid hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / 0.3); } button:hover { /* hover 加深:l - 10% */ background: hsl(var(--color-primary-h) var(--color-primary-s) calc(var(--color-primary-l) - 10%)); } ``` 只改 hue 就换主题(不用每个 shade 独立定义): ```js root.style.setProperty('--color-primary-h', '270') // 紫 root.style.setProperty('--color-primary-h', '0') // 红 root.style.setProperty('--color-primary-h', '140') // 绿 ``` 省定义。 ## 用 color-mix(CSS Color 5) ```css button { background: var(--color-primary); } button:hover { background: color-mix(in srgb, var(--color-primary) 80%, black); } ``` `color-mix` 让浏览器自动算"加深 20%",不需要 hover-specific 变量。 Chrome 111+ / Safari 16.2+ / Firefox 113+ 支持。 ## 用户自定义主色:color picker ```tsx function ThemeCustomizer() { const [hue, setHue] = useState(220) useEffect(() => { document.documentElement.style.setProperty('--color-primary-h', hue.toString()) localStorage.setItem('theme-hue', hue.toString()) }, [hue]) return ( <div> <label>主色 hue</label> <input type="range" min="0" max="360" value={hue} onChange={e => setHue(+e.target.value)} /> <div style={{ background: `hsl(${hue} 90% 56%)`, width: 100, height: 30 }} /> </div> ) } ``` slider 拖动 → 全站颜色实时变。 保存到 localStorage → 下次访问还原。 ## CSS Color 5 高级 ```css :root { --color-primary: oklch(60% 0.2 250); } .text-on-primary { /* 自动算对比色 */ color: oklch(from var(--color-primary) calc(l > 50% ? 0 : 100%) 0 0); } ``` oklch 色彩空间感知更线性,主题派生更自然。 relative color syntax 让 "从 primary 派生 text" 一行写完。 Browser 支持 2024 大多 evergreen 已经 OK。 ## 暗色模式 ```css :root { --color-bg: white; --color-text: #111; } @media (prefers-color-scheme: dark) { :root { --color-bg: #0d1117; --color-text: #e6edf3; } } /* 手动覆盖 */ [data-mode="dark"] { --color-bg: #0d1117; --color-text: #e6edf3; } [data-mode="light"] { --color-bg: white; --color-text: #111; } ``` `<html data-mode="dark">` 强制;不设 attr 跟系统。 ## 防止 FOUC 如果初始化 theme 在 React 之后 → 闪一下默认主题再切。 解决:inline script 在 head 顶部立刻设: ```html <head> <script> (function() { const saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) })() </script> <link rel="stylesheet" href="/main.css"> </head> ``` 页面渲染前 theme attribute 已经在 → 没有 FOUC。 ## CSS 框架配合 Tailwind 用 CSS variables 已经标准化(v3.3+): ```js // tailwind.config.ts theme: { extend: { colors: { primary: 'hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / <alpha-value>)', }, }, } ``` ```html <button class="bg-primary text-primary-foreground">Click</button> <!-- bg-primary 自动用 CSS variable --> ``` 切换 variable → 所有 Tailwind class 立刻变。 ## 性能 CSS variable 修改是浏览器层面 reflow / repaint,非常快。 全站千个 element 用同一 variable 切换 < 1 frame(16ms)。 对比"重新 load stylesheet" 切换: - 重新解析 CSS(10-100ms) - 全 DOM repaint - 偶尔 flash variable 切换胜出。 ## 与 CSS-in-JS 对比 ```tsx // emotion / styled-components 风格 const StyledButton = styled.button` background: ${props => props.theme.primary}; ` <ThemeProvider theme={{ primary: 'blue' }}> <StyledButton>Click</StyledButton> </ThemeProvider> ``` CSS-in-JS theming: - 优点:JS 完整控制 + 静态 type - 缺点:bundle 大 + 运行时开销 + SSR 复杂 CSS variable: - 优点:原生 + 零运行时 + 兼容任何框架 - 缺点:无类型 + JS 改时要 string 我个人 2024 后偏向 CSS variable + Tailwind / shadcn 的方案, CSS-in-JS 用得少了。 ## 限制 / 注意 ### 1. variable 不能用在 @media 条件本身 ```css :root { --break: 768px; } /* ❌ 不行 */ @media (min-width: var(--break)) { ... } ``` @media 查询的值必须是 literal。 ### 2. variable 不能改 @keyframes ```css @keyframes slide { from { left: 0; } to { left: var(--target); } /* 这能用 */ } ``` keyframes 内部用 variable OK;但改 variable 后正在跑的动画不会重新算。 ### 3. 旧 IE / 老 Safari 不支持 IE 11 全不支持;Safari 9.1+ 支持但有 bug。 2024 这些已经不需考虑。 ## 我们的主题系统 ```css :root { /* 基础 hue 用户可调 */ --hue: 220; /* 派生:primary 系列 */ --color-primary-50: hsl(var(--hue) 90% 96%); --color-primary-100: hsl(var(--hue) 90% 90%); --color-primary-500: hsl(var(--hue) 90% 56%); --color-primary-700: hsl(var(--hue) 90% 36%); /* 派生:text on primary(自动选黑/白) */ --text-on-primary: white; /* 中性色 */ --color-gray-50: oklch(98% 0 0); --color-gray-500: oklch(50% 0 0); --color-gray-900: oklch(20% 0 0); } @media (prefers-color-scheme: dark) { :root { --color-primary-500: hsl(var(--hue) 80% 65%); /* 暗色下提亮 */ } } ``` 用户改 `--hue` → 整套主题色协调地变。 设计师 once 调好 hue 与各 shade 关系,用户只动 hue 不破坏视觉一致。 ## 踩过的坑 1. **`var(--x)` 没 fallback** → variable 没定义时整个声明无效: ```css color: var(--missing); /* 整个 color 不生效 */ color: var(--missing, black); /* fallback 到 black */ ``` 2. **JS setProperty 大小写敏感**:variable 名是 `--Color-Primary` 但 setProperty('--color-primary', ...) 设错变量。 3. **shadow DOM 隔离**:custom element 内部不继承 :root 变量。 需要在 shadow boundary 显式 forward。 4. **calc 不能跨单位**:`calc(var(--x) * 2px)` 错(var 已经带单位)。 存 unitless number + 在 calc 加 unit。 5. **打印模式**:`@media print` 没单独考虑主题 variable → 打印出来 是 default 颜色。专门 `@media print` 覆盖。

用 CSS 变量 + prefers-color-scheme 实现暗色模式(含手动切换)

暗色模式现在是基础体验。正确做法是用 CSS 变量统一所有颜色, 然后 `@media (prefers-color-scheme: dark)` 覆盖变量。 手动切换则用 `[data-theme="dark"]` 选择器。 ## 1. 颜色变量化 ```css :root { --bg: #ffffff; --card: #f8f9fa; --text: #1a1a1a; --text-soft: #6b7280; --border: #e5e7eb; --primary: #2563eb; --primary-soft: #eff6ff; } body { background: var(--bg); color: var(--text); } .card { background: var(--card); border: 1px solid var(--border); } ``` 任何颜色都引用变量,绝不写死 `color: #333`。 ## 2. 自动跟随系统(最少代码) ```css @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; --card: #1e293b; --text: #f1f5f9; --text-soft: #94a3b8; --border: #334155; --primary: #60a5fa; --primary-soft: #1e3a8a; } } ``` 只这样:暗色模式自动跟系统设置变。 ## 3. 手动切换(覆盖系统设置) 加 `[data-theme]` 优先级: ```css :root { --bg: #fff; /* ... */ } @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; /* ... */ } } [data-theme="dark"] { --bg: #0f172a; /* ... */ } [data-theme="light"] { --bg: #ffffff; /* ... */ } ``` JS: ```js function setTheme(theme) { // 'dark' | 'light' | 'system' if (theme === 'system') { document.documentElement.removeAttribute('data-theme') localStorage.removeItem('theme') } else { document.documentElement.setAttribute('data-theme', theme) localStorage.setItem('theme', theme) } } // 启动时还原 const saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) ``` ## 4. 避免 FOUC(First Of Unstyled Content) 如果上面的代码放在 `<script>` 模块末尾,会有一闪而过的错误主题。 解决:把"还原主题"提前到 `<head>` 顶部、inline 脚本: ```html <head> <script> (function() { var saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) })() </script> <!-- 后面才是 CSS --> </head> ``` inline 脚本同步执行,CSS 应用前主题已就位。 ## 5. 监听系统切换 用户用着用着把系统切到暗色——网页要立刻跟上: ```js const mq = window.matchMedia('(prefers-color-scheme: dark)') mq.addEventListener('change', e => { // 只在没设手动主题时跟随 if (!localStorage.getItem('theme')) { // CSS @media 会自动响应,这里不需要做什么,但如果你有 JS // 在用 mediaquery 判断当前主题,要刷新 updateChartTheme(e.matches ? 'dark' : 'light') } }) ``` ## 6. 图像 / iframe 内容怎么办 普通 `<img>` 没法跟随暗色。常用方案: ```html <picture> <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)"> <img src="logo-light.svg" alt="Logo"> </picture> ``` SVG icon 是 CSS color 控制的(用 `currentColor`): ```html <svg class="icon" fill="currentColor">...</svg> ``` ```css .icon { color: var(--text); } ``` iframe 内容(地图、第三方嵌入)就只能自己看是否支持 `?theme=dark` 参数。 ## 7. 配色技巧 不是简单地"颜色取反",几个要点: - 暗色背景不要纯黑(#000),用 #0d1117 / #0f172a 这种"接近黑" 减少 contrast 疲劳 - 卡片背景比 body 稍亮(如 #161b22) - 文字 #e6edf3 而不是 #fff,避免高对比刺眼 - 主色 brand color 通常要在暗色下稍微 desaturate + lighten - shadow 在暗色下基本看不见,可以用 border 替代分隔 GitHub Dark / Tailwind slate 系列是优秀范本,直接借用色值。 ## 8. 给暗色添加缓动 切换时颜色突变很扎眼,加 transition: ```css :root { /* ... */ } body, .card, .button { transition: background-color .2s, color .2s, border-color .2s; } ``` 但别把 `transition: all` 加在所有元素上,会触发不必要的重排。 ## 踩过的坑 - 写死的 SVG 颜色 / inline style 是暗色模式的常见漏网之鱼。grep 整个 代码库 `#[0-9a-fA-F]{3,6}` 看哪些没用变量。 - 图表库 / 代码高亮通常自带主题,需要单独切。Pygments / Highlight.js 都有 light + dark theme 包。 - 系统暗色模式但用户手动选浅色——很多人不太会用,记得在 settings 里 加 "跟随系统 / 强制亮色 / 强制暗色" 三选项。 - 别忘了 favicon / `meta[name=theme-color]` 也支持 prefers-color-scheme: ```html <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)"> ```

用 perf 找出 CPU 100% 进程的热点函数(含火焰图)

应用 CPU 跑满,不知道卡在哪个函数?`perf` 是 Linux 性能分析的"瑞士军刀": 低开销采样,看到 CPU 时间花在每个函数 / 每行指令上。 配合 FlameGraph 出火焰图,一眼看清谁吃 CPU。 ## 1. 装 perf ```bash sudo apt install -y linux-tools-common linux-tools-$(uname -r) sudo perf --version ``` CentOS / RHEL:`sudo yum install perf`。 ## 2. 系统级采样 ```bash # 采样全系统 10 秒 sudo perf record -F 99 -a -g -- sleep 10 # -F 99: 每秒 99 次采样(避免和定时任务整数倍重合) # -a: 全系统 # -g: 抓 call graph ls perf.data # 几 MB sudo perf report ``` `perf report` TUI 里: - `+/-` 展开 / 折叠调用栈 - `a` 显示汇编 - `/` 搜函数名 按 CPU 百分比从高到低排: ``` 17.32% nginx libc-2.31.so [.] __memcpy_avx_unaligned 12.45% nginx nginx [.] ngx_http_parse_header_line 8.21% nginx libssl.so.3 [.] ssl3_read_bytes ... ``` ## 3. 单进程采样 ```bash sudo perf record -F 99 -p <PID> -g -- sleep 30 sudo perf report ``` `sleep 30` 是采样时长。`-p <PID>` 限定单进程。 ## 4. 火焰图(一图胜千言) ```bash # 装 FlameGraph 脚本 git clone https://github.com/brendangregg/FlameGraph ~/FlameGraph # 采样 sudo perf record -F 99 -p <PID> -g -- sleep 30 sudo perf script > /tmp/out.perf # 生成 SVG ~/FlameGraph/stackcollapse-perf.pl /tmp/out.perf > /tmp/out.folded ~/FlameGraph/flamegraph.pl /tmp/out.folded > /tmp/flame.svg # 浏览器打开 /tmp/flame.svg xdg-open /tmp/flame.svg ``` 火焰图怎么读: - **X 轴**:CPU 占用(宽度 = 占用比例,与时间无关) - **Y 轴**:调用栈深度(栈顶在上) - **宽块**:耗 CPU 的函数(从顶往下看,找最宽的就是热点) ## 5. 看具体函数的汇编 / 源码 ```bash sudo perf report --stdio # 文本输出 sudo perf annotate function_name # 看汇编 + 哪一行最热 ``` 需要程序带 debug symbols(`-g` 编译,或装 `*-dbg` 包): ```bash sudo apt install -y nginx-dbg postgresql-16-dbgsym ``` ## 6. 上下文切换 / 系统调用 / 缓存命中 ```bash # 看进程的上下文切换 / 缺页 / cache miss sudo perf stat -p <PID> -- sleep 10 # Performance counter stats for process id 12345: # 3,532.18 msec task-clock (10 cpus utilized) # 48,927 context-switches # 13,200 cpu-migrations # 1,234 page-faults # 9,876,543,210 cycles # 4,321,098,765 instructions (0.44 insn per cycle) # 789,012,345 branches # 34,567,890 branch-misses (4.38% of all branches) # 看具体的硬件事件 sudo perf stat -e cache-misses,cache-references,L1-dcache-load-misses -p <PID> -- sleep 10 ``` `branch-misses` 高(> 5%)通常意味着分支预测不友好的代码。 `instructions per cycle` < 1 是性能差的信号。 ## 7. live 模式(top 风格) ```bash sudo perf top -p <PID> # 实时看哪些函数当前最热 ``` 排查"刚才一瞬间 CPU 高了"很有用。 ## 8. trace syscall(替代 strace 的高性能版) ```bash sudo perf trace -p <PID> # 实时显示进程的所有 syscall(比 strace 开销小 10x) sudo perf trace -s -p <PID> -- sleep 10 # 统计 10 秒内的 syscall 次数 + 耗时 ``` ## 9. Python / Node / Java 特殊处理 perf 默认看不到解释器 / VM 里的函数名,需要特殊 hook: ```bash # Python: 用 py-spy 替代 perf pip install py-spy sudo py-spy record -o profile.svg --pid <PID> # Node.js: 用 0x(npm install -g 0x)或 node --perf-basic-prof node --perf-basic-prof app.js # 然后 perf record 会读 /tmp/perf-<PID>.map 解析 JS 函数名 # Java: 用 async-profiler java -agentpath:/path/to/libasyncProfiler.so=start,event=cpu,file=profile.html ... ``` ## 10. 远程 / 容器内 ```bash # 容器内的进程:在 host 上跑 perf,看到的是 host 视角的 PID sudo perf record -F 99 -p $(pgrep -f my-container-process) -g -- sleep 10 # 如果容器内 / 进程内没 debug symbols,host 上的 perf 看到的也是 [unknown] # 解决:把容器内的 /usr/lib/debug 也挂到 host,或在容器里跑 perf ``` ## 11. 实战案例:Python web 应用慢 ```bash # 看哪个进程在烧 CPU top -H -p $(pgrep gunicorn | head -1) # 多个线程?PID 列里的 TID sudo py-spy top --pid <TID> # 实时看每个 Python 函数 CPU 占比 # 或者出火焰图 sudo py-spy record -o /tmp/flame.svg --pid <PID> --duration 30 ``` ## 12. 注意采样精度 `-F 99` 是每秒 99 次。意味着: - 持续 100ms 的函数:约 10 个样本,足够看见 - 持续 1ms 的函数:约 0.1 样本,看不到 短函数 + 高频调用看 `-F 999` 或 `-F 4999`(CPU 开销变大但更精细)。 ## 踩过的坑 - 没装 debug symbols:火焰图全是 `[unknown]` 一片黑。装 `*-dbg` / 重编译加 `-g`。 - 容器 + minimal image:image 里没 debug symbols,分析复合 image 把 dbg 包打进去。 - `perf record` 写盘飞快(GB 级 perf.data),磁盘满:缩短采样时间, 或者 perf script 后立刻删 perf.data。 - root 才能 perf:调 `sysctl kernel.perf_event_paranoid=-1` 让普通用户 可以采样(生产环境慎用,有信息泄露风险)。

CSS View Transitions API:原生页面切换动画(不再装 Framer Motion)

## 起因 要做"列表页 → 详情页"的过渡动画——卡片放大变成详情头图。 传统做法:装 Framer Motion / GSAP 写 layoutId 切换,几十行 JS。 `View Transitions API` 是 2024 年 Chrome 全面支持的浏览器原生功能, 两行 CSS + 一行 JS 就能让任何 DOM 切换变成 morph 动画。 ## 解决方案 ### 1. 最简单的同页面状态切换 ```html <style> ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; } </style> <button id="toggle">切换</button> <div id="content">Hello</div> <script> document.getElementById('toggle').addEventListener('click', () => { if (!document.startViewTransition) { // fallback: 不支持的浏览器直接改 document.getElementById('content').textContent = Math.random().toString(36).slice(2, 7) return } document.startViewTransition(() => { document.getElementById('content').textContent = Math.random().toString(36).slice(2, 7) }) }) </script> ``` 效果:内容用平滑 crossfade 切换,而不是瞬间替换。 默认动画时长 0.25s。 ### 2. 给特定元素命名 → 跨页面 morph ```html <style> .product-card img { view-transition-name: product-hero; } .product-detail .hero { view-transition-name: product-hero; } </style> ``` 列表页的卡片图和详情页的头图都叫 `product-hero`。 切换页面时(用 startViewTransition 包裹)浏览器自动 morph 一个元素到另一个。 ```ts // 列表页点击跳详情时 function navigate(href) { if (!document.startViewTransition) { location.href = href return } document.startViewTransition(() => { // 这里切换 DOM;SPA 用 router push router.push(href) }) } ``` 效果:卡片图无缝放大到详情页大图位置,浏览器算 FLIP 动画。 **不需要 Framer Motion 的 layoutId**。 ### 3. SPA 路由集成 #### React Router 6.4+ ```tsx import { useNavigate } from 'react-router-dom' import { flushSync } from 'react-dom' function MyLink({ to, children }) { const navigate = useNavigate() return ( <a href={to} onClick={(e) => { e.preventDefault() if (!document.startViewTransition) { navigate(to) return } document.startViewTransition(() => { flushSync(() => { navigate(to) }) // flushSync 让 React 立刻渲染,view transition 才能比较新旧 DOM }) }} > {children} </a> ) } ``` #### SvelteKit `+layout.svelte` 加一行: ```svelte <script> import { beforeNavigate } from '$app/navigation' beforeNavigate(({ complete }) => { if (document.startViewTransition) { const transition = document.startViewTransition(() => complete) } }) </script> ``` #### Astro 直接 `<ViewTransitions />` 标签开启全站,无需自己 hook: ```astro --- import { ViewTransitions } from 'astro:transitions' --- <head> <ViewTransitions /> </head> ``` ### 4. 自定义动画 ```css ::view-transition-old(product-hero) { animation: 0.3s ease-out fade-out; } ::view-transition-new(product-hero) { animation: 0.4s ease-in fade-in; } @keyframes fade-out { to { opacity: 0; transform: translateY(-20px); } } @keyframes fade-in { from { opacity: 0; transform: translateY(20px); } } ``` 每个命名元素独立设动画。无名的全局用 `::view-transition-old(root)`。 ### 5. 同时多个 view-transition-name 详情页同时 morph 头图 + 标题 + 按钮: ```css .list .card .img { view-transition-name: hero-img; } .list .card .title { view-transition-name: hero-title; } .detail .hero { view-transition-name: hero-img; } .detail h1 { view-transition-name: hero-title; } ``` 浏览器同时为 hero-img 和 hero-title 做 morph,看起来三个元素 "飞到"详情页位置。 注意:**同一时间 viewport 内每个 view-transition-name 只能有一个元素**。 列表页和详情页的元素不会同时存在,但列表页有两张卡片都叫 `hero-img` 就出错。要给每张卡片不同 name: ```css .card-1 .img { view-transition-name: hero-1; } .card-2 .img { view-transition-name: hero-2; } ``` 或者用 CSS variable: ```css .card .img { view-transition-name: var(--vt-name); } ``` ```jsx <div class="card" style={{ '--vt-name': `card-${id}` }}> ``` ### 6. 检测支持 ```ts if ('startViewTransition' in document) { // 支持 } else { // 不支持,立刻切换(无动画) } ``` Chrome / Edge 111+ 全支持;Safari 18+ 支持单文档;跨文档(MPA)navigation 还要 Chrome 126+。Firefox 还在开发。 不支持时优雅降级:动画不出现,但不影响功能。 ## 实战 demo ```html <!DOCTYPE html> <html> <head> <style> body { margin: 0; font-family: sans-serif; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; } .card { background: white; border-radius: 8px; overflow: hidden; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.1); } .card img { width: 100%; height: 200px; object-fit: cover; } .detail { display: none; padding: 24px; } .detail.show { display: block; } .detail .hero { width: 100%; height: 400px; object-fit: cover; border-radius: 12px; } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; } </style> </head> <body> <div class="grid" id="grid"> <div class="card" data-id="1"> <img src="https://picsum.photos/seed/1/800/600" style="view-transition-name: hero-1"> <p>Card 1</p> </div> <div class="card" data-id="2"> <img src="https://picsum.photos/seed/2/800/600" style="view-transition-name: hero-2"> <p>Card 2</p> </div> </div> <div class="detail" id="detail"> <img class="hero" id="hero-img"> <button onclick="back()">← back</button> </div> <script> document.querySelectorAll('.card').forEach(card => { card.addEventListener('click', () => { const id = card.dataset.id const img = card.querySelector('img') // 详情页用同样的 view-transition-name const detailImg = document.getElementById('hero-img') detailImg.style.viewTransitionName = `hero-${id}` detailImg.src = img.src.replace('800/600', '1600/800') document.startViewTransition(() => { document.getElementById('grid').style.display = 'none' document.getElementById('detail').classList.add('show') }) }) }) function back() { document.startViewTransition(() => { document.getElementById('detail').classList.remove('show') document.getElementById('grid').style.display = 'grid' }) } </script> </body> </html> ``` 打开 Chrome 看,点 card 图片"飞"成大图 + 反向退出。零依赖。 ## 效果 - 列表 → 详情过渡从"瞬间切换"变"流畅 morph",体验提升明显 - bundle 减去 ~30KB(不装 Framer Motion) - 与现有 framework 集成只需 5-10 行 hook 代码 - 不支持的浏览器 graceful fallback 到无动画 ## 跨文档 transitions(MPA) Chrome 126+ 支持多页面 navigation 时的 view transition, 完全不需要 SPA。在两个 HTML 页面里 CSS 加: ```css @view-transition { navigation: auto; } ``` 浏览器 navigate 时自动应用 view transition。**SSR / 静态站也能有 SPA 般的过渡**。 ## 踩过的坑 1. **viewport 外的元素不参与**:如果列表卡片在视口外,detail 元素 morph 起点错误。让点击的卡片先 scrollIntoView 再触发 transition。 2. **同名元素冲突**:CSS 报"view-transition-name must be unique"。 不要在 React map 里所有 item 用同 name。 3. **transition 期间 click 失效**:transition 时整页冻结。复杂交互前 注意 transition duration 别太长。 4. **iOS Safari 18 之前不支持**:移动端覆盖度 2024 末期才到 80%+。 渐进增强是必须的。 5. **图片 src 切换 + view transition 并发**:浏览器对新 image 还没 下载完时 transition 显示空白。`<img>` 加 `decoding="sync"` 或 pre-cache 大图。

PostgreSQL 表分区(partitioning):让 10 亿行表也能秒查

单张 PG 表超过几亿行后: - 索引体积膨胀,新插入慢 - VACUUM / 索引重建动辄几小时 - 删旧数据扫全表慢 分区把一张大表按某列(通常是时间)分成多个物理子表,PG 透明 routing 查询到对应分区。下面是声明式分区(PG 10+)的完整流程。 ## 1. 按月分区一张事件表 ```sql -- 主表(不能直接插数据,只是定义结构 + 分区策略) CREATE TABLE events ( id BIGSERIAL, user_id BIGINT NOT NULL, type TEXT NOT NULL, payload JSONB, occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (id, occurred_at) -- partition key 必须在 PK 里 ) PARTITION BY RANGE (occurred_at); -- 索引:每个分区自动继承 CREATE INDEX events_user_id_idx ON events (user_id); CREATE INDEX events_type_idx ON events (type); ``` ## 2. 创建实际分区 ```sql CREATE TABLE events_2026_05 PARTITION OF events FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); CREATE TABLE events_2026_06 PARTITION OF events FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); CREATE TABLE events_2026_07 PARTITION OF events FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); ``` INSERT 进 `events` 时 PG 自动 routing: ```sql INSERT INTO events (user_id, type, occurred_at) VALUES (42, 'login', '2026-06-15 10:00'); -- 实际写入 events_2026_06 ``` ## 3. 查询:partition pruning ```sql EXPLAIN SELECT count(*) FROM events WHERE occurred_at >= '2026-06-01' AND occurred_at < '2026-07-01'; -- Aggregate -- -> Seq Scan on events_2026_06 -- 只扫一个分区!其它分区直接 skip ``` 如果 WHERE 没限定 partition key,PG 会扫所有分区,性能可能反而比单表差。 **所有查询都要带 partition key 条件**。 ## 4. 自动建未来分区 每月手动建分区容易忘。用 [pg_partman](https://github.com/pgpartman/pg_partman) 扩展: ```sql CREATE EXTENSION pg_partman; SELECT partman.create_parent( p_parent_table => 'public.events', p_control => 'occurred_at', p_type => 'range', p_interval => '1 month', p_premake => 6 -- 提前 6 个月建分区 ); ``` 加个定时任务定期跑维护: ```sql SELECT partman.run_maintenance(); ``` 自动建未来分区 + 自动删过期分区(如果配了 retention)。 ## 5. 删历史数据:秒杀 传统方案:`DELETE FROM events WHERE occurred_at < '2024-01-01'` — 几亿行 DELETE,VACUUM 几小时。 分区方案: ```sql DROP TABLE events_2024_01; -- 或: ALTER TABLE events DETACH PARTITION events_2024_01; -- DETACH 让分区脱离主表但保留物理;可以备份后再 DROP ``` DDL 操作,毫秒级完成 + 释放磁盘空间。 ## 6. 分区策略选择 | 策略 | 适合 | |---|---| | RANGE | 按时间 / 数值范围(最常用) | | LIST | 按枚举值(如 country='CN' 一个分区) | | HASH | 按 hash 模 N,均匀分布;适合 user_id 这种高基数 | ```sql -- LIST CREATE TABLE orders (...) PARTITION BY LIST (country); CREATE TABLE orders_cn PARTITION OF orders FOR VALUES IN ('CN'); CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US'); CREATE TABLE orders_other PARTITION OF orders DEFAULT; -- HASH CREATE TABLE users (...) PARTITION BY HASH (id); CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0); CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1); CREATE TABLE users_p2 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 2); CREATE TABLE users_p3 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 3); ``` ## 7. 索引在分区表里 ```sql -- 在主表上建索引 = 在所有分区上建对应索引 CREATE INDEX events_user_id_idx ON events (user_id); -- 新分区自动继承 CREATE TABLE events_2026_08 PARTITION OF events FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); \d events_2026_08 -- 看到 user_id 索引自动存在 ``` PG 12 之后这是默认行为;PG 11 需要手动给每个分区单独建。 ## 8. UNIQUE 约束的限制 ```sql -- ❌ 主表 UNIQUE 必须包含 partition key ALTER TABLE events ADD UNIQUE (user_id); -- ERROR -- ✅ ALTER TABLE events ADD UNIQUE (user_id, occurred_at); ``` 跨分区的全局唯一性 PG 原生不支持。如果业务要"id 全表唯一", 要么把 id 加 partition key 一起当唯一,要么用 UUID(碰撞概率天文)。 ## 9. INSERT 性能 分区表 INSERT 比单表稍慢(PG 要算 routing),但写大表反而更快 (小分区 + 小索引 = 写少)。10 亿行规模分区表能比单表快 5-10 倍写入。 ## 10. ATTACH / DETACH 分区 把已经存在的表挂为新分区: ```sql CREATE TABLE events_2025_archived (... 同 events 结构 ...); -- 数据 COPY 进去... ALTER TABLE events ATTACH PARTITION events_2025_archived FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); ``` ATTACH 时 PG 会扫描确认数据都满足分区条件(耗时)。生产建议先 `ALTER TABLE ... ADD CONSTRAINT events_2025_check CHECK (occurred_at >= '2025-01-01' AND occurred_at < '2025-02-01')` 让 PG 跳过扫描。 ## 11. 跨分区 query 优化 ```sql -- 这条会扫所有分区 SELECT * FROM events WHERE user_id = 42; -- 加 partition key 限定能 prune SELECT * FROM events WHERE user_id = 42 AND occurred_at >= '2026-01-01'; ``` 设计分区时考虑常见查询的 WHERE 条件。 ## 12. 监控 ```sql -- 各分区大小 SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class WHERE relname LIKE 'events_%' ORDER BY pg_relation_size(oid) DESC; -- 各分区行数(统计估算,快) SELECT relname, reltuples::bigint AS rows FROM pg_class WHERE relname LIKE 'events_%'; ``` ## 踩过的坑 - 分区数过多(> 1000)→ planner 慢,每次查询都要遍历所有分区元数据。 保持 < 1000 分区,或合并老分区。 - 没配 partman maintenance / 没自动建未来分区 → INSERT 找不到分区报错。 应用 down 直到手动建。 - 跨分区 UNIQUE 不可能 → 业务设计阶段就要明确"全表唯一" vs "分区内唯一"。 - 老 PG 9.x 用继承(INHERITS)实现 partition,PG 10+ 的声明式 partition 完全不同 + 更好用。不要混淆。

Python: GIL / asyncio / multiprocessing / threading 选谁

## 起因 新人经常困惑:"Python 怎么做并发?asyncio / threading / multiprocessing 都是干嘛的?什么时候用哪个?" 下面用具体场景拆开。 ## GIL 是什么 Python(CPython 实现)的 GIL(Global Interpreter Lock)让同一时刻 只有一个 thread 在跑 Python bytecode。 threading 在 CPU 密集任务上**得不到并行加速**。 CPython 3.13+ 实验性的 "free-threaded" build 移除 GIL,但默认还是有 GIL,下面假设默认。 ## 三种并发选哪个 | 场景 | 推荐 | |---|---| | IO 密集(HTTP / DB / file) + 高并发 | asyncio | | IO 密集 + 现有同步代码 + 中等并发 | threading | | CPU 密集(数学 / 加密 / 解析) | multiprocessing | | 数据科学(numpy / pandas) | 用 numpy 内部并行 | | 跑 N 个独立 task(如批处理) | concurrent.futures.ProcessPoolExecutor | ## 详细 1:asyncio (IO 密集) ```python import asyncio import aiohttp async def fetch(url): async with aiohttp.ClientSession() as s: async with s.get(url) as r: return await r.text() async def main(): urls = ['https://x.com', 'https://y.com', 'https://z.com'] results = await asyncio.gather(*[fetch(u) for u in urls]) print(len(results)) asyncio.run(main()) ``` 3 个 fetch 并发跑(不是真的并行,但 IO 等待时事件循环切换)。 适合: - web server(FastAPI / Sanic / aiohttp) - API gateway - crawler - WebSocket 服务端 GIL 不阻碍 IO,所以 asyncio 单进程能处理 10k+ 并发连接。 ## 详细 2:threading (IO 密集 + legacy code) ```python import threading import requests def fetch(url): return requests.get(url).text threads = [threading.Thread(target=fetch, args=(u,)) for u in urls] for t in threads: t.start() for t in threads: t.join() ``` 跟 asyncio 类似的 IO 并发,但用同步代码 + thread。 threading 优势: - 不用改成 async - 现有 sync code 直接用 - thread pool 简单 劣势: - 创建 thread 开销大(asyncio coroutine 几 KB / thread 几 MB) - 上下文切换贵 - 并发上限低(几百 thread vs asyncio 万级 coroutine) 实际: - Django / Flask 用 thread-based server (gunicorn sync workers): 通常够用 - 高并发 / 长连接 → 改 asyncio ## 详细 3:multiprocessing (CPU 密集) ```python from multiprocessing import Pool def cpu_heavy(n): return sum(i * i for i in range(n)) with Pool(processes=8) as pool: results = pool.map(cpu_heavy, [10_000_000] * 8) ``` 8 个 Python 进程并行跑 → 真正利用多核 CPU。 GIL 是 per-process 的。多进程绕过 GIL → 多核并行计算。 代价: - 进程启动慢(几十 ms) - 进程间通信只能 pickle(大数据传输贵) - 每进程独立 RAM(不共享 Python 对象) 适合: - 图像处理批量 - 机器学习预处理 - 复杂数学 / 解析 ## 详细 4:concurrent.futures(统一 API) ```python from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor # IO 密集 → thread with ThreadPoolExecutor(max_workers=20) as ex: results = list(ex.map(fetch_url, urls)) # CPU 密集 → process with ProcessPoolExecutor(max_workers=8) as ex: results = list(ex.map(cpu_heavy, datas)) ``` `concurrent.futures` 是 thread / process 的统一封装。 简单"batch 处理"场景首选。 ## 详细 5:用 numpy / pandas / pytorch 替代手写 CPU 并发 ```python import numpy as np # ❌ 手写循环(慢 + GIL) result = [x * 2 + y for x, y in zip(arr1, arr2)] # ✅ numpy vectorize result = arr1 * 2 + arr2 # C 层并行 + SIMD ``` NumPy / pandas / PyTorch 内部 release GIL 跑 C 代码 + 多核 SIMD。 "用对工具"比"加并发"快得多。 ## 混合:asyncio + thread pool asyncio 里需要调同步代码(如 pandas / requests): ```python import asyncio async def main(): loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, blocking_function, arg) ``` `run_in_executor` 把同步函数丢线程池跑,async 继续。 小心:仍受 GIL 限制(同步函数还是单核)。CPU 密集时换 `ProcessPoolExecutor`: ```python from concurrent.futures import ProcessPoolExecutor pool = ProcessPoolExecutor() result = await loop.run_in_executor(pool, cpu_heavy, data) ``` ## 实战:crawler 需求:抓 10000 个网页 + 解析(IO 密集 + 轻量 CPU)。 ```python import asyncio import aiohttp from bs4 import BeautifulSoup async def fetch_and_parse(session, url): async with session.get(url) as r: html = await r.text() # 解析在主线程(轻量) soup = BeautifulSoup(html, 'html.parser') return soup.title.string if soup.title else '' async def main(urls): semaphore = asyncio.Semaphore(50) # 限并发 async with aiohttp.ClientSession() as session: async def bounded(url): async with semaphore: return await fetch_and_parse(session, url) results = await asyncio.gather(*[bounded(u) for u in urls]) return results asyncio.run(main(urls)) ``` 50 并发同时跑 → 10000 URL 几分钟完成。 单 Python 进程,内存几百 MB。 如果解析很重(NLP / image),把解析丢 process pool: ```python async def fetch_and_dispatch(session, url, pool): async with session.get(url) as r: html = await r.text() loop = asyncio.get_running_loop() # 解析跑 process pool(绕过 GIL) return await loop.run_in_executor(pool, heavy_parse, html) ``` ## 实战:图像批量处理 CPU 密集 → multiprocessing: ```python from multiprocessing import Pool from PIL import Image def process(path): img = Image.open(path) img.thumbnail((800, 800)) img.save(path.replace('.jpg', '_thumb.jpg')) if __name__ == '__main__': with Pool(processes=8) as pool: pool.map(process, image_paths) ``` `if __name__ == '__main__':` 必须(multiprocessing fork 模式要)。 8 核机器 ~ 8x 加速。 ## free-threaded Python (3.13t) Python 3.13 引入 `--disable-gil` build: ```bash # 装 free-threaded version uv python install 3.13t ``` 理论上 threading 在 CPU 密集任务能并行。 但: - 实验性 + 慢一些(单线程慢 10-20%) - C extension 兼容性问题(很多包还没支持) - 几年内还不是默认 生产暂时仍用 GIL build + multiprocessing。 ## 性能对比(10000 URL fetch) | 方法 | 时间 | 内存 | |---|---|---| | sync requests + for loop | 30 min | 50 MB | | threading + ThreadPool(20) | 3 min | 200 MB | | threading + ThreadPool(200) | 1.5 min | 800 MB | | asyncio + aiohttp + sem(50) | 2 min | 150 MB | | asyncio + aiohttp + sem(200) | 45s | 250 MB | | multiprocessing | 没意义(IO 任务) | - | IO 任务:asyncio 全胜。 CPU 任务(10000 张图缩略): | 方法 | 时间 | |---|---| | sync for loop | 50 min | | threading | 48 min(GIL,几乎没加速) | | multiprocessing(8) | 8 min | | numpy vectorize 改写(若适用) | 5 min | ## 选型决策树 ``` 你的任务是? ├── IO 密集(网络 / 文件 / DB) │ ├── 高并发(万级)→ asyncio │ ├── 已有 sync code 不想改 → threading + ThreadPoolExecutor │ └── 简单批处理 → concurrent.futures.ThreadPoolExecutor │ ├── CPU 密集 │ ├── 数学 / 矩阵 → numpy / pytorch(向量化) │ └── 一般计算 → multiprocessing / ProcessPoolExecutor │ └── 混合 └── asyncio + run_in_executor(ProcessPoolExecutor) ``` ## 踩过的坑 1. **threading + requests**:默认 `requests.Session` 不 thread-safe。 每线程独立 session 或用 `aiohttp` async。 2. **multiprocessing + fork**:Linux fork 复制整个进程 → 大数据 in parent 也复制 → 内存爆。改 `spawn` 或者把 data 写文件 worker 读。 3. **asyncio 里调 `time.sleep(5)`** → 阻塞整个 event loop。 `await asyncio.sleep(5)`。 4. **mixed event loop**:多个 `asyncio.run` 嵌套 → 报"event loop already running"。一般 `asyncio.run` 只在 main 调一次。 5. **multiprocessing on Windows / Mac**:spawn 模式要求 worker 函数 可 pickle + 子进程重新 import module → 用 `if __name__ == '__main__':` 保护。

自托管 Tempo + Grafana 替代 Jaeger 做分布式追踪后端

## 起因 之前用 Jaeger 做 trace 后端。痛点: - ES 后端吃内存 + 维护 ES 集群烦 - UI 比 Grafana 弱,跟 Prometheus / Loki 不在一处看 - trace 数据保留几天就磁盘满 Tempo 是 Grafana Labs 的开源 trace backend,专为对象存储设计: - 后端是 S3 / GCS / 任意对象存储(便宜 + 无限容量) - 不用 ES,省内存 - 跟 Grafana / Loki / Prometheus 同生态,单 UI 串起 metric / log / trace ## 安装 `docker-compose.yml`(开发 / 单机生产): ```yaml services: tempo: image: grafana/tempo:2.6.0 command: ['-config.file=/etc/tempo/tempo.yaml'] volumes: - ./tempo.yaml:/etc/tempo/tempo.yaml - tempo-data:/var/tempo ports: - "3200:3200" # tempo API - "4317:4317" # OTLP gRPC - "4318:4318" # OTLP HTTP grafana: image: grafana/grafana:11.2.0 ports: ["3000:3000"] environment: GF_AUTH_ANONYMOUS_ENABLED: 'true' GF_AUTH_ANONYMOUS_ORG_ROLE: 'Admin' volumes: - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/ds.yaml volumes: tempo-data: ``` `tempo.yaml`: ```yaml server: http_listen_port: 3200 distributor: receivers: otlp: protocols: grpc: { endpoint: 0.0.0.0:4317 } http: { endpoint: 0.0.0.0:4318 } ingester: trace_idle_period: 10s max_block_duration: 5m compactor: compaction: block_retention: 168h # 7 天保留 storage: trace: backend: local # 本地磁盘;生产换 s3 / gcs local: path: /var/tempo/traces wal: path: /var/tempo/wal ``` 生产 storage 切到 S3: ```yaml storage: trace: backend: s3 s3: endpoint: s3.us-east-1.amazonaws.com bucket: my-tempo-traces access_key: ${AWS_ACCESS_KEY_ID} secret_key: ${AWS_SECRET_ACCESS_KEY} wal: path: /var/tempo/wal ``` S3 / Backblaze B2 / Cloudflare R2 都可。Tempo 把 trace 块存对象存储, "无限"容量 + 极便宜(B2 $6/TB/月)。 ## Grafana 接 Tempo `grafana-datasources.yaml`: ```yaml apiVersion: 1 datasources: - name: Tempo type: tempo access: proxy url: http://tempo:3200 isDefault: true jsonData: tracesToLogsV2: datasourceUid: 'loki' spanStartTimeShift: '-1m' spanEndTimeShift: '1m' tags: ['service.name', 'pod', 'container'] serviceMap: datasourceUid: 'prometheus' nodeGraph: enabled: true ``` 启动后 Grafana → Explore → 选 Tempo → 输 trace ID 直接查; 或用 Search 按 service / operation / tag 找。 ## 应用端发 trace 跟之前 OpenTelemetry 那篇一样,应用配 OTLP 上报到 `tempo:4317`: ```bash OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4317 \ opentelemetry-instrument myapp ``` Tempo 完全 OTLP 兼容,不需要换 SDK。 ## TraceQL(强大查询语言) Tempo 2.x 支持 TraceQL,类似 LogQL 但针对 trace: ``` # 找耗时 > 1s 的 trace { duration > 1s } # 找特定 service 的 error { service.name = "api" && status = error } # 找 span 含特定 attr { span.http.status_code = 500 } # 找包含两个特定操作的 trace { name = "db.query" } && { name = "redis.set" } ``` 比 Jaeger UI 表单过滤强大很多。复杂查询能精确定位"哪类调用慢 / 错"。 ## 与 Loki 关联:trace ↔ log trace 视图里点 span → 显示该时段对应服务的 log(Loki 拉)。 反过来:Loki 日志里有 trace_id → 点击直接跳 trace 视图。 让 OTel SDK 在 log 中注入 trace_id: ```python import logging from opentelemetry.instrumentation.logging import LoggingInstrumentor LoggingInstrumentor().instrument(set_logging_format=True) ``` log 自动带 trace_id / span_id。Loki 配置认识这个字段。 ## 与 Prometheus 关联:service graph + metric ```yaml # tempo.yaml metrics_generator: registry: external_labels: source: tempo storage: path: /var/tempo/generator/wal remote_write: - url: http://prometheus:9090/api/v1/write overrides: defaults: metrics_generator: processors: [service-graphs, span-metrics] ``` Tempo 自动从 trace 生成: - `traces_service_graph_request_total`:服务调用关系(A 调 B 多少次) - `traces_spanmetrics_latency_bucket`:每 endpoint 的延迟分布 这些指标进 Prometheus 后 Grafana 仪表盘 + 告警。 **不需要应用端写 metric 代码**,trace 自动衍生 metric。 ## 资源占用对比 我们生产 25 个服务,trace 量大约 10k spans/s: | | Jaeger + ES | Tempo + S3 | |---|---|---| | 总 RAM 占用 | ~12 GB | ~3 GB | | 存储 7 天 | 200 GB SSD ($30/月 EBS) | 80 GB S3 ($2/月) | | 维护负担 | ES 集群 ops | tempo single binary | | UI 体验 | Jaeger UI ok | Grafana 统一 | | TraceQL | ❌ | ✅ | Tempo 显著省 + 体验更好。 ## 与替代品 | | Tempo | Jaeger | SigNoz | DataDog APM | |---|---|---|---|---| | 后端 | 对象存储 | ES / Cassandra | ClickHouse | 商业云 | | 自托管 | ✅ 简单 | ✅ 但 ES 麻烦 | ✅ 中 | ❌ | | 价格 | 极便宜 | 维护贵 | 中 | 贵 | | 与 metric/log 集成 | Grafana 统一 | 分散 | 内置 | DD 统一 | 预算紧 → Tempo / Jaeger。 要 metric/log/trace one-stop → Grafana stack 或 DataDog(贵但省心)。 ## 采样 100% 采样 trace 数据爆炸。生产建议: ```yaml processors: probabilistic_sampler: sampling_percentage: 10 ``` 10% 全采。或更智能 tail sampling:保留所有 error + 慢 trace + 5% 普通。 收集端(OTel Collector)配采样,Tempo 端不再处理。 ## 完整 stack 图 ``` 应用 (Python/Go/Node) ↓ OTLP OTel Collector (采样 + 路由) ↓ OTLP Tempo (trace 存 S3) ↑ TraceQL Grafana (UI) ← Loki (logs) ← Prometheus (metrics) ``` 3 个数据库(trace/log/metric),1 个 UI(Grafana),完全开源。 ## 踩过的坑 1. **block_retention 配错**:`168h` 7 天;如果想 30 天写 `720h`。 单位 `m` `h` `d` 都识别。 2. **对象存储 list 频繁**:Tempo 每几秒 list bucket 找新 block, B2 / R2 收 API 调用费。配 `query_frontend.search.cache_control` 减少 list 频率。 3. **WAL 损坏**:单机突然断电 → wal 损坏 → tempo 启动失败。 `rm -rf /var/tempo/wal/*` 丢失正在 ingest 的几秒数据,重启 OK。 生产用 PV / 持久 disk。 4. **多 tenant 没配**:默认 single-tenant;多团队共用同一 Tempo 要开 multitenancy + Auth header。 5. **search 索引慢**:trace 查找需要 search index,老版本 search 慢。 2.0+ 显著改进;用最新。 ## 切换的实际感受 从 Jaeger + ES 切到 Tempo + S3 用了 1 周(双跑 + 切流量): - 内存释放 9 GB - 存储费用降 15x - Grafana 统一仪表盘体验"看 metric → 跳 trace → 跳 log" 流畅 - TraceQL 让"高 latency P99 的 trace" 一行 query 出来 强烈推荐新项目用 Tempo + Grafana stack 而非 Jaeger。

React 19 Streaming SSR + Suspense:渐进式首屏渲染

## 起因 传统 SSR(getServerSideProps): 1. 服务端拉所有数据 2. render 整个 HTML 3. 发给浏览器 慢的部分(如调 5 个 API)阻塞整个 response。用户看白屏几秒。 Streaming SSR 让服务端**边 render 边 flush**:先把 layout / shell 推 出去,慢部分用 `<Suspense fallback>` 标记 → 浏览器先显示 fallback → 慢部分 ready 后追加 HTML 替换 fallback。 React 18 引入,19 完善。Next.js App Router 默认就是 streaming。 ## 解决方案 ### Next.js 14+ App Router ```tsx // app/posts/[id]/page.tsx import { Suspense } from 'react' async function fetchPost(id) { await new Promise(r => setTimeout(r, 200)) // 200ms return { title: '...', body: '...' } } async function fetchRelated(id) { await new Promise(r => setTimeout(r, 2000)) // 2s(慢) return [{ ... }] } async function fetchComments(id) { await new Promise(r => setTimeout(r, 1500)) // 1.5s return [{ ... }] } export default async function Page({ params }: ...) { const post = await fetchPost(params.id) // 必须先等这个 return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> <Suspense fallback={<div>loading related...</div>}> <Related id={params.id} /> </Suspense> <Suspense fallback={<div>loading comments...</div>}> <Comments id={params.id} /> </Suspense> </article> ) } async function Related({ id }) { const related = await fetchRelated(id) return <RelatedList items={related} /> } async function Comments({ id }) { const comments = await fetchComments(id) return <CommentList items={comments} /> } ``` 效果: - 200ms 后浏览器看到 article 标题 + body + 两个 "loading..." fallback - 1.5s 后 comments 替换 fallback - 2s 后 related 替换 fallback vs 传统 SSR 必须等 2s 才有任何内容。**首屏感知速度大幅提升**。 ## React 19 标准 API 不用 Next.js 也能 streaming SSR: ```tsx import { renderToReadableStream } from 'react-dom/server' import { Suspense } from 'react' async function handler(req) { const stream = await renderToReadableStream( <html> <body> <App /> </body> </html>, { bootstrapScripts: ['/main.js'], } ) return new Response(stream, { headers: { 'Content-Type': 'text/html' }, }) } ``` App 内 `<Suspense>` 包慢组件 → 自动 stream。 ## use() Hook(React 19) ```tsx import { use } from 'react' function Comments({ id }: { id: string }) { const comments = use(fetchComments(id)) // 直接调 promise! return <CommentList items={comments} /> } function Page({ params }) { return ( <Suspense fallback={<div>loading...</div>}> <Comments id={params.id} /> </Suspense> ) } ``` `use(promise)` 在 Suspense boundary 内 throw promise → React 等 resolve 后重渲。比 async 函数组件更简洁。 也能 use(context): ```tsx function Foo() { const theme = use(ThemeContext) // 替代 useContext return ... } ``` ## Partial pre-rendering(Next.js 14+ 实验性) 把"完全静态" + "完全动态" 之外加第三种:"骨架静态 + slot 动态": ```tsx export default function Page() { return ( <> <StaticHeader /> {/* build 时渲染,CDN cache */} <Suspense fallback={<Skeleton />}> <DynamicCart /> {/* 每请求 render */} </Suspense> </> ) } ``` CDN 立刻返回 static shell + skeleton,CDN 后端 stream 动态部分。 最优 LCP。 ## 与 client-side fetching 对比 ```tsx // 客户端 fetch(旧 SPA) function Comments({ id }) { const { data, isPending } = useQuery({ queryKey: ['comments', id], queryFn: () => fetchComments(id), }) if (isPending) return <Skeleton /> return <CommentList items={data} /> } ``` vs streaming SSR: | | client fetch | streaming SSR | |---|---|---| | TTFB | 极快(CDN) | 略慢(服务端等数据) | | FCP | 慢(要 JS hydrate + fetch) | 快(HTML 直接来) | | SEO | 差(爬虫不跑 JS) | 好 | | 客户端 bundle | 大 | 小(server component 不进 bundle) | | 复杂度 | 中 | 低(async function 直接写) | SEO / 首屏感知重 → streaming SSR。 交互重 / 后台 app → client fetch 仍合适。 ## 多服务串行 → 并行 ```tsx // ❌ 串行 async function Page() { const a = await fetchA() const b = await fetchB() const c = await fetchC() return <Component data={{ a, b, c }} /> } // 三个 fetch 加起来时间 // ✅ 并行 async function Page() { const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]) return <Component data={{ a, b, c }} /> } ``` 或者每个独立 Suspense 各自 stream: ```tsx function Page() { return ( <> <Suspense fallback={<...>}><A /></Suspense> <Suspense fallback={<...>}><B /></Suspense> <Suspense fallback={<...>}><C /></Suspense> </> ) } ``` 各自独立"快的先到"。 ## error boundary 跟 Suspense 配套 ```tsx <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Loading />}> <SlowComponent /> </Suspense> </ErrorBoundary> ``` Suspense 处理 loading;ErrorBoundary 处理 throw。两者分工。 Next.js 14:每个文件夹放 `error.tsx`(自动 boundary)+ `loading.tsx` (自动 Suspense)。 ## 性能数据 我们一个商品详情页: | | TTFB | FCP | LCP | |---|---|---|---| | 传统 SSR | 2.4s | 2.4s | 2.4s | | Streaming + 3 Suspense | 200ms | 350ms | 1.8s | | + Partial pre-render | 50ms | 100ms | 1.6s | 用户感知差距巨大。Web Vitals 全绿。 ## 注意 / 限制 ### 1. async component 仅 server side ```tsx async function ServerComp() { // ✅ 只能在 server component 用 const data = await fetch(...) return <div>{data}</div> } ``` client component 要异步必须用 `use()`: ```tsx 'use client' function ClientComp({ promise }) { const data = use(promise) // ✅ return <div>{data}</div> } ``` ### 2. 静态 export 与 streaming 不兼容 `next export` 模式只有静态 HTML,没 server runtime,streaming 无意义。 ### 3. CDN cache streaming response 是 chunked transfer。Cloudflare 等 CDN 默认 **不缓存 chunked response**。要 cache 必须改回 fixed-length。 特性 trade-off。 ### 4. browser 历史问题 Safari 14 之前对 streaming HTML 表现不一致,第一屏可能闪。 现代浏览器都没事。 ## 调试 Network → response headers 看 `transfer-encoding: chunked` 确认 streaming 模式。 Chrome DevTools Performance → record load → 看 HTML chunks 到达 时间。 ## 与 Astro / Qwik 对比 Astro 默认是 server-render-with-islands(client 只 hydrate 交互 组件),Qwik 是 resumable(client 0 JS 启动),两者都侧重首屏速度。 React 19 streaming + RSC 是同方向但仍有 React runtime 开销。 极致 perf 试试 Qwik;React 生态成熟度 + RSC 仍是主流。 ## 踩过的坑 1. **顶层 await 阻塞 stream**:page.tsx 顶部 `await fetch(...)` 阻塞 所有 stream。把它移到子组件 + Suspense 才能 stream。 2. **Suspense 套了但没 fallback**:fallback 不会显示。永远写 `fallback={<X />}` 而非 `{...}`。 3. **client component import server lib**:bundle 暴大或运行时 error。 严格分 "use client" / server-only 边界。 4. **error boundary 在 Suspense 内** vs 外:内只接 stream 那段错; 外接 Suspense 自己 + 内部错。看意图分。 5. **dev 跟 prod 行为不同**:dev 没 cache + 较多 console,prod 体感 差异大。永远以 prod build 量真实体验。

bash 脚本 5 个安全前缀:让 silent failure 显形

## 起因 线上发生过这个 bug:deploy 脚本第 3 步 `cp` 失败(路径变了), 但脚本继续跑第 4 步 `restart`,导致服务用了老配置 + 看着 deploy 成功。 半小时后才发现版本没真换。 bash 默认行为:命令失败继续往下跑。这是脚本灾难的根源。 下面是我每个生产脚本都加的 5 个前缀。 ## 5 个安全选项 放每个 bash 脚本开头: ```bash #!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' ``` 逐个解释: ### 1. `set -e`:任一命令 fail 立即退出 ```bash set -e cp /tmp/foo /backup/foo # 失败 echo "this won't print" # 不再执行 ``` 唯一例外:被 `if` / `while` 测试的命令 / 用 `||` `&&` 链接的命令 / `!` 否定的命令。 ```bash set -e if cp /tmp/foo /backup/; then echo ok fi # cp 失败时 if 走 else 分支,脚本继续 cp /tmp/foo /backup/ || echo "cp failed" # || 让 cp 失败不中断脚本 ``` ### 2. `set -u`:用未定义变量立即报错 ```bash set -u echo "$undef" # bash: undef: unbound variable # 立即退出 ``` 防"变量名打错"导致 `rm -rf $WRONG_NAME/file` 跑成 `rm -rf /file` (如果 `WRONG_NAME` 没定义就是空 → `rm -rf /file` 极危险)。 要允许"可能未定义": ```bash echo "${MY_VAR:-default}" # 未定义时用 default echo "${MY_VAR:?must be set}" # 未定义时报错且自定义消息 ``` ### 3. `set -o pipefail`:pipeline 任一段失败 → 整段失败 ```bash set -o pipefail curl https://example.com/data.json | jq .users | head # curl 失败时(如 404),jq 仍正常处理(处理 empty),head 正常 # 没 pipefail:echo $? = 0(最后一段 head 成功) # 有 pipefail:echo $? = curl 的退出码 ``` pipeline 中间失败默认被忽略。pipefail 让它显形。 ### 4. `IFS=$'\n\t'`:把"按空格分词"改成按换行 / tab ```bash # 默认 IFS=" \t\n" files="hello world.txt" for f in $files; do echo "$f" done # hello # world.txt # 一个文件名被当成两个 IFS=$'\n\t' files=$(ls) for f in $files; do echo "$f" # 包含空格的文件名也作为一个 done ``` 文件名 / 输入含空格时分词错误是经典 bug。`IFS=$'\n\t'` 减少踩坑。 (更彻底用数组 + `for f in "${arr[@]}"` 引号包裹。) ### 5. `set -x`:调试时显示每条命令(可选) ```bash set -x cp a b # 输出:+ cp a b rm b # 输出:+ rm b ``` 不要生产开(输出 noise + 可能 log 敏感数据)。临时 debug 加 / 移。 ## 完整模板 ```bash #!/usr/bin/env bash # 描述:部署 X 服务的脚本 # 用法:./deploy.sh [staging|prod] set -euo pipefail IFS=$'\n\t' # 1. 校验参数 if [ $# -lt 1 ]; then echo "Usage: $0 [staging|prod]" >&2 exit 64 fi ENV=$1 case "$ENV" in staging|prod) ;; *) echo "unknown env: $ENV" >&2; exit 64 ;; esac # 2. 环境变量校验 : "${API_TOKEN:?API_TOKEN must be set}" : "${TARGET_HOST:?TARGET_HOST must be set}" # 3. 工作目录 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # 4. 临时文件清理 TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT # 5. 主逻辑 echo "=== building ===" make build echo "=== deploying to $ENV ===" rsync -avz dist/ "$TARGET_HOST:/srv/myapp/" echo "=== restarting ===" ssh "$TARGET_HOST" 'systemctl restart myapp' echo "=== verifying ===" sleep 5 HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://$TARGET_HOST/") if [ "$HTTP_CODE" != "200" ]; then echo "post-deploy check failed: HTTP $HTTP_CODE" >&2 exit 1 fi echo "=== done ===" ``` ## 其它常用 pattern ### trap:清理 + 异常处理 ```bash TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT # 正常退出 / 错误退出 / Ctrl-C 都执行清理 trap 'echo "ERROR: line $LINENO"; cleanup' ERR # set -e 触发的退出会执行 ERR trap ``` ### 函数化 + log ```bash log() { echo "[$(date -Iseconds)] $*" >&2; } die() { log "FATAL: $*"; exit 1; } main() { log "start" do_step_1 || die "step 1 failed" do_step_2 || die "step 2 failed" log "ok" } main "$@" ``` ### check command exists ```bash require() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1" } require jq require rsync require ssh ``` ### 安全的 mktemp ```bash TMPFILE=$(mktemp) # 比 TMPFILE=/tmp/foo$$ 安全(mktemp 用 random + safe perms) trap 'rm -f "$TMPFILE"' EXIT ``` ### 不要 cd 后忘记 cd 回来 ```bash # ❌ cd /tmp && do_stuff # 之后的命令在 /tmp 跑 # ✅ 用 subshell 包 (cd /tmp && do_stuff) # 退出 subshell 自动回原目录 # 或 pushd / popd pushd /tmp >/dev/null do_stuff popd >/dev/null ``` ## 静态检查:shellcheck ```bash brew install shellcheck shellcheck deploy.sh ``` shellcheck 几百条规则查"潜在 bug + 反模式"。比如: - 没 quote 的变量(`$var` vs `"$var"`) - 用 `[[ ]]` 还是 `[ ]` - 多余的 `cat` (Useless Use of Cat) - 未定义的变量 pre-commit / CI 跑 shellcheck 强制无错。 ## bash vs sh vs zsh - `#!/bin/sh`:POSIX shell,最 portable,功能少 - `#!/bin/bash`:bash 特性可用(`[[ ]]` / `<()` / 数组等) - `#!/usr/bin/env bash`:找 PATH 里的 bash(Mac brew bash 比系统 3.2 新) 生产脚本写 `bash` 而非 `sh`,能用现代特性。 极小 alpine container 没装 bash → 用 `sh` 或装 bash。 ## 几个真实例子 ### A. `rm -rf "${BUILD_DIR}/"` 删错 `BUILD_DIR` 没定义 → `rm -rf /`。 `set -u` 阻止: ``` bash: BUILD_DIR: unbound variable ``` 或者用 `: "${BUILD_DIR:?}"` 显式断言。 ### B. `curl | bash` 中间断 下载安装脚本时,pipefail + curl 校验: ```bash URL=https://example.com/install.sh curl -fsSL "$URL" -o /tmp/installer.sh sha256sum -c expected.sha256 bash /tmp/installer.sh # 比 curl | bash 安全:能验签 + 失败时不执行 ``` ### C. ssh 远程脚本 ```bash # 远端的环境变量 / set -e 都要在 ssh 里设 ssh server <<'EOF' set -euo pipefail cd /srv/app git pull systemctl restart myapp EOF ``` 注意 `<<'EOF'`(单引号)防止 local 解释 `$var`;本地变量要传 `<< EOF` (无引号)。 ## 效果 我们 ops 团队把所有 shell 脚本套用这套规范 + pre-commit shellcheck 强制后: - "中途失败但脚本继续"类 bug 归零 - 调试时间下降明显(trap + log 一眼看到失败行) - 新人脚本上线 review 时间 -50%(shellcheck 帮 review) ## 踩过的坑 1. **`set -e` + function 退出码**:function 里 `return 1` 不立即退出 外层(只有命令)。要 `local var; var=$(cmd) || return 1` 显式判断。 2. **`[[ var = "x" ]]` 单 `=` 在 sh 里**:bash 单 `=` 也行;纯 sh 用 `[ "$var" = "x" ]` 单括号 + 单 `=`。 3. **pipefail + 期望 pipeline 部分失败**: ```bash set -o pipefail grep foo huge.log | head -1 # head 提前关闭 stdin → grep 收 SIGPIPE → 失败 ``` `|| true` 容忍这种情况。 4. **subshell 里 set -e 不继承**: ```bash set -e ( false; echo "still here" ) # subshell 里 set -e 仍有效 echo "main" # 这行根据 () 退出码决定 ``` 实际 () 子 shell 默认继承 -e,但有些版本 / 行为不一。验证。 5. **`echo` 不可靠**:含 `-` / `\` 的字符串可能被 echo 解释。 生产用 `printf '%s\n' "$var"`。

用 cgroup v2 / systemd slice 限制服务的 CPU / 内存

## 起因 一台服务器同时跑应用 + cron 任务 + 数据库。某个 cron 任务(数据 处理脚本)偶尔吃 90% CPU + 8GB 内存,把主应用挤到 OOM kill。 "给 cron 任务限定最大资源"是基础隔离。 cgroup(control group)是 Linux 内核功能,systemd 在 v2 之后用得很直接。 ## 解决方案 ### 1. 系统当前 cgroup 状态 ```bash # 看 cgroup 树 systemd-cgls # 看每个 unit 的资源占用 systemd-cgtop # 当前 cgroup 版本(v2 推荐,现代 distro 默认) cat /sys/fs/cgroup/cgroup.controllers # memory cpu io pids ... ``` ### 2. 给单个 service 限资源 ```ini # /etc/systemd/system/data-pipeline.service [Service] ExecStart=/usr/local/bin/process-data.sh # CPU:最多用 2 个核(200% = 2 * 100%) CPUQuota=200% CPUWeight=50 # 相对优先级(默认 100,调低让出 CPU) # 内存:硬上限 4GB,超过 OOM kill 这个 service(不影响别的) MemoryMax=4G MemoryHigh=3.5G # 软上限,超过后系统 throttle 这个进程的分配 # IO:限速 50MB/s 读 + 50MB/s 写(针对某块磁盘) IOReadBandwidthMax=/dev/sda 50M IOWriteBandwidthMax=/dev/sda 50M # tasks(线程 / 进程数) TasksMax=64 ``` `sudo systemctl daemon-reload && sudo systemctl restart data-pipeline`。 之后这个 service 超过 4GB 内存就被 OOM killed(service 重启), 不会影响系统其它进程。 ### 3. 实时调整(不重启 service) ```bash sudo systemctl set-property data-pipeline.service \ MemoryMax=2G CPUQuota=150% # 校验 systemctl show data-pipeline.service -p MemoryMax,CPUQuota ``` `set-property` 立刻生效 + 写到 `/etc/systemd/system.control/`,重启 保留。 ### 4. 把多个 service 归一组:slice ```ini # /etc/systemd/system/background.slice [Unit] Description=Background batch jobs [Slice] CPUWeight=10 # 后台任务低优先级 CPUQuota=400% # 整组合计最多 4 核 MemoryMax=8G # 整组合计最多 8GB IOWeight=10 ``` ```ini # data-pipeline.service [Service] Slice=background.slice # 不需要再单独设 CPUQuota / MemoryMax,由 slice 限制 # log-cleaner.service [Service] Slice=background.slice # image-resizer.service [Service] Slice=background.slice ``` `background.slice` 整体限到 4 核 + 8GB,里面 3 个 service 自动竞争分配。 主应用(不在这个 slice 里)不受影响。 ### 5. 给用户 / 会话限资源 `/etc/systemd/system/user-1001.slice`(用户 ID 1001 的所有进程): ```ini [Slice] CPUQuota=200% MemoryMax=4G ``` 或编辑用户的: ```bash sudo systemctl edit user-1001.slice ``` 防"某个用户跑了一个内存炸弹把整机器 OOM"。 ### 6. 临时跑一个命令带限制 ```bash systemd-run --scope --slice=background.slice -p MemoryMax=1G -p CPUQuota=50% \ ./bigjob.sh ``` 不需要建 service unit,临时挂在 background.slice 下跑。 ### 7. 看 cgroup 实际占用 ```bash # 某个 service 当前 RAM cat /sys/fs/cgroup/system.slice/data-pipeline.service/memory.current # 上限 cat /sys/fs/cgroup/system.slice/data-pipeline.service/memory.max # CPU 时间累计 cat /sys/fs/cgroup/system.slice/data-pipeline.service/cpu.stat ``` `systemctl status data-pipeline` 也会显示 Tasks / Memory / CPU。 或更直观: ```bash systemd-cgtop -d 1 # 实时刷新每 cgroup 资源占用 ``` ### 8. 监控 OOM kill ```bash # 查 dmesg 里的 oom_kill sudo dmesg | grep -i 'killed process' # journal 里看是哪个 cgroup 触发 OOM sudo journalctl -k --since '1 day ago' | grep oom ``` `MemoryMax` 触发的 OOM 只 kill 那个 cgroup 里的进程,不影响其它。 进程死了 systemd 按 `Restart=on-failure` 重启它(或留死状态等告警)。 ## 几个常用 hardening 选项 ```ini # 文件系统 ProtectSystem=strict # / 全只读 ProtectHome=true # /home 不可见 ReadWritePaths=/var/log/myapp # 例外可写 # 内核能力 NoNewPrivileges=true # exec 后不能升 cap PrivateTmp=true # /tmp 私有 PrivateDevices=true # /dev 极简 PrivateNetwork=true # 网络命名空间隔离(注意:不能访问外网) # user 隔离 User=trio Group=trio DynamicUser=true # 动态分配 UID(最安全,但要求 service 真不需要持久 user) # 资源 LimitNOFILE=65536 # 文件描述符 TasksMax=1024 ``` `systemd-analyze security <unit>` 给每个 service 评一个 0-10 分的 安全分,按提示加 hardening。 ## 效果 - 后台 batch job 不再挤死主应用 - 一个 cron 任务跑飞了只 OOM 自己 cgroup,systemd 重启它 - 多个低优先级服务自动让 CPU 给业务 - 资源审计:每个 service / slice 用了多少一目了然 ## 与 Docker / K8s 对比 容器(docker / k8s)其实就是 cgroup + namespace + 镜像分发。 单机服务用 systemd cgroup 已经够,不需要为了"资源隔离"就上 Docker。 ```bash # 等价物 docker run --cpus 2 --memory 4g ... # vs systemctl set-property myservice.service CPUQuota=200% MemoryMax=4G ``` ## 踩过的坑 1. **cgroup v1 vs v2**:老 distro / docker 默认 v1,systemd unified hierarchy 用 v2。混用导致某些 unit option 不生效。 `systemd.unified_cgroup_hierarchy=1` 内核参数强制 v2。 2. **MemoryMax 太严格**:服务正常工作时偶尔 spike → 频繁 OOM 重启 雪崩。给 50% buffer。 3. **PrivateNetwork=true 但服务要访问 DB**:隔离过头,service 起不来 连不上 DB。这个选项只给完全离线的工具用。 4. **CPUQuota=100%(一个核)但服务是多线程**:会被严格限速到 1 核 throughput,多线程优势没了。看实际负载决定。 5. **DynamicUser + 文件持久化**:DynamicUser 每次启动 UID 可能不同, 服务写到 `/var/lib/myservice/` 的文件下次 UID 变了读不了。 `StateDirectory=myservice` 让 systemd 管这个目录的权限。

Borg + Borgmatic 做本地 + 异地双备份(一份配置两个仓库)

Borg 是 Python 写的去重备份工具,自带客户端加密、压缩、增量、仓库 deduplication。功能上和 Restic 相近,但生态早成熟两年,老牌系统管理 员居多。 Borgmatic 是 Borg 的 YAML wrapper,把 "备份 → 检查 → 清理 → 通知" 四件事写在一个配置文件里。 ## 安装 ```bash sudo apt install -y borgbackup borgmatic # 也可以 pip install --user borgmatic 拿更新版本 ``` ## 仓库初始化 我们做两份:本地 NAS + 异地(rsync.net / hetzner storage box / 自建机)。 两次 init: ```bash # 本地 NAS(NFS / SMB 挂载到 /mnt/nas) borg init -e repokey /mnt/nas/borg/host-foo # 异地,通过 ssh+borg serve borg init -e repokey \ ssh://[email protected]:23/./borg/host-foo ``` 每次 init 会要求设置 passphrase;同样这两个密码 **丢了就完了**。 ## Borgmatic 配置 `/etc/borgmatic/config.yaml`: ```yaml location: source_directories: - /etc - /srv - /home/yourname/projects exclude_patterns: - '*/node_modules' - '*/__pycache__' - '*/.venv' - '*/target' - '*/.cache' exclude_caches: true exclude_if_present: - .nobackup repositories: - path: /mnt/nas/borg/host-foo label: nas - path: ssh://[email protected]:23/./borg/host-foo label: offsite storage: encryption_passcommand: 'cat /etc/borgmatic/passphrase' compression: zstd,3 archive_name_format: '{hostname}-{now:%Y%m%d-%H%M%S}' borg_keep_exclude_tags: true retention: keep_daily: 7 keep_weekly: 4 keep_monthly: 12 keep_yearly: 5 consistency: checks: - name: repository frequency: 2 weeks - name: archives frequency: 1 month hooks: before_backup: - echo "starting backup at $(date)" after_backup: - curl -fsSL --retry 3 'https://hc-ping.com/<uuid>' || true on_error: - curl -fsSL --retry 3 'https://hc-ping.com/<uuid>/fail' || true ``` `/etc/borgmatic/passphrase`: ``` my-very-long-random-passphrase ``` 权限:`sudo chmod 600 /etc/borgmatic/passphrase`。 ## 跑 ```bash sudo borgmatic --verbosity 1 # 等价于:create + prune + 周期性 check ``` 只跑某一步: ```bash sudo borgmatic create # 只备份 sudo borgmatic check # 只校验 sudo borgmatic prune # 只清理 sudo borgmatic list # 看快照列表 ``` ## systemd timer Borgmatic 包自带 unit: ```bash sudo systemctl enable --now borgmatic.timer systemctl list-timers borgmatic.timer ``` 或者自己写 timer 覆盖时间。 ## 还原 ```bash # 列快照 sudo borgmatic list --repository /mnt/nas/borg/host-foo # 或 sudo borg list /mnt/nas/borg/host-foo # 看快照内容 sudo borg list /mnt/nas/borg/host-foo::host-foo-20260520-024300 | head # 还原一个文件到当前目录 cd /tmp sudo borg extract /mnt/nas/borg/host-foo::host-foo-20260520-024300 etc/nginx/nginx.conf # 挂载快照为只读文件系统(很方便) sudo mkdir /mnt/snap sudo borg mount /mnt/nas/borg/host-foo::host-foo-20260520-024300 /mnt/snap ls /mnt/snap sudo borg umount /mnt/snap ``` ## 双仓库的取舍 - 本地仓库快但 fate-shared:火灾水患时一起没。 - 异地仓库慢但是真灾备;如果带宽紧张就只把 `/etc` `/srv/critical` 推异地。 可以拆配置: ```yaml repositories: - path: /mnt/nas/borg/host-foo label: nas # 仅在某些 source_directory 同步到异地,靠 borg patterns 文件 ``` 更简单的做法:写两份 borgmatic 配置 `local.yaml` / `offsite.yaml`, 各自有 source_directories,timer 在不同时间触发。 ## 踩过的坑 - Borg 仓库 **不能** 让两个 client 同时写。两台机器要往同一个仓库 备份必须串行或用 `--lock-wait`。 - 加密类型 `repokey` 把 key 放仓库里(密码加密),方便但密码弱时不安全; `keyfile` 把 key 放客户端 `~/.config/borg/keys/`,要单独备份。 - 大文件改动(虚拟机磁盘)每次 dedup 都要扫整个文件,慢。建议这种文件 排除,单独做内置工具的快照备份。 - 远端 ssh 用专门 key 加 `command="borg serve --restrict-to-repository ..."` 限制;别用你的常规 key 跑 borg。

sqlc:Go 里写 SQL 但拿到类型安全的 generated code

## 起因 Go 写数据库代码两条路: - 用 ORM(GORM):自动 query 但隐藏 SQL;复杂 join 难表达 - 手写 sql.DB:每条 query 自己写、scanner 自己 typed、容易写错 `sqlc` 是另一条路:写纯 SQL,工具生成完全类型化的 Go 函数。 两者优点都有:SQL 完全可控 + Go 调用类型安全。 ## 解决方案 ### 1. 装 ```bash go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest # 或 brew install sqlc / Docker ``` ### 2. 配 sqlc.yaml ```yaml version: "2" sql: - engine: "postgresql" queries: "db/query.sql" schema: "db/schema.sql" gen: go: package: "db" out: "internal/db" sql_package: "pgx/v5" # 或 "database/sql" ``` ### 3. 写 schema.sql ```sql -- db/schema.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, nickname TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE posts ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, body TEXT NOT NULL, published_at TIMESTAMPTZ ); ``` ### 4. 写 query.sql ```sql -- db/query.sql -- name: GetUser :one SELECT * FROM users WHERE id = $1; -- name: GetUserByEmail :one SELECT * FROM users WHERE email = $1; -- name: ListUsers :many SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2; -- name: CreateUser :one INSERT INTO users (email, nickname) VALUES ($1, $2) RETURNING *; -- name: UpdateUserNickname :exec UPDATE users SET nickname = $2 WHERE id = $1; -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; -- name: ListUserPosts :many SELECT p.*, u.nickname AS author_nickname FROM posts p JOIN users u ON u.id = p.user_id WHERE p.user_id = $1 ORDER BY p.published_at DESC LIMIT $2 OFFSET $3; -- name: CountPostsByUser :one SELECT count(*) FROM posts WHERE user_id = $1; ``` `-- name: X :one|many|exec|execrows` 告诉 sqlc 生成什么类型函数: - `:one` 返回单行 - `:many` 返回多行 - `:exec` 不返回(INSERT/UPDATE/DELETE) - `:execrows` 返回影响行数 ### 5. 生成 ```bash sqlc generate ``` `internal/db/` 下生成: ``` internal/db/ ├── db.go # 接口 + Queries struct ├── models.go # 表 → Go struct └── query.sql.go # 每个 query → Go 函数 ``` `models.go`: ```go type User struct { ID int64 Email string Nickname string CreatedAt time.Time } type Post struct { ID int64 UserID int64 Title string Body string PublishedAt pgtype.Timestamptz } ``` `query.sql.go`: ```go const getUser = `-- name: GetUser :one SELECT id, email, nickname, created_at FROM users WHERE id = $1 ` func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { row := q.db.QueryRow(ctx, getUser, id) var u User err := row.Scan(&u.ID, &u.Email, &u.Nickname, &u.CreatedAt) return u, err } ``` 类型完全推断自 schema。 ### 6. 用 ```go import ( "context" "github.com/jackc/pgx/v5/pgxpool" "myapp/internal/db" ) func main() { ctx := context.Background() pool, _ := pgxpool.New(ctx, "postgresql://localhost/myapp") defer pool.Close() queries := db.New(pool) // 创建 user, err := queries.CreateUser(ctx, db.CreateUserParams{ Email: "[email protected]", Nickname: "Alice", }) // 查 u, _ := queries.GetUserByEmail(ctx, "[email protected]") // 列表 posts, _ := queries.ListUserPosts(ctx, db.ListUserPostsParams{ UserID: u.ID, Limit: 20, Offset: 0, }) for _, p := range posts { fmt.Println(p.Title, p.AuthorNickname) } } ``` 写错列名 / 类型 / 参数 → 编译报错。重构 schema 后 `sqlc generate` 所有不兼容的 query 调用都立刻被编译器抓出。 ### 7. JOIN 出来的"虚拟表"自动生成 struct ```sql -- name: ListUserPosts :many SELECT p.*, u.nickname AS author_nickname FROM posts p JOIN users u ON u.id = p.user_id WHERE p.user_id = $1; ``` 生成: ```go type ListUserPostsRow struct { ID int64 UserID int64 Title string Body string PublishedAt pgtype.Timestamptz AuthorNickname string } func (q *Queries) ListUserPosts(...) ([]ListUserPostsRow, error) { ... } ``` 新结构体自动产生,不需要自己定义 DTO。 ### 8. transaction ```go tx, err := pool.Begin(ctx) defer tx.Rollback(ctx) qtx := queries.WithTx(tx) user, err := qtx.CreateUser(ctx, ...) _, err = qtx.CreateProfile(ctx, ...) return tx.Commit(ctx) ``` `WithTx(tx)` 返回绑定到事务的 queries 实例。所有调用都在同事务。 ### 9. 跑迁移 sqlc 不管迁移。配合 [goose](https://github.com/pressly/goose) / [golang-migrate](https://github.com/golang-migrate/migrate) / [atlas](https://atlasgo.io): ```bash goose -dir migrations postgres "postgresql://..." up ``` `migrations/0001_init.sql`: ```sql -- +goose Up CREATE TABLE users (...); -- +goose Down DROP TABLE users; ``` sqlc 的 schema.sql 通常是迁移结果的"快照"(开发时方便)。 生产以 migration 序列为准。 ### 10. 动态 query sqlc 主要服务于"静态 SQL"。动态 WHERE / ORDER BY 灵活性差。 解决: - 简单分页 / 排序:参数化 `LIMIT $1 OFFSET $2` - 可选 filter:`WHERE (@email::text IS NULL OR email = @email)` - 真正动态:用 squirrel / goqu 等 query builder,sqlc 处理静态部分 ## 与 GORM / ent 对比 | | GORM | ent | sqlc | |---|---|---|---| | 哲学 | ORM 全自动 | schema-first ORM | 写 SQL,生成 type-safe 代码 | | 学习曲线 | 低 | 中 | 极低(你已经会 SQL) | | 性能 | 中(reflection) | 高 | 高(直接 SQL) | | 复杂 JOIN | 难表达 | 好 | 自然写 SQL | | 类型安全 | 弱 | 强 | 强 | | migration | 内置 | 内置 | 需配合工具 | 我的取向:业务大量 SQL → sqlc;CRUD 简单 → GORM 也行;schema 变化 频繁 + 业务复杂 → ent。 ## 效果 我们 5 万行 Go 代码用 sqlc 替代手写 sql.DB.Query: - DB 相关 bug 减少 80%(编译期捕获) - 重构 schema 时编译器告诉所有要改的地方 - 团队新人 onboarding:会 SQL 就能写,不需要学 ORM 语法 - 性能比 GORM 快 ~30%(无 reflection / 直接 prepared statement) ## 踩过的坑 1. **nullable 列变 pgtype**:column `NULL` 在 Go 是 `pgtype.Text` / `pgtype.Int4` 等,不是 string / int。 `String.Valid` 字段判 null。 `SET sqlc.go.emit_pointers_for_null_types = true` 改用 `*string` 等。 2. **改 schema 后忘 sqlc generate**:编译失败但不知道为啥。 把 `sqlc generate` 加进 `go generate` + Makefile / justfile。 3. **复杂 query plan 看不见**:写了个 SQL 跑很慢,没人在 ORM 层 优化。`EXPLAIN ANALYZE` 是基本功 —— ORM 怎么也帮不上忙。 4. **time.Time vs pgtype.Timestamptz**:默认 timestamptz 列生成 `pgtype.Timestamptz`。`Time` 字段拿值,`Valid` 判 null。 设 `emit_exact_table_names = true` 让生成名跟列对应。 5. **JOIN 列名冲突**:两表都有 `id` 列 → 生成 struct 字段冲突。 SQL 里手动 alias:`SELECT p.id AS post_id, u.id AS user_id ...`。