已稳定运行 · 持续更新原创技术文章

在 秋千云网络
记录技术,也记录成长

专注后端架构、云原生运维、前端工程化与数据库实践。把踩过的坑写下来,把想明白的道理讲清楚。

9原创文章
7技术分类
30热门标签
6 年持续更新

精选推荐

最新文章

查看全部 →
没有找到相关文章,换个关键词试试吧 🔍

ARTICLES

全部技术文章

这里汇总了「秋千云网络」发布的所有原创文章,共 9 篇。可按分类筛选或直接搜索关键词。

2026

9 篇
没有匹配的文章,换个条件试试吧 🔍

关于本站 ​

秋千云网络(QIUQIAN CLOUD)是一个个人技术博客站点,自 2020 年起持续更新,主要记录后端架构、云原生运维、前端工程化以及数据库领域的原创技术文章与实践经验。

站点定位 ​

这个站点不是资讯聚合,也不是教程搬运。它更像一本公开的工程笔记:把工作中真实遇到的问题、踩过的坑、最终验证有效的方案完整写下来,让每一个读到的人都能直接拿去用。

主要内容方向包括:

方向说明
后端开发服务架构设计、并发模型、缓存与消息队列实践
云原生Kubernetes 集群、容器编排、服务治理与可观测性
前端工程化构建优化、性能调优、类型系统与组件设计
数据库索引原理、慢查询优化、事务与分布式存储
运维部署Linux 加固、自动化部署、监控告警体系

技术栈说明 ​

本站是一个纯前端静态站点,没有任何后端服务:

  • 框架:Vue 3 + Vite
  • 静态站点生成:VitePress
  • 样式方案:TailwindCSS
  • 内容来源:全部文章使用 Markdown 编写,构建时生成静态 HTML

这样做的好处是部署简单、访问速度快、可长期维护,也不需要承担服务器运行与数据库维护的成本。

内容授权 ​

本站所有原创文章采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议。欢迎转载,但请保留原文链接与作者署名,且不得用于商业用途。

联系方式 ​

  • 网站名称:秋千云网络
  • 联系邮箱:admin@qiuqiancloud.cn
  • 备案号:苏ICP备2026005977号-7

如果你在文章中发现任何错误,或者有想讨论的技术问题,欢迎通过邮件与我交流。

返回文章列表

深入理解 HTTP/3 与 QUIC:下一代网络传输协议到底解决了什么

从 TCP 队头阻塞讲到 QUIC 的 0-RTT 握手、连接迁移与多路复用,结合真实抓包数据,分析 HTTP/3 在弱网环境下的性能优势与落地成本。

网络协议 2026-09-18 约 18 分钟 # HTTP/3# QUIC# 网络协议# 性能优化

HTTP/2 在 2015 年发布时被寄予厚望,它用二进制分帧和单连接多路复用解决了 HTTP/1.1 的队头阻塞问题。但很多团队上线 HTTP/2 后发现,性能提升并没有想象中那么大——在丢包率较高的网络环境下,甚至可能比 HTTP/1.1 更慢。

问题的根源不在应用层,而在它脚下的 TCP。

TCP 层队头阻塞:HTTP/2 无法绕过的天花板 ​

HTTP/2 的多路复用把多个请求拆成帧,在同一条 TCP 连接上交错传输。TCP 本身把数据视为一个有序的字节流,它不知道哪些字节属于哪个流。

于是问题出现了:只要有一个 TCP 报文丢失,内核就必须等待该报文重传完成,才能把后续已到达的数据交付给应用层。此时所有 HTTP/2 流都被卡住——这就是TCP 层队头阻塞(Head-of-Line Blocking)。

HTTP/1.1  →  每个域名 6 条连接,各自独立,一条卡住不影响其他
HTTP/2    →  1 条 TCP 连接,丢一个包,所有流全卡
HTTP/3    →  基于 UDP,每个流独立重组,互不影响

对丢包率敏感的场景(移动网络、跨境链路)来说,这几乎是致命的。

QUIC 的四个关键设计 ​

QUIC 把可靠性机制从内核搬到了用户态,跑在 UDP 之上。这带来了一系列 TCP 无法做到的优化。

1. 流级别的独立重传 ​

QUIC 在协议内部实现了流的概念,每个流有独立的序列号和重组缓冲。一个流丢包只影响该流,其他流的数据可以正常交付。

这从根本上消除了传输层的队头阻塞——HTTP/3 终于让 HTTP/2 的设计意图完整落地。

2. 0-RTT 与 1-RTT 握手 ​

TCP + TLS 1.3 的完整握手需要 3 次往返(TCP 三次握手 + TLS 1.3 握手)。QUIC 把传输握手和加密握手合并:

场景TCP + TLS 1.3QUIC
首次连接3 RTT1 RTT
恢复连接3 RTT0 RTT
连接迁移重新握手无需握手

对延迟敏感的首屏加载来说,省下的 2 个 RTT 是实打实的。

3. 连接迁移 ​

TCP 连接由四元组(源 IP、源端口、目的 IP、目的端口)标识。手机从 Wi-Fi 切到 4G,IP 变了,连接就断了,必须重建。

QUIC 用 Connection ID 标识连接,与 IP 地址解耦。网络切换时连接不中断,正在传输的数据可以继续——这对移动端体验提升非常明显。

4. 用户态拥塞控制 ​

因为跑在用户态,QUIC 可以更快地迭代拥塞控制算法(CUBIC、BBR 等),不需要等待操作系统内核升级。可插拔的拥塞控制也让不同业务可以选择不同策略。

抓包看一次真实握手 ​

在支持 HTTP/3 的站点上抓包,一次 0-RTT 恢复连接大致是这样:

text
Client                              Server
  |------ Initial (ClientHello) ------>|   含 0-RTT 早期数据
  |<----- Initial (ServerHello) -------|
  |<----- Handshake (Encrypted Ext) ---|
  |------ Handshake (Finished) ------->|
  |<========== 1-RTT 应用数据 =========>|

整个握手过程被压缩到 1 个往返以内,浏览器服务端配置开启 Alt-Svc 后即可自动升级。

落地成本与注意事项 ​

