深入理解 HTTP/3 与 QUIC:下一代网络传输协议到底解决了什么
从 TCP 队头阻塞讲到 QUIC 的 0-RTT 握手、连接迁移与多路复用,结合真实抓包数据,分析 HTTP/3 在弱网环境下的性能优势与落地成本。
专注后端架构、云原生运维、前端工程化与数据库实践。把踩过的坑写下来,把想明白的道理讲清楚。
从 TCP 队头阻塞讲到 QUIC 的 0-RTT 握手、连接迁移与多路复用,结合真实抓包数据,分析 HTTP/3 在弱网环境下的性能优势与落地成本。
三主三从架构、etcd 集群、VIP 漂移、Ingress 与持久化存储,把生产级集群搭建的每一步都跑通并记录成可复现的清单。
Future 的惰性求值、Waker 唤醒机制、多线程调度器的工作窃取算法,以及如何写出真正不阻塞的异步代码。
从资源体积、加载优先级、渲染阻塞到交互响应,逐项拆解 21 个可落地的优化点,并附上真实项目的性能指标对比数据。
B-Tree、GIN、BRIN 索引的适用场景,执行计划怎么看,以及一次线上慢查询从 8 秒优化到 40 毫秒的完整过程。
一份 compose 文件搞定应用、反向代理与自动 HTTPS,附带备份脚本与升级流程,新手也能一次跑通。
SSH 密钥登录、防火墙策略、fail2ban 防爆破、最小权限账户与日志审计,帮你把暴露在公网的服务器守住。
条件类型、映射类型、infer 推断与递归,配合 12 个真实业务场景,让类型系统真正为你所用而不是拖后腿。
ARTICLES
这里汇总了「秋千云网络」发布的所有原创文章,共 9 篇。可按分类筛选或直接搜索关键词。
从 TCP 队头阻塞讲到 QUIC 的 0-RTT 握手、连接迁移与多路复用,结合真实抓包数据,分析 HTTP/3 在弱网环境下的性能优势与落地成本。
三主三从架构、etcd 集群、VIP 漂移、Ingress 与持久化存储,把生产级集群搭建的每一步都跑通并记录成可复现的清单。
Future 的惰性求值、Waker 唤醒机制、多线程调度器的工作窃取算法,以及如何写出真正不阻塞的异步代码。
从资源体积、加载优先级、渲染阻塞到交互响应,逐项拆解 21 个可落地的优化点,并附上真实项目的性能指标对比数据。
B-Tree、GIN、BRIN 索引的适用场景,执行计划怎么看,以及一次线上慢查询从 8 秒优化到 40 毫秒的完整过程。
一份 compose 文件搞定应用、反向代理与自动 HTTPS,附带备份脚本与升级流程,新手也能一次跑通。
SSH 密钥登录、防火墙策略、fail2ban 防爆破、最小权限账户与日志审计,帮你把暴露在公网的服务器守住。
条件类型、映射类型、infer 推断与递归,配合 12 个真实业务场景,让类型系统真正为你所用而不是拖后腿。
秋千云网络(QIUQIAN CLOUD)是一个个人技术博客站点,自 2020 年起持续更新,主要记录后端架构、云原生运维、前端工程化以及数据库领域的原创技术文章与实践经验。
这个站点不是资讯聚合,也不是教程搬运。它更像一本公开的工程笔记:把工作中真实遇到的问题、踩过的坑、最终验证有效的方案完整写下来,让每一个读到的人都能直接拿去用。
主要内容方向包括:
| 方向 | 说明 |
|---|---|
| 后端开发 | 服务架构设计、并发模型、缓存与消息队列实践 |
| 云原生 | Kubernetes 集群、容器编排、服务治理与可观测性 |
| 前端工程化 | 构建优化、性能调优、类型系统与组件设计 |
| 数据库 | 索引原理、慢查询优化、事务与分布式存储 |
| 运维部署 | Linux 加固、自动化部署、监控告警体系 |
本站是一个纯前端静态站点,没有任何后端服务:
这样做的好处是部署简单、访问速度快、可长期维护,也不需要承担服务器运行与数据库维护的成本。
本站所有原创文章采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议。欢迎转载,但请保留原文链接与作者署名,且不得用于商业用途。
如果你在文章中发现任何错误,或者有想讨论的技术问题,欢迎通过邮件与我交流。
从 TCP 队头阻塞讲到 QUIC 的 0-RTT 握手、连接迁移与多路复用,结合真实抓包数据,分析 HTTP/3 在弱网环境下的性能优势与落地成本。
HTTP/2 在 2015 年发布时被寄予厚望,它用二进制分帧和单连接多路复用解决了 HTTP/1.1 的队头阻塞问题。但很多团队上线 HTTP/2 后发现,性能提升并没有想象中那么大——在丢包率较高的网络环境下,甚至可能比 HTTP/1.1 更慢。
问题的根源不在应用层,而在它脚下的 TCP。
HTTP/2 的多路复用把多个请求拆成帧,在同一条 TCP 连接上交错传输。TCP 本身把数据视为一个有序的字节流,它不知道哪些字节属于哪个流。
于是问题出现了:只要有一个 TCP 报文丢失,内核就必须等待该报文重传完成,才能把后续已到达的数据交付给应用层。此时所有 HTTP/2 流都被卡住——这就是TCP 层队头阻塞(Head-of-Line Blocking)。
HTTP/1.1 → 每个域名 6 条连接,各自独立,一条卡住不影响其他
HTTP/2 → 1 条 TCP 连接,丢一个包,所有流全卡
HTTP/3 → 基于 UDP,每个流独立重组,互不影响对丢包率敏感的场景(移动网络、跨境链路)来说,这几乎是致命的。
QUIC 把可靠性机制从内核搬到了用户态,跑在 UDP 之上。这带来了一系列 TCP 无法做到的优化。
QUIC 在协议内部实现了流的概念,每个流有独立的序列号和重组缓冲。一个流丢包只影响该流,其他流的数据可以正常交付。
这从根本上消除了传输层的队头阻塞——HTTP/3 终于让 HTTP/2 的设计意图完整落地。
TCP + TLS 1.3 的完整握手需要 3 次往返(TCP 三次握手 + TLS 1.3 握手)。QUIC 把传输握手和加密握手合并:
| 场景 | TCP + TLS 1.3 | QUIC |
|---|---|---|
| 首次连接 | 3 RTT | 1 RTT |
| 恢复连接 | 3 RTT | 0 RTT |
| 连接迁移 | 重新握手 | 无需握手 |
对延迟敏感的首屏加载来说,省下的 2 个 RTT 是实打实的。
TCP 连接由四元组(源 IP、源端口、目的 IP、目的端口)标识。手机从 Wi-Fi 切到 4G,IP 变了,连接就断了,必须重建。
QUIC 用 Connection ID 标识连接,与 IP 地址解耦。网络切换时连接不中断,正在传输的数据可以继续——这对移动端体验提升非常明显。
因为跑在用户态,QUIC 可以更快地迭代拥塞控制算法(CUBIC、BBR 等),不需要等待操作系统内核升级。可插拔的拥塞控制也让不同业务可以选择不同策略。
在支持 HTTP/3 的站点上抓包,一次 0-RTT 恢复连接大致是这样:
Client Server
|------ Initial (ClientHello) ------>| 含 0-RTT 早期数据
|<----- Initial (ServerHello) -------|
|<----- Handshake (Encrypted Ext) ---|
|------ Handshake (Finished) ------->|
|<========== 1-RTT 应用数据 =========>|整个握手过程被压缩到 1 个往返以内,浏览器服务端配置开启 Alt-Svc 后即可自动升级。
HTTP/3 并不是免费的午餐,实际部署时需要注意这些点:
tcpdump 看到的是加密的 UDP 载荷。我的建议是分场景判断:
一个务实的做法:先开启
Alt-Svc让浏览器自动协商,同时保留 HTTP/2 回退,用真实用户监控(RUM)对比两组的首字节时间与完整加载时间,再决定是否全量切换。
HTTP/3 的价值不在于"更快",而在于把不可控的传输层变成了可控的应用层。流级重传解决了 TCP 队头阻塞,连接迁移解决了移动网络切换,用户态实现让协议可以快速演进。
它不会让所有站点都变快,但对那些真正受制于网络质量的业务来说,这是一次值得投入的升级。
三主三从架构、etcd 集群、VIP 漂移、Ingress 与持久化存储,把生产级集群搭建的每一步都跑通并记录成可复现的清单。
单 master 的集群只适合学习和测试环境。一旦 master 宕机,整个集群就失去了控制平面,无法调度、无法自愈。这篇文章记录我搭建一套三主三从高可用集群的完整过程,每一步都可以直接复用。
先说清楚目标拓扑,避免中途返工。
| 角色 | 数量 | 说明 |
|---|---|---|
| Master(控制平面) | 3 | 同时运行 kube-apiserver、controller-manager、scheduler |
| etcd | 3 | 与 master 同机部署,独立数据盘 |
| Worker(工作节点) | 3 | 运行业务负载 |
| VIP | 1 | 通过 keepalived 在 master 间漂移 |
为什么 etcd 必须是奇数台? etcd 使用 Raft 协议,需要多数派(quorum)才能写入。3 台允许 1 台故障,5 台允许 2 台故障。偶数台不会提高容错能力,反而降低可用性。
所有节点执行以下操作。
kubelet 要求关闭 swap,否则调度器的资源计算会失准。
swapoff -a
sed -i '/swap/s/^/#/' /etc/fstabcat > /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 --systemKubernetes 1.24 之后移除了 dockershim,推荐直接使用 containerd。
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 containerdSystemdCgroup = true 这一项非常关键,忘了改会导致 kubelet 与 containerd 的 cgroup 驱动不一致,Pod 起不来。
apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl # 防止意外升级在第一台 master 上初始化集群,接口绑定到 VIP:
# 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 端口。
# 在 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 即可加入完成后确认状态:
kubectl get nodes -o wide
kubectl -n kube-system get pods全部 Ready 才算成功。
安装 CNI 插件(以 Calico 为例):
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml部署 Ingress Controller,建议用 DaemonSet + hostNetwork 模式,避免多一跳转发:
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 即可:
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"}}}'集群跑起来只是开始,下面这些验证缺一不可:
kubectl 依然可用,VIP 正常漂移最容易忽略的是 etcd 备份。很多人配了定时快照但从没验证过恢复流程,真出事时才发现快照是坏的。没有演练过的备份等于没有备份。
高可用集群的难点不在安装命令,而在细节:cgroup 驱动、VIP 健康检查、etcd 多数派、证书轮换。把上面这份清单逐条跑通,你就得到了一个可以承接生产流量的集群底座。
Rust 的异步模型和 Go、JavaScript 都不太一样。理解它最关键的一步,是搞清楚 Future 到底在什么时候执行——很多"代码没跑起来"的困惑都源于这一点。
在 JavaScript 里,一个 async 函数调用后会立即开始执行。在 Rust 里不是:
async fn hello() {
println!("hello");
}
fn main() {
let fut = hello(); // 这里什么都不会打印
// fut 被 drop,任务从未执行
}hello() 只是构造了一个 Future,不调用 poll 就不会有任何副作用。这是 Rust 零成本抽象的核心:编译器把 async 函数体转换成状态机,每一步的进度都保存在栈帧里。
Future 的定义非常简洁:
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。
Context 里携带一个 Waker。当 Future 返回 Pending 前,它必须把 Waker 注册到底层的事件源上(比如 epoll、kqueue)。
poll() → Pending(注册 waker 到 epoll)
↓
事件就绪
↓
epoll 触发 → waker.wake() → 重新调度该任务 → poll()这个设计让运行时可以在任务等待期间去执行其他任务,而不是空转。
如果底层 I/O 就绪了但没有调用 wake(),任务就会永远挂起——这是手写 Future 最常见的 bug。
Tokio 运行时主要由三部分组成:
| 组件 | 职责 |
|---|---|
| 调度器(Scheduler) | 管理任务队列,决定哪个任务在哪个线程上执行 |
| I/O 驱动(Reactor) | 基于 epoll/kqueue/IOCP 处理就绪事件,唤醒对应 Waker |
| 时间驱动(Timer) | 时间轮实现,处理 sleep、timeout 等 |
调度器有三种模式:
!Send 的场景Send 任务Multi-thread 调度器为每个 worker 线程维护一个双端队列(deque):
// 每个 worker 持有自己的本地队列
// 空闲线程会尝试从其他 worker 的队列头部窃取任务这个设计在负载不均时能自动平衡,同时降低对全局锁的依赖。
这是新手最容易犯的错误:
tokio::spawn(async {
std::thread::sleep(Duration::from_secs(3)); // ❌ 阻塞整个 worker 线程
});std::thread::sleep 会阻塞当前 worker 线程,该线程上排队的其他任务全部延迟。正确做法:
tokio::spawn(async {
tokio::time::sleep(Duration::from_secs(3)).await; // ✅ 让出执行权
});
// CPU 密集型任务用 spawn_blocking
tokio::task::spawn_blocking(|| {
heavy_computation()
}).await.unwrap();判断标准很简单:只要一个操作的等待时间超过几十微秒,就必须用异步版本。
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;
}关键点:
Semaphore 控制并发度,防止把目标站点打挂spawn 让任务真正并发,而不是串行等待join_all 一次性等待所有任务FuturesUnordered 或流式处理。std::sync::Mutex 的 guard 跨 await 会破坏 Send,应该用 tokio::sync::Mutex,或者把锁的范围收窄。select! 忘记处理分支:未处理的分支被 drop,可能丢失数据。一个实用的调优顺序:先确认没有阻塞调用,再检查锁竞争,最后才考虑调线程数。绝大多数"Tokio 性能问题"的根因都是第一条。
理解 Rust 异步只需要抓住三件事:Future 是惰性的状态机、Waker 是唤醒的唯一入口、worker 线程绝不能被阻塞。剩下的都是围绕这三点做的工程优化。
去年接手的一个后台管理系统,首屏加载 4.8 秒,用户抱怨"点了没反应"。经过两轮优化后降到 1.5 秒。这篇文章把整个过程完整记录下来。
没有度量就没有优化。第一步是确定要看的指标:
| 指标 | 含义 | 目标 |
|---|---|---|
| FCP | 首次内容绘制 | < 1.5s |
| LCP | 最大内容绘制 | < 2.5s |
| TBT | 总阻塞时间 | < 200ms |
| CLS | 累计布局偏移 | < 0.1 |
| INP | 交互到下一次绘制 | < 200ms |
用 Lighthouse 跑基线,同时在真实环境接入 web-vitals 采集 RUM 数据。实验室数据看问题,真实数据看影响面。
npx vite-bundle-visualizer打开可视化报告后,我们的问题一目了然:
lodash 全量引入,占了 71KB(gzip)moment + 所有语言包,占了 230KBecharts 打进了主包// ❌ 全量引入
import _ from 'lodash'
// ✅ 只引入用到的方法
import debounce from 'lodash/debounce'moment 换成 dayjs,体积从 230KB 降到 7KB,API 几乎兼容。
路由级懒加载是收益最高的改动:
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
}
]同时用 manualChunks 把稳定的第三方库拆成独立 chunk,利用浏览器缓存:
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
charts: ['echarts'],
editor: ['@wangeditor/editor']
}
}
}
}这一步做完,主包从 1.8MB 降到 320KB。
<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">把首屏关键 CSS 内联到 HTML 里,其余样式异步加载,可以明显降低 FCP。
<img src="cover.avif" loading="lazy" decoding="async" width="800" height="450" alt="">显式声明 width / height 能避免布局偏移(CLS)。
原来一个页面渲染 2000 行表格,主线程阻塞接近 1.2 秒。换成虚拟滚动后只渲染可视区域的 20 行,阻塞降到几乎不可感知。
把一个大循环拆成多个小任务,用 requestIdleCallback 或 scheduler.yield() 让出主线程:
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() // 让出主线程
}
}// ❌ 读写交替触发多次重排
el.style.width = el.offsetWidth + 10 + 'px'
// ✅ 先读后写
const w = el.offsetWidth
el.style.width = w + 10 + 'px'location ~* \.(js|css|woff2|avif|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}文件名带内容哈希,可以放心用 immutable。
相比 gzip,Brotli 在文本资源上通常还能再小 15%~20%:
brotli on;
brotli_comp_level 6;
brotli_types text/css application/javascript application/json image/svg+xml;启用 HTTP/3 后,弱网用户的首字节时间平均下降 30% 左右。静态资源走 CDN,能显著降低跨地域延迟。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 主包体积(gzip) | 1.8 MB | 320 KB | -82% |
| FCP | 2.9s | 0.9s | -69% |
| LCP | 4.8s | 1.5s | -69% |
| TBT | 1180ms | 160ms | -86% |
| Lighthouse 性能分 | 38 | 94 | +56 |
性能优化最容易踩的坑是优化了用户感知不到的东西。比如花两天把某个二级页面的包减小 200KB,而这个页面日均访问量不到 50 次。先用 RUM 数据排出优先级,把精力放在影响面最大的路径上。
21 个优化点里,真正带来 80% 收益的只有四条:代码分割、按需引入、大列表虚拟滚动、静态资源长缓存。先做这四件事,再考虑其他细节。
线上订单列表页突然变慢,接口耗时从 200 毫秒涨到 8 秒。这篇文章记录完整的排查与优化过程,并顺带把 PostgreSQL 的索引机制讲清楚。
PostgreSQL 自带的 pg_stat_statements 是排查利器:
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:
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;EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;关键输出:
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这里有两个明确信号:
表上原本有一个索引:
CREATE INDEX idx_orders_tenant ON orders (tenant_id);问题在于选择性太差:tenant_id = 1024 匹配了 31 万行,占全表 6%。优化器计算后认为走索引再回表(随机 I/O)的成本比顺序全表扫描更高,于是直接放弃了索引。
这引出一个重要结论:索引不是加了就一定会用,优化器是算成本的。
把等值条件放前面,范围条件放最后:
CREATE INDEX CONCURRENTLY idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC);顺序原则:
等值查询列 → 等值查询列 → 范围/排序列
tenant_id status created_atcreated_at DESC 与查询的 ORDER BY created_at DESC 方向一致,PostgreSQL 可以直接从索引读取有序数据,完全跳过 Sort 节点。
CREATE INDEX CONCURRENTLY ...普通 CREATE INDEX 会持有 SHARE 锁阻塞写入;CONCURRENTLY 允许读写并行,代价是耗时更长且可能失败(失败会留下 invalid 索引,需要手动删除重建)。
如果查询总是带上同样的状态条件,部分索引体积更小、更快:
CREATE INDEX idx_orders_active
ON orders (tenant_id, created_at DESC)
WHERE status IN ('paid', 'shipped');只索引需要的数据,索引体积能减少 70% 以上。
优化后重新执行:
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 ms | 41 ms |
| 扫描方式 | Seq Scan | Index Scan |
| 读取缓冲区 | 178233 | 24 |
| 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 消除膨胀一个反直觉的事实:删掉无用的索引,往往比新增索引更能提升整体性能。我曾经在一个表上删掉 4 个从未被使用的索引,写入吞吐提升了近 30%。
慢查询优化的标准流程是:找到慢查询 → 看执行计划 → 判断是索引缺失还是选择性问题 → 建立合适的复合/部分索引 → 验证并持续监控。索引设计的核心,是让查询的过滤条件和排序方向都能在同一个索引里被满足。
自建博客最麻烦的从来不是写内容,而是部署。这篇文章给出一套可以直接复制的 Docker Compose 方案,把站点、反向代理和 HTTPS 证书一次性搞定。
blog/
├── docker-compose.yml
├── Caddyfile
├── site/ # 构建产物(静态文件)
│ ├── index.html
│ └── assets/
└── backup.sh选择 Caddy 而不是 Nginx,是因为它能自动申请并续期 Let's Encrypt 证书,省掉 certbot 的全部配置工作。
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:就这么简单。不需要数据库,不需要应用服务——静态站点的最大优势就在这里。
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 卷保存证书,重启不会重复申请。
docker compose up -d
docker compose logs -f web看到 certificate obtained successfully 就说明 HTTPS 已经就绪。验证:
curl -I https://qiuqiancloud.cn站点是纯静态的,更新内容只需要替换 site/ 目录:
# 本地构建
npm run build
# 同步到服务器(rsync 增量传输,只传变化的文件)
rsync -avz --delete dist/ user@server:/opt/blog/site/
# 无需重启容器,Caddy 直接读取新文件因为挂了 :ro 只读卷且 Caddy 是实时读文件,不需要重启容器,更新即时生效。这是静态站点相比应用服务的一大优势。
只需要备份配置和证书(内容可以从 Git 恢复):
#!/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:
0 3 * * * /opt/blog/backup.sh >> /var/log/blog-backup.log 2>&1docker 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 负责备份。整套方案没有状态、没有数据库、没有需要维护的中间件,是长期稳定运行成本最低的选择。
一台刚上线的公网服务器,平均在 15 分钟内就会迎来第一次 SSH 爆破尝试。这篇清单是我在多台生产服务器上实际验证过的加固步骤,按优先级排列。
# /etc/ssh/sshd_config
PermitRootLogin no需要管理权限时先用普通账户登录,再 sudo 或 su。
# 本地生成密钥(推荐 ed25519)
ssh-keygen -t ed25519 -C "admin@example.com"
# 上传公钥
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server确认密钥能正常登录后,再关闭密码认证:
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no务必先验证密钥登录成功再关闭密码登录,否则会把自己锁在服务器外面。保持一个已登录的 SSH 会话开着,作为退路。
Port 22022这不能阻止针对性攻击,但能挡掉绝大多数无差别的自动化扫描,让日志清爽很多。
AllowUsers deploy admin
# 或按网段限制
AllowUsers admin@192.168.1.*LoginGraceTime 30
MaxAuthTries 3
MaxSessions 5
ClientAliveInterval 300
ClientAliveCountMax 2改完后用 sshd -t 校验语法,再 systemctl reload sshd。
只放行必要端口,其余默认拒绝:
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# /etc/fail2ban/jail.local
[sshd]
enabled = true
port = 22022
maxretry = 3
findtime = 600
bantime = 86400
banaction = nftables-multiportmaxretry = 3 + bantime = 24h 的组合,能在 10 分钟内把常见扫描器全部挡掉。
即使服务器本身配好了防火墙,安全组也应该只放行必要端口。两层防护,任何一层配置失误都不至于直接暴露。
# Debian / Ubuntu
apt update && apt upgrade -y
apt install -y unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades# /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 = 1systemctl list-unit-files --state=enabled
systemctl disable --now <不需要的服务>每多一个监听端口,就多一个潜在攻击面。
不要因为"配置麻烦"就关掉它。生产环境建议至少以 permissive 模式运行一段时间,观察审计日志后再切到 enforcing。
业务进程使用独立系统账户,禁止直接用 root 跑应用:
useradd -r -s /usr/sbin/nologin appuserchmod 600 /etc/ssh/sshd_config
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chown -R root:root /etc/sshfind / -perm -4000 -type f 2>/dev/nullSUID 程序是提权的重要途径,定期检查有没有多余的可执行文件被设置了 SUID。
# /etc/fstab
tmpfs /tmp tmpfs defaults,noexec,nosuid,nodev,size=1G 0 0noexec 能阻止攻击者把恶意程序下载到 /tmp 后直接执行。
apt install -y auditd
# 监控 sudo 与关键配置文件
auditctl -w /etc/passwd -p wa -k passwd_change
auditctl -w /etc/sudoers -p wa -k sudoers_change本地日志被清理是入侵后的常见操作。把日志实时转发到远程 syslog 服务器,能做基本的防篡改。
# 查看失败登录
lastb | head -20
# 查看成功登录
last -20
# 统计攻击来源 IP
grep "Failed password" /var/log/auth.log | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head对 CPU 突增、异常外连、磁盘异常写入设置告警。挖矿木马和 C2 通信都有明显的资源特征。
把上面能自动化的部分串起来:
#!/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% 的自动化攻击。
安全不是一次性任务,而是持续的运维习惯。
缓存集群从 4 台扩容到 5 台,结果整个缓存层几乎全部失效,数据库瞬间被打满——这是分布式系统里最经典的事故之一。根因就是普通取模哈希。
假设用 hash(key) % N 决定数据落在哪个节点,N 是节点数。
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):
0
|
N3 --+-- N1
/ \
| |
\ /
N2 ------+这样当新增一个节点 N4 时,只有 N4 顺时针前一个节点的一部分数据需要迁移,其余节点不受影响。平均迁移比例是 1/N,而不是 1 - N/(N+1)。
朴素的一致性哈希有个严重问题:哈希函数的输出并非均匀分布,节点在环上的位置可能挤在一起。
极端情况下,如果 4 个节点都落在环的某一个扇区,那么第一个节点会承担几乎全部流量。
解决办法是引入虚拟节点(Virtual Node):
物理节点 N1 → 映射为 N1#1, N1#2, ... N1#150
物理节点 N2 → 映射为 N2#1, N2#2, ... N2#150每个物理节点对应 150 个虚拟节点散布在环上,流量分布就会非常均匀。经验值是每个物理节点 100~200 个虚拟节点,再多收益递减。
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]]关键实现点:
用 10 万个 key 做测试,对比不同配置的分布均匀度:
| 配置 | 标准差(越接近 0 越均匀) | 扩容迁移比例 |
|---|---|---|
| 无虚拟节点,4 节点 | 18420 | — |
| 100 虚拟节点,4 节点 | 1240 | — |
| 150 虚拟节点,4 节点 | 812 | — |
| 150 虚拟节点,4 → 5 节点 | 869 | 19.8% |
迁移比例接近理论最优值 1/5 = 20%,分布标准差从 18420 降到 812,效果非常明显。
对比普通取模扩容的 80% 迁移率,一致性哈希把影响面压缩了 4 倍。
一定不要用语言内置的 hash()(比如 Python 的 hash() 在 3.3 之后对字符串加了随机盐),否则进程重启后映射全变。必须用 MD5、SHA1 这类确定性哈希。
如果机器配置不同,可以让高配机器对应更多虚拟节点:
hash_ring.add_node('node-a', weight=2) # 承担双倍流量一致性哈希只解决分布问题,不解决热点问题。如果某个 key 的访问量特别大,无论落在哪个节点都会成为瓶颈。这种场景需要额外做本地缓存或二级分片。
节点宕机时,其数据按顺时针顺序交给下一个节点。如果这个"下一个节点"同时承担了自己和宕机节点的流量,可能过载。生产环境通常配合副本机制使用。
一个有趣的事实:Google 的 Maglev 算法为了解决一致性哈希的查找效率和分布均匀性,用了完全不同的思路——预计算一张固定大小的查找表,查找复杂度 O(1),代价是节点变更时重建整张表。
一致性哈希解决的核心问题是:让节点数量的变化只影响局部数据。虚拟节点解决了它的分布均匀性缺陷。它是分布式缓存、分片存储、负载均衡的基础设施级算法,值得每个后端工程师吃透。
很多人对 TypeScript 的印象停留在"给变量加个类型"。实际上,TS 的类型系统是一门完备的编程语言——它有条件判断、有循环(递归)、有变量(泛型参数)、有模式匹配(infer)。掌握这些能力,你能让类型系统替你完成大量重复的校验工作。
type Box<T> = { value: T }
const a: Box<number> = { value: 1 }
const b: Box<string> = { value: 'hello' }T 就像一个类型层面的参数,调用时才确定。
interface User {
id: number
name: string
email: string
}
type UserKey = keyof User // 'id' | 'name' | 'email'type UserName = User['name'] // string
type IdOrName = User['id' | 'name'] // number | stringtype A = { a: string }
type B = { b: number }
type Both = A & B // { a: string; b: number }
type Either = A | B // 满足其一即可type IsString<T> = T extends string ? true : false
type R1 = IsString<'hello'> // true
type R2 = IsString<42> // false注意 extends 在条件类型里表示"可赋值给"(assignable),不是继承。
当条件类型作用于裸类型参数并且传入联合类型时,会自动分发:
type ToArray<T> = T extends any ? T[] : never
type R = ToArray<string | number>
// 等价于 (string[] | number[]),而不是 (string | number)[]这个特性非常有用,但也是很多"类型不符合预期"的根源。想禁用分发,把参数包起来即可:
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never
type R2 = ToArrayNoDist<string | number> // (string | number)[]infer 让你从类型中"提取"出一部分:
// 提取函数返回值
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还可以提取数组的首尾元素:
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// 把每个属性变成可选
type MyPartial<T> = { [K in keyof T]?: T[K] }
// 把每个属性变成只读
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }配合 as 子句可以做键重映射:
// 给每个 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 没有循环语法,但支持递归。
// 深度只读
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K]
}// 路径类型:把嵌套对象的所有路径展平成字符串联合
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。遇到时可以用尾递归优化写法,或者干脆拆分成多个类型。
把上面的技巧串起来,实现"根据接口路径自动推断返回类型":
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。
类型体操报错往往难以理解。几个实用技巧:
// @ts-expect-error 反向验证:确认某段代码确实应该报错never 做穷尽检查: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、映射类型、递归。剩下的都是这四个的组合。
写类型时记住一个原则:如果一个类型复杂到需要写注释才能看懂,那它可能过于复杂了。 优先选简单的类型 + 清晰的运行时校验,而不是让类型系统承担所有工作。