HTTP/3 并不是免费的午餐,实际部署时需要注意这些点:

  • UDP 被封锁:部分企业网络和运营商会对 UDP 限速甚至阻断,必须保留 HTTP/2 作为回退路径。
  • CPU 开销更高:QUIC 的加解密和拥塞控制在用户态完成,同样的 QPS 下 CPU 占用通常比 HTTP/2 高 15%~40%,需要做好容量规划。
  • 可观测性变差:传统基于 TCP 的抓包分析工具链需要重新适配,tcpdump 看到的是加密的 UDP 载荷。
  • 负载均衡支持:需要支持 QUIC 的 LB 或者能正确处理 Connection ID 的网关,否则连接迁移优势会被中间设备吃掉。

何时值得上 HTTP/3 ​

我的建议是分场景判断:

  1. 面向移动端、弱网用户为主 —— 收益最明显,优先上。
  2. 跨境业务、链路丢包率高 —— 收益明显,建议上。
  3. 纯内网服务间调用 —— 内网丢包极低,收益有限,可以先观望。
  4. 已有完善 HTTP/2 优化(CDN、预连接、资源预加载)—— 增量收益会比较小。

一个务实的做法:先开启 Alt-Svc 让浏览器自动协商,同时保留 HTTP/2 回退,用真实用户监控(RUM)对比两组的首字节时间与完整加载时间,再决定是否全量切换。

小结 ​

HTTP/3 的价值不在于"更快",而在于把不可控的传输层变成了可控的应用层。流级重传解决了 TCP 队头阻塞,连接迁移解决了移动网络切换,用户态实现让协议可以快速演进。

它不会让所有站点都变快,但对那些真正受制于网络质量的业务来说,这是一次值得投入的升级。

返回文章列表

从零搭建高可用 Kubernetes 集群:一份完整实践笔记

三主三从架构、etcd 集群、VIP 漂移、Ingress 与持久化存储,把生产级集群搭建的每一步都跑通并记录成可复现的清单。

云原生 2026-09-12 约 22 分钟 # Kubernetes# 容器编排# 高可用# 运维部署

单 master 的集群只适合学习和测试环境。一旦 master 宕机,整个集群就失去了控制平面,无法调度、无法自愈。这篇文章记录我搭建一套三主三从高可用集群的完整过程,每一步都可以直接复用。

一、架构规划 ​

先说清楚目标拓扑,避免中途返工。

角色数量说明
Master(控制平面)3同时运行 kube-apiserver、controller-manager、scheduler
etcd3与 master 同机部署,独立数据盘
Worker(工作节点)3运行业务负载
VIP1通过 keepalived 在 master 间漂移

为什么 etcd 必须是奇数台? etcd 使用 Raft 协议,需要多数派(quorum)才能写入。3 台允许 1 台故障,5 台允许 2 台故障。偶数台不会提高容错能力,反而降低可用性。

二、系统基础配置 ​

所有节点执行以下操作。

关闭 swap ​

kubelet 要求关闭 swap,否则调度器的资源计算会失准。

bash
swapoff -a
sed -i '/swap/s/^/#/' /etc/fstab

加载内核模块与参数 ​

bash
cat > /etc/modules-load.d/k8s.conf <<EOF
overlay
br_netfilter
EOF

cat > /etc/sysctl.d/k8s.conf <<EOF
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

modprobe overlay && modprobe br_netfilter
sysctl --system

安装容器运行时 ​

Kubernetes 1.24 之后移除了 dockershim,推荐直接使用 containerd。

bash
apt-get install -y containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl restart containerd

SystemdCgroup = true 这一项非常关键,忘了改会导致 kubelet 与 containerd 的 cgroup 驱动不一致,Pod 起不来。

三、部署高可用控制平面 ​

1. 安装 kubeadm / kubelet / kubectl ​

bash
apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl   # 防止意外升级

2. 配置 VIP 与 keepalived ​

在第一台 master 上初始化集群,接口绑定到 VIP:

yaml
# kubeadm-config.yaml
apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 192.168.10.11
  bindPort: 6443
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
controlPlaneEndpoint: "192.168.10.100:6443"   # VIP
networking:
  podSubnet: "10.244.0.0/16"
  serviceSubnet: "10.96.0.0/12"
kubernetesVersion: "v1.30.0"

keepalived 负责让 VIP 在 master 之间漂移,健康检查探测 6443 端口。

3. 加入其余 master 与 worker ​

bash
# 在 master-1 生成加入命令
kubeadm token create --print-join-command

# master 节点使用 --control-plane
kubeadm join 192.168.10.100:6443 --token xxx \
  --discovery-token-ca-cert-hash sha256:xxx \
  --control-plane --certificate-key xxx

# worker 节点去掉 --control-plane 即可

加入完成后确认状态:

bash
kubectl get nodes -o wide
kubectl -n kube-system get pods

全部 Ready 才算成功。

四、网络与 Ingress ​

安装 CNI 插件(以 Calico 为例):

bash
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml

部署 Ingress Controller,建议用 DaemonSet + hostNetwork 模式,避免多一跳转发:

bash
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/baremetal/deploy.yaml

外部流量走向:DNS → VIP → ingress-nginx → Service → Pod。

五、持久化存储 ​

生产环境建议使用独立存储(Ceph、NFS、云盘 CSI)。测试环境用 local-path 即可:

bash
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml
kubectl patch storageclass local-path \
  -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

六、上线前必做的检查 ​

集群跑起来只是开始,下面这些验证缺一不可:

  • [ ] 关掉一台 master,kubectl 依然可用,VIP 正常漂移
  • [ ] 关掉一台 worker,Pod 在 5 分钟内被重新调度
  • [ ] etcd 快照备份已配置且实际恢复演练过
  • [ ] 证书有效期检查(kubeadm 默认 1 年,需配置自动轮换)
  • [ ] 集群监控(Prometheus + Grafana)与告警已接入
  • [ ] 审计日志(audit log)已开启

最容易忽略的是 etcd 备份。很多人配了定时快照但从没验证过恢复流程,真出事时才发现快照是坏的。没有演练过的备份等于没有备份。

小结 ​

高可用集群的难点不在安装命令,而在细节:cgroup 驱动、VIP 健康检查、etcd 多数派、证书轮换。把上面这份清单逐条跑通,你就得到了一个可以承接生产流量的集群底座。

返回文章列表

Rust 异步编程:Tokio 运行时原理剖析与实战

Future 的惰性求值、Waker 唤醒机制、多线程调度器的工作窃取算法,以及如何写出真正不阻塞的异步代码。

后端开发 2026-09-05 约 25 分钟 # Rust# 异步编程# Tokio# 并发

Rust 的异步模型和 Go、JavaScript 都不太一样。理解它最关键的一步,是搞清楚 Future 到底在什么时候执行——很多"代码没跑起来"的困惑都源于这一点。

Future 是惰性的 ​

在 JavaScript 里,一个 async 函数调用后会立即开始执行。在 Rust 里不是:

rust
async fn hello() {
    println!("hello");
}

fn main() {
    let fut = hello();   // 这里什么都不会打印
    // fut 被 drop,任务从未执行
}

hello() 只是构造了一个 Future,不调用 poll 就不会有任何副作用。这是 Rust 零成本抽象的核心:编译器把 async 函数体转换成状态机,每一步的进度都保存在栈帧里。

Future 的定义非常简洁:

rust
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),
    Pending,
}

Pending 表示"还没准备好,等我的时候别忙等,我会通知你"。这里的"通知"就是 Waker。

Waker:异步的唤醒机制 ​

Context 里携带一个 Waker。当 Future 返回 Pending 前,它必须把 Waker 注册到底层的事件源上(比如 epoll、kqueue)。

text
poll() → Pending(注册 waker 到 epoll)
  ↓
  事件就绪
  ↓
epoll 触发 → waker.wake() → 重新调度该任务 → poll()

这个设计让运行时可以在任务等待期间去执行其他任务,而不是空转。

如果底层 I/O 就绪了但没有调用 wake(),任务就会永远挂起——这是手写 Future 最常见的 bug。

Tokio 的运行时结构 ​

Tokio 运行时主要由三部分组成:

组件职责
调度器(Scheduler)管理任务队列,决定哪个任务在哪个线程上执行
I/O 驱动(Reactor)基于 epoll/kqueue/IOCP 处理就绪事件,唤醒对应 Waker
时间驱动(Timer)时间轮实现,处理 sleep、timeout 等

调度器有三种模式:

  • Current-thread:单线程,适合轻量场景或需要 !Send 的场景
  • Multi-thread:基于工作窃取的多线程调度器,默认选择
  • LocalSet:在 multi-thread 运行时中运行非 Send 任务

工作窃取调度器 ​

Multi-thread 调度器为每个 worker 线程维护一个双端队列(deque):

  • 本地任务从尾部入队、尾部取出(LIFO,缓存友好)
  • 其他线程从头部窃取(FIFO,减少竞争)
rust
// 每个 worker 持有自己的本地队列
// 空闲线程会尝试从其他 worker 的队列头部窃取任务

这个设计在负载不均时能自动平衡,同时降低对全局锁的依赖。

千万不要在异步任务里阻塞 ​

这是新手最容易犯的错误:

rust
tokio::spawn(async {
    std::thread::sleep(Duration::from_secs(3));  // ❌ 阻塞整个 worker 线程
});

std::thread::sleep 会阻塞当前 worker 线程,该线程上排队的其他任务全部延迟。正确做法:

rust
tokio::spawn(async {
    tokio::time::sleep(Duration::from_secs(3)).await;  // ✅ 让出执行权
});

// CPU 密集型任务用 spawn_blocking
tokio::task::spawn_blocking(|| {
    heavy_computation()
}).await.unwrap();

判断标准很简单:只要一个操作的等待时间超过几十微秒,就必须用异步版本。

一个实战例子:并发限流的爬虫 ​

rust
use tokio::sync::Semaphore;
use std::sync::Arc;

#[tokio::main]
async fn main() {
    let permits = Arc::new(Semaphore::new(10)); // 最多 10 个并发
    let mut handles = Vec::new();

    for url in urls {
        let permit = permits.clone().acquire_owned().await.unwrap();
        handles.push(tokio::spawn(async move {
            let body = fetch(&url).await;
            drop(permit);   // 释放信号量
            body
        }));
    }

    // join_all 并发等待,比逐个 await 快得多
    let results = futures::future::join_all(handles).await;
}

关键点:

  1. Semaphore 控制并发度,防止把目标站点打挂
  2. spawn 让任务真正并发,而不是串行等待
  3. join_all 一次性等待所有任务

常见性能陷阱 ​

  • 过度 spawn:每次请求都 spawn 一个任务会产生调度开销,必要时用 FuturesUnordered 或流式处理。
  • 锁跨 await 持有:std::sync::Mutex 的 guard 跨 await 会破坏 Send,应该用 tokio::sync::Mutex,或者把锁的范围收窄。
  • select! 忘记处理分支:未处理的分支被 drop,可能丢失数据。
  • 无限任务堆积:没有背压机制时,生产速度超过消费速度会导致内存飙升。

一个实用的调优顺序:先确认没有阻塞调用,再检查锁竞争,最后才考虑调线程数。绝大多数"Tokio 性能问题"的根因都是第一条。

小结 ​

理解 Rust 异步只需要抓住三件事:Future 是惰性的状态机、Waker 是唤醒的唯一入口、worker 线程绝不能被阻塞。剩下的都是围绕这三点做的工程优化。

返回文章列表

前端性能优化实战:让首屏加载时间缩短 3 倍

从资源体积、加载优先级、渲染阻塞到交互响应,逐项拆解 21 个可落地的优化点,并附上真实项目的性能指标对比数据。

前端工程化 2026-08-29 约 16 分钟 # 性能优化# 前端# Web# 构建

去年接手的一个后台管理系统,首屏加载 4.8 秒,用户抱怨"点了没反应"。经过两轮优化后降到 1.5 秒。这篇文章把整个过程完整记录下来。

先量化,再优化 ​

没有度量就没有优化。第一步是确定要看的指标:

指标含义目标
FCP首次内容绘制< 1.5s
LCP最大内容绘制< 2.5s
TBT总阻塞时间< 200ms
CLS累计布局偏移< 0.1
INP交互到下一次绘制< 200ms

用 Lighthouse 跑基线,同时在真实环境接入 web-vitals 采集 RUM 数据。实验室数据看问题,真实数据看影响面。

一、先把包体积砍下来 ​

1. 分析依赖构成 ​

bash
npx vite-bundle-visualizer

打开可视化报告后,我们的问题一目了然:

  • lodash 全量引入,占了 71KB(gzip)
  • moment + 所有语言包,占了 230KB
  • 一个内部组件库把整个 echarts 打进了主包

2. 按需引入与替换 ​

js
// ❌ 全量引入
import _ from 'lodash'

// ✅ 只引入用到的方法
import debounce from 'lodash/debounce'

moment 换成 dayjs,体积从 230KB 降到 7KB,API 几乎兼容。

3. 代码分割 ​

路由级懒加载是收益最高的改动:

js
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')
  }
]

同时用 manualChunks 把稳定的第三方库拆成独立 chunk,利用浏览器缓存:

js
build: {
  rollupOptions: {
    output: {
      manualChunks: {
        vue: ['vue', 'vue-router', 'pinia'],
        charts: ['echarts'],
        editor: ['@wangeditor/editor']
      }
    }
  }
}

这一步做完,主包从 1.8MB 降到 320KB。

二、优化加载优先级 ​

4. 关键资源预加载 ​

html
<link rel="preload" as="font" href="/fonts/main.woff2" crossorigin>
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">

5. 首页模块内联,其余按需 ​

把首屏关键 CSS 内联到 HTML 里,其余样式异步加载,可以明显降低 FCP。

6. 图片懒加载 + 现代格式 ​

html
<img src="cover.avif" loading="lazy" decoding="async" width="800" height="450" alt="">

显式声明 width / height 能避免布局偏移(CLS)。

三、减少渲染阻塞 ​

7. 大列表虚拟滚动 ​

原来一个页面渲染 2000 行表格,主线程阻塞接近 1.2 秒。换成虚拟滚动后只渲染可视区域的 20 行,阻塞降到几乎不可感知。

8. 长任务切片 ​

把一个大循环拆成多个小任务,用 requestIdleCallback 或 scheduler.yield() 让出主线程:

js
async function processInChunks(items, chunkSize = 100) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize)
    chunk.forEach(processItem)
    await scheduler.yield()   // 让出主线程
  }
}

9. 避免布局抖动 ​

js
// ❌ 读写交替触发多次重排
el.style.width = el.offsetWidth + 10 + 'px'

// ✅ 先读后写
const w = el.offsetWidth
el.style.width = w + 10 + 'px'

四、缓存与传输 ​

10. 静态资源长缓存 ​

nginx
location ~* \.(js|css|woff2|avif|webp)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

文件名带内容哈希,可以放心用 immutable。

11. 开启 Brotli 压缩 ​

相比 gzip,Brotli 在文本资源上通常还能再小 15%~20%:

nginx
brotli on;
brotli_comp_level 6;
brotli_types text/css application/javascript application/json image/svg+xml;

12. HTTP/3 与 CDN ​

启用 HTTP/3 后,弱网用户的首字节时间平均下降 30% 左右。静态资源走 CDN,能显著降低跨地域延迟。

优化前后对比 ​

指标优化前优化后变化
主包体积(gzip)1.8 MB320 KB-82%
FCP2.9s0.9s-69%
LCP4.8s1.5s-69%
TBT1180ms160ms-86%
Lighthouse 性能分3894+56

一个提醒 ​

性能优化最容易踩的坑是优化了用户感知不到的东西。比如花两天把某个二级页面的包减小 200KB,而这个页面日均访问量不到 50 次。先用 RUM 数据排出优先级,把精力放在影响面最大的路径上。

小结 ​

21 个优化点里,真正带来 80% 收益的只有四条:代码分割、按需引入、大列表虚拟滚动、静态资源长缓存。先做这四件事,再考虑其他细节。

返回文章列表

PostgreSQL 索引原理与慢查询调优实录

B-Tree、GIN、BRIN 索引的适用场景,执行计划怎么看,以及一次线上慢查询从 8 秒优化到 40 毫秒的完整过程。

数据库 2026-08-21 约 19 分钟 # PostgreSQL# 索引# 数据库# 性能优化

线上订单列表页突然变慢,接口耗时从 200 毫秒涨到 8 秒。这篇文章记录完整的排查与优化过程,并顺带把 PostgreSQL 的索引机制讲清楚。

一、先定位慢查询 ​

PostgreSQL 自带的 pg_stat_statements 是排查利器:

sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
  round(mean_exec_time::numeric, 2) AS avg_ms,
  calls,
  round(total_exec_time::numeric) AS total_ms,
  rows,
  query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

按总耗时排序通常比按平均耗时更有价值——一个平均 50 毫秒但每秒调用 1000 次的查询,比一个平均 3 秒但每天调用 2 次的查询更值得优化。

我们定位到的问题 SQL:

sql
SELECT *
FROM orders
WHERE tenant_id = 1024
  AND status IN ('paid', 'shipped')
  AND created_at >= '2026-08-01'
ORDER BY created_at DESC
LIMIT 20;

二、读懂执行计划 ​

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

关键输出:

text
Limit  (cost=184320.55..184320.60 rows=20 width=312) (actual time=7213.4..7213.5 rows=20 loops=1)
  ->  Sort  (cost=184320.55..185120.30 rows=319900 width=312) (actual time=7213.2..7213.2 rows=20 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Seq Scan on orders  (cost=0.00..176540.00 rows=319900 width=312) (actual time=0.9..6980.1 rows=318742 loops=1)
              Filter: ((tenant_id = 1024) AND (status = ANY (...)) AND (created_at >= ...))
              Rows Removed by Filter: 4681203
Buffers: shared hit=4210 read=178233

这里有两个明确信号:

  1. Seq Scan —— 全表扫描,5 百万行里过滤出 31 万行
  2. Rows Removed by Filter: 4681203 —— 99% 的行被读出来又丢弃

三、为什么原来的索引没用上 ​

表上原本有一个索引:

sql
CREATE INDEX idx_orders_tenant ON orders (tenant_id);

问题在于选择性太差:tenant_id = 1024 匹配了 31 万行,占全表 6%。优化器计算后认为走索引再回表(随机 I/O)的成本比顺序全表扫描更高,于是直接放弃了索引。

这引出一个重要结论:索引不是加了就一定会用,优化器是算成本的。

四、对症下药 ​

1. 建立复合索引 ​

把等值条件放前面,范围条件放最后:

sql
CREATE INDEX CONCURRENTLY idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC);

顺序原则:

text
等值查询列  →  等值查询列  →  范围/排序列
tenant_id      status          created_at

2. 让索引覆盖排序 ​

created_at DESC 与查询的 ORDER BY created_at DESC 方向一致,PostgreSQL 可以直接从索引读取有序数据,完全跳过 Sort 节点。

3. 用 CONCURRENTLY 避免锁表 ​

sql
CREATE INDEX CONCURRENTLY ...

普通 CREATE INDEX 会持有 SHARE 锁阻塞写入;CONCURRENTLY 允许读写并行,代价是耗时更长且可能失败(失败会留下 invalid 索引,需要手动删除重建)。

4. 考虑部分索引 ​

如果查询总是带上同样的状态条件,部分索引体积更小、更快:

sql
CREATE INDEX idx_orders_active
ON orders (tenant_id, created_at DESC)
WHERE status IN ('paid', 'shipped');

只索引需要的数据,索引体积能减少 70% 以上。

五、优化结果 ​

优化后重新执行:

text
Limit  (cost=1.24..18.90 rows=20 width=312) (actual time=0.032..0.041 rows=20 loops=1)
  ->  Index Scan using idx_orders_tenant_status_created on orders
        (actual time=0.030..0.038 rows=20 loops=1)
Buffers: shared hit=24
指标优化前优化后
执行时间7213 ms41 ms
扫描方式Seq ScanIndex Scan
读取缓冲区17823324
Sort 节点有无

快了 175 倍。

六、索引类型选择速查 ​

索引类型适用场景
B-Tree等值、范围、排序(默认,覆盖 90% 场景)
GIN数组、JSONB、全文检索
GiST几何数据、范围类型、最近邻搜索
BRIN超大表、数据按物理顺序写入(如时间序列)
Hash仅等值查询,很少用

其中 BRIN 值得一提:对按时间顺序写入的日志表,BRIN 索引只有几 MB,却能顶替几百 MB 的 B-Tree。

七、索引维护 ​

索引不是建完就一劳永逸:

  • 监控膨胀:pg_stat_user_indexes 查看 idx_scan,长期为 0 的索引是纯粹的写入负担
  • 定期重建:频繁更新的表可以用 REINDEX CONCURRENTLY 消除膨胀
  • 关注写放大:每个索引都会让 INSERT / UPDATE 变慢,索引不是越多越好

一个反直觉的事实:删掉无用的索引,往往比新增索引更能提升整体性能。我曾经在一个表上删掉 4 个从未被使用的索引,写入吞吐提升了近 30%。

小结 ​

慢查询优化的标准流程是:找到慢查询 → 看执行计划 → 判断是索引缺失还是选择性问题 → 建立合适的复合/部分索引 → 验证并持续监控。索引设计的核心,是让查询的过滤条件和排序方向都能在同一个索引里被满足。

返回文章列表

用 Docker Compose 五分钟部署一套静态博客系统

一份 compose 文件搞定应用、反向代理与自动 HTTPS,附带备份脚本与升级流程,新手也能一次跑通。

运维部署 2026-08-14 约 11 分钟 # Docker# 部署# Nginx# 运维部署

自建博客最麻烦的从来不是写内容,而是部署。这篇文章给出一套可以直接复制的 Docker Compose 方案,把站点、反向代理和 HTTPS 证书一次性搞定。

整体结构 ​

text
blog/
├── docker-compose.yml
├── Caddyfile
├── site/                # 构建产物(静态文件)
│   ├── index.html
│   └── assets/
└── backup.sh

选择 Caddy 而不是 Nginx,是因为它能自动申请并续期 Let's Encrypt 证书,省掉 certbot 的全部配置工作。

一份 compose 文件 ​

yaml
services:
  web:
    image: caddy:2-alpine
    container_name: blog-web
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./site:/srv:ro
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  caddy_data:
  caddy_config:

就这么简单。不需要数据库,不需要应用服务——静态站点的最大优势就在这里。

Caddyfile 配置 ​

text
qiuqiancloud.cn, www.qiuqiancloud.cn {
    root * /srv
    encode zstd gzip
    file_server

    # 静态资源长缓存
    @static {
        path *.js *.css *.woff2 *.avif *.webp *.svg
    }
    header @static Cache-Control "public, max-age=31536000, immutable"

    # HTML 不缓存,保证内容更新即时生效
    @html {
        path *.html /
    }
    header @html Cache-Control "public, max-age=0, must-revalidate"

    # 安全响应头
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }

    # 错误页
    handle_errors {
        rewrite * /404.html
        file_server
    }
}

首次启动时 Caddy 会自动完成 ACME 挑战、申请证书并配置续期,caddy_data 卷保存证书,重启不会重复申请。

启动与验证 ​

bash
docker compose up -d
docker compose logs -f web

看到 certificate obtained successfully 就说明 HTTPS 已经就绪。验证:

bash
curl -I https://qiuqiancloud.cn

内容更新流程 ​

站点是纯静态的,更新内容只需要替换 site/ 目录:

bash
# 本地构建
npm run build

# 同步到服务器(rsync 增量传输,只传变化的文件)
rsync -avz --delete dist/ user@server:/opt/blog/site/

# 无需重启容器,Caddy 直接读取新文件

因为挂了 :ro 只读卷且 Caddy 是实时读文件,不需要重启容器,更新即时生效。这是静态站点相比应用服务的一大优势。

备份脚本 ​

只需要备份配置和证书(内容可以从 Git 恢复):

bash
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR=/opt/backups/blog
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"

# 备份配置和证书卷
tar -czf "$BACKUP_DIR/blog-$STAMP.tar.gz" \
    -C /opt/blog docker-compose.yml Caddyfile site

# 只保留最近 30 份
find "$BACKUP_DIR" -name 'blog-*.tar.gz' -mtime +30 -delete

echo "backup done: $BACKUP_DIR/blog-$STAMP.tar.gz"

加入 crontab:

cron
0 3 * * * /opt/blog/backup.sh >> /var/log/blog-backup.log 2>&1

升级 Caddy ​

bash
docker compose pull
docker compose up -d

滚动更新,服务中断通常在 1 秒以内。

常见问题 ​

证书申请失败:检查 80 端口是否被占用、域名解析是否已生效。ACME 的 HTTP-01 挑战必须能从公网访问 80 端口。

Caddy 不断重启:多半是 Caddyfile 语法错误,用 docker compose logs web 看具体行号。

更新后没生效:浏览器缓存了 HTML。确认 Cache-Control 配置正确,或用 curl -I 检查响应头。

一个容易被忽略的细节:Caddy 的 caddy_data 卷一定要持久化。如果每次重建都重新申请证书,Let's Encrypt 有严格的频率限制(同一域名每周 50 份),超了会被临时封禁。

小结 ​

静态博客的部署可以做到极简:Caddy 负责 HTTPS 和缓存,rsync 负责发布,tar 负责备份。整套方案没有状态、没有数据库、没有需要维护的中间件,是长期稳定运行成本最低的选择。

返回文章列表

Linux 服务器安全加固:20 条超实用清单

SSH 密钥登录、防火墙策略、fail2ban 防爆破、最小权限账户与日志审计,帮你把暴露在公网的服务器守住。

运维部署 2026-08-06 约 14 分钟 # Linux# 安全# SSH# 运维部署

一台刚上线的公网服务器,平均在 15 分钟内就会迎来第一次 SSH 爆破尝试。这篇清单是我在多台生产服务器上实际验证过的加固步骤,按优先级排列。

一、账户与登录(最高优先级) ​

1. 禁用 root 远程登录 ​

bash
# /etc/ssh/sshd_config
PermitRootLogin no

需要管理权限时先用普通账户登录,再 sudo 或 su。

2. 改用密钥认证,关闭密码登录 ​

bash
# 本地生成密钥(推荐 ed25519)
ssh-keygen -t ed25519 -C "admin@example.com"

# 上传公钥
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server

确认密钥能正常登录后,再关闭密码认证:

bash
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no

务必先验证密钥登录成功再关闭密码登录,否则会把自己锁在服务器外面。保持一个已登录的 SSH 会话开着,作为退路。

3. 修改默认 SSH 端口 ​

bash
Port 22022

这不能阻止针对性攻击,但能挡掉绝大多数无差别的自动化扫描,让日志清爽很多。

4. 限制登录来源 ​

bash
AllowUsers deploy admin
# 或按网段限制
AllowUsers admin@192.168.1.*

5. 设置登录超时与重试次数 ​

bash
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 5
ClientAliveInterval 300
ClientAliveCountMax 2

改完后用 sshd -t 校验语法,再 systemctl reload sshd。

二、防火墙 ​

6. 配置 nftables / iptables ​

只放行必要端口,其余默认拒绝:

bash
nft add table inet filter
nft add chain inet filter input '{ type filter hook input priority 0 ; policy drop ; }'
nft add rule inet filter input ct state established,related accept
nft add rule inet filter input iif lo accept
nft add rule inet filter input tcp dport { 22022, 80, 443 } accept
nft add rule inet filter input icmp type echo-request accept

7. 用 fail2ban 自动封禁爆破源 ​

ini
# /etc/fail2ban/jail.local
[sshd]
enabled  = true
port     = 22022
maxretry = 3
findtime = 600
bantime  = 86400
banaction = nftables-multiport

maxretry = 3 + bantime = 24h 的组合,能在 10 分钟内把常见扫描器全部挡掉。

8. 云厂商安全组二次收口 ​

即使服务器本身配好了防火墙,安全组也应该只放行必要端口。两层防护,任何一层配置失误都不至于直接暴露。

三、系统与内核 ​

9. 及时打补丁 ​

bash
# Debian / Ubuntu
apt update && apt upgrade -y
apt install -y unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades

10. 内核安全参数 ​

bash
# /etc/sysctl.d/99-security.conf
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_syncookies = 1
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

11. 关闭不需要的服务 ​

bash
systemctl list-unit-files --state=enabled
systemctl disable --now <不需要的服务>

每多一个监听端口,就多一个潜在攻击面。

12. 启用 SELinux 或 AppArmor ​

不要因为"配置麻烦"就关掉它。生产环境建议至少以 permissive 模式运行一段时间,观察审计日志后再切到 enforcing。

四、权限与文件 ​

13. 遵循最小权限原则 ​

业务进程使用独立系统账户,禁止直接用 root 跑应用:

bash
useradd -r -s /usr/sbin/nologin appuser

14. 收紧敏感文件权限 ​

bash
chmod 600 /etc/ssh/sshd_config
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chown -R root:root /etc/ssh

15. 排查 SUID 文件 ​

bash
find / -perm -4000 -type f 2>/dev/null

SUID 程序是提权的重要途径,定期检查有没有多余的可执行文件被设置了 SUID。

16. 挂载选项加固 ​

bash
# /etc/fstab
tmpfs  /tmp  tmpfs  defaults,noexec,nosuid,nodev,size=1G  0 0

noexec 能阻止攻击者把恶意程序下载到 /tmp 后直接执行。

五、审计与监控 ​

17. 开启命令审计 ​

bash
apt install -y auditd
# 监控 sudo 与关键配置文件
auditctl -w /etc/passwd -p wa -k passwd_change
auditctl -w /etc/sudoers -p wa -k sudoers_change

18. 集中收集日志 ​

本地日志被清理是入侵后的常见操作。把日志实时转发到远程 syslog 服务器,能做基本的防篡改。

19. 监控异常登录 ​

bash
# 查看失败登录
lastb | head -20

# 查看成功登录
last -20

# 统计攻击来源 IP
grep "Failed password" /var/log/auth.log | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head

20. 配置监控告警 ​

对 CPU 突增、异常外连、磁盘异常写入设置告警。挖矿木马和 C2 通信都有明显的资源特征。

一次性检查脚本 ​

把上面能自动化的部分串起来:

bash
#!/usr/bin/env bash
echo "=== SSH 配置检查 ==="
sshd -T | grep -E 'permitrootlogin|passwordauthentication|maxauthtries'

echo "=== 监听端口 ==="
ss -tulnp

echo "=== 最近失败登录 Top10 ==="
lastb 2>/dev/null | head -10

echo "=== SUID 文件数量 ==="
find / -perm -4000 -type f 2>/dev/null | wc -l

echo "=== 可用更新数量 ==="
apt list --upgradable 2>/dev/null | wc -l

echo "=== 磁盘与内存 ==="
df -h / && free -h

建议每月跑一次,形成习惯。

小结 ​

这套清单里,性价比最高的前四条是:关闭 root 登录、只用密钥认证、配置 fail2ban、限制来源 IP。它们能在半小时内完成,却能挡掉 99% 的自动化攻击。

安全不是一次性任务,而是持续的运维习惯。

返回文章列表

分布式系统中的一致性哈希算法详解

为什么普通取模在扩缩容时会雪崩?虚拟节点如何解决数据倾斜?手写一版可用的一致性哈希实现并做压测验证。

分布式系统 2026-07-28 约 17 分钟 # 一致性哈希# 分布式系统# 算法# 负载均衡

缓存集群从 4 台扩容到 5 台,结果整个缓存层几乎全部失效,数据库瞬间被打满——这是分布式系统里最经典的事故之一。根因就是普通取模哈希。

普通取模为什么会雪崩 ​

假设用 hash(key) % N 决定数据落在哪个节点,N 是节点数。

text
N = 4 时:key="user:1001" → hash % 4 = 1 → 节点 1
N = 5 时:key="user:1001" → hash % 5 = 3 → 节点 3

问题非常直观:只要 N 变化,几乎所有的 key 映射目标都会改变。

数学上的结论是:节点数从 N 变为 M 时,需要迁移的数据比例约为 1 - N/M(N < M)。从 4 台扩到 5 台,意味着 80% 的缓存失效。这些请求会瞬间穿透到数据库——这就是"缓存雪崩"。

一致性哈希的核心思想 ​

一致性哈希把哈希空间组织成一个环(通常取 0 ~ 2^32 - 1):

  1. 对每个节点的标识(如 IP:端口)做哈希,映射到环上的位置
  2. 对每个数据的 key 做哈希,也映射到环上
  3. 数据归属顺时针方向遇到的第一个节点
text
        0
        |
   N3 --+-- N1
   /         \
  |           |
   \         /
   N2 ------+

这样当新增一个节点 N4 时,只有 N4 顺时针前一个节点的一部分数据需要迁移,其余节点不受影响。平均迁移比例是 1/N,而不是 1 - N/(N+1)。

数据倾斜与虚拟节点 ​

朴素的一致性哈希有个严重问题:哈希函数的输出并非均匀分布,节点在环上的位置可能挤在一起。

极端情况下,如果 4 个节点都落在环的某一个扇区,那么第一个节点会承担几乎全部流量。

解决办法是引入虚拟节点(Virtual Node):

text
物理节点 N1 → 映射为 N1#1, N1#2, ... N1#150
物理节点 N2 → 映射为 N2#1, N2#2, ... N2#150

每个物理节点对应 150 个虚拟节点散布在环上,流量分布就会非常均匀。经验值是每个物理节点 100~200 个虚拟节点,再多收益递减。

手写一版实现 ​

python
import hashlib
import bisect


def _hash(key: str) -> int:
    """使用 MD5 取前 8 字节,保证分布均匀且稳定"""
    return int(hashlib.md5(key.encode()).hexdigest()[:8], 16)


class ConsistentHash:
    def __init__(self, nodes=None, replicas=150):
        self.replicas = replicas
        self._ring = {}          # hash 值 -> 节点名
        self._sorted_keys = []   # 有序的 hash 值
        for node in nodes or []:
            self.add_node(node)

    def add_node(self, node: str) -> None:
        for i in range(self.replicas):
            key = _hash(f'{node}#{i}')
            self._ring[key] = node
            bisect.insort(self._sorted_keys, key)

    def remove_node(self, node: str) -> None:
        for i in range(self.replicas):
            key = _hash(f'{node}#{i}')
            self._ring.pop(key, None)
            idx = bisect.bisect_left(self._sorted_keys, key)
            if idx < len(self._sorted_keys) and self._sorted_keys[idx] == key:
                self._sorted_keys.pop(idx)

    def get_node(self, key: str) -> str | None:
        if not self._ring:
            return None
        h = _hash(key)
        # 二分查找顺时针第一个节点
        idx = bisect.bisect_right(self._sorted_keys, h)
        if idx == len(self._sorted_keys):
            idx = 0   # 成环,回到起点
        return self._ring[self._sorted_keys[idx]]

关键实现点:

  • 有序数组 + 二分查找:查找复杂度 O(log N),比遍历所有虚拟节点快得多
  • 成环处理:当哈希值大于所有节点位置时回到环的起点
  • MD5 截断:保证不同虚拟节点的哈希值均匀分布

实测数据 ​

用 10 万个 key 做测试,对比不同配置的分布均匀度:

配置标准差(越接近 0 越均匀)扩容迁移比例
无虚拟节点,4 节点18420—
100 虚拟节点,4 节点1240—
150 虚拟节点,4 节点812—
150 虚拟节点,4 → 5 节点86919.8%

迁移比例接近理论最优值 1/5 = 20%,分布标准差从 18420 降到 812,效果非常明显。

对比普通取模扩容的 80% 迁移率,一致性哈希把影响面压缩了 4 倍。

实际应用中的注意事项 ​

1. 哈希函数要稳定 ​

一定不要用语言内置的 hash()(比如 Python 的 hash() 在 3.3 之后对字符串加了随机盐),否则进程重启后映射全变。必须用 MD5、SHA1 这类确定性哈希。

2. 节点权重 ​

如果机器配置不同,可以让高配机器对应更多虚拟节点:

python
hash_ring.add_node('node-a', weight=2)  # 承担双倍流量

3. 缓存热点问题 ​

一致性哈希只解决分布问题,不解决热点问题。如果某个 key 的访问量特别大,无论落在哪个节点都会成为瓶颈。这种场景需要额外做本地缓存或二级分片。

4. 与一致性哈希环配合的故障转移 ​

节点宕机时,其数据按顺时针顺序交给下一个节点。如果这个"下一个节点"同时承担了自己和宕机节点的流量,可能过载。生产环境通常配合副本机制使用。

工业界的实践 ​

  • Redis Cluster:没有用一致性哈希,而是用了 16384 个固定哈希槽(slot),本质上是预分片的思路
  • Cassandra / DynamoDB:使用一致性哈希 + 多副本,虚拟节点是标配
  • Nginx upstream consistent:内置了一致性哈希模块,用于后端负载均衡
  • Envoy / gRPC:使用 ring hash 或 maglev 算法

一个有趣的事实:Google 的 Maglev 算法为了解决一致性哈希的查找效率和分布均匀性,用了完全不同的思路——预计算一张固定大小的查找表,查找复杂度 O(1),代价是节点变更时重建整张表。

小结 ​

一致性哈希解决的核心问题是:让节点数量的变化只影响局部数据。虚拟节点解决了它的分布均匀性缺陷。它是分布式缓存、分片存储、负载均衡的基础设施级算法,值得每个后端工程师吃透。

返回文章列表

TypeScript 类型体操:从入门到写出自己的工具类型

条件类型、映射类型、infer 推断与递归,配合 12 个真实业务场景,让类型系统真正为你所用而不是拖后腿。

前端工程化 2026-07-19 约 20 分钟 # TypeScript# 类型系统# 前端

很多人对 TypeScript 的印象停留在"给变量加个类型"。实际上,TS 的类型系统是一门完备的编程语言——它有条件判断、有循环(递归)、有变量(泛型参数)、有模式匹配(infer)。掌握这些能力,你能让类型系统替你完成大量重复的校验工作。

一、基础积木 ​

泛型:类型的参数 ​

ts
type Box<T> = { value: T }

const a: Box<number> = { value: 1 }
const b: Box<string> = { value: 'hello' }

T 就像一个类型层面的参数,调用时才确定。

keyof:取出所有键 ​

ts
interface User {
  id: number
  name: string
  email: string
}

type UserKey = keyof User   // 'id' | 'name' | 'email'

索引访问:按需取类型 ​

ts
type UserName = User['name']             // string
type IdOrName = User['id' | 'name']      // number | string

联合与交叉 ​

ts
type A = { a: string }
type B = { b: number }

type Both = A & B          // { a: string; b: number }
type Either = A | B        // 满足其一即可

二、条件类型:类型层面的 if ​

ts
type IsString<T> = T extends string ? true : false

type R1 = IsString<'hello'>   // true
type R2 = IsString<42>        // false

注意 extends 在条件类型里表示"可赋值给"(assignable),不是继承。

分布式条件类型 ​

当条件类型作用于裸类型参数并且传入联合类型时,会自动分发:

ts
type ToArray<T> = T extends any ? T[] : never

type R = ToArray<string | number>
// 等价于 (string[] | number[]),而不是 (string | number)[]

这个特性非常有用,但也是很多"类型不符合预期"的根源。想禁用分发,把参数包起来即可:

ts
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never
type R2 = ToArrayNoDist<string | number>   // (string | number)[]

三、infer:类型层面的模式匹配 ​

infer 让你从类型中"提取"出一部分:

ts
// 提取函数返回值
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never

// 提取 Promise 内部类型
type Unwrap<T> = T extends Promise<infer U> ? U : T

type R = Unwrap<Promise<string>>   // string

还可以提取数组的首尾元素:

ts
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never
type Last<T extends any[]>  = T extends [...any[], infer L] ? L : never

type F = First<[1, 2, 3]>   // 1
type L = Last<[1, 2, 3]>    // 3

四、映射类型:批量改造对象 ​

ts
// 把每个属性变成可选
type MyPartial<T> = { [K in keyof T]?: T[K] }

// 把每个属性变成只读
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }

配合 as 子句可以做键重映射:

ts
// 给每个 key 加上 get 前缀
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

interface State {
  name: string
  age: number
}

type StateGetters = Getters<State>
// { getName: () => string; getAge: () => number }

这个模式在状态管理库(Pinia、Redux Toolkit)里用得非常多。

五、递归:类型层面的循环 ​

TypeScript 没有循环语法,但支持递归。

ts
// 深度只读
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K]
}
ts
// 路径类型:把嵌套对象的所有路径展平成字符串联合
type Paths<T, Prefix extends string = ''> = {
  [K in keyof T & string]: T[K] extends object
    ? Paths<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`
}[keyof T & string]

interface Config {
  server: { host: string; port: number }
  debug: boolean
}

type ConfigPath = Paths<Config>
// 'server.host' | 'server.port' | 'debug'

Paths 是表单校验库、配置读取工具的核心类型技巧。

递归类型有深度上限(默认约 50 层),超过会报 Type instantiation is excessively deep。遇到时可以用尾递归优化写法,或者干脆拆分成多个类型。

六、实战:一个类型安全的 API 客户端 ​

把上面的技巧串起来,实现"根据接口路径自动推断返回类型":

ts
interface ApiRoutes {
  'GET /users': { id: number; name: string }[]
  'GET /users/:id': { id: number; name: string; email: string }
  'POST /users': { id: number }
}

// 从路径中提取参数
type ExtractParams<P extends string> =
  P extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : P extends `${string}:${infer Param}`
      ? Param
      : never

type A = ExtractParams<'/users/:id/posts/:postId'>
// 'id' | 'postId'

// 类型安全的请求函数
async function request<M extends keyof ApiRoutes & string>(
  method: M,
  ...args: ExtractParams<M> extends never ? [] : [params: Record<ExtractParams<M>, string | number>]
): Promise<ApiRoutes[M]> {
  return null as any
}

// 使用
const users = await request('GET /users')                 // ✅ 正确
const user  = await request('GET /users/:id', { id: 1 })  // ✅ 正确
// await request('GET /users/:id')                        // ❌ 编译报错:缺少参数

这样写出来的客户端,参数缺失或拼错路径会在编辑器里直接标红,而不是等到运行时才 404。

七、调试类型错误的技巧 ​

类型体操报错往往难以理解。几个实用技巧:

  1. 用 // @ts-expect-error 反向验证:确认某段代码确实应该报错
  2. 用类型别名逐步拆解:把中间结果取个名字,鼠标悬停能看到实际展开结果
  3. 用 never 做穷尽检查:
ts
type Status = 'pending' | 'success' | 'failed'

function assertNever(x: never): never {
  throw new Error(`未处理的状态: ${x}`)
}

function handle(s: Status) {
  switch (s) {
    case 'pending': return 1
    case 'success': return 2
    case 'failed':  return 3
    default: return assertNever(s)   // 新增状态时会编译报错
  }
}

这样当你给 Status 加一个新值时,所有没处理的 switch 都会报错,而不是悄悄漏掉。

小结 ​

类型体操的价值不在于炫技,而在于把运行时的错误提前到编译期。真正值得掌握的就四样:条件类型、infer、映射类型、递归。剩下的都是这四个的组合。

写类型时记住一个原则:如果一个类型复杂到需要写注释才能看懂,那它可能过于复杂了。 优先选简单的类型 + 清晰的运行时校验,而不是让类型系统承担所有工作。