diff --git a/notes/Docker.md b/notes/Docker.md
index c67da0a8..df7c1ecb 100644
--- a/notes/Docker.md
+++ b/notes/Docker.md
@@ -5,9 +5,10 @@
* [二、与虚拟机的比较](#二与虚拟机的比较)
* [三、优势](#三优势)
* [四、使用场景](#四使用场景)
- * [五、镜像与容器](#五镜像与容器)
- * [六、实现原理](#六实现原理)
+ * [五、实现原理](#五实现原理)
+ * [六、镜像与容器](#六镜像与容器)
* [七、Kubernetes](#七kubernetes)
+ * [附录:实测](#附录实测)
* [参考资料](#参考资料)
@@ -70,17 +71,7 @@ Docker 具有轻量级以及隔离性的特点,在将代码集成到一个 Doc
Docker 轻量级的特点使得它很适合用于部署、维护、组合微服务。
-## 五、镜像与容器
-
-镜像是一种静态的结构,可以看成面向对象里面的类,而容器是镜像的一个实例。
-
-镜像包含着容器运行时所需要的代码以及其它组件,它是一种分层结构,每一层都是只读的(read-only layers)。构建镜像时,会一层一层构建,前一层是后一层的基础。镜像的这种分层存储结构很适合镜像的复用以及定制。
-
-构建容器时,通过在镜像的基础上添加一个可写层(writable layer),用来保存着容器运行过程中的修改。
-
-
-
-## 六、实现原理
+## 五、实现原理
容器就是宿主机上的普通进程,Docker 用 Linux 内核的两个机制把它和其它进程隔开:
@@ -101,11 +92,178 @@ $ echo $$
因为所有容器共享宿主机的内核,所以容器比虚拟机轻量,但隔离性也更弱:内核漏洞可能让容器中的进程影响宿主机。
+## 六、镜像与容器
+
+镜像是一种静态的结构,可以看成面向对象里面的类,而容器是镜像的一个实例。
+
+镜像包含着容器运行时所需要的代码以及其它组件,它是一种分层结构,每一层都是只读的(read-only layers)。构建镜像时,会一层一层构建,前一层是后一层的基础。镜像的这种分层存储结构很适合镜像的复用以及定制。
+
+构建容器时,通过在镜像的基础上添加一个可写层(writable layer),用来保存着容器运行过程中的修改。
+
+
+
+分层由联合文件系统实现,Docker 默认使用 OverlayFS:镜像的各层是只读的下层目录(lowerdir),容器的可写层是上层目录(upperdir),两者合并后挂载为容器的根目录。
+
+容器第一次修改镜像中的某个文件时,先把整个文件复制到上层再修改,这就是写时复制;删除文件时在上层放一个同名的白障(whiteout)文件把它遮住。镜像本身始终不变,所以同一个镜像可以同时启动多个容器。
+
+例如在订单服务的容器中修改 /etc/alpine-release、删除 /etc/motd 后,docker diff 显示 C /etc/alpine-release 和 D /etc/motd,上层目录中多了 alpine-release 的完整副本和一个名为 motd 的白障文件(Docker 28.4 实测,见[附录 A3](#a3-overlayfs-的可写层))。
+
+可写层随容器一起删除,需要长期保存的数据(例如 MySQL 的数据文件)要放在数据卷中,见下文。
+
+### 用 Dockerfile 构建镜像
+
+Dockerfile 是描述怎样构建镜像的文本文件,每条指令在上一步的结果上执行,修改文件系统的指令(RUN、COPY、ADD)各产生一层。
+
+下面把一个 Spring Boot 3 订单服务打包成镜像,它提供 GET /orders/{id} 接口,返回订单的状态:
+
+```dockerfile
+# 第一阶段:用 Maven 和 JDK 编译打包
+FROM maven:3.9-eclipse-temurin-21 AS build
+WORKDIR /src
+COPY pom.xml .
+RUN mvn -q dependency:go-offline
+COPY src ./src
+RUN mvn -q package -DskipTests
+
+# 第二阶段:只把 jar 复制到 JRE 镜像中
+FROM eclipse-temurin:21-jre-alpine
+WORKDIR /app
+COPY --from=build /src/target/order-service-1.0.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
+```
+
+其中用到的指令:
+
+- FROM:指定基础镜像,AS build 给这个构建阶段命名;
+- WORKDIR:设置后续指令的工作目录,目录不存在时自动创建;
+- COPY:把构建上下文(docker build 指定的目录)中的文件复制到镜像中,--from=build 表示从前一个阶段复制;
+- RUN:构建镜像时执行命令,执行结果保存为新的一层;
+- EXPOSE:说明容器监听的端口,只起文档作用,运行时仍要用 -p 发布端口;
+- ENTRYPOINT:容器启动时执行的命令。
+
+在 Dockerfile 所在目录执行 docker build -t order-service:1.0 .(最后的点表示把当前目录作为构建上下文),就得到了名为 order-service、标签为 1.0 的镜像。
+
+#### 1. 构建缓存
+
+构建时 Docker 逐条检查指令:指令本身和它用到的文件(COPY 按文件内容计算校验和)都与上次相同,就直接复用上次构建的层。某一层失效后,它之后的所有层都要重新构建。
+
+所以变化少的步骤要放在前面。上面的 Dockerfile 先只复制 pom.xml 并下载依赖,再复制源码,只改源码时下载依赖的那一层仍然命中缓存。
+
+首次构建用了 214.6 秒,其中下载依赖 209.6 秒;改一行源码后重新构建只用 6.4 秒。如果一开始就复制全部文件再执行 mvn package,每次改源码都要重新下载依赖,用了 107.5 秒(Docker 28.4 实测,见[附录 A1](#a1-构建缓存与多阶段构建))。
+
+
+
+构建上下文中不需要的文件(例如 target、.git 目录)写进 .dockerignore,既减少发送给 Docker 的数据,也避免它们的变化让缓存失效。
+
+#### 2. 多阶段构建
+
+编译需要 Maven 和 JDK,运行只需要 JRE 和 jar 包。多阶段构建(multi-stage build)在一个 Dockerfile 中写多个 FROM,每个 FROM 开始一个阶段,最终的镜像只包含最后一个阶段的内容,前面阶段中的 Maven、源码和下载的依赖都不会进入最终镜像。
+
+上面的订单服务镜像解压后约 236 MB,主要是 JRE 162 MB 和 jar 包 24 MB;在 Maven 镜像中一个阶段完成构建和运行,镜像约 680 MB,多出了 JDK、Maven 和构建时下载的依赖(见[附录 A1](#a1-构建缓存与多阶段构建))。镜像越小拉取越快,包含的工具越少,可被利用的漏洞也越少。
+
+减小镜像的其它做法:
+
+- 选择更小的基础镜像,例如 -alpine、-slim 版本;
+- 把下载、安装和清理写在同一条 RUN 中,因为在后面的层中删除文件只是把它遮住,前面的层并没有变小。
+
+#### 3. 常用指令的区别
+
+- COPY 与 ADD:COPY 只复制本地文件;ADD 还能下载远程 URL 和 Git 仓库、自动解压本地的 tar 包。只是复制文件时用 COPY,行为明确;
+- RUN 与 CMD、ENTRYPOINT:RUN 在构建镜像时执行,结果写入镜像;CMD 和 ENTRYPOINT 只是记录容器启动时要执行的命令;
+- CMD 与 ENTRYPOINT:docker run 镜像名之后的参数会替换 CMD,而追加到 ENTRYPOINT 之后。例如 docker run order-service:1.0 --server.port=9090 启动后,容器中的 1 号进程是 java -jar app.jar --server.port=9090。通常 ENTRYPOINT 写固定的命令,CMD 写可以被覆盖的默认参数。
+
+ENTRYPOINT 和 CMD 应写成 JSON 数组的 exec 形式。写成 shell 形式(例如 ENTRYPOINT java -jar app.jar)时,命令由 /bin/sh -c 启动,Shell 成为 1 号进程时不会把 docker stop 发出的 SIGTERM 转发给 Java,Docker 等待 10 秒后改用 SIGKILL 强制结束,应用来不及优雅停止。
+
+例如 ENTRYPOINT java -jar app.jar; echo "order-service exited" 的容器停止用了 10.3 秒,退出码 137;exec 形式只用 0.7 秒,退出码 143,日志中有 Spring Boot 的 Graceful shutdown complete(见[附录 A2](#a2-exec-形式与-shell-形式))。需要注意的是,Alpine 的 sh 在命令只有一条时会直接用它替换自己,这时 Java 仍然是 1 号进程。
+
+### 运行容器:端口、数据卷与网络
+
+```sh
+docker run -d --name order -p 8080:8080 order-service:1.0
+```
+
+-d 表示在后台运行,--name 给容器命名,-p 宿主机端口:容器端口 把宿主机 8080 端口收到的连接转发到容器的 8080 端口。
+
+每个容器有自己的网络命名空间,两个容器都监听 8080 不会冲突,冲突的是宿主机端口。再用 -p 8080:8080 启动第二个容器时报错 Bind for 0.0.0.0:8080 failed: port is already allocated,改用 -p 8081:8080 即可(见[附录 A4](#a4-端口数据卷网络与-compose))。同样,宿主机上已有 MySQL 占用 3306 时,容器中的 MySQL 可以映射为 -p 3307:3306;端口被哪个进程占用可以用 ss -lntp 或 lsof -i :3306 查看。
+
+#### 1. 数据卷
+
+数据卷(volume)是由 Docker 管理、独立于容器生命周期的目录,用 -v 卷名:容器内路径 挂载。例如把 MySQL 的数据目录放在数据卷 mysql-data 中:
+
+```sh
+docker run -d --name mysql -p 3316:3306 -v mysql-data:/var/lib/mysql \
+ -e MYSQL_ROOT_PASSWORD=Root2026 mysql:8.0
+```
+
+在其中建表并插入订单 1024,执行 docker rm -f mysql 删除容器,再用同样的命令创建新容器,订单 1024 仍然在(见[附录 A4](#a4-端口数据卷网络与-compose))。
+
+-v 后面写宿主机的绝对路径时是绑定挂载(bind mount),例如 -v /data/mysql:/var/lib/mysql,宿主机上可以直接查看和备份这些文件,配置文件也常用这种方式挂载进容器。数据卷不依赖宿主机的目录结构,是官方推荐的持久化方式。
+
+#### 2. 网络
+
+Docker 默认使用 bridge 网络:每个容器得到一个私有 IP,通过宿主机上的虚拟网桥 docker0 互相通信,访问外部时做 NAT,-p 则是在宿主机上添加了一条转发规则。其它常见的网络模式:
+
+- host:容器直接使用宿主机的网络命名空间,不需要端口映射,但没有网络隔离;
+- none:容器只有回环网卡;
+- overlay:把多台宿主机上的容器连成一个网络,用于 Docker Swarm 等跨主机的场景。
+
+容器之间要按名字访问。默认 bridge 网络中的容器只能用 IP 互访,而 IP 在容器重建后会变;用 docker network create shop 创建的网络带有内置的 DNS,加入该网络的容器可以用容器名访问,例如 mysql 解析为 172.19.0.2,在默认网络中则解析失败(见[附录 A4](#a4-端口数据卷网络与-compose))。
+
+需要注意的是,容器中的 localhost 指容器自己,访问宿主机上的服务要用宿主机的 IP(Docker Desktop 中可以用 host.docker.internal)。
+
+#### 3. Docker Compose
+
+多个容器一起部署时,用 Docker Compose 把上面的参数写进一个 YAML 文件,docker compose up -d 一次启动全部容器。Compose 自动创建一个网络,服务名就是容器的主机名:
+
+```yaml
+services:
+ mysql:
+ image: mysql:8.0
+ environment:
+ MYSQL_ROOT_PASSWORD: Root2026
+ volumes:
+ - mysql-data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-pRoot2026"]
+ interval: 3s
+ retries: 20
+ order:
+ image: order-service:1.0
+ ports:
+ - "8080:8080"
+ depends_on:
+ mysql:
+ condition: service_healthy
+volumes:
+ mysql-data:
+```
+
+depends_on 默认只保证依赖的容器先启动,不等它能提供服务;加上 condition: service_healthy 后,等 MySQL 的健康检查通过才启动订单服务,实测订单服务比 MySQL 晚约 9.6 秒启动(见[附录 A4](#a4-端口数据卷网络与-compose))。Compose 只管理单台机器上的容器,跨机器的调度、自愈和扩缩容由 Kubernetes 负责。
+
+### 容器的常见问题排查
+
+常用命令:
+
+- docker ps -a:列出包括已退出容器在内的所有容器,状态一栏带有退出码,例如 Exited (1);
+- docker logs -f --tail 100 order:查看容器的标准输出和标准错误,所以容器中的应用应把日志打印到标准输出;
+- docker inspect order:查看容器的完整配置和状态,例如退出码、是否因内存不足被杀(State.OOMKilled)、挂载的目录和 IP;
+- docker exec -it order sh:在运行中的容器里执行命令,例如查看配置文件、测试网络是否连通;
+- docker stats:实时查看每个容器的 CPU、内存、网络和磁盘 I/O,例如订单服务空闲时内存约 160 MiB;
+- docker system df:查看镜像、容器、数据卷和构建缓存占用的磁盘,docker image prune -a 删除没有被任何容器使用的镜像。
+
+常见问题:
+
+- 容器启动后立即退出:容器的生命周期跟随 1 号进程,主进程结束容器就退出。例如启动 MySQL 时没有设置 MYSQL_ROOT_PASSWORD,容器状态为 Exited (1),docker logs 中列出了需要设置的环境变量(见[附录 A4](#a4-端口数据卷网络与-compose));把服务放到后台运行的启动命令(例如 nginx 不加 daemon off)也会让主进程立即结束;
+- 退出码 137:进程被 SIGKILL 杀死(128 + 9),常见原因是超过内存限制被内核杀掉,或者 docker stop 等待超时;143 是收到 SIGTERM 后退出;
+- 宿主机上能运行,容器中不能:常见原因是容器中缺少依赖的库或命令(Alpine 使用 musl 而不是 glibc)、镜像的 CPU 架构与机器不一致(arm64 与 amd64)、程序连接的 localhost 指向了容器自己、文件权限不同;
+- Docker 守护进程重启:默认情况下守护进程退出时会停止所有容器,开启 live-restore 后容器在守护进程重启期间继续运行;需要自动恢复的容器用 --restart unless-stopped 启动。
+
## 七、Kubernetes
Docker 解决的是单台机器上怎样运行容器。服务有几十个、部署在几十台机器上时,还需要决定每个容器放在哪台机器上、挂了谁来重启、怎样扩容、怎样不停机升级,这就是 Kubernetes(K8s)做的事情。
-K8s 的核心思想是声明期望的状态,由控制器不断把实际状态调整为期望状态。例如下面的配置声明 order-service 要运行 3 个副本,并通过一个 Service 对外提供访问:
+K8s 的核心思想是声明期望的状态,由控制器不断把实际状态调整为期望状态。例如下面的配置声明订单服务要运行 3 个副本,并通过一个 Service 提供统一的访问入口:
```yaml
apiVersion: apps/v1
@@ -124,11 +282,16 @@ spec:
spec:
containers:
- name: app
- image: nginx:1.27-alpine
+ image: order-service:1.0
ports:
- - containerPort: 80
+ - containerPort: 8080
+ resources:
+ requests: { cpu: 250m, memory: 256Mi }
+ limits: { cpu: "1", memory: 512Mi }
readinessProbe:
- httpGet: { path: /, port: 80 }
+ httpGet: { path: /actuator/health/readiness, port: 8080 }
+ livenessProbe:
+ httpGet: { path: /actuator/health/liveness, port: 8080 }
---
apiVersion: v1
kind: Service
@@ -139,23 +302,492 @@ spec:
app: order-service
ports:
- port: 80
- targetPort: 80
+ targetPort: 8080
```
其中的几个概念:
-- Pod:K8s 调度的最小单位,包含一个或几个共享网络和存储的容器,每个 Pod 有自己的 IP,重建后 IP 会变;
+- Pod:K8s 调度的最小单位,包含一个或几个容器,每个 Pod 有自己的 IP,重建后 IP 会变。同一个 Pod 中的容器共享网络、IPC 和 UTS 命名空间,可以用 localhost 互相访问、主机名相同,也可以挂载同一个存储卷,但默认不共享 PID 命名空间(见[附录 A6](#a6-pod-的创建资源限制与共享的命名空间));
- Deployment:管理一组相同的 Pod,负责保持副本数、扩缩容和滚动升级;
- Service:为一组 Pod 提供固定的虚拟 IP 和域名,把请求负载均衡到其中就绪的 Pod 上;
- 控制平面:API Server 接收所有请求,etcd 保存集群状态,调度器决定 Pod 放在哪个节点,控制器管理器运行各种控制器;每个节点上的 kubelet 负责启动和检查本节点的容器。
-在单节点 K8s(k3s v1.31)上实际操作的结果如下:
+在两个节点的 K8s 集群(k3s v1.31.4)上实际操作的结果如下(见[附录 A5](#a5-部署自愈与滚动升级)):
-- kubectl apply 之后约 20 秒,3 个 Pod 全部就绪,Service 得到虚拟 IP 10.43.189.89,在集群内用 http://order-service 就能访问;
-- 删除一个 Pod,Deployment 创建的 ReplicaSet 发现副本数少于 3,立即创建新的 Pod,约 1.2 秒后恢复为 3/3,新 Pod 的 IP 与原来不同,Service 自动更新了后端列表;
-- kubectl scale 把副本数改为 5;kubectl set image 修改镜像版本后,Deployment 创建新的 ReplicaSet,逐个用新版本的 Pod 替换旧的,约 12 秒完成滚动升级,期间一直有就绪的 Pod 在提供服务,需要时可以用 kubectl rollout undo 回滚。
+- kubectl apply 之后约 21 秒,3 个 Pod 全部就绪,分布在两个节点上,Service 得到虚拟 IP 10.43.210.38,在集群内用 http://order-service 就能访问,请求被分到不同的 Pod;
+- 删除一个 Pod,Deployment 创建的 ReplicaSet 发现副本数少于 3,0.3 秒内创建了新的 Pod,约 11 秒后新 Pod 启动完成并通过就绪检查,恢复为 3/3,新 Pod 的 IP 与原来不同,Service 自动更新了后端列表;
+- kubectl scale 修改副本数;kubectl set image 把镜像改为 order-service:1.1 后,Deployment 创建新的 ReplicaSet,逐个用新版本的 Pod 替换旧的,约 33 秒完成滚动升级,期间一直有就绪的 Pod 在提供服务,需要时可以用 kubectl rollout undo 回滚。
-readinessProbe 是就绪探针:Pod 启动后要通过检查才会加入 Service 的后端列表,避免把请求转发给还没启动完成的实例。
+readinessProbe 是就绪探针:Pod 启动后要通过检查才会加入 Service 的后端列表,避免把请求转发给还没启动完成的实例。livenessProbe 是存活探针:连续失败 3 次(默认值)时 kubelet 重启容器,用于发现进程还在、但已经不能工作的情况,例如死锁。Spring Boot 的 Actuator 在 K8s 中自动提供 /actuator/health/readiness 和 /actuator/health/liveness 两个端点。
+
+删除 Pod(缩容、滚动升级)时,控制平面把它从 Service 的后端中摘除,同时 kubelet 向容器的 1 号进程发送 SIGTERM,30 秒(terminationGracePeriodSeconds 的默认值)后进程还没退出就发送 SIGKILL。应用收到 SIGTERM 后应停止接收新请求、处理完已有请求再退出,Spring Boot 3.4 起默认开启这种优雅停机。摘除后端需要一点时间才能传到每个节点,所以常用 preStop 钩子先等待几秒(例如 sleep 5),再让应用开始停止。
+
+### Pod 的创建过程
+
+K8s 的各组件不直接互相调用,而是都通过 API Server 协作:每个组件监听(watch)自己关心的对象,发现实际状态与期望不一致就采取行动,再把结果写回 API Server。以订单服务中被调度到节点 d1-agent 的一个 Pod 为例:
+
+
+
+1. kubectl 把 Deployment 发给 API Server,API Server 完成认证、鉴权和准入检查后写入 etcd;
+2. 控制器管理器中的 Deployment 控制器发现新的 Deployment,创建 ReplicaSet;ReplicaSet 控制器发现副本数不足,创建 3 个 Pod 对象,这时的 Pod 还没有指定节点;
+3. 调度器发现未调度的 Pod,先过滤掉不满足条件的节点(资源不足、有不能容忍的污点等),再给剩下的节点打分,选出 d1-agent 写入 Pod 的 nodeName;
+4. d1-agent 上的 kubelet 发现分配给自己的 Pod,通过容器运行时接口(Container Runtime Interface, CRI)让 containerd 创建 Pod 沙箱:先启动一个 pause 容器持有网络命名空间,再调用 CNI 插件为它配置网卡、分配 IP 10.42.1.5;Pod 声明了存储卷时,还会通过 CSI 插件把卷挂载到节点上;
+5. kubelet 拉取镜像(这里镜像已在节点上),创建并启动容器;
+6. kubelet 执行探针,就绪探针通过后把 Pod 的状态更新为 Ready;
+7. EndpointSlice 控制器把 10.42.1.5:8080 加入 order-service 的后端,各节点的 kube-proxy 随之更新转发规则。
+
+从提交到这个 Pod 就绪用了 21 秒:创建对象和调度约 1 秒,创建容器 11 秒,Spring Boot 启动和就绪探针 9 秒(见[附录 A6](#a6-pod-的创建资源限制与共享的命名空间))。创建容器较慢是因为实验中的节点运行在 Docker 容器里,containerd 只能用逐个复制文件的 native 快照准备根文件系统。kubectl describe pod 末尾的 Events 按顺序记录了 Scheduled、Pulled、Created、Started 等事件,Pod 起不来时先看这里。
+
+YAML 中的 resources 声明了 Pod 需要的资源:
+
+- requests 是调度的依据,调度器只把 Pod 放到剩余可分配资源不少于 requests 的节点上,CPU 紧张时也按 requests 的比例分配 CPU 时间;
+- limits 是运行时的上限,由 kubelet 写入容器的 cgroup。订单服务的 limits 是 1 个 CPU 和 512 MiB,容器中的 cpu.max 为 100000 100000,memory.max 为 536870912(见[附录 A6](#a6-pod-的创建资源限制与共享的命名空间))。
+
+超过 CPU 上限的进程被限流,变慢但不会被杀;超过内存上限的进程被内核杀掉,状态为 OOMKilled。
+
+### Service 与 Ingress
+
+Pod 的 IP 会变,调用方不能直接写 Pod 的 IP。Service 用标签选出一组 Pod,提供一个固定的虚拟 IP(ClusterIP)和域名 order-service(完整域名 order-service.default.svc.cluster.local)。EndpointSlice 控制器维护其中就绪 Pod 的地址,每个节点上的 kube-proxy 把它们写成 iptables 或 IPVS 规则。
+
+订单服务所在的节点上,与 order-service 相关的 iptables 规则是:发往 10.43.210.38:80 的包,以 1/3 的概率 DNAT 到 10.42.0.8:8080,剩下的以 1/2 的概率 DNAT 到 10.42.1.4:8080,其余 DNAT 到 10.42.1.5:8080,三个 Pod 被选中的概率相同(见[附录 A7](#a7-service-与-ingress))。虚拟 IP 没有绑定在任何网卡上,只存在于每个节点的转发规则中。
+
+Service 按访问范围分为几种类型:
+
+- ClusterIP:默认类型,只能在集群内访问;
+- NodePort:在 ClusterIP 的基础上,在每个节点上打开同一个端口(默认范围 30000~32767),访问任一节点的这个端口都会转发到后端的 Pod,例如订单服务被分配了 31887 端口;
+- LoadBalancer:在 NodePort 的基础上,由云厂商创建一个外部负载均衡器,把流量转发到各节点;
+- ExternalName:把服务名映射为一个外部域名(DNS 的 CNAME 记录),不做转发。
+
+Service 工作在传输层,只认 IP 和端口。对外提供多个 HTTP 服务时,每个服务一个 LoadBalancer 成本高,也不能按域名和路径分发,这时用 Ingress。Ingress 是一组 HTTP 路由规则,例如把 shop.example.com/orders 转发到 order-service:
+
+```yaml
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: shop
+spec:
+ rules:
+ - host: shop.example.com
+ http:
+ paths:
+ - path: /orders
+ pathType: Prefix
+ backend:
+ service:
+ name: order-service
+ port:
+ number: 80
+```
+
+Ingress 只是规则,真正处理请求的是 Ingress 控制器,例如 Ingress-NGINX、Traefik(k3s 自带),它们本身作为 Pod 运行,通过 LoadBalancer 或 NodePort 类型的 Service 对外暴露。实测中带 Host: shop.example.com 访问 /orders/1024 返回订单数据,访问 /users/1 或换成其它域名都返回 404(见[附录 A7](#a7-service-与-ingress))。
+
+
+
+需要注意的是,Ingress 控制器通常不经过 Service 的虚拟 IP,而是从 EndpointSlice 取得 Pod 的地址后直接转发给 Pod(Traefik 的默认行为),Service 在这里只用来找到后端。Ingress API 已经冻结,不再增加新功能,新功能加在它的后继 Gateway API 中。
+
+Pod 之间的通信由 CNI 插件实现。K8s 的网络模型要求每个 Pod 有集群内唯一的 IP,任意两个 Pod 不经过 NAT 就能直接通信。k3s 默认的 Flannel 给每个节点分配一个网段(实验中 d1-server 上的 Pod 是 10.42.0.x,d1-agent 上的是 10.42.1.x),跨节点的包用 VXLAN 封装后经节点之间的网络发送。
+
+### Pod 异常状态的排查
+
+Pod 的阶段(phase)只有 Pending、Running、Succeeded、Failed、Unknown 五种,kubectl get pods 的 STATUS 一栏显示得更细。常见的异常状态(k3s 实测,见[附录 A8](#a8-pod-的异常状态)):
+
+- Pending:Pod 还没有调度到节点上。describe 的事件中有 FailedScheduling 时是调度失败,例如申请 20 个 CPU 时为 0/2 nodes are available: 2 Insufficient cpu;两个节点都有污点 dedicated=db:NoSchedule、Pod 没有对应的容忍时为 2 node(s) had untolerated taint;Pod 使用的 PVC 没有绑定到存储卷时也会停在这里;
+- ImagePullBackOff:拉取镜像失败,常见原因是镜像名或标签写错、私有仓库没有配置凭证(imagePullSecrets)、节点访问不了镜像仓库,kubelet 在 ErrImagePull 和 ImagePullBackOff 之间反复重试;
+- CrashLoopBackOff:容器启动后反复退出,kubelet 每次重启前的等待时间按 10 秒、20 秒、40 秒翻倍,最长 5 分钟。kubectl logs --previous 查看上一次退出前的日志,例如把 server.port 写成 abc 时,日志中是 APPLICATION FAILED TO START 和具体的配置错误,退出码 1;
+- OOMKilled:容器超过内存上限被内核杀掉,退出码 137,之后同样进入 CrashLoopBackOff。例如内存上限 256 MiB,JVM 堆却设为 300 MB 并在启动时全部占用,Java 应用的堆和堆外内存加起来要小于 limits;
+- Running 但 READY 为 0/1:进程在运行,但就绪探针不通过,Pod 不会收到 Service 转发的请求,例如探针路径写成不存在的 /ready 时,事件中是 Readiness probe failed: HTTP probe failed with statuscode: 404。
+
+排查的顺序通常是:kubectl get pods -o wide 看状态、重启次数和所在节点;kubectl describe pod 看事件;kubectl logs 看应用日志,容器重启过的加 --previous;kubectl exec 进入容器检查配置和网络;kubectl top pod 看资源占用(需要 metrics-server)。
+
+节点 NotReady 指控制平面一段时间没有收到节点上 kubelet 的心跳,这个时间由 node-monitor-grace-period 决定(v1.31 默认 40 秒,v1.32 起 50 秒)。停掉工作节点 d1-agent 后 32 秒,它变为 NotReady,Conditions 中是 Kubelet stopped posting node status,并被加上 node.kubernetes.io/unreachable 污点;上面的 Pod 同时被标记为未就绪,从 Service 的后端中摘除。Pod 默认容忍这个污点 300 秒,又过了约 5 分钟才被驱逐,Deployment 在 d1-server 上补足了副本(见[附录 A9](#a9-节点-notready))。
+
+排查 NotReady 时先用 kubectl describe node 看 Conditions:
+
+- Ready 为 Unknown:kubelet 没有上报状态,登录节点检查 kubelet 和容器运行时是否在运行(systemctl status kubelet、journalctl -u kubelet),以及节点到 API Server 的网络和证书;
+- Ready 为 False:kubelet 在上报,但节点不健康,例如 CNI 插件没有就绪,原因写在 Message 中;
+- MemoryPressure、DiskPressure、PIDPressure 为 True:节点的内存、磁盘或进程数不足,kubelet 会驱逐节点上的 Pod 来回收资源。
+
+## 附录:实测
+
+以下实验的环境为 Apple M1 Pro(10 核)、macOS 上的 Docker Desktop,Docker Engine 28.4.0(使用 containerd 镜像存储),Linux 内核 6.10.14(aarch64)。订单服务基于 Spring Boot 3.5.16、JDK 21,只有一个 GET /orders/{id} 接口,返回订单号、状态和主机名,并引入了 spring-boot-starter-actuator 提供健康检查。
+
+### A1 构建缓存与多阶段构建
+
+正文中的 Dockerfile,冷缓存时首次构建(节选):
+
+```text
+$ time docker build --progress=plain -t order-service:1.0 order-service
+#11 [build 4/6] RUN mvn -q dependency:go-offline
+#11 DONE 209.6s
+#13 [build 6/6] RUN mvn -q package -DskipTests
+#13 DONE 3.2s
+3:34.63 total(zsh 的 time 输出)
+```
+
+把源码中的 "SHIPPED" 改为 "PAID" 后重新构建:
+
+```text
+#8 [build 3/6] COPY pom.xml .
+#8 CACHED
+#10 [build 4/6] RUN mvn -q dependency:go-offline
+#10 CACHED
+#11 [build 5/6] COPY src ./src
+#11 DONE 0.0s
+#12 [build 6/6] RUN mvn -q package -DskipTests
+#12 DONE 4.8s
+6.411 total
+```
+
+对照组只有一个阶段、先复制全部文件:
+
+```dockerfile
+FROM maven:3.9-eclipse-temurin-21
+WORKDIR /src
+COPY . .
+RUN mvn -q package -DskipTests
+ENTRYPOINT ["java", "-jar", "target/order-service-1.0.jar"]
+```
+
+```text
+# 首次构建(--no-cache)
+#8 [4/4] RUN mvn -q package -DskipTests
+#8 DONE 96.2s
+real 1m40.139s
+# 改一行源码后重新构建
+#7 [3/4] COPY . .
+#7 DONE 0.1s
+#8 [4/4] RUN mvn -q package -DskipTests
+#8 DONE 102.4s
+real 1m47.494s
+```
+
+镜像大小取 docker history 中各层大小之和(解压后):多阶段构建的镜像 235.7 MB,其中 Alpine 3.24 根文件系统 9.31 MB、字体和证书等系统包 40.6 MB、JRE 162 MB、jar 包 23.8 MB;单阶段的镜像 680.2 MB,其中 Ubuntu 根文件系统 110 MB、系统包 76.2 MB 与 90.5 MB、JDK 309 MB、Maven 11.1 MB、mvn package 这一层(依赖和编译结果)83.3 MB。
+
+### A2 exec 形式与 shell 形式
+
+在正文的镜像上分别用两种形式启动,6 秒后执行 docker stop:
+
+```text
+# exec 形式:ENTRYPOINT ["java", "-jar", "app.jar"]
+stop took 0.68 s
+ExitCode=143
+... GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
+... GracefulShutdown : Graceful shutdown complete
+
+# shell 形式:ENTRYPOINT java -jar app.jar; echo "order-service exited"
+$ docker exec order-shell ps -o pid,comm,args
+PID COMMAND COMMAND
+ 1 sh /bin/sh -c java -jar app.jar; echo "order-service exited"
+ 6 java java -jar app.jar
+stop took 10.34 s
+ExitCode=137
+(日志中没有 Graceful shutdown)
+```
+
+shell 形式写成 ENTRYPOINT java -jar app.jar 或 ENTRYPOINT echo "starting" && java -jar app.jar 时,ps 显示 1 号进程是 java,停止耗时 0.43 s 和 0.24 s,退出码都是 143。
+
+用 docker run order-service:1.0 --server.port=9090 启动:
+
+```text
+$ docker exec order9090 ps -o pid,args
+PID COMMAND
+ 1 java -jar app.jar --server.port=9090
+... TomcatWebServer : Tomcat started on port 9090 (http) with context path '/'
+```
+
+### A3 OverlayFS 的可写层
+
+```text
+$ docker exec ovl head -1 /proc/mounts
+overlay / overlay rw,relatime,
+ lowerdir=.../snapshots/426/fs:.../snapshots/354/fs:...(共 8 个目录),
+ upperdir=.../snapshots/427/fs,
+ workdir=.../snapshots/427/work 0 0
+$ docker exec ovl sh -c 'echo note >> /etc/alpine-release; rm /etc/motd'
+$ docker diff ovl
+C /etc
+C /etc/alpine-release
+D /etc/motd
+C /tmp
+A /tmp/hsperfdata_root/1
+A /tmp/tomcat.8080.7999032892732268743
+...
+# 在 Docker Desktop 的虚拟机中查看 upperdir/etc
+-rw-r--r-- 1 root root 12 Oct 28 10:19 alpine-release
+c--------- 2 root root 0, 0 Oct 28 10:19 motd
+```
+
+白障文件是主次设备号为 0, 0 的字符设备。/tmp 下新增的文件是 JVM 和 Tomcat 运行时创建的。
+
+### A4 端口、数据卷、网络与 Compose
+
+```text
+$ docker run -d --name order -p 8080:8080 order-service:1.0
+$ curl localhost:8080/orders/1024
+{"host":"cb371bf03045","id":1024,"status":"SHIPPED"}
+$ docker run -d --name order2 -p 8080:8080 order-service:1.0
+docker: Error response from daemon: failed to set up container networking: driver failed programming
+external connectivity on endpoint order2 (...): Bind for 0.0.0.0:8080 failed: port is already allocated
+$ docker run -d --name order2 -p 8081:8080 order-service:1.0
+$ curl localhost:8081/orders/1024
+{"status":"SHIPPED","id":1024,"host":"3a76640c3641"}
+$ docker stats --no-stream
+NAME CPU % MEM USAGE / LIMIT NET I/O PIDS
+order 0.24% 162.2MiB / 7.654GiB 1.72kB / 725B 43
+order2 0.33% 153.3MiB / 7.654GiB 1.47kB / 683B 43
+```
+
+MySQL 8.0:
+
+```text
+$ docker run -d --name mysql-bad mysql:8.0
+$ docker ps -a --filter name=mysql-bad
+mysql-bad Exited (1) 2 seconds ago
+$ docker logs mysql-bad
+ You need to specify one of the following as an environment variable:
+ - MYSQL_ROOT_PASSWORD
+ - MYSQL_ALLOW_EMPTY_PASSWORD
+ - MYSQL_RANDOM_ROOT_PASSWORD
+
+$ docker network create shop
+$ docker run -d --name mysql --network shop -p 3316:3306 -v mysql-data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=Root2026 mysql:8.0
+$ mysql -h127.0.0.1 -P3316 -uroot -p -e "create database shop; create table shop.orders(id bigint primary key, status varchar(16)); insert into shop.orders values (1024, 'SHIPPED')"
+$ docker rm -f mysql
+$ docker run ...(同上,重新创建容器)
+$ mysql -h127.0.0.1 -P3316 -uroot -p -e "select * from shop.orders"
+id status
+1024 SHIPPED
+
+$ docker run --rm --network shop busybox nslookup mysql
+Name: mysql
+Address: 172.19.0.2
+$ docker run --rm busybox nslookup mysql
+** server can't find mysql: NXDOMAIN
+```
+
+正文中的 compose.yaml:
+
+```text
+$ docker compose up -d
+ Container shop-mysql-1 Started
+ Container shop-mysql-1 Waiting
+ Container shop-mysql-1 Healthy
+ Container shop-order-1 Starting
+ Container shop-order-1 Started
+$ docker inspect -f '{{.Name}} started {{.State.StartedAt}}' shop-mysql-1 shop-order-1
+/shop-mysql-1 started 2026-10-28T10:18:50.539721208Z
+/shop-order-1 started 2026-10-28T10:19:00.181142879Z
+```
+
+### A5 部署、自愈与滚动升级
+
+两个节点的 k3s v1.31.4:server 节点 d1-server(控制平面兼工作节点)和 agent 节点 d1-agent 各运行在一个 Docker 特权容器中,容器内不能再用 OverlayFS,containerd 使用 native 快照(--snapshotter native)。订单服务的 1.0、1.1 两个镜像用 docker save 导出后导入两个节点的 containerd。
+
+```text
+$ kubectl apply -f order.yaml
+all ready after 21.1 s
+$ kubectl get pods -o wide
+NAME READY STATUS RESTARTS AGE IP NODE
+order-service-5ccc589fd8-b76g2 1/1 Running 0 20s 10.42.1.5 d1-agent
+order-service-5ccc589fd8-vb2lx 1/1 Running 0 20s 10.42.1.4 d1-agent
+order-service-5ccc589fd8-vz9hj 1/1 Running 0 20s 10.42.0.8 d1-server
+$ kubectl get svc order-service
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+order-service ClusterIP 10.43.210.38 80/TCP 21s
+$ kubectl exec order-service-5ccc589fd8-b76g2 -- wget -qO- http://order-service/orders/1024(4 次)
+{"host":"order-service-5ccc589fd8-vz9hj","id":1024,"status":"SHIPPED"}
+{"host":"order-service-5ccc589fd8-vb2lx","id":1024,"status":"SHIPPED"}
+{"host":"order-service-5ccc589fd8-vz9hj","id":1024,"status":"SHIPPED"}
+{"status":"SHIPPED","id":1024,"host":"order-service-5ccc589fd8-b76g2"}
+```
+
+删除一个 Pod 后每 0.1~0.2 秒查询一次 Pod 列表:
+
+```text
+$ kubectl delete pod/order-service-5ccc589fd8-b76g2
+new Pod object created after 0.34 s
+3/3 ready again after 11.2 s
+NAME READY STATUS RESTARTS AGE IP NODE
+order-service-5ccc589fd8-494s9 1/1 Running 0 11s 10.42.0.9 d1-server
+...
+NAME ADDRESSTYPE PORTS ENDPOINTS
+order-service-m2pjv IPv4 8080 10.42.1.4,10.42.0.8,10.42.0.9
+$ kubectl set image deploy/order-service app=order-service:1.1
+rolling update finished after 33.2 s
+NAME DESIRED CURRENT READY
+order-service-5b6f68cdcf 3 3 3
+order-service-5ccc589fd8 0 0 0
+$ kubectl rollout undo deploy/order-service
+undo finished after 32.9 s
+```
+
+### A6 Pod 的创建、资源限制与共享的命名空间
+
+A5 中 Pod order-service-5ccc589fd8-b76g2 的事件和状态:
+
+```text
+$ kubectl get events(Deployment 与 ReplicaSet 的事件,节选)
+2026-10-28T10:21:39Z Deployment ScalingReplicaSet deployment-controller Scaled up replica set order-service-5ccc589fd8 to 3
+2026-10-28T10:21:40Z ReplicaSet SuccessfulCreate replicaset-controller Created pod: order-service-5ccc589fd8-b76g2
+$ kubectl get events --field-selector involvedObject.name=order-service-5ccc589fd8-b76g2
+TIME REASON FROM MESSAGE
+ Scheduled Successfully assigned default/order-service-5ccc589fd8-b76g2 to d1-agent
+2026-10-28T10:21:40Z Pulled kubelet Container image "order-service:1.0" already present on machine
+2026-10-28T10:21:51Z Created kubelet Created container app
+2026-10-28T10:21:51Z Started kubelet Started container app
+2026-10-28T10:21:52Z Unhealthy kubelet Readiness probe failed: Get "http://10.42.1.5:8080/actuator/health/readiness": dial tcp 10.42.1.5:8080: connect: connection refused
+$ kubectl get pod order-service-5ccc589fd8-b76g2 -o jsonpath='{range .status.conditions[*]}...{end}'(节选)
+PodScheduled 2026-10-28T10:21:40Z
+Ready 2026-10-28T10:22:00Z
+$ kubectl exec order-service-5ccc589fd8-b76g2 -- cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.weight
+536870912
+100000 100000
+10
+```
+
+Scheduled 事件的 firstTimestamp 为空,PodScheduled 条件的时间为 10:21:40。cpu.weight 10 由 requests 的 250m 换算而来。
+
+一个 Pod 中放订单服务和一个 busybox 容器(sleep 3600),分别查看 1 号进程的命名空间:
+
+```text
+$ kubectl exec sidecar-demo -c sidecar -- wget -qO- localhost:8080/orders/1024
+{"host":"sidecar-demo","id":1024,"status":"SHIPPED"}
+$ kubectl exec sidecar-demo -c sidecar -- ps
+PID USER TIME COMMAND
+ 1 root 0:00 sleep 3600
+[app] [sidecar]
+net net:[4026533666] net net:[4026533666]
+ipc ipc:[4026534201] ipc ipc:[4026534201]
+uts uts:[4026534200] uts uts:[4026534200]
+pid pid:[4026534204] pid pid:[4026534207]
+mnt mnt:[4026534203] mnt mnt:[4026534206]
+```
+
+两个容器的 hostname 都是 sidecar-demo;节点上可以看到这个 Pod 的 /pause 进程。
+
+### A7 Service 与 Ingress
+
+在 d1-agent 上查看 kube-proxy 写入的规则(节选):
+
+```text
+$ iptables-save -t nat | grep default/order-service
+-A KUBE-SERVICES -d 10.43.210.38/32 -p tcp -m comment --comment "default/order-service cluster IP" -m tcp --dport 80 -j KUBE-SVC-QWJNOBD5KHUXZAD6
+-A KUBE-SVC-QWJNOBD5KHUXZAD6 -m comment --comment "default/order-service -> 10.42.0.8:8080" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-CRY6GKLRKVOMXZX4
+-A KUBE-SVC-QWJNOBD5KHUXZAD6 -m comment --comment "default/order-service -> 10.42.1.4:8080" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-4ZNL5PABF7B3TQCL
+-A KUBE-SVC-QWJNOBD5KHUXZAD6 -m comment --comment "default/order-service -> 10.42.1.5:8080" -j KUBE-SEP-FZGSJQ7SZ4EWXRUD
+-A KUBE-SEP-CRY6GKLRKVOMXZX4 -p tcp -m comment --comment "default/order-service" -m tcp -j DNAT --to-destination 10.42.0.8:8080
+```
+
+正文中的 Ingress,k3s 自带的 Traefik 2.11 处理,在 d1-server 上访问节点 IP 的 80 端口:
+
+```text
+$ kubectl get ingress shop
+NAME CLASS HOSTS ADDRESS PORTS AGE
+shop traefik shop.example.com 172.19.0.2,172.19.0.3 80 5s
+$ wget -qO- --header "Host: shop.example.com" http://172.19.0.2/orders/1024
+{"host":"order-service-5ccc589fd8-vb2lx","id":1024,"status":"SHIPPED"}
+$ wget --header "Host: shop.example.com" http://172.19.0.2/users/1
+ HTTP/1.1 404 Not Found
+$ wget --header "Host: other.example.com" http://172.19.0.2/orders/1024
+ HTTP/1.1 404 Not Found
+```
+
+把 order-service 改为 NodePort:
+
+```text
+$ kubectl patch svc order-service -p '{"spec":{"type":"NodePort"}}'
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+order-service NodePort 10.43.210.38 80:31887/TCP 103s
+$ wget -qO- http://172.19.0.3:31887/orders/1024 (在 d1-server 上访问 d1-agent 的 NodePort)
+{"status":"SHIPPED","id":1024,"host":"order-service-5ccc589fd8-b76g2"}
+```
+
+### A8 Pod 的异常状态
+
+在命名空间 lab 中创建 5 个故意出错的 Pod:
+
+```yaml
+# pending-demo:申请 20 个 CPU
+resources: { requests: { cpu: "20" } }
+# pull-demo:不存在的标签
+image: order-service:9.9
+# crash-demo:错误的启动参数
+args: ["--server.port=abc"]
+# oom-demo:堆比内存上限大,并在启动时全部占用
+env:
+ - { name: JAVA_TOOL_OPTIONS, value: "-Xms300m -Xmx300m -XX:+AlwaysPreTouch" }
+resources: { limits: { memory: 256Mi } }
+# notready-demo:就绪探针的路径不存在
+readinessProbe:
+ httpGet: { path: /ready, port: 8080 }
+```
+
+```text
+$ kubectl get pods -n lab (约 2 分钟后)
+NAME READY STATUS RESTARTS AGE
+crash-demo 0/1 CrashLoopBackOff 4 (35s ago) 2m8s
+notready-demo 0/1 Running 0 2m8s
+oom-demo 0/1 CrashLoopBackOff 4 (32s ago) 2m8s
+pending-demo 0/1 Pending 0 2m8s
+pull-demo 0/1 ImagePullBackOff 0 2m8s
+
+pending-demo Warning FailedScheduling default-scheduler 0/2 nodes are available: 2 Insufficient cpu. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
+pull-demo Warning Failed kubelet Failed to pull image "order-service:9.9": failed to pull and unpack image "docker.io/library/order-service:9.9": failed to resolve reference ...
+ Warning Failed kubelet Error: ErrImagePull
+ Warning Failed kubelet Error: ImagePullBackOff
+crash-demo Warning BackOff kubelet Back-off restarting failed container app in pod crash-demo_lab(...)
+notready-demo Warning Unhealthy kubelet Readiness probe failed: HTTP probe failed with statuscode: 404
+
+oom-demo lastState: OOMKilled exitCode=137
+crash-demo lastState: Error exitCode=1
+$ kubectl logs -n lab crash-demo --previous
+APPLICATION FAILED TO START
+Description:
+Invalid value 'abc' for configuration property 'server.port' (originating from '"server.port" from property source "commandLineArgs"'). Validation failed for the following reason:
+Failed to convert to type java.lang.Integer
+```
+
+oom-demo 在第一次被杀之前 kubectl get pods 显示的状态是 OOMKilled。给两个节点都加上污点后创建 Pod:
+
+```text
+$ kubectl taint nodes d1-server d1-agent dedicated=db:NoSchedule
+$ kubectl run -n lab taint-demo --image=order-service:1.0
+ Warning FailedScheduling 5s default-scheduler 0/2 nodes are available: 2 node(s) had untolerated taint {dedicated: db}. ...
+$ kubectl taint nodes d1-server d1-agent dedicated=db:NoSchedule-
+(8 秒后 taint-demo 在 d1-server 上 Running)
+```
+
+### A9 节点 NotReady
+
+用 docker stop 停掉 d1-agent 所在的容器,相当于节点宕机:
+
+```text
+10:28:55 $ docker stop d1-k3s-agent
+10:29:28 d1-agent NotReady after 32 s
+ Type Status LastTransitionTime Reason Message
+ MemoryPressure Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+ DiskPressure Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+ PIDPressure Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+ Ready Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+Taints: node.kubernetes.io/unreachable:NoSchedule
+10:34:33 pods on d1-agent marked for deletion after 337 s
+NAME READY STATUS IP NODE
+order-service-5ccc589fd8-24sqr 1/1 Terminating 10.42.1.8 d1-agent
+order-service-5ccc589fd8-2f56l 1/1 Terminating 10.42.1.9 d1-agent
+order-service-5ccc589fd8-2vrx4 1/1 Running 10.42.0.11 d1-server
+order-service-5ccc589fd8-4gzps 1/1 Running 10.42.0.17 d1-server
+order-service-5ccc589fd8-gzsbv 1/1 Running 10.42.0.16 d1-server
+$ kubectl get pod -o jsonpath='{.items[0].spec.tolerations}'
+[{"effect":"NoExecute","key":"node.kubernetes.io/not-ready","operator":"Exists","tolerationSeconds":300},
+ {"effect":"NoExecute","key":"node.kubernetes.io/unreachable","operator":"Exists","tolerationSeconds":300}]
+```
+
+节点失联后,Pod 无法确认已经停止,一直显示 Terminating。恢复 d1-agent、重新部署后再停一次,每 4 秒查看 EndpointSlice 中各地址的 ready:
+
+```text
++33s node=Ready endpoints: 10.42.0.19=true 10.42.1.14=true 10.42.1.13=true
++37s node=NotReady endpoints: 10.42.0.19=true 10.42.1.14=false 10.42.1.13=false
+```
## 参考资料
@@ -168,7 +800,29 @@ readinessProbe 是就绪探针:Pod 启动后要通过检查才会加入 Servic
- [What is Docker](https://www.docker.com/what-docker)
- [持续集成是什么?](http://www.ruanyifeng.com/blog/2015/09/continuous-integration.html)
- [Understanding the image layers | Docker Docs](https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/)
+- [Dockerfile reference | Docker Docs](https://docs.docker.com/reference/dockerfile/)
+- [Building best practices | Docker Docs](https://docs.docker.com/build/building/best-practices/)
+- [Multi-stage builds | Docker Docs](https://docs.docker.com/build/building/multi-stage/)
+- [Docker build cache | Docker Docs](https://docs.docker.com/build/cache/)
+- [OverlayFS storage driver | Docker Docs](https://docs.docker.com/engine/storage/drivers/overlayfs-driver/)
+- [Volumes | Docker Docs](https://docs.docker.com/engine/storage/volumes/)
+- [Bridge network driver | Docker Docs](https://docs.docker.com/engine/network/drivers/bridge/)
+- [Control startup order | Docker Docs](https://docs.docker.com/compose/how-tos/startup-order/)
+- [Live restore | Docker Docs](https://docs.docker.com/engine/daemon/live-restore/)
- [Kubernetes 文档:Deployments](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)
- [Kubernetes 文档:Service](https://kubernetes.io/docs/concepts/services-networking/service/)
+- [Kubernetes 文档:Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/)
+- [Kubernetes 文档:Pod Lifecycle](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/)
+- [Kubernetes 文档:Liveness, Readiness, and Startup Probes](https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/)
+- [Kubernetes 文档:Resource Management for Pods and Containers](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/)
+- [Kubernetes 文档:Kubernetes Scheduler](https://kubernetes.io/docs/concepts/scheduling-eviction/kube-scheduler/)
+- [Kubernetes 文档:Taints and Tolerations](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/)
+- [Kubernetes 文档:Node Status](https://kubernetes.io/docs/reference/node/node-status/)
+- [Kubernetes 文档:Services, Load Balancing, and Networking](https://kubernetes.io/docs/concepts/services-networking/)
+- [Kubernetes 文档:Debug Pods](https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/)
+- [Spring Boot 文档:Graceful Shutdown](https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html)
+- [Spring Boot 文档:Kubernetes Probes](https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints.kubernetes-probes)
+- [Traefik 文档:Kubernetes Ingress](https://doc.traefik.io/traefik/v2.11/routing/providers/kubernetes-ingress/)
+- [K3s 文档:Basic Network Options](https://docs.k3s.io/networking/basic-network-options)
- [namespaces(7) — Linux manual page](https://man7.org/linux/man-pages/man7/namespaces.7.html)
- [cgroups(7) — Linux manual page](https://man7.org/linux/man-pages/man7/cgroups.7.html)
diff --git a/notes/MySQL.md b/notes/MySQL.md
index 898bc92e..74cf3d47 100644
--- a/notes/MySQL.md
+++ b/notes/MySQL.md
@@ -4,10 +4,10 @@
* [一、索引](#一索引)
* [B+ Tree 原理](#b-tree-原理)
* [MySQL 索引](#mysql-索引)
- * [索引优化](#索引优化)
- * [索引失效](#索引失效)
* [索引的优点](#索引的优点)
* [索引的使用条件](#索引的使用条件)
+ * [索引优化](#索引优化)
+ * [索引失效](#索引失效)
* [二、查询性能优化](#二查询性能优化)
* [一条 SQL 的执行过程](#一条-sql-的执行过程)
* [使用 Explain 进行分析](#使用-explain-进行分析)
@@ -116,6 +116,20 @@ MEMORY 引擎支持哈希索引,InnoDB 不能显式创建哈希索引,但有
空间数据索引(R-Tree)用于地理数据,会从所有维度来索引数据,可以按任意维度组合查询,需要使用 GIS 相关的函数来维护数据。MyISAM 支持空间数据索引,InnoDB 从 MySQL 5.7 起也支持。
+### 索引的优点
+
+- 大大减少了服务器需要扫描的数据行数;
+- 帮助服务器避免排序和分组时创建临时表:B+Tree 索引是有序的,可以直接用于 ORDER BY 和 GROUP BY;
+- 将随机 I/O 变为顺序 I/O:B+Tree 索引是有序的,相邻的数据存储在一起。
+
+### 索引的使用条件
+
+- 对于非常小的表,大部分情况下简单的全表扫描比建立索引更高效;
+- 对于中到大型的表,索引就非常有效;
+- 对于特大型的表,建立和维护索引的代价也随之增长,可以使用分区等技术直接区分出需要查询的一组数据,而不是一条记录一条记录地匹配;
+- 索引并不是越多越好:插入、删除数据和修改索引列时,要同时维护每个相关的索引,索引也要占用存储空间。在 orders 表上插入 50 万行,没有二级索引时约 2.0 s,有 4 个二级索引时约 5.2 s,这 4 个索引共占 73 MB,而数据本身是 89 MB(见[附录 A3](#a3-二级索引对写入的影响));
+- 适合建索引的是经常出现在 WHERE、JOIN、ORDER BY、GROUP BY 中,并且区分度高的列,区分度很低的列(例如只有几种取值的状态)单独建索引的作用不大。
+
### 索引优化
#### 1. 独立的列
@@ -217,20 +231,6 @@ WHERE user_id = 5 AND status > 0 AND create_time > '2025-01-05' -- key_len = 5
需要注意的是,是否使用索引是优化器按成本估算决定的。走二级索引的每一行都要回表,匹配的行越多回表越多,超过一定比例后全表扫描反而更快:`create_time > '2025-01-23'` 匹配 9.9 万行,走 idx_create_time;`create_time > '2025-01-02'` 匹配 191 万行,就选择了全表扫描。IN 也一样,`user_id IN` 5 个值、1 万个值时都走索引,2 万个值时就改为全表扫描。因此同一种写法有时走索引有时不走,可能是因为匹配的数据量不同。
-### 索引的优点
-
-- 大大减少了服务器需要扫描的数据行数;
-- 帮助服务器避免排序和分组时创建临时表:B+Tree 索引是有序的,可以直接用于 ORDER BY 和 GROUP BY;
-- 将随机 I/O 变为顺序 I/O:B+Tree 索引是有序的,相邻的数据存储在一起。
-
-### 索引的使用条件
-
-- 对于非常小的表,大部分情况下简单的全表扫描比建立索引更高效;
-- 对于中到大型的表,索引就非常有效;
-- 对于特大型的表,建立和维护索引的代价也随之增长,可以使用分区等技术直接区分出需要查询的一组数据,而不是一条记录一条记录地匹配;
-- 索引并不是越多越好:插入、删除数据和修改索引列时,要同时维护每个相关的索引,索引也要占用存储空间。在 orders 表上插入 50 万行,没有二级索引时约 2.0 s,有 4 个二级索引时约 5.2 s,这 4 个索引共占 73 MB,而数据本身是 89 MB(见[附录 A3](#a3-二级索引对写入的影响));
-- 适合建索引的是经常出现在 WHERE、JOIN、ORDER BY、GROUP BY 中,并且区分度高的列,区分度很低的列(例如只有几种取值的状态)单独建索引的作用不大。
-
## 二、查询性能优化
### 一条 SQL 的执行过程
@@ -735,7 +735,6 @@ COUNT(*) 130.47 ms
- Baron Schwartz, Peter Zaitsev, Vadim Tkachenko, 等. 高性能 MySQL[M]. 电子工业出版社, 2013.
- 姜承尧. MySQL 技术内幕: InnoDB 存储引擎 [M]. 机械工业出版社, 2011.
- [20+ 条 MySQL 性能优化的最佳经验](https://www.jfox.info/20-tiao-mysql-xing-nen-you-hua-de-zui-jia-jing-yan.html)
-- [服务端指南 数据存储篇 | MySQL(09) 分库与分表带来的分布式困境与应对之策](http://blog.720ui.com/2017/mysql_core_09_multi_db_table2/ "服务端指南 数据存储篇 | MySQL(09) 分库与分表带来的分布式困境与应对之策")
- [How to create unique row ID in sharded databases?](https://stackoverflow.com/questions/788829/how-to-create-unique-row-id-in-sharded-databases)
- [SQL Azure Federation – Introduction](http://geekswithblogs.net/shaunxu/archive/2012/01/07/sql-azure-federation-ndash-introduction.aspx "Title of this entry.")
- [MySQL 索引背后的数据结构及算法原理](http://blog.codinglabs.org/articles/theory-of-mysql-index.html)
diff --git a/notes/Redis - 进阶.md b/notes/Redis - 进阶.md
new file mode 100644
index 00000000..bd5066ac
--- /dev/null
+++ b/notes/Redis - 进阶.md
@@ -0,0 +1,128 @@
+# Redis - 进阶
+
+* [Redis - 进阶](#redis---进阶)
+ * [四、数据结构](#四数据结构)
+ * [对象编码](#对象编码)
+ * [字典](#字典)
+ * [十、事件](#十事件)
+ * [文件事件](#文件事件)
+ * [时间事件](#时间事件)
+ * [事件的调度与执行](#事件的调度与执行)
+ * [附录:实测](#附录实测)
+ * [A1 SDS 的空间预分配](#a1-sds-的空间预分配)
+ * [参考资料](#参考资料)
+
+
+
+## 四、数据结构
+
+### 对象编码
+
+主线见[Redis · 对象编码](Redis.md#对象编码),这里接着 SDS 的空间预分配,看追加时实际分配了多少内存。
+
+例如对 60 字节、没有剩余空间的字符串再 APPEND 1 字节,SDS 按 2 × 61 = 122 字节申请(内存分配器向上取整为 128 字节),追加后剩余 63 字节;对 2040000 字节的字符串追加后剩余约 1 MB(Redis 7.4 实测,见[附录 A1](#a1-sds-的空间预分配))。
+
+### 字典
+
+主线见[Redis · 字典](Redis.md#字典),这里展开渐进式 rehash 的具体过程:
+
+- 字典中有两张哈希表 ht_table[0] 和 ht_table[1],平时只用 ht_table[0];
+- 键的数量达到桶数时扩容,ht_table[1] 的大小为不小于键数加 1 的 2 的幂;有子进程在执行 BGSAVE 或 AOF 重写时,为了减少写时复制的内存页,要达到桶数的 4 倍才扩容;
+- 键的数量不超过桶数的 1/8 时缩容,过程相同;
+- rehashidx 记录迁移到了哪个桶,之后每次对这个字典增删改查,都顺便把一个桶中的所有键迁移到 ht_table[1],serverCron 也会定期花不超过 1 毫秒继续迁移;
+- 迁移期间新写入的键只放入 ht_table[1],查找和删除时两张表都可能要查;
+- 全部迁移完成后,ht_table[1] 变为 ht_table[0],rehashidx 恢复为 -1。
+
+
+
+以上依据 Redis 7.4 的 dict.c。Redis 7.0 之前的两张表是 dict 中的 dictht ht[2],渐进式 rehash 的过程相同。
+
+## 十、事件
+
+主线见[Redis · 十、事件](Redis.md#十事件),以下三节讲 Redis 的事件循环是怎样实现的;单线程的 Redis 为什么快,见主线的[单线程为什么快](Redis.md#单线程为什么快)。
+
+### 文件事件
+
+服务器通过套接字与客户端或者其它服务器进行通信,文件事件就是对套接字操作的抽象。
+
+Redis 基于 Reactor 模式开发了自己的网络事件处理器,使用 I/O 多路复用程序来同时监听多个套接字,并将到达的事件传送给文件事件分派器,分派器会根据套接字产生的事件类型调用相应的事件处理器。
+
+
+
+### 时间事件
+
+服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。
+
+时间事件又分为:
+
+- 定时事件:是让一段程序在指定的时间之内执行一次;
+- 周期性事件:是让一段程序每隔指定时间就执行一次。
+
+Redis 将所有时间事件都放在一个无序链表中,通过遍历整个链表查找出已到达的时间事件,并调用相应的事件处理器。
+
+### 事件的调度与执行
+
+服务器需要不断监听文件事件的套接字才能得到待处理的文件事件,但是不能一直监听,否则时间事件无法在规定的时间内执行,因此监听时间应该根据距离现在最近的时间事件来决定。
+
+事件调度与执行由 aeProcessEvents 函数负责,伪代码如下:
+
+```python
+def aeProcessEvents():
+ # 获取到达时间离当前时间最接近的时间事件
+ time_event = aeSearchNearestTimer()
+ # 计算最接近的时间事件距离到达还有多少毫秒
+ remaind_ms = time_event.when - unix_ts_now()
+ # 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设为 0
+ if remaind_ms < 0:
+ remaind_ms = 0
+ # 根据 remaind_ms 的值,创建 timeval
+ timeval = create_timeval_with_ms(remaind_ms)
+ # 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 决定
+ aeApiPoll(timeval)
+ # 处理所有已产生的文件事件
+ processFileEvents()
+ # 处理所有已到达的时间事件
+ processTimeEvents()
+```
+
+将 aeProcessEvents 函数置于一个循环里面,加上初始化和清理函数,就构成了 Redis 服务器的主函数,伪代码如下:
+
+```python
+def main():
+ # 初始化服务器
+ init_server()
+ # 一直处理事件,直到服务器关闭为止
+ while server_is_not_shutdown():
+ aeProcessEvents()
+ # 服务器关闭,执行清理操作
+ clean_server()
+```
+
+从事件处理的角度来看,服务器运行流程如下:
+
+
+
+## 附录:实测
+
+以下实验的环境为 Apple M1 Pro(10 核)、macOS,客户端和服务器在同一台机器上,Redis 7.4.1 从源码编译,使用默认配置,不开启持久化。
+
+### A1 SDS 的空间预分配
+
+Redis 7.4.1(内存分配器为 libc),启动时加 --enable-debug-command yes,用 DEBUG SDSLEN 查看值的 len、avail(剩余空间)和实际分配的内存:
+
+```text
+SET s(50 字节) val_sds_len:50, val_sds_avail:10, val_zmalloc: 64 encoding raw
+APPEND s 10 字节 val_sds_len:60, val_sds_avail:0, val_zmalloc: 64
+APPEND s 1 字节 val_sds_len:61, val_sds_avail:63, val_zmalloc: 128
+SET big(2000000 字节) val_sds_len:2000000, val_sds_avail:31606, val_zmalloc: 2031616
+APPEND big 40000 字节 val_sds_len:2040000, val_sds_avail:1072950, val_zmalloc: 3112960
+SET 44 字节 encoding embstr,SET 45 字节 encoding raw
+```
+
+对应的源码在 sds.c 的 _sdsMakeRoomFor():剩余空间足够时直接返回;否则新长度小于 SDS_MAX_PREALLOC(1 MB)时乘以 2,不小于时加上 1 MB。SET 50 字节时的 10 字节、SET 大字符串时的 31606 字节剩余空间来自内存分配器的取整和协议缓冲区,不是预分配。
+
+## 参考资料
+
+- [黄健宏. Redis 设计与实现 [M]. 机械工业出版社, 2014.](http://redisbook.com/index.html)
+- [Redis 7.4.1 源码 dict.c](https://github.com/redis/redis/blob/7.4.1/src/dict.c)
+- [Redis 7.4.1 源码 sds.c](https://github.com/redis/redis/blob/7.4.1/src/sds.c)
diff --git a/notes/Redis.md b/notes/Redis.md
index aa0d3174..c0b2a2a7 100644
--- a/notes/Redis.md
+++ b/notes/Redis.md
@@ -9,11 +9,9 @@
* [SET](#set)
* [HASH](#hash)
* [ZSET](#zset)
- * [三、数据结构](#三数据结构)
- * [对象编码](#对象编码)
- * [字典](#字典)
- * [跳跃表](#跳跃表)
- * [四、使用场景](#四使用场景)
+ * [BITMAP](#bitmap)
+ * [HyperLogLog 与 GEO](#hyperloglog-与-geo)
+ * [三、使用场景](#三使用场景)
* [计数器](#计数器)
* [缓存](#缓存)
* [查找表](#查找表)
@@ -22,6 +20,10 @@
* [分布式锁实现](#分布式锁实现)
* [排行榜](#排行榜)
* [其它](#其它)
+ * [四、数据结构](#四数据结构)
+ * [对象编码](#对象编码)
+ * [字典](#字典)
+ * [跳跃表](#跳跃表)
* [五、Redis 与 Memcached](#五redis-与-memcached)
* [数据类型](#数据类型)
* [数据持久化](#数据持久化)
@@ -35,9 +37,6 @@
* [九、事务](#九事务)
* [Lua 脚本](#lua-脚本)
* [十、事件](#十事件)
- * [文件事件](#文件事件)
- * [时间事件](#时间事件)
- * [事件的调度与执行](#事件的调度与执行)
* [单线程为什么快](#单线程为什么快)
* [十一、复制](#十一复制)
* [连接过程](#连接过程)
@@ -55,8 +54,9 @@
* [A3 单线程的吞吐量和 CPU 时间](#a3-单线程的吞吐量和-cpu-时间)
* [A4 Redis Cluster](#a4-redis-cluster)
* [A5 Sentinel 故障转移](#a5-sentinel-故障转移)
- * [A6 SDS 的空间预分配](#a6-sds-的空间预分配)
- * [A7 热点计数](#a7-热点计数)
+ * [A6 热点计数](#a6-热点计数)
+ * [A7 Bitmap 的签到和日活](#a7-bitmap-的签到和日活)
+ * [A8 HyperLogLog 的误差和内存](#a8-hyperloglog-的误差和内存)
* [参考资料](#参考资料)
@@ -207,66 +207,94 @@ OK
6) "92"
```
-## 三、数据结构
+### BITMAP
-### 对象编码
+位图(Bitmap)不是一种独立的数据类型,而是 STRING 上按位读写的一组命令:值仍然是 SDS 中的字节数组,TYPE 返回 string,第 offset 位表示整数 offset 是否在集合中。STRING 最大 512 MB,所以 offset 的范围是 0~232 − 1。
-同一种数据类型会根据元素的数量和大小选择不同的底层结构,称为编码,可以用 OBJECT ENCODING 查看。元素少时使用 listpack,它把所有元素连续地存放在一块内存中,节省内存,查找时虽然要逐个比较,但元素很少时并不慢;元素增多后转换为查找更快的结构。以下是在 Redis 7.4 上用默认配置验证的结果:
+它适合成员能编号为 0~N 的整数、并且只需要记录“是或否”的场景,例如某个用户某天是否签到、某个用户今天是否访问过。主要有以下命令:
-| 类型 | 元素少时 | 超过阈值后 | 阈值 |
-| --- | --- | --- | --- |
-| STRING | int(整数)、embstr | raw | 44 字节 |
-| LIST | listpack | quicklist(多个 listpack 组成的链表) | 单个 listpack 超过 8 KB |
-| HASH | listpack | hashtable | 512 个字段,或者有值超过 64 字节 |
-| SET | intset(全是整数)、listpack | hashtable | 整数 512 个,其它 128 个 |
-| ZSET | listpack | skiplist(跳跃表 + 字典) | 128 个成员,或者有成员超过 64 字节 |
+- SETBIT key offset 0|1:设置一位并返回这一位原来的值,字符串不够长时自动扩展,新增的位为 0;
+- GETBIT key offset:读取一位,超出字符串长度的位当作 0;
+- BITCOUNT key [start end [BYTE|BIT]]:统计值为 1 的位数,范围默认按字节,Redis 7.0 起可以按位指定;
+- BITPOS key 0|1:查找第一个 0 或 1 所在的位置;
+- BITOP AND|OR|XOR|NOT destkey key [key ...]:对多个位图做位运算,结果写入 destkey;
+- BITFIELD:把一段连续的位当作整数读写,例如 GET u28 0 把前 28 位读成一个无符号整数,也可以用 INCRBY 把每几位当作一个小计数器。
-例如一个 HASH 有 512 个字段时编码为 listpack,再写入一个字段就变为 hashtable。ZSET 转换后同时使用跳跃表和字典:字典按成员查分数,跳跃表按分数排序和范围查询。
+#### 1. 签到
-字符串的内容保存在简单动态字符串(Simple Dynamic String, SDS)中,它在字符数组前面记录了已用的长度 len 和分配的容量 alloc:
+每个用户每月一个键 sign:{用户 ID}:{年月},第 n 号签到就把 offset n − 1 置为 1,一个月最多 31 位,只占 4 个字节。下图是用户 1001 十月 1 号到 28 号的签到情况:
-- 取长度是 O(1),不用像 C 字符串那样遍历到 \0;
-- 按 len 而不是 \0 判断结尾,可以保存任意二进制数据;
-- 追加时空间不够才重新分配,并且多分配一些:新长度小于 1 MB 时分配新长度的 2 倍,否则多分配 1 MB,连续追加时不用每次都重新分配内存。
+
-例如对 60 字节、没有剩余空间的字符串再 APPEND 1 字节,SDS 按 2 × 61 = 122 字节申请(内存分配器向上取整为 128 字节),追加后剩余 63 字节;对 2040000 字节的字符串追加后剩余约 1 MB(Redis 7.4 实测,见[附录 A6](#a6-sds-的空间预分配))。embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次,44 字节的上限正好让两者一起放进 64 字节;它是只读的,APPEND 等修改会先把它转为 raw。
+```text
+> setbit sign:1001:202610 27 1 -- 28 号签到,返回原来的值 0
+(integer) 0
+> setbit sign:1001:202610 27 1 -- 重复签到,返回 1
+(integer) 1
+> getbit sign:1001:202610 3 -- 4 号是否签到
+(integer) 0
+> bitcount sign:1001:202610 -- 本月签到天数
+(integer) 23
+> bitpos sign:1001:202610 0 -- 第一次漏签的位置
+(integer) 3
+> bitfield sign:1001:202610 get u28 0 -- 1~28 号的 28 位
+1) (integer) 251606975
+```
-### 字典
+SETBIT 返回 1 说明今天已经签过,可以据此避免重复发放签到奖励。
-键空间、HASH 和 SET 的 hashtable 编码、ZSET 中按成员查分数的部分,都是用字典(dict)实现的。字典是一个哈希表,用拉链法解决哈希冲突:桶的数量是 2 的幂,键的哈希值与桶数减 1 做位与得到桶的下标,落在同一个桶的键用链表连起来。
+连续签到天数用 BITFIELD 一次读出 1 号到今天的位。每个字节从最高位开始编号,所以今天对应整数的最低位,在应用中从最低位开始数连续的 1,遇到 0 停止:251606975 的二进制末尾是 0111111,截至 28 号连续签到 6 天。“连续签到 7 天送优惠券”就是判断这个数是否达到 7,跨月时还要接着读上个月的键。
-键越来越多时链表变长,查找变慢,需要扩大桶数组并把所有键重新放一遍,称为 rehash。Redis 用一个线程执行命令,一个有几百万个键的字典一次性 rehash 会让服务器停顿很久,所以采用渐进式 rehash:
+内存方面,每人一年 365 位只有 46 字节,但每个键还有键名、对象头和字典项的开销。每人每年一个键时,实测平均每个键约 150 字节,1 亿用户一年约 15 GB,其中位图本身只有 4.6 GB(Redis 7.4 实测,见[附录 A7](#a7-bitmap-的签到和日活))。
-- 字典中有两张哈希表 ht_table[0] 和 ht_table[1],平时只用 ht_table[0];
-- 键的数量达到桶数时扩容,ht_table[1] 的大小为不小于键数加 1 的 2 的幂;有子进程在执行 BGSAVE 或 AOF 重写时,为了减少写时复制的内存页,要达到桶数的 4 倍才扩容;
-- 键的数量不超过桶数的 1/8 时缩容,过程相同;
-- rehashidx 记录迁移到了哪个桶,之后每次对这个字典增删改查,都顺便把一个桶中的所有键迁移到 ht_table[1],serverCron 也会定期花不超过 1 毫秒继续迁移;
-- 迁移期间新写入的键只放入 ht_table[1],查找和删除时两张表都可能要查;
-- 全部迁移完成后,ht_table[1] 变为 ht_table[0],rehashidx 恢复为 -1。
+#### 2. 日活统计
-
+用户 ID 是从 1 开始连续分配的整数时,也可以反过来每天一个键 active:{日期},offset 是用户 ID,所有用户当天的状态放在同一个位图中。100 万个用户只需要 106 / 8 = 125000 字节,约 122 KB:
-以上依据 Redis 7.4 的 dict.c。Redis 7.0 之前的两张表是 dict 中的 dictht ht[2],渐进式 rehash 的过程相同。
+```text
+> setbit active:20261028 1000 1 -- 用户 1000 今天访问过
+(integer) 0
+> bitcount active:20261028 -- 当天活跃人数
+(integer) 369445
+> bitop and active:7d:and active:20261022 active:20261023 ... active:20261028
+(integer) 125001
+> bitcount active:7d:and -- 7 天每天都活跃的人数
+(integer) 100184
+> bitop or active:7d:or active:20261022 active:20261023 ... active:20261028
+(integer) 125001
+> bitcount active:7d:or -- 7 天内活跃过的人数(周活)
+(integer) 925445
+```
-### 跳跃表
+BITOP 返回结果的字节数。100 万用户的位图上,BITCOUNT 的服务器执行时间约 15 微秒,对 7 天的位图做 BITOP AND 约 84 微秒;同一天的 36.9 万个活跃用户如果用 SET 保存,要占 14 MB(Redis 7.4 实测,见[附录 A7](#a7-bitmap-的签到和日活))。
-跳跃表(skiplist)是有序集合在成员较多时的底层结构,它在一条有序链表上增加了多层索引。
+签到也可以这样存,每天一个键记录所有用户。两种键各有所长:按用户分键,查一个人一个月的情况只读一个键;按日期分键,统计一天的人数、多天的交集和并集只需一两条命令,并且没有大量小键的开销,1 亿用户一年是 365 × 12.5 MB ≈ 4.6 GB。
-最底层是包含所有成员、按分值排序的链表。每个节点插入时随机决定层数:以 1/4 的概率升高一层,最多 32 层。所以大约每 4 个节点有 1 个出现在第 2 层,每 16 个节点有 1 个出现在第 3 层,平均每个节点约 1.33 个指针。
+#### 3. 用户 ID 稀疏
-查找时从最高层开始向右走,下一个节点比目标大时下降一层,直到最底层。每层平均只走几步,查找、插入和删除的期望时间复杂度都是 O(logN)。下图演示了查找 26 的过程。
+SETBIT 会把字符串扩展到 offset 所在的字节,中间的内存一次分配完。用户 ID 从 1 亿开始时,第一个用户就要分配 12.5 MB;offset 为 40 亿时要分配 500 MB,这次 SETBIT 耗时约 100 毫秒,之后对它执行一次 BITCOUNT 约 92 毫秒,这期间服务器不能处理其它命令。64 位的雪花 ID 超过了 232 − 1,根本不能直接作为 offset。
-
+解决方案:
-与红黑树等平衡树相比,跳跃表有以下优点:
+- 先把 ID 转换成从 0 开始的连续序号,例如减去起始值,或者为用户另外分配一个自增序号;
+- 位图很大时按序号分段拆成多个键,键名为序号 / M,offset 为序号 mod M,避免单个大键;
+- 活跃的 ID 很少时改用 SET 或 HyperLogLog:1000 个分布在 1 亿以内的 ID,位图要 12.5 MB,SET 只要 30 KB。按实测每个整数成员在 SET 中约占 38 字节(304 位),活跃 ID 不到 ID 范围的 1/300 时 SET 更省内存。
-- 插入和删除只需要修改每一层前驱节点的指针,不需要旋转等操作来维护平衡;
-- 范围查询方便,找到起点后沿最底层链表向后遍历即可,ZRANGEBYSCORE 就是这样实现的;
-- 实现和调试更简单。
+#### 4. 与布隆过滤器的区别
-Redis 的跳跃表还在每个指针上记录了跨度,即这一步跳过了多少个节点,查找时把经过的跨度相加就得到排名,所以 ZRANK 也是 O(logN)。
+Bitmap 与布隆过滤器的区别在于映射方式:Bitmap 一个整数对应一位,结果是精确的,但成员必须能编号成范围不大的整数;布隆过滤器用多个哈希函数把任意字符串映射到位数组的多个位置,可以保存 URL、手机号这类无法编号的 Key,代价是存在误判,见 [缓存穿透](缓存.md#缓存穿透)。所以 ID 是自增整数时,可以直接用 Bitmap 记录哪些 ID 存在来拦截不存在的请求;其它 Key 需要布隆过滤器,它的位数组可以存放在 Redis 的位图中,由客户端算出各个位置后用 SETBIT 写入、GETBIT 检查。
-## 四、使用场景
+### HyperLogLog 与 GEO
+
+HyperLogLog(HLL)用于估计一个集合中不同元素的个数,例如页面的独立访客数(Unique Visitor, UV):PFADD 添加元素,PFCOUNT 返回估计值,PFMERGE 合并多个 HLL,例如把 7 天的 UV 合并成周 UV。
+
+它不保存元素本身:每个元素的 64 位哈希值按低 14 位分到 16384 个寄存器之一,寄存器用 6 位记录剩余位中第一个 1 出现位置的最大值。连续的 0 越长的哈希值越少见,这些最大值就大致反映了不同元素的个数。寄存器共 16384 × 6 位 = 12 KB,这就是每个键占用内存的上限,标准误差为 1.04 / √16384 ≈ 0.81%。代价是结果只是估计值,也不能判断某个元素是否存在或者取出元素。
+
+例如 100 万个不同的元素,PFCOUNT 返回 999674(误差 −0.03%),HLL 占 12 KB,同样的元素用 SET 保存要 30 MB;30 组各 100 万个元素的误差均方根为 0.80%,最大为 2.0%(Redis 7.4 实测,见[附录 A8](#a8-hyperloglog-的误差和内存))。
+
+GEO 用于“附近的门店”这类查询:GEOADD 保存经纬度,GEOSEARCH 查找某个点一定半径或矩形内的成员并按距离排序,GEODIST 计算两点间的距离。它底层就是一个 ZSET,经纬度交错编码成 52 位的 Geohash 整数作为分值,相邻的点分值相近,查询时只需查找覆盖查询范围的 9 个区域对应的分值区间;代价是距离和范围有误差,官方文档说明最坏可达 0.5%。
+
+## 三、使用场景
### 计数器
@@ -274,13 +302,13 @@ Redis 的跳跃表还在每个指针上记录了跨度,即这一步跳过了
命令在服务器上原子执行,多个客户端同时加 1 不会丢失更新;数据在内存中,也适合这种频繁读写的场景。
-如果直接在数据库中计数,所有请求都要更新同一行,行锁让这些更新只能一个接一个地执行。对同一行执行 UPDATE counter SET cnt = cnt + 1,MySQL 用 1、8、50 个连接时每秒都只有 5000~7000 次;Redis 的 INCR 用 50 个连接每秒约 10 万次(MySQL 8.0、Redis 7.4 实测,见[附录 A7](#a7-热点计数))。
+如果直接在数据库中计数,所有请求都要更新同一行,行锁让这些更新只能一个接一个地执行。对同一行执行 UPDATE counter SET cnt = cnt + 1,MySQL 用 1、8、50 个连接时每秒都只有 5000~7000 次;Redis 的 INCR 用 50 个连接每秒约 10 万次(MySQL 8.0、Redis 7.4 实测,见[附录 A6](#a6-热点计数))。
常用的做法是在 Redis 中计数,再定期把计数写回数据库。单个计数仍然太热时,可以拆成多个键,例如 views:1024:0~views:1024:9,每次随机给其中一个加 1,读取时求和。点赞这类每个用户只能计一次的计数,还要记录点过赞的用户,见下文的 [点赞功能](#点赞功能)。
### 缓存
-将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率,见 [缓存](缓存.md)。
+将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。缓存与数据库的一致性,以及缓存穿透、击穿和雪崩的处理,见 [缓存](缓存.md)。
### 查找表
@@ -326,6 +354,58 @@ ZREVRANK rank:likes article:1001 -- 第几名,从 0 开始
Set 可以实现交集、并集等操作,从而实现共同好友等功能。
+签到、日活这类只需记录“是或否”的统计可以用 Bitmap,独立访客数这类允许少量误差的去重计数可以用 HyperLogLog,见 [BITMAP](#bitmap) 和 [HyperLogLog 与 GEO](#hyperloglog-与-geo)。
+
+## 四、数据结构
+
+### 对象编码
+
+同一种数据类型会根据元素的数量和大小选择不同的底层结构,称为编码,可以用 OBJECT ENCODING 查看。元素少时使用 listpack,它把所有元素连续地存放在一块内存中,节省内存,查找时虽然要逐个比较,但元素很少时并不慢;元素增多后转换为查找更快的结构。以下是在 Redis 7.4 上用默认配置验证的结果:
+
+| 类型 | 元素少时 | 超过阈值后 | 阈值 |
+| --- | --- | --- | --- |
+| STRING | int(整数)、embstr | raw | 44 字节 |
+| LIST | listpack | quicklist(多个 listpack 组成的链表) | 单个 listpack 超过 8 KB |
+| HASH | listpack | hashtable | 512 个字段,或者有值超过 64 字节 |
+| SET | intset(全是整数)、listpack | hashtable | 整数 512 个,其它 128 个 |
+| ZSET | listpack | skiplist(跳跃表 + 字典) | 128 个成员,或者有成员超过 64 字节 |
+
+例如一个 HASH 有 512 个字段时编码为 listpack,再写入一个字段就变为 hashtable。ZSET 转换后同时使用跳跃表和字典:字典按成员查分数,跳跃表按分数排序和范围查询。
+
+Redis 7.0 之前用的是压缩列表(ziplist),每个元素要记录前一个元素的长度,这个长度占 1 或 5 个字节,在中间插入或删除元素时可能让后面的元素依次变长,称为连锁更新;listpack 的每个元素只记录自己的长度,避免了这个问题。
+
+字符串的内容保存在简单动态字符串(Simple Dynamic String, SDS)中,它在字符数组前面记录了已用的长度 len 和分配的容量 alloc:
+
+- 取长度是 O(1),不用像 C 字符串那样遍历到 \0;
+- 按 len 而不是 \0 判断结尾,可以保存任意二进制数据;
+- 追加时空间不够才重新分配,并且多分配一些:新长度小于 1 MB 时分配新长度的 2 倍,否则多分配 1 MB,连续追加时不用每次都重新分配内存。
+
+embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次,44 字节的上限正好让两者一起放进 64 字节;它是只读的,APPEND 等修改会先把它转为 raw。追加时实际分配的内存见[进阶:对象编码](Redis%20-%20进阶.md#对象编码)。
+
+### 字典
+
+键空间、HASH 和 SET 的 hashtable 编码、ZSET 中按成员查分数的部分,都是用字典(dict)实现的。字典是一个哈希表,用拉链法解决哈希冲突:桶的数量是 2 的幂,键的哈希值与桶数减 1 做位与得到桶的下标,落在同一个桶的键用链表连起来。
+
+键越来越多时链表变长,查找变慢,需要扩大桶数组并把所有键重新放一遍,称为 rehash。Redis 用一个线程执行命令,一个有几百万个键的字典一次性 rehash 会让服务器停顿很久,所以采用渐进式 rehash:同时保留新旧两张哈希表,每次对字典增删改查时顺便迁移一个桶,把迁移的开销分摊到之后的操作中。两张表的使用规则和扩容、缩容的条件见[进阶:字典](Redis%20-%20进阶.md#字典)。
+
+### 跳跃表
+
+跳跃表(skiplist)是有序集合在成员较多时的底层结构,它在一条有序链表上增加了多层索引。
+
+最底层是包含所有成员、按分值排序的链表。每个节点插入时随机决定层数:以 1/4 的概率升高一层,最多 32 层。所以大约每 4 个节点有 1 个出现在第 2 层,每 16 个节点有 1 个出现在第 3 层,平均每个节点约 1.33 个指针。
+
+查找时从最高层开始向右走,下一个节点比目标大时下降一层,直到最底层。每层平均只走几步,查找、插入和删除的期望时间复杂度都是 O(logN)。下图演示了查找 26 的过程。
+
+
+
+与红黑树等平衡树相比,跳跃表有以下优点:
+
+- 插入和删除只需要修改每一层前驱节点的指针,不需要旋转等操作来维护平衡;
+- 范围查询方便,找到起点后沿最底层链表向后遍历即可,ZRANGEBYSCORE 就是这样实现的;
+- 实现和调试更简单。
+
+Redis 的跳跃表还在每个指针上记录了跨度,即这一步跳过了多少个节点,查找时把经过的跨度相加就得到排名,所以 ZRANK 也是 O(logN)。
+
## 五、Redis 与 Memcached
两者都是非关系型内存键值数据库,主要有以下不同:
@@ -484,68 +564,7 @@ return n + 1
## 十、事件
-Redis 服务器是一个事件驱动程序。
-
-### 文件事件
-
-服务器通过套接字与客户端或者其它服务器进行通信,文件事件就是对套接字操作的抽象。
-
-Redis 基于 Reactor 模式开发了自己的网络事件处理器,使用 I/O 多路复用程序来同时监听多个套接字,并将到达的事件传送给文件事件分派器,分派器会根据套接字产生的事件类型调用相应的事件处理器。
-
-
-
-### 时间事件
-
-服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。
-
-时间事件又分为:
-
-- 定时事件:是让一段程序在指定的时间之内执行一次;
-- 周期性事件:是让一段程序每隔指定时间就执行一次。
-
-Redis 将所有时间事件都放在一个无序链表中,通过遍历整个链表查找出已到达的时间事件,并调用相应的事件处理器。
-
-### 事件的调度与执行
-
-服务器需要不断监听文件事件的套接字才能得到待处理的文件事件,但是不能一直监听,否则时间事件无法在规定的时间内执行,因此监听时间应该根据距离现在最近的时间事件来决定。
-
-事件调度与执行由 aeProcessEvents 函数负责,伪代码如下:
-
-```python
-def aeProcessEvents():
- # 获取到达时间离当前时间最接近的时间事件
- time_event = aeSearchNearestTimer()
- # 计算最接近的时间事件距离到达还有多少毫秒
- remaind_ms = time_event.when - unix_ts_now()
- # 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设为 0
- if remaind_ms < 0:
- remaind_ms = 0
- # 根据 remaind_ms 的值,创建 timeval
- timeval = create_timeval_with_ms(remaind_ms)
- # 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 决定
- aeApiPoll(timeval)
- # 处理所有已产生的文件事件
- processFileEvents()
- # 处理所有已到达的时间事件
- processTimeEvents()
-```
-
-将 aeProcessEvents 函数置于一个循环里面,加上初始化和清理函数,就构成了 Redis 服务器的主函数,伪代码如下:
-
-```python
-def main():
- # 初始化服务器
- init_server()
- # 一直处理事件,直到服务器关闭为止
- while server_is_not_shutdown():
- aeProcessEvents()
- # 服务器关闭,执行清理操作
- clean_server()
-```
-
-从事件处理的角度来看,服务器运行流程如下:
-
-
+Redis 服务器是一个事件驱动程序:主线程在一个循环中用 I/O 多路复用等待套接字上的读写事件(文件事件),处理完这些事件后再执行到期的定时任务(时间事件)。事件的分类和调度过程见[进阶:事件](Redis%20-%20进阶.md#十事件)。
### 单线程为什么快
@@ -829,22 +848,7 @@ master_port:6401
NOREPLICAS Not enough good replicas to write.
```
-### A6 SDS 的空间预分配
-
-Redis 7.4.1(内存分配器为 libc),启动时加 --enable-debug-command yes,用 DEBUG SDSLEN 查看值的 len、avail(剩余空间)和实际分配的内存:
-
-```text
-SET s(50 字节) val_sds_len:50, val_sds_avail:10, val_zmalloc: 64 encoding raw
-APPEND s 10 字节 val_sds_len:60, val_sds_avail:0, val_zmalloc: 64
-APPEND s 1 字节 val_sds_len:61, val_sds_avail:63, val_zmalloc: 128
-SET big(2000000 字节) val_sds_len:2000000, val_sds_avail:31606, val_zmalloc: 2031616
-APPEND big 40000 字节 val_sds_len:2040000, val_sds_avail:1072950, val_zmalloc: 3112960
-SET 44 字节 encoding embstr,SET 45 字节 encoding raw
-```
-
-对应的源码在 sds.c 的 _sdsMakeRoomFor():剩余空间足够时直接返回;否则新长度小于 SDS_MAX_PREALLOC(1 MB)时乘以 2,不小于时加上 1 MB。SET 50 字节时的 10 字节、SET 大字符串时的 31606 字节剩余空间来自内存分配器的取整和协议缓冲区,不是预分配。
-
-### A7 热点计数
+### A6 热点计数
MySQL 8.0.44(innodb_flush_log_at_trx_commit 和 sync_binlog 都为 1),Redis 7.4.1,Apple M1 Pro。MySQL 用多个进程同时执行自动提交的 UPDATE counter SET cnt = cnt + 1 WHERE id = 1,最后核对 cnt;Redis 用 redis-benchmark 对同一个键执行 INCR:
@@ -859,6 +863,94 @@ $ redis-benchmark -p 6395 -c <连接数> -n 200000 --csv incr counter:1
50 个连接 "incr counter:1","101368.47",...
```
+### A7 Bitmap 的签到和日活
+
+Redis 7.4.1(内存分配器为 libc),键加了实验用的前缀,下面的输出中已去掉。内存是写入前后 INFO memory 中 used_memory 的差,服务器执行时间取自 SLOWLOG(slowlog-log-slower-than 设为 0)。
+
+签到的例子先对 1~27 号中签到的日期执行 SETBIT,再执行正文中的命令:
+
+```text
+> setbit sign:1001:202610 27 1
+0
+> setbit sign:1001:202610 27 1
+1
+> getbit sign:1001:202610 3
+0
+> bitcount sign:1001:202610
+23
+> bitpos sign:1001:202610 0
+3
+> bitfield sign:1001:202610 get u28 0
+251606975
+> strlen sign:1001:202610
+4
+> type sign:1001:202610
+string
+> setbit t 0 1 之后 get t
+"\x80"
+> setbit t 7 1 之后 get t
+"\x81"
+```
+
+每人每年一个键:对 10 万个键 sign:{用户 ID}:2026 各执行 SETBIT 364 1:
+
+```text
+STRLEN 46,MEMORY USAGE 136,10 万个键 used_memory 增加 15433696,平均每个键 154 字节
+```
+
+日活:用户 ID 为 1~100 万,模拟 10 月 22 日~28 日 7 天,每 10 个用户有 1 个每天都活跃,其余用户每天以 30% 的概率活跃,用 Python 集合计算的真实值核对:
+
+```text
+active:20261028 STRLEN 125001,MEMORY USAGE 131144,7 个键 used_memory 增加 903024
+BITCOUNT active:20261028 -> 369445(真实值 369445),服务器执行 15 us
+BITOP AND(7 个键) -> BITCOUNT 100184(真实值 100184),BITOP 服务器执行 84 us
+BITOP OR(7 个键) -> BITCOUNT 925445(真实值 925445)
+同一天的 369445 个用户 ID 用 SADD 写入 SET:used_memory 增加 14120832,编码 hashtable
+```
+
+用户 ID 稀疏:
+
+```text
+SETBIT offset 100000000:客户端耗时 1.3 ms,STRLEN 12500001,used_memory 增加 12501072
+SETBIT offset 4000000000:客户端耗时 99.4 ms,STRLEN 500000001,used_memory 增加 500007296
+ 对它执行 BITCOUNT:服务器执行 91777 us
+SETBIT offset 4294967295:客户端耗时 74.6 ms,STRLEN 536870912,used_memory 增加 536887680
+SETBIT offset 4294967296 -> ERR bit offset is not an integer or out of range
+1000 个随机 ID 分布在 1~1 亿:位图 used_memory 增加 12501056,SET 增加 30048(编码 hashtable)
+```
+
+### A8 HyperLogLog 的误差和内存
+
+Redis 7.4.1,向一个 HLL 写入 n 个不同的字符串 user:0~user:{n − 1}:
+
+```text
+1000 个不同元素:PFCOUNT 1007,误差 +0.70%,STRLEN 1910,MEMORY USAGE 2616
+100000 个不同元素:PFCOUNT 99471,误差 -0.53%,STRLEN 12304,MEMORY USAGE 12856
+1000000 个不同元素:PFCOUNT 999674,误差 -0.03%,STRLEN 12304,MEMORY USAGE 12856
+同样 100 万个 user:i 用 SET 保存:used_memory 增加 30080144
+```
+
+元素少时 STRLEN 为 1910 字节,是稀疏编码;转为密集编码后是 12288 字节的寄存器加 16 字节的头部。再写入 30 组、每组 100 万个不同的字符串:
+
+```text
+30 组误差(%) [1.41, 0.33, 0.19, -0.7, -0.19, -0.02, 0.84, 0.73, -1.02, -0.42, 0.59, -1.79, 0.32, -0.71, -0.48, -0.25, 0.25, 0.72, 0.57, 1.29, -0.35, 0.06, -0.17, 0.33, -0.63, 1.07, -0.37, 0.23, -1.05, -2.02]
+均方根 0.80%,绝对值最大 2.02%,绝对值不超过 0.81% 的组数 22
+```
+
+GEO 的键类型:
+
+```text
+> geoadd shops 120.1551 30.2741 shop:1 120.1620 30.2799 shop:2 120.2100 30.2460 shop:3
+3
+> type shops
+zset
+> geosearch shops fromlonlat 120.1560 30.2750 byradius 2 km asc withdist
+shop:1
+0.1321
+shop:2
+0.7932
+```
+
## 参考资料
- Carlson J L. Redis in Action [M]. Manning, 2013.
@@ -871,6 +963,12 @@ $ redis-benchmark -p 6395 -c <连接数> -n 200000 --csv incr counter:1
- [Key eviction | Redis](https://redis.io/docs/latest/develop/reference/eviction/)
- [EXPIRE | Redis](https://redis.io/docs/latest/commands/expire/)
- [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/)
+- [Redis bitmaps](https://redis.io/docs/latest/develop/data-types/strings/bitmaps/)
+- [SETBIT | Redis](https://redis.io/docs/latest/commands/setbit/)
+- [BITCOUNT | Redis](https://redis.io/docs/latest/commands/bitcount/)
+- [BITFIELD | Redis](https://redis.io/docs/latest/commands/bitfield/)
+- [HyperLogLog | Redis](https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/)
+- [GEOADD | Redis](https://redis.io/docs/latest/commands/geoadd/)
- [Redis: Scale with Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/)
- [High availability with Redis Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/)
- [Redis replication](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/)
@@ -880,3 +978,4 @@ $ redis-benchmark -p 6395 -c <连接数> -n 200000 --csv incr counter:1
- [Redis 7.4.1 源码 dict.c](https://github.com/redis/redis/blob/7.4.1/src/dict.c)
- [Redis 7.4.1 源码 t_zset.c](https://github.com/redis/redis/blob/7.4.1/src/t_zset.c)
- [Redis 7.4.1 源码 sds.c](https://github.com/redis/redis/blob/7.4.1/src/sds.c)
+- [Redis 7.4.1 源码 hyperloglog.c](https://github.com/redis/redis/blob/7.4.1/src/hyperloglog.c)
diff --git a/notes/Spring.md b/notes/Spring.md
index f0f478a3..775b7c3e 100644
--- a/notes/Spring.md
+++ b/notes/Spring.md
@@ -73,23 +73,6 @@ public class OrderService {
### Bean 的生命周期
-容器启动时,AbstractApplicationContext 的 refresh() 分两步创建 Bean。第一步解析配置:扫描 @Component,解析 @Bean 方法和自动配置类,把每个 Bean 的类名、作用域、依赖等信息保存为 BeanDefinition,这时还没有创建任何对象。这一步由 ConfigurationClassPostProcessor 完成,它是一个 BeanFactoryPostProcessor,这类扩展点在创建对象之前读取或修改 BeanDefinition。
-
-第二步在 finishBeanFactoryInitialization() 中,按 BeanDefinition 依次创建所有非懒加载的单例,创建过程依靠反射。在一个 Bean 的构造函数和带 @Autowired 的 Setter 方法中打印调用栈,按调用顺序整理如下(Spring Boot 3.3 实测,见[附录 A3](#a3-bean-创建的调用栈),最后一行 initializeBean 来自源码):
-
-```text
-AbstractApplicationContext.refresh()
-└─ finishBeanFactoryInitialization() → preInstantiateSingletons() 遍历 BeanDefinition
- └─ getBean() → doGetBean() 先查单例缓存,没有再创建
- └─ createBean() → doCreateBean()
- ├─ createBeanInstance() → Constructor.newInstance() 实例化:反射调用构造函数
- ├─ populateBean() → AutowiredAnnotationBeanPostProcessor
- │ .postProcessProperties() → Method.invoke() 属性填充:反射调用 Setter
- └─ initializeBean() Aware 回调、初始化、AOP 代理
-```
-
-@Autowired 加在字段上时,同样由 postProcessProperties() 处理,区别是用 Field.set() 反射赋值。
-
一个单例 Bean 从创建到销毁会经过以下步骤:
1. 实例化:调用构造函数创建对象;
@@ -116,6 +99,23 @@ AbstractApplicationContext.refresh()
11 destroy-method
```
+容器启动时,AbstractApplicationContext 的 refresh() 分两步创建 Bean。第一步解析配置:扫描 @Component,解析 @Bean 方法和自动配置类,把每个 Bean 的类名、作用域、依赖等信息保存为 BeanDefinition,这时还没有创建任何对象。这一步由 ConfigurationClassPostProcessor 完成,它是一个 BeanFactoryPostProcessor,这类扩展点在创建对象之前读取或修改 BeanDefinition。
+
+第二步在 finishBeanFactoryInitialization() 中,按 BeanDefinition 依次创建所有非懒加载的单例,创建过程依靠反射。在一个 Bean 的构造函数和带 @Autowired 的 Setter 方法中打印调用栈,按调用顺序整理如下(Spring Boot 3.3 实测,见[附录 A3](#a3-bean-创建的调用栈),最后一行 initializeBean 来自源码):
+
+```text
+AbstractApplicationContext.refresh()
+└─ finishBeanFactoryInitialization() → preInstantiateSingletons() 遍历 BeanDefinition
+ └─ getBean() → doGetBean() 先查单例缓存,没有再创建
+ └─ createBean() → doCreateBean()
+ ├─ createBeanInstance() → Constructor.newInstance() 实例化:反射调用构造函数
+ ├─ populateBean() → AutowiredAnnotationBeanPostProcessor
+ │ .postProcessProperties() → Method.invoke() 属性填充:反射调用 Setter
+ └─ initializeBean() Aware 回调、初始化、AOP 代理
+```
+
+@Autowired 加在字段上时,同样由 postProcessProperties() 处理,区别是用 Field.set() 反射赋值。
+
### Bean 的作用域与线程安全
Bean 默认是单例(singleton)的,整个容器中只有一个对象,所有请求共用它。其它常用的作用域:
diff --git a/notes/pics/bloom-filter.png b/notes/pics/bloom-filter.png
index d8d148ff..5b801c41 100644
Binary files a/notes/pics/bloom-filter.png and b/notes/pics/bloom-filter.png differ
diff --git a/notes/pics/circuit-breaker.png b/notes/pics/circuit-breaker.png
index 025bdec1..0af779b2 100644
Binary files a/notes/pics/circuit-breaker.png and b/notes/pics/circuit-breaker.png differ
diff --git a/notes/pics/csrf.png b/notes/pics/csrf.png
index 0a841bb1..cadd6640 100644
Binary files a/notes/pics/csrf.png and b/notes/pics/csrf.png differ
diff --git a/notes/pics/docker-build-cache.png b/notes/pics/docker-build-cache.png
new file mode 100644
index 00000000..5896b984
Binary files /dev/null and b/notes/pics/docker-build-cache.png differ
diff --git a/notes/pics/k8s-pod-create.png b/notes/pics/k8s-pod-create.png
new file mode 100644
index 00000000..3c17b867
Binary files /dev/null and b/notes/pics/k8s-pod-create.png differ
diff --git a/notes/pics/k8s-traffic.png b/notes/pics/k8s-traffic.png
new file mode 100644
index 00000000..19a01132
Binary files /dev/null and b/notes/pics/k8s-traffic.png differ
diff --git a/notes/pics/lru.png b/notes/pics/lru.png
index ddaf7fe5..18e31dbe 100644
Binary files a/notes/pics/lru.png and b/notes/pics/lru.png differ
diff --git a/notes/pics/paxos-example.png b/notes/pics/paxos-example.png
index 179cba73..e61a3ffe 100644
Binary files a/notes/pics/paxos-example.png and b/notes/pics/paxos-example.png differ
diff --git a/notes/pics/rate-limit.png b/notes/pics/rate-limit.png
index b5c608ad..1138ff63 100644
Binary files a/notes/pics/rate-limit.png and b/notes/pics/rate-limit.png differ
diff --git a/notes/pics/ratelimit-window.png b/notes/pics/ratelimit-window.png
new file mode 100644
index 00000000..38c10607
Binary files /dev/null and b/notes/pics/ratelimit-window.png differ
diff --git a/notes/pics/red-packet-flow.png b/notes/pics/red-packet-flow.png
new file mode 100644
index 00000000..7f441af1
Binary files /dev/null and b/notes/pics/red-packet-flow.png differ
diff --git a/notes/pics/redis-bitmap-sign.png b/notes/pics/redis-bitmap-sign.png
new file mode 100644
index 00000000..a04d344a
Binary files /dev/null and b/notes/pics/redis-bitmap-sign.png differ
diff --git a/notes/pics/saga.png b/notes/pics/saga.png
index 813b7fdf..84563f5b 100644
Binary files a/notes/pics/saga.png and b/notes/pics/saga.png differ
diff --git a/notes/pics/spring-circular.png b/notes/pics/spring-circular.png
index fd2cf777..dd4ea5da 100644
Binary files a/notes/pics/spring-circular.png and b/notes/pics/spring-circular.png differ
diff --git a/notes/pics/xxl-job-sharding.png b/notes/pics/xxl-job-sharding.png
new file mode 100644
index 00000000..79f9e3ed
Binary files /dev/null and b/notes/pics/xxl-job-sharding.png differ
diff --git a/notes/分布式.md b/notes/分布式.md
index 73f1978c..f55f0c2d 100644
--- a/notes/分布式.md
+++ b/notes/分布式.md
@@ -36,11 +36,19 @@
* [八、分布式 ID](#八分布式-id)
* [雪花算法](#雪花算法)
* [九、幂等](#九幂等)
+ * [十、分布式定时任务](#十分布式定时任务)
+ * [只执行一次](#只执行一次)
+ * [调度中心](#调度中心)
+ * [分片](#分片)
+ * [失败与宕机](#失败与宕机)
+ * [定时扫描与延迟消息](#定时扫描与延迟消息)
* [附录:实测](#附录实测)
* [A1 自增主键与 UUID 主键](#a1-自增主键与-uuid-主键)
* [A2 雪花算法](#a2-雪花算法)
* [A3 Redisson 的可重入与看门狗](#a3-redisson-的可重入与看门狗)
* [A4 重复提交与唯一索引](#a4-重复提交与唯一索引)
+ * [A5 定时任务的重复执行与加锁](#a5-定时任务的重复执行与加锁)
+ * [A6 分片与按分片重试](#a6-分片与按分片重试)
* [参考资料](#参考资料)
@@ -462,6 +470,8 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了
读请求则要另外处理:旧 Leader 如果直接用本地数据回答读请求,可能返回旧数据,所以 Raft 要求 Leader 在处理只读请求前,先与大多数节点交换一次心跳,确认自己仍是 Leader。另外,新 Leader 刚上任时还不知道哪些日志已经提交,要先提交一条当前任期的空日志,之后才能处理只读请求。
+需要注意的是,Leader 只对当前任期的日志按“大多数节点已写入”判断是否提交。之前任期留下的日志即使已经复制到大多数节点,也可能被之后的 Leader 覆盖,要等当前任期的一条日志提交后才随之提交(论文第 5.4.2 节和图 8)。这也是上文新 Leader 要先提交一条空日志的原因。
+
Raft 与 Paxos 达成的结果是等价的,区别在于对 Leader 的依赖程度:
- Paxos 中任何一个 Proposer 都可以发起提议,选出一个主 Proposer 只是性能上的优化(也用来避免上文的活锁)。Multi-Paxos 中日志的每个位置各自独立地确定,新的主节点还要先找回自己缺少的日志;
@@ -649,6 +659,89 @@ public synchronized long nextId() {
用 Token 实现时,Token 的过期时间按用户在页面上停留的时长设置,例如 30 分钟。DEL 返回 1 之后处理失败时,这个 Token 已经删除,客户端要重新获取 Token 再提交。
+## 十、分布式定时任务
+
+定时任务指的是按时间规则自动执行的任务,例如每月 1 日凌晨 2 点给所有用户发送上月的账单。只有一个实例时,用 Spring 的 `@Scheduled(cron = "0 0 2 1 * ?")` 就能实现。
+
+问题在于服务部署多个实例之后,每个实例都有自己的调度器,到点时各自执行一次。账单服务的两个实例 A、B 同时触发,100 个用户每人收到了两条账单短信(MySQL 8.0 实测,见[附录 A5](#a5-定时任务的重复执行与加锁))。多个实例定时扫描 [本地消息表](#本地消息表) 时也一样,同一条消息会被转发多次。
+
+分布式定时任务要解决三个问题:
+
+- 同一次触发只执行一次;
+- 数据量大时由多个实例分摊;
+- 执行失败或者实例宕机时能够补上。
+
+### 只执行一次
+
+最简单的做法是到点时所有实例都去抢一把 [分布式锁](#一分布式锁),抢到的执行,抢不到的直接跳过,不等待。
+
+锁的键要对应“这一次触发”,并且执行完也不删除,等它自然过期:
+
+```text
+SET lock:job:bill:2026-11 10.0.0.11 NX EX 3600
+```
+
+这是因为各实例的时钟存在偏差。如果用固定的键 lock:job:bill 并在执行完立即删除,任务只要几十毫秒,A 执行完删除了锁,时钟慢 300 ms 的 B 才触发,照样能拿到锁再发一遍;键中带上账单月份并且不删除,B 就拿不到锁(Redis 7.4 实测,各 3 轮,见[附录 A5](#a5-定时任务的重复执行与加锁))。给 @Scheduled 加锁的 ShedLock 库提供了 lockAtLeastFor(锁至少持有的时长),解决的也是这个问题。
+
+用数据库时,可以在执行记录表的 (job_name, fire_time) 上建唯一索引,插入成功的实例执行,报错 1062 的实例跳过,原理与 [数据库的唯一索引](#数据库的唯一索引) 相同,还顺便留下了执行记录。
+
+抢锁的做法不需要额外部署,一个实例宕机时其它实例照常触发。缺点是每次只有一个实例在工作;拿到锁的实例中途崩溃时,锁还没有过期,其它实例也不会接手;执行记录、失败告警和手动重跑都要自己实现。
+
+### 调度中心
+
+XXL-JOB 等框架把“什么时候触发”和“执行什么”拆开(以下以 XXL-JOB 3.5 的文档和源码为准):
+
+- 调度中心:独立部署,保存任务配置,按 Cron 表达式计算下次触发时间,到点时发出调度请求,本身不执行业务代码;
+- 执行器:嵌入在业务服务中,启动时按应用名(AppName)把自己的地址注册到调度中心,之后每 30 秒发一次心跳,超过 90 秒没有心跳就被移出注册表;收到调度请求后运行任务代码,结束后把结果回调给调度中心。
+
+调度请求、心跳和回调都是 HTTP 请求。
+
+调度中心本身也部署多个节点,它们共用一个 MySQL。每轮扫描前先用 SELECT ... FOR UPDATE 锁住 xxl_job_lock 表中的一行,拿到锁的节点才查询 5 秒内到期的任务,并在同一个事务中更新它们的下次触发时间。其它节点等事务提交后才能拿到锁,查到的已经是更新后的时间,所以同一次触发只会被发出一次。
+
+执行器有多个实例时,调度中心按任务的路由策略选择实例:第一个、轮询、一致性哈希、故障转移等策略每次只发给一个实例,分片广播则发给全部实例。需要注意的是,官方文档也提到集群中仍有小概率重复触发,任务本身还是要做到 [幂等](#九幂等)。
+
+### 分片
+
+只有一个实例执行时,100 万个用户的账单可能要发几个小时。分片指的是把数据按规则分成几份,每个实例只处理其中一份。
+
+XXL-JOB 的分片广播把一次触发发给执行器的每个在线实例,并带上分片序号 index(从 0 开始)和分片总数 total(在线实例数)。任务代码用 XxlJobHelper.getShardIndex() 和 getShardTotal() 取得这两个参数,按 ID 取模只查询自己的那一份:
+
+```sql
+SELECT id FROM users WHERE id % 2 = 0; -- 执行器 A,分片 0/2
+SELECT id FROM users WHERE id % 2 = 1; -- 执行器 B,分片 1/2
+```
+
+
+
+1 万个用户、每条短信模拟 1 ms 的外部调用时,单个实例用时 16.9 秒;两个实例各处理 5000 个,用时 8.8 秒,没有遗漏和重复(MySQL 8.0 实测,见[附录 A6](#a6-分片与按分片重试))。
+
+total 取的是触发时的在线实例数,扩容后下一次触发自动分成更多份。取模时每个分片都要扫描全部 ID,数据量很大时可以改为按 ID 范围分片,每个分片只扫描自己的区间。
+
+不用框架时,也可以由一个实例扫描出待处理的用户 ID,发送到 [消息队列](消息队列.md),由所有实例一起消费,适合每条数据处理较慢、需要削峰的场景。
+
+### 失败与宕机
+
+一次任务分为调度和执行两个阶段,失败的处理方式不同:
+
+- 调度时实例宕机:故障转移策略按顺序向各个实例发送心跳检测,把请求发给第一个响应的实例;
+- 执行失败:任务可以配置失败重试次数,调度中心发现执行失败后再触发一次。分片任务只重试失败的那一片,沿用原来的分片参数,例如 0/2;
+- 执行中宕机:执行器不会再回调结果,调度中心发现一条调度记录处于“运行中”超过 10 分钟、对应的执行器也已经不在线,就把它标记为失败,再按执行失败处理。
+
+单机的 @Scheduled 没有这些机制,触发时间只保存在进程的内存中,实例停机期间错过的触发不会补执行。
+
+重试的前提是任务可以重复执行。账单任务每发送一个用户就记录下来,例如在账单表的 (user_id, month) 上建唯一索引,重试时跳过已经发送过的用户,做法见 [幂等](#九幂等)。实例 A 发送 2000 条后崩溃,按分片参数 0/2 重试,只补发了剩下的 3000 条,1 万个用户每人一条(见[附录 A6](#a6-分片与按分片重试))。
+
+处理条件也要写成“所有待处理的数据”,例如关闭所有超时未支付的订单,而不是关闭上一分钟创建的订单,这样某一次执行失败,下一次触发也能补上。
+
+### 定时扫描与延迟消息
+
+定时任务适合按周期批量处理的场景,例如月度账单、日终对账、清理过期数据和补偿扫描。每条数据各有到期时间的场景,例如下单 30 分钟未支付自动关闭,定时扫描有两个问题:
+
+- 精度取决于扫描间隔:每分钟扫描一次,订单最晚要到第 31 分钟才关闭;
+- 扫描间隔越短,查询越频繁,而每次只有少量订单到期。
+
+这类场景更适合 [延迟消息](消息队列.md#延迟消息):下单时发送一条 30 分钟后投递的消息,到期只处理这一个订单。实际使用时常把两者结合,延迟消息负责按时关闭,低频的定时扫描兜底,处理消息丢失的订单。
+
## 附录:实测
环境为 Apple M1 Pro、macOS,MySQL 8.0.44,JDK 21。
@@ -730,6 +823,71 @@ MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orde
唯一索引 第 1~5 轮:同一个 request_id 并发提交 10 次,创建订单 1 个,其余 9 次报错 1062
```
+### A5 定时任务的重复执行与加锁
+
+Redis 7.4.1,MySQL 8.0.44,Python 3 + redis-py、pymysql。h3_users 中有 100 个用户,两个进程 A、B 模拟账单服务的两个实例,在同一时刻触发(B 的时钟慢 300 ms 时,B 晚 0.3 秒触发)。任务查询全部用户,每个用户向 h3_bill_sms 插入一条短信记录:
+
+```python
+if mode == 'release': # 固定的键,执行完删除
+ if not r.set('lock:job:bill', name, nx=True, px=60000): skip()
+elif mode == 'period': # 键中带账单月份,执行完不删除
+ if not r.set('lock:job:bill:2026-11', name, nx=True, ex=3600): skip()
+for uid in select_ids(): # SELECT id FROM h3_users
+ insert_sms(uid, '2026-11', name) # INSERT INTO h3_bill_sms (user_id, month, instance) ...
+if mode == 'release' and r.get('lock:job:bill') == name.encode():
+ r.delete('lock:job:bill')
+```
+
+每种情况运行 3 轮,结果相同(固定键、时钟一致时,拿到锁的实例各轮不同),下面是第 1 轮:
+
+```text
+== 不加锁,两个实例时钟一致
+ A 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
+ B 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
+ 短信 200 条,覆盖用户 100 个,收到两条及以上的用户 100 个
+== 固定键加锁、执行完删除,时钟一致
+ A 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
+ B 触发 +0.001s 未拿到锁,跳过
+ 短信 100 条,覆盖用户 100 个,收到两条及以上的用户 0 个
+== 固定键加锁、执行完删除,B 的时钟慢 300ms
+ A 触发 +0.000s 查到 100 人,发送 100 条,用时 0.02s
+ B 触发 +0.300s 查到 100 人,发送 100 条,用时 0.02s
+ 短信 200 条,覆盖用户 100 个,收到两条及以上的用户 100 个
+== 键中带账单月份、执行完不删除,B 的时钟慢 300ms
+ A 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
+ B 触发 +0.300s 未拿到锁,跳过
+ 短信 100 条,覆盖用户 100 个,收到两条及以上的用户 0 个
+```
+
+### A6 分片与按分片重试
+
+环境同 A5。h3_users 中有 1 万个用户,每发送一条短信先等待 1 ms,模拟调用短信服务。发送前先向 h3_bill 插入 (user_id, month),它是主键,插入失败说明已经发送过,跳过这个用户:
+
+```python
+for uid in select_ids(total, index): # SELECT id FROM h3_users WHERE id % total = index
+ if execute('INSERT IGNORE INTO h3_bill (user_id, month) VALUES (%s, %s)', (uid, '2026-11')) == 0:
+ continue
+ time.sleep(0.001)
+ insert_sms(uid, '2026-11', name)
+```
+
+这个写法在插入之后、发送之前崩溃时会漏发一个用户,实验中的崩溃点在发送之后。
+
+```text
+== 单个实例处理全部用户
+ A 分片 0/1 查到 10000 人,发送 10000 条,用时 16.94s
+== 两个实例按 id % 2 分片
+ A 分片 0/2 查到 5000 人,发送 5000 条,用时 8.75s
+ B 分片 1/2 查到 5000 人,发送 5000 条,用时 8.76s
+ 短信 10000 条,覆盖用户 10000 个,收到两条及以上的用户 0 个
+== 分片 0 发送 2000 条后崩溃(os._exit),之后按原分片参数 0/2 重试
+ A 分片 0/2 发送 2000 条后崩溃
+ B 分片 1/2 查到 5000 人,发送 5000 条,用时 8.72s
+ 短信 7000 条,覆盖用户 7000 个,收到两条及以上的用户 0 个
+ A2 分片 0/2 查到 5000 人,发送 3000 条,用时 4.92s
+ 重试后 短信 10000 条,覆盖用户 10000 个,收到两条及以上的用户 0 个
+```
+
## 参考资料
- [Apache Dubbo: Architecture](https://dubbo.apache.org/docs/v2.7/user/preface/architecture/)
@@ -765,3 +923,6 @@ MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orde
- [NEAT ALGORITHMS - PAXOS](https://web.archive.org/web/20260307090655/http://harry.me/blog/2014/12/27/neat-algorithms-paxos/)
- [In Search of an Understandable Consensus Algorithm](https://raft.github.io/raft.pdf)
- [Leaf——美团点评分布式 ID 生成系统](https://tech.meituan.com/2017/04/21/mt-leaf.html)
+- [分布式任务调度平台 XXL-JOB](https://www.xuxueli.com/xxl-job/)
+- [XXL-JOB 源码:JobScheduleHelper](https://github.com/xuxueli/xxl-job/blob/3.5.0/xxl-job-admin/src/main/java/com/xxl/job/admin/business/scheduler/thread/JobScheduleHelper.java)
+- [ShedLock](https://github.com/lukas-krecan/ShedLock)
diff --git a/notes/消息队列.md b/notes/消息队列.md
index 0dc4bdbf..2d38ea69 100644
--- a/notes/消息队列.md
+++ b/notes/消息队列.md
@@ -19,9 +19,9 @@
* [死信队列](#死信队列)
* [七、Kafka](#七kafka)
* [分区与消费者组](#分区与消费者组)
+ * [副本与可靠性](#副本与可靠性)
* [存储](#存储)
* [为什么吞吐量高](#为什么吞吐量高)
- * [副本与可靠性](#副本与可靠性)
* [附录:实测](#附录实测)
* [A1 批量对吞吐量的影响](#a1-批量对吞吐量的影响)
* [A2 重复消费](#a2-重复消费)
@@ -234,6 +234,37 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(
消费者加入、退出或者分区数变化时,组内会重新分配分区,称为重平衡(Rebalance)。重平衡期间被重新分配的分区暂停消费,还可能造成重复消费。一个常见的原因是处理一批消息的时间超过了 max.poll.interval.ms(默认 5 分钟),消费者被认为已经失效,它的分区被分给其他消费者。
+### 副本与可靠性
+
+每个分区有多个副本,分布在不同的 broker 上,其中一个是 Leader,负责处理读写,其余的 Follower 不断从 Leader 拉取数据。与 Leader 保持同步的副本集合称为 ISR(In-Sync Replicas),Follower 超过 replica.lag.time.max.ms(默认 30 秒)没有追上 Leader 就会被移出 ISR。Kafka 4.0 起完全移除了 ZooKeeper,Leader 选举等元数据由内置的 KRaft 控制器管理,它基于 Raft 协议实现。
+
+例如创建 3 个分区、3 个副本的主题,然后用 kill -9 停掉 broker 3(3 个节点的 KRaft 集群实测):
+
+```text
+停止前
+Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1
+Partition: 1 Leader: 3 Replicas: 3,1,2 Isr: 3,1,2
+Partition: 2 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
+停止后
+Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,1
+Partition: 1 Leader: 1 Replicas: 3,1,2 Isr: 1,2
+Partition: 2 Leader: 1 Replicas: 1,2,3 Isr: 1,2
+```
+
+分区 1 的 Leader 从 broker 3 切换到了 ISR 中的 broker 1,其它分区只是 ISR 中少了 3。broker 3 重启并追上数据后,又回到了 ISR 中。
+
+生产者的 acks 参数决定写入多少个副本才算成功:
+
+- acks=0:不等待确认,可能丢消息;
+- acks=1:Leader 写入就返回,Leader 宕机而 Follower 还没同步时会丢消息;
+- acks=all:ISR 中的副本都写入才返回,Kafka 3.0 起是默认值。
+
+acks=all 要配合 min.insync.replicas 使用,否则 ISR 只剩 Leader 一个时,acks=all 就退化成了 acks=1。min.insync.replicas 设为 2 时,ISR 少于 2 个副本,broker 就拒绝 acks=all 的写入。上面停掉 broker 3 之后 ISR 还有 2 个副本,写入正常;把 min.insync.replicas 改为 3 再写入,生产者重试几次后报错 NotEnoughReplicasException,改用 acks=1 则仍然能写入。常用的配置是 3 个副本、min.insync.replicas=2、acks=all,一个副本故障时既不丢消息,也不停止写入。
+
+生产者没有收到确认就会重试,这可能让同一条消息写入两次。Kafka 3.0 起默认开启幂等生产者:broker 给每个生产者分配一个 ID,生产者给发往每个分区的消息编上序号,broker 发现序号重复就丢弃。后文查看 .log 文件时,每个批次都带有 producerId: 1001 和 baseSequence 字段。幂等只保证单个分区内不重复;要让写入多个分区的消息同时成功或失败,需要使用事务,消费者设置 isolation.level=read_committed 后只读取已提交事务中的消息。
+
+消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息(见[附录 A2](#a2-重复消费))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
+
### 存储
每个分区在磁盘上是一个目录,日志按大小切分成多个段(Segment),文件名是段中第一条消息的偏移量。例如把段的大小设为 1 MB(默认 1 GB),向一个只有一个分区的主题写入 3 万条 100 字节的消息后,目录中的文件和大小如下(Kafka 4.4 实测,省略了 .timeindex 等文件):
@@ -275,37 +306,6 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(
分区并行:一个主题的多个分区分布在不同的 broker 上,读写由多台机器共同承担。
-### 副本与可靠性
-
-每个分区有多个副本,分布在不同的 broker 上,其中一个是 Leader,负责处理读写,其余的 Follower 不断从 Leader 拉取数据。与 Leader 保持同步的副本集合称为 ISR(In-Sync Replicas),Follower 超过 replica.lag.time.max.ms(默认 30 秒)没有追上 Leader 就会被移出 ISR。Kafka 4.0 起完全移除了 ZooKeeper,Leader 选举等元数据由内置的 KRaft 控制器管理,它基于 Raft 协议实现。
-
-例如创建 3 个分区、3 个副本的主题,然后用 kill -9 停掉 broker 3(3 个节点的 KRaft 集群实测):
-
-```text
-停止前
-Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1
-Partition: 1 Leader: 3 Replicas: 3,1,2 Isr: 3,1,2
-Partition: 2 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
-停止后
-Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,1
-Partition: 1 Leader: 1 Replicas: 3,1,2 Isr: 1,2
-Partition: 2 Leader: 1 Replicas: 1,2,3 Isr: 1,2
-```
-
-分区 1 的 Leader 从 broker 3 切换到了 ISR 中的 broker 1,其它分区只是 ISR 中少了 3。broker 3 重启并追上数据后,又回到了 ISR 中。
-
-生产者的 acks 参数决定写入多少个副本才算成功:
-
-- acks=0:不等待确认,可能丢消息;
-- acks=1:Leader 写入就返回,Leader 宕机而 Follower 还没同步时会丢消息;
-- acks=all:ISR 中的副本都写入才返回,Kafka 3.0 起是默认值。
-
-acks=all 要配合 min.insync.replicas 使用,否则 ISR 只剩 Leader 一个时,acks=all 就退化成了 acks=1。min.insync.replicas 设为 2 时,ISR 少于 2 个副本,broker 就拒绝 acks=all 的写入。上面停掉 broker 3 之后 ISR 还有 2 个副本,写入正常;把 min.insync.replicas 改为 3 再写入,生产者重试几次后报错 NotEnoughReplicasException,改用 acks=1 则仍然能写入。常用的配置是 3 个副本、min.insync.replicas=2、acks=all,一个副本故障时既不丢消息,也不停止写入。
-
-生产者没有收到确认就会重试,这可能让同一条消息写入两次。Kafka 3.0 起默认开启幂等生产者:broker 给每个生产者分配一个 ID,生产者给发往每个分区的消息编上序号,broker 发现序号重复就丢弃。前面查看 .log 文件时,每个批次都带有 producerId: 1001 和 baseSequence 字段。幂等只保证单个分区内不重复;要让写入多个分区的消息同时成功或失败,需要使用事务,消费者设置 isolation.level=read_committed 后只读取已提交事务中的消息。
-
-消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息(见[附录 A2](#a2-重复消费))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
-
## 附录:实测
环境为 Apple M1 Pro、macOS,Kafka 4.4.0 的 3 个节点的 KRaft 集群运行在同一台机器上(端口 9091~9093,命令中的 $B 为这三个地址)。
diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md
index ffa828dc..178d0e64 100644
--- a/notes/系统设计基础.md
+++ b/notes/系统设计基础.md
@@ -113,14 +113,83 @@
### 限流、降级与熔断
-- 限流:请求量超过系统的处理能力时,拒绝多余的请求,常用令牌桶和漏桶算法;
+- 限流:请求量超过系统的处理能力时,拒绝多余的请求;
- 降级:主动关闭部分非核心功能,从而保证核心功能可用;
- 熔断:某个依赖的服务持续出错时,暂时不再调用它而是直接返回默认结果,避免故障沿着调用链扩散。
-漏桶以固定速率处理请求,桶满时丢弃新的请求;令牌桶以固定速率放入令牌,请求拿到令牌才能被处理,桶中积累的令牌允许一定的突发流量。
+#### 1. 限流算法
+
+限流要判断的是:最近一段时间已经放行了多少请求,下一个请求还能不能放行。下面都以“每个用户每秒最多下单 5 次”为例。
+
+固定窗口(计数器)把时间切成 1 秒的窗口,每个窗口一个计数器,超过 5 次就拒绝。用 Redis 实现时,键名带上用户和秒数,例如 order:limit:1001:1700000001,INCR 返回 1 时再设置过期时间,Redis 文档中 INCR 一节给出了这种做法。
+
+固定窗口的问题在窗口边界。0.9 秒和 1.1 秒各来 5 个请求,分别落在两个窗口中,都能通过,0.24 秒内放行了 10 次,是上限的 2 倍。
+
+滑动窗口统计的是“当前时刻往前 1 秒”内放行的请求数,窗口随时间移动。可以用有序集合(ZSET)记录每个放行的请求:成员是唯一的请求 ID,分值是毫秒时间戳;新请求到来时,先删除 1 秒以前的记录,剩下的不足 5 条才放行并写入。
+
+同样的请求,1.1 秒时往前 1 秒已有 5 次,后 5 个请求被拒绝;1.95 秒时 0.9 秒的记录已经移出窗口,请求通过(Redis 7.4 实测,见[附录 A13](#a13-限流算法)):
+
+
+
+滑动窗口的代价是每个放行的请求都要存一条记录。上限为每秒 1000 次时,一个用户的 ZSET 约占 100 KB,而固定窗口的计数器只占 56 字节。折中的做法是把 1 秒分成若干小格,每格一个计数器,把落在窗口内的格子相加,格子越多越精确。
+
+漏桶把请求放进队列,以固定速率取出处理,例如每 200 毫秒一个,队列满了再来的请求被拒绝。它的输出最平滑,但突发的请求要排队等待。Nginx 的 limit_req 就是漏桶,burst 是允许排队的请求数,加上 nodelay 后 burst 以内的请求不再排队,而是立即处理。
+
+令牌桶以固定速率放入令牌,例如每秒 5 个,桶中最多存 5 个,请求取到令牌才放行。空闲一段时间后桶是满的,可以一次放行 5 个请求,之后每 200 毫秒放行一个。
+令牌桶不需要定时放入令牌,只要记下剩余的令牌数和上次更新的时间,请求到来时补上这段时间应放入的令牌,再判断够不够。Guava 的 RateLimiter 和 Spring Cloud Gateway 的 Redis 限流器都是这样实现的。RateLimiter 的 acquire() 在令牌不足时让调用线程等待,tryAcquire() 则立即返回 false(Guava 33.4 实测,见[附录 A14](#a14-guava-的-ratelimiter))。
+
+四种算法的对比:
+
+| 算法 | 突发请求 | 需要保存的数据 | 适用场景 |
+| --- | --- | --- | --- |
+| 固定窗口 | 窗口边界处最多放行 2 倍 | 每个窗口一个计数器 | 精度要求不高的限制,例如每天的调用次数 |
+| 滑动窗口 | 任意 1 秒内都不超过上限 | 每个请求一条记录,或分格计数 | 按用户、按接口的精确限制 |
+| 漏桶 | 排队,按固定速率处理 | 请求队列 | 保护处理能力固定的下游,例如调用外部接口 |
+| 令牌桶 | 允许不超过桶容量的突发 | 令牌数和时间戳 | 允许短时突发的接口限流 |
+
+#### 2. 分布式限流
+
+服务部署了多个实例时,只在每个实例的内存中计数是不够的:4 个实例各限 5 次/秒,总量就变成 20 次/秒,扩容后还会变化;同一个用户的请求被分到不同实例,单个实例看到的次数也不准。因此要把计数放到所有实例共享的 Redis 中。
+
+判断和扣减必须是原子的,否则两个请求可能同时读到还剩 1 个令牌,都被放行。做法是用 Lua 脚本,令牌数和更新时间存在一个哈希表中:
+
+```lua
+-- KEYS[1] 限流键;ARGV:每秒放入的令牌数、桶的容量、当前时间(毫秒)
+local rate = tonumber(ARGV[1])
+local capacity = tonumber(ARGV[2])
+local now = tonumber(ARGV[3])
+local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
+local tokens = tonumber(b[1]) or capacity
+local ts = tonumber(b[2]) or now
+-- 不用定时放令牌,按距离上次请求的时间补上这段时间应放入的令牌
+tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate / 1000)
+local allowed = 0
+if tokens >= 1 then
+ tokens = tokens - 1
+ allowed = 1
+end
+redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
+redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) * 2)
+return allowed
+```
+
+调用时传入用户的限流键、每秒 5 个、容量 5 和当前的毫秒时间。同一时刻连续 7 个请求,前 5 个通过、后 2 个被拒绝;400 毫秒后补充了 2 个令牌,3 个请求中通过 2 个;空闲 10 秒后,令牌也只补到容量 5 个(见[附录 A13](#a13-限流算法))。
+
+分布式限流还要考虑以下几点:
+
+- 每次判断都多一次 Redis 往返,并且一个限流键只落在一个 Redis 节点上,一个键每秒约能判断 9 万~10 万次(Redis 7.4 实测,见[附录 A13](#a13-限流算法));全局总量的限流超过这个量级时,可以把令牌分到多个键,或者让每个实例一次领取一批令牌在本地使用;
+- 时间由各个实例传入时,实例之间的时钟要同步,也可以在脚本中用 Redis 的 TIME 命令取时间;
+- Redis 不可用时,可以退回到每个实例自己的单机限流,避免因为限流组件故障而拒绝所有请求。
+
+限流通常放在多个位置:网关按 IP、用户或接口限流,挡住大部分突发和恶意的流量;服务内部按接口或热点参数限流,例如 Sentinel 可以按 QPS 或并发线程数设置阈值,超过后直接拒绝、预热(Warm Up)或者匀速排队。阈值来自压测得到的容量,并留出余量,见[压测与容量规划](#压测与容量规划)。
+
+被限流的请求返回 429 Too Many Requests,可以带上 Retry-After 首部告诉客户端多久之后重试(RFC 6585)。
+
+#### 3. 熔断
+
熔断器有关闭、打开和半开三种状态:失败率超过阈值时打开;打开一段时间后进入半开状态,放行少量请求试探,成功则关闭,失败则重新打开。
@@ -171,6 +240,120 @@ UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账。
+### 同类场景:抢券、抢红包与抽奖
+
+抢券、抽奖和抢红包也是大量用户在同一时刻争抢有限的资源,上面的限流、Redis 预扣、异步落库和数据库兜底都可以直接套用。下面只讲它们各自不同的部分。
+
+#### 1. 抢券
+
+券的发放总量就是库存,预扣的 Lua 脚本与秒杀基本相同,区别主要在限领和使用。
+
+每人限领 N 张时,把脚本中的已购用户集合换成 Hash(用户 ID → 已领张数),已领张数小于 N 才扣库存并 HINCRBY,返回值就是“第几张”。例如券 501 发 100 张、每人限领 2 张,80 个用户各并发请求 5 次:恰好发出 100 张,50 个用户各领到 2 张,没有人超过 2 张(Redis 7.4 实测,见[附录 A17](#a17-抢券的限领与核销))。领取的人很多时这个 Hash 会成为[大 Key](缓存.md#大-key-与热-key),活动结束后要设置过期时间或用 UNLINK 删除。
+
+领到的券写入用户券表,以“第几张”作为 seq,在(user_id, coupon_id, seq)上建唯一索引,消息重复投递时插入失败,不会多发:
+
+```sql
+CREATE TABLE user_coupon (
+ id BIGINT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ coupon_id BIGINT NOT NULL, -- 券的模板,例如 501:满 100 减 20
+ seq TINYINT NOT NULL, -- 该用户领的第几张
+ status TINYINT NOT NULL, -- 0 未使用,1 已锁定,2 已使用
+ order_id BIGINT,
+ valid_end DATETIME NOT NULL, -- 有效期,例如领取后 7 天
+ UNIQUE KEY uk_user_coupon (user_id, coupon_id, seq)
+);
+```
+
+用户查询“我的券”时按 user_id 查询,因此这张表分库分表时按 user_id 切分。
+
+券的有效期写在每条用户券上,是否过期在使用时按 valid_end 判断,不依赖定时任务或延迟消息把状态改为“已过期”。这类任务只用于提醒和展示,即使延迟或积压,也不会让过期的券被用掉。
+
+核销分两步:下单时锁定,支付成功后改为已使用。锁定用条件更新,影响行数为 1 才算成功,同一张券因此只能被一个订单锁定;订单取消或超时未支付时,再把状态改回未使用:
+
+```sql
+UPDATE user_coupon SET status = 1, order_id = 9001
+WHERE id = 70001 AND user_id = 1001 AND status = 0 AND valid_end > NOW();
+```
+
+#### 2. 抽奖
+
+抽奖比抢券多了一步:先按概率决定抽中哪个奖品,再扣这个奖品的库存。
+
+概率一般配置为权重。例如一等奖 1%(库存 10 个)、二等奖 9%(库存 500 个)、三等奖 30%(不限量)、谢谢参与 60%,按 10000 份累加成区间 [0, 100)、[100, 1000)、[1000, 4000)、[4000, 10000)。服务端生成 0~9999 的随机数,落在哪个区间就是哪个奖品;奖品多时可以对区间二分查找,或者预先展开成 10000 格的数组直接按下标取,用空间换时间。
+
+抽中限量的奖品后,再用与秒杀相同的 Lua 脚本扣减这个奖品的库存。扣减失败说明奖品已经发完,这时应降级为“谢谢参与”或兜底奖品,而不是重新抽一次。这是因为重新抽相当于把一等奖的 1% 按比例分给其它奖品,二等奖的实际概率会从 9% 变为 9 ÷ 99 ≈ 9.09%,发出的奖品超出预算。
+
+每人每天的抽奖次数可以在 Redis 中用 INCR 计数并按天设置过期时间,返回值超过上限就拒绝。“每人最多中一次大奖”这类限制也按降级处理,因此实际中奖率会低于配置值,上线前可以用大量模拟统计各奖品的频率来核对。
+
+#### 3. 抢红包
+
+抢红包与秒杀的区别在于每一份的金额不同:发红包时要把总金额拆成若干份,抢的时候每人拿走一份。下面以用户 1001 在群里发的拼手气红包 8001 为例,共 100 元、10 个。
+
+##### 3.1 金额的拆分
+
+金额用整数的分表示,避免浮点数的误差。常用的拆分方法是二倍均值法:剩余 R 分、还有 k 份时,本份在 1 到 2R ÷ k − 1 分(除法向下取整)之间均匀随机,最后一份拿走剩余的全部金额。例如第一份在 1~1999 分之间,即 0.01~19.99 元。
+
+上限比剩余平均值的 2 倍少 1 分,保证拿走本份之后,剩下的每一份至少还有 1 分。均匀随机的均值约等于当时的剩余平均值,所以每个位置的期望都是 10 元,先抢和后抢的期望相同;但越靠后的位置可取的范围越大,波动也越大。
+
+对这个红包模拟拆分 10 万次,每次的总额都是 100.00 元、每份至少 0.01 元,各位置的统计如下(单位:元,见[附录 A15](#a15-红包金额的拆分)):
+
+```text
+位置 1 2 3 4 5 6 7 8 9 10
+平均值 10.02 10.01 9.95 10.02 9.99 9.98 10.00 10.01 9.99 10.03
+标准差 5.77 5.80 5.89 5.97 6.06 6.20 6.41 6.84 7.69 7.71
+最小值 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01 0.01
+最大值 19.99 22.19 24.57 27.70 31.79 34.46 39.05 45.85 60.50 68.33
+```
+
+##### 3.2 预先拆分与实时计算
+
+拆分的时机有两种:
+
+- 预先拆分:发红包时就拆成 10 份,用 RPUSH 放入 Redis 列表,抢的时候 LPOP 一份。抢的路径只有一次弹出,金额在发的时候就已确定,可以随红包一起落库备查;代价是每一份都要占用内存,份数多时更明显;
+- 实时计算:Redis 中只保存剩余金额和剩余个数,抢的时候在 Lua 脚本中按二倍均值法算出本份金额,再更新这两个值。占用的内存固定,但每次领取都要在脚本中计算。Redis 7 中脚本的随机数生成器每次调用都会随机设置种子,可以直接调用 math.random。
+
+两种做法用的是同一个拆分方法,金额的分布相同,在 Lua 脚本中执行时也都不会超发。红包的份数通常不多,预先拆分的实现更简单。
+
+##### 3.3 并发领取与落库
+
+
+
+领取用下面的 Lua 脚本,判断是否抢过、弹出一份、记录领取者三步作为一个整体执行:
+
+```lua
+-- KEYS[1]:金额列表,KEYS[2]:领取记录(Hash,用户 ID → 金额),ARGV[1]:用户 ID
+if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then
+ return -1 -- 已经抢过
+end
+local amount = redis.call('LPOP', KEYS[1])
+if not amount then
+ return 0 -- 已抢完
+end
+redis.call('HSET', KEYS[2], ARGV[1], amount)
+return tonumber(amount)
+```
+
+列表中只有 10 份,LPOP 每次弹出一份,最多 10 个请求成功,发出的总额不会超过 100 元。每人只能抢一次则依赖脚本的原子性:如果在应用中依次执行 HEXISTS、LPOP、HSET,同一个用户的两个并发请求可能都通过判断,各弹出一份。
+
+例如 100 个用户各并发请求 2 次:使用脚本时恰好 10 人各抢到一份,合计 100.00 元;分成三条命令执行 3 次,分别有 4、3、4 个用户抢到了两份,而领取记录被 HSET 覆盖,只剩 6、7、6 条,发出的金额与记录对不上(Redis 7.4 实测,见[附录 A16](#a16-并发抢红包与异步落库))。
+
+同一个红包的键名都带上 {8001} 这样的哈希标签,使它们落在 Redis 集群的同一个槽中,脚本才能同时操作;不同的红包则分散到不同的节点。
+
+抢红包是写密集的场景,每抢到一份都要写一条记录、修改一次剩余金额。这些写入由 Redis 先承接,脚本返回成功后再发送消息,由消费者在一个事务中写入数据库:
+
+```sql
+INSERT INTO red_packet_grab (packet_id, user_id, amount) VALUES (8001, 7, 276);
+UPDATE red_packet SET remain_amount = remain_amount - 276, remain_num = remain_num - 1
+WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
+```
+
+领取记录表在(packet_id, user_id)上有唯一索引,重复投递的消息插入失败,整个事务回滚,剩余金额不会被扣两次(见[接收端的可靠性](消息队列.md#接收端的可靠性));UPDATE 的条件是数据库这一层的兜底。
+
+应用在脚本执行成功之后、消息发出之前宕机时,这条领取只记录在 Redis 中。因此红包抢完或过期后,要用 Redis 中的领取记录与数据库对账,补发缺失的消息。例如每条消息都投递两次、其中一个用户的消息全部丢失:18 条消息插入 9 条、冲突 9 条,对账补发后剩余金额为 0、领取记录 10 条,合计正好 100 元(MySQL 8.0 实测,见[附录 A16](#a16-并发抢红包与异步落库))。
+
+红包一般有有效期,例如 24 小时。到期后先完成对账,再把数据库中的剩余金额退回给发送者。
+
## 七、海量数据处理
这类问题的特点是数据量远大于内存,例如“100 GB 的文件,内存只有 4 GB”。常用的思路是分而治之:先把数据切成能放进内存的小块,分别处理后再合并结果。
@@ -297,7 +480,7 @@ p20 p19 p18 ... p11
容量要按峰值计算,不能按平均值。平时的峰值约是平均值的 1.4 倍,大促峰值则约是平均值的 14 倍。
-10 万 QPS 直接落到数据库上风险很大。本机回环实测的 MySQL 主键点查在 16 至 128 个连接时达到 17 万至 19 万 QPS(见[附录 A4](#a4-mysql-主键点查的并发扫描)),但这是客户端与服务端同机、没有网络开销的结果;生产环境经过网络和其它负载后的上限会低很多。因此需要在数据库之前挡住绝大部分请求。
+10 万 QPS 直接落到数据库上风险很大。实测的 MySQL 主键点查在 16 至 128 个连接时达到 17 万至 19 万 QPS(见[附录 A4](#a4-mysql-主键点查的并发扫描)),但这是客户端与服务端同机、没有网络开销的结果;生产环境经过网络和其它负载后的上限会低很多。因此需要在数据库之前挡住绝大部分请求。
### 读路径的逐层分流
@@ -313,7 +496,7 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
#### 3. Redis
-商品详情、价格和库存分别存放在 Redis 的键中,用一次 MGET 取回。Redis 的请求量为 2 万 QPS,约为本机 GET 实测中位数(12.5 万 QPS,见[附录 A7](#a7-redis-get-的并发扫描))的 1/6。假设 98% 的请求命中,回源到数据库的请求为 400 QPS。
+商品详情、价格和库存分别存放在 Redis 的键中,用一次 MGET 取回。Redis 的请求量为 2 万 QPS,约为单实例 GET 实测中位数(12.5 万 QPS,见[附录 A7](#a7-redis-get-的并发扫描))的 1/6。假设 98% 的请求命中,回源到数据库的请求为 400 QPS。
#### 4. 数据库
@@ -323,7 +506,7 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
命中率的影响比想象中大。Redis 命中率从 98% 降到 90%,回源 QPS 从 400 增至 2000。
-百万 QPS 时,若命中率保持不变,进入 Redis 的请求增至 20 万 QPS。按每个分片约 4 万 QPS 的规划值(假设为本机实测的约 1/3)计算,需要 5 个分片;回源到数据库的请求只增至 4000 QPS。因此高并发读的扩展主要落在缓存层和应用层的水平扩展上,而不是升级单台数据库。
+百万 QPS 时,若命中率保持不变,进入 Redis 的请求增至 20 万 QPS。按每个分片约 4 万 QPS 的规划值(假设为上述实测值的约 1/3)计算,需要 5 个分片;回源到数据库的请求只增至 4000 QPS。因此高并发读的扩展主要落在缓存层和应用层的水平扩展上,而不是升级单台数据库。
### 写路径:库存与下单
@@ -333,9 +516,9 @@ CDN 边缘节点缓存商品基础信息的 JSON,存活时间(Time To Live,
InnoDB 使用行级锁,并采用两阶段锁(Two-Phase Locking, 2PL),事务持有的行锁一般要到提交或回滚时才释放([InnoDB Transaction Model](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-model.html))。因此同一行的更新只能串行执行,单行的吞吐量约为 1 ÷ 锁持有时间。
-本机实测验证了这一点:对同一行执行自动提交的 UPDATE,吞吐量在 1 到 64 个并发之间稳定在 0.8 万至 1 万 TPS,不随并发增加;分散到 100 行后,64 个并发达到 3.4 万 TPS(见[附录 A5](#a5-热点行与分散行的-update))。
+实测验证了这一点:对同一行执行自动提交的 UPDATE,吞吐量在 1 到 64 个并发之间稳定在 0.8 万至 1 万 TPS,不随并发增加;分散到 100 行后,64 个并发达到 3.4 万 TPS(见[附录 A5](#a5-热点行与分散行的-update))。
-本机回环上单条查询的往返约为 0.03 ms(见[附录 A6](#a6-本机单条查询的往返延迟)),生产环境的事务内则有多次网络往返。假设同机房往返为 0.5 ms,事务内有 3 次往返(查询并加锁、更新库存、插入订单),锁持有约 1.6 ms,单行上限约为 625 TPS,低于 800 QPS。解决办法是拆分库存:把 1 万件分成 10 行,每行 1000 件,每行约 80 QPS,远低于上限。
+客户端与服务端在同一台机器上时,单条查询的往返约为 0.03 ms(见[附录 A6](#a6-本机单条查询的往返延迟)),生产环境的事务内则有多次网络往返。假设同机房往返为 0.5 ms,事务内有 3 次往返(查询并加锁、更新库存、插入订单),锁持有约 1.6 ms,单行上限约为 625 TPS,低于 800 QPS。解决办法是拆分库存:把 1 万件分成 10 行,每行 1000 件,每行约 80 QPS,远低于上限。
拆分后,某一行售罄而其它行仍有库存时,要换一行重试,并定期把库存在各行之间调拨:
@@ -384,7 +567,7 @@ Google SRE 对过载的建议是先返回降级的响应,即数据更少、计
拐点的位置随环境波动。同一台机器上的三轮测试,数值并不完全一致:第一轮 16 个连接时已达到 9.5 万 QPS,200 个连接时的 P99 为 7.19 ms,而第二、三轮约为 2 ms。因此压测要重复多次并取中位数,只在相同规格的环境中比较结果。Redis 文档也指出,逐条同步发送命令测的是网络和客户端的延迟,要测服务端本身需要多个连接([Redis Benchmark](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/benchmarks/))。
-数据库也有类似的拐点。MySQL 主键点查在 16 个连接时为 16.9 万 QPS,128 个连接时为 19.2 万 QPS,吞吐量只增加 14%,平均响应时间却从 0.095 ms 增至 0.668 ms(见[附录 A4](#a4-mysql-主键点查的并发扫描))。本机压测的客户端与服务端共用 10 个核心,吞吐量很快接近这台机器的上限,之后的并发只增加排队时间。热点行的更新则从一开始就没有并发收益。
+数据库也有类似的拐点。MySQL 主键点查在 16 个连接时为 16.9 万 QPS,128 个连接时为 19.2 万 QPS,吞吐量只增加 14%,平均响应时间却从 0.095 ms 增至 0.668 ms(见[附录 A4](#a4-mysql-主键点查的并发扫描))。压测时客户端与服务端在同一台机器上,共用 10 个核心,吞吐量很快接近这台机器的上限,之后的并发只增加排队时间。热点行的更新则从一开始就没有并发收益。
并发数 = 吞吐量 × 响应时间,这一关系即 Little 定律(Little's law),见[性能指标](#性能指标)。它可以用来估算同时在处理的请求数。例如 10 万 QPS、平均响应时间 5 ms(假设)时,系统中约有 500 个请求同时在处理。单台实例按 1200 QPS 计算时,同时处理约 6 个请求,线程池和连接池要在此基础上留出余量。
@@ -453,7 +636,7 @@ Google SRE 的经验是用 CPU 等资源而不是 QPS 作为容量的度量,
秒传指的是服务端已经有内容相同的文件时,客户端不需要上传任何分片,直接建立引用。判断依据是文件的内容哈希:客户端在初始化前计算整文件的 SHA-256,服务端在文件表中查到相同的哈希,就直接返回文件已存在。
-秒传的代价在于先要读完整个文件来计算哈希。在本机上,同时计算整文件和全部分片的 SHA-256,300 MiB 耗时约 0.31 秒,2 GiB 约 2 秒(Python hashlib 实测,见[附录 A9](#a9-分片上传的最小实现与实测))。浏览器中的 Web Crypto API 不支持流式输入,digest 要求把完整数据读入内存,因此大文件的哈希通常要用增量算法计算。
+秒传的代价在于先要读完整个文件来计算哈希。同时计算整文件和全部分片的 SHA-256,300 MiB 耗时约 0.31 秒,2 GiB 约 2 秒(Python hashlib 实测,见[附录 A9](#a9-分片上传的最小实现与实测))。浏览器中的 Web Crypto API 不支持流式输入,digest 要求把完整数据读入内存,因此大文件的哈希通常要用增量算法计算。
需要注意的是,只按哈希判断文件是否存在时,任何知道哈希的用户都可以“秒传”别人的文件,并借此探测某个文件是否存在。秒传应当只在当前用户自己的文件空间内建立引用,存储中的文件仍然只保存一份。实测中,同一内容换了文件名再次上传,初始化直接返回已存在,没有发送任何分片。
@@ -1144,6 +1327,256 @@ SELECT id, amount FROM sys_order WHERE id = 2;
+----+---------+
```
+### A13 限流算法
+
+环境:Redis 7.4.1,Apple M1 Pro,macOS,默认配置。客户端用 redis-py 调用 Lua 脚本,时间戳由客户端以毫秒传入,便于精确复现窗口边界。令牌桶的脚本与正文相同,固定窗口和滑动窗口的脚本如下:
+
+```lua
+-- 固定窗口:KEYS[1] 为“限流键:窗口编号”;ARGV:上限、窗口毫秒数
+local n = redis.call('INCR', KEYS[1])
+if n == 1 then
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
+end
+if n <= tonumber(ARGV[1]) then
+ return 1
+end
+return 0
+```
+
+```lua
+-- 滑动窗口(日志):KEYS[1] 限流键;ARGV:窗口毫秒数、上限、当前时间(毫秒)、请求 ID
+local window = tonumber(ARGV[1])
+local limit = tonumber(ARGV[2])
+local now = tonumber(ARGV[3])
+redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
+if redis.call('ZCARD', KEYS[1]) < limit then
+ redis.call('ZADD', KEYS[1], now, ARGV[4])
+ redis.call('PEXPIRE', KEYS[1], window)
+ return 1
+end
+return 0
+```
+
+固定窗口的键为“限流键:时间戳 // 1000”,上限 5,窗口 1000 毫秒;滑动窗口的窗口为 1000 毫秒、上限 5,请求 ID 为 req-0、req-1……;令牌桶为每秒 5 个、容量 5。原始输出:
+
+```text
+== 固定窗口与滑动窗口:上限 5 次/秒,用户 1001
+时间(ms) 固定窗口 滑动窗口
+ 900 通过 通过
+ 910 通过 通过
+ 920 通过 通过
+ 930 通过 通过
+ 940 通过 通过
+ 1100 通过 拒绝
+ 1110 通过 拒绝
+ 1120 通过 拒绝
+ 1130 通过 拒绝
+ 1140 通过 拒绝
+ 1950 拒绝 通过
+0.90~1.14 秒内通过:固定窗口 10 次,滑动窗口 5 次
+== 令牌桶:每秒 5 个令牌,容量 5,用户 1001
+ 0 ms 通过 剩余令牌 4.0
+ 0 ms 通过 剩余令牌 3.0
+ 0 ms 通过 剩余令牌 2.0
+ 0 ms 通过 剩余令牌 1.0
+ 0 ms 通过 剩余令牌 0.0
+ 0 ms 拒绝 剩余令牌 0.0
+ 0 ms 拒绝 剩余令牌 0.0
+ 400 ms 通过 剩余令牌 1.0
+ 400 ms 通过 剩余令牌 0.0
+ 400 ms 拒绝 剩余令牌 0.0
+ 10400 ms 通过 剩余令牌 4.0
+ 10400 ms 通过 剩余令牌 3.0
+ 10400 ms 通过 剩余令牌 2.0
+ 10400 ms 通过 剩余令牌 1.0
+ 10400 ms 通过 剩余令牌 0.0
+ 10400 ms 拒绝 剩余令牌 0.0
+ 10400 ms 拒绝 剩余令牌 0.0
+```
+
+内存用 MEMORY USAGE SAMPLES 0 测量:写入 1000 条记录的 ZSET(成员形如 req-1000000,分值为毫秒时间戳)为 102016 字节,多次运行在 101888~102384 字节之间;固定窗口的计数器为 56 字节,令牌桶的哈希表为 104 字节。
+
+单个键的吞吐量用 redis-benchmark 测量(本机回环,50 个连接,每项 200000 次,同一个键),令牌桶脚本先用 SCRIPT LOAD 加载:
+
+```text
+redis-benchmark -n 200000 -c 50 -q INCR t-rl:bench:incr
+redis-benchmark -n 200000 -c 50 -q EVALSHA 1 t-rl:bench:tb 5 5 1700000000000
+
+-- 第 1 次
+INCR: 138696.25 requests per second, p50=0.279 msec
+EVALSHA: 99700.90 requests per second, p50=0.415 msec
+-- 第 2 次
+INCR: 143266.47 requests per second, p50=0.263 msec
+EVALSHA: 87719.30 requests per second, p50=0.479 msec
+-- 第 3 次
+INCR: 125156.45 requests per second, p50=0.311 msec
+EVALSHA: 101112.23 requests per second, p50=0.415 msec
+```
+
+### A14 Guava 的 RateLimiter
+
+环境:Guava 33.4.8-jre,JDK 21.0.13,Apple M1 Pro。
+
+```java
+RateLimiter limiter = RateLimiter.create(5); // 每秒 5 个
+for (int i = 1; i <= 7; i++) {
+ double waited = limiter.acquire(); // 返回等待的秒数
+}
+Thread.sleep(2000);
+for (int i = 1; i <= 7; i++) {
+ double waited = limiter.acquire();
+}
+for (int i = 1; i <= 3; i++) {
+ limiter.tryAcquire(); // 不等待
+}
+```
+
+```text
+== 新建后连续 7 次 acquire()
+第 1 次 等待 0.000 秒,此时 0.001 秒
+第 2 次 等待 0.188 秒,此时 0.211 秒
+第 3 次 等待 0.189 秒,此时 0.410 秒
+第 4 次 等待 0.190 秒,此时 0.605 秒
+第 5 次 等待 0.195 秒,此时 0.808 秒
+第 6 次 等待 0.191 秒,此时 1.009 秒
+第 7 次 等待 0.191 秒,此时 1.210 秒
+== 空闲 2 秒后连续 7 次 acquire()
+第 1 次 等待 0.000 秒,此时 0.000 秒
+第 2 次 等待 0.000 秒,此时 0.000 秒
+第 3 次 等待 0.000 秒,此时 0.000 秒
+第 4 次 等待 0.000 秒,此时 0.000 秒
+第 5 次 等待 0.000 秒,此时 0.001 秒
+第 6 次 等待 0.000 秒,此时 0.001 秒
+第 7 次 等待 0.199 秒,此时 0.210 秒
+== 紧接着 tryAcquire()(不等待)
+tryAcquire() = false
+tryAcquire() = false
+tryAcquire() = false
+```
+
+RateLimiter.create() 创建的 SmoothBursty 最多积累 1 秒的令牌,即 5 个。空闲后前 5 次用的是积累的令牌;第 6 次也不等待,因为 RateLimiter 让当前请求先通过,把欠下的时间算到下一个请求上(SmoothRateLimiter 源码注释),所以第 7 次等待约 0.2 秒。
+
+### A15 红包金额的拆分
+
+100 元(10000 分)分 10 份,二倍均值法拆分 10 万次,统计每个位置的平均值、标准差、最小值和最大值,并检查每次的总额和最小的一份(Python 3):
+
+```python
+def split(total, n):
+ """单位为分。前 n-1 份在 [1, 2 × 剩余平均 − 1] 中均匀随机,最后一份拿剩余。"""
+ res = []
+ for left in range(n, 1, -1): # left:包括本份在内还剩几份
+ x = random.randint(1, 2 * total // left - 1)
+ res.append(x)
+ total -= x
+ res.append(total)
+ return res
+```
+
+```text
+runs=100000 total=100.00 n=10 bad=0 # bad:总额不等于 10000 分或某份小于 1 分的次数
+pos mean std min max
+ 1 10.02 5.77 0.01 19.99
+ 2 10.01 5.80 0.01 22.19
+ 3 9.95 5.89 0.01 24.57
+ 4 10.02 5.97 0.01 27.70
+ 5 9.99 6.06 0.01 31.79
+ 6 9.98 6.20 0.01 34.46
+ 7 10.00 6.41 0.01 39.05
+ 8 10.01 6.84 0.01 45.85
+ 9 9.99 7.69 0.01 60.50
+ 10 10.03 7.71 0.01 68.33
+```
+
+### A16 并发抢红包与异步落库
+
+红包 8001 为 100 元分 10 个,用户 0~99 各请求 2 次,同一用户的两次请求相邻提交,共 200 个请求由 64 个线程并发执行。预先拆分的脚本与正文相同;实时计算的脚本如下:
+
+```lua
+-- KEYS[1]:红包(Hash:remain 剩余金额,count 剩余个数),KEYS[2]:领取记录,ARGV[1]:用户 ID
+if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then
+ return -1
+end
+local remain = tonumber(redis.call('HGET', KEYS[1], 'remain'))
+local count = tonumber(redis.call('HGET', KEYS[1], 'count'))
+if count == 0 then
+ return 0
+end
+local amount = remain -- 最后一个拿剩余
+if count > 1 then
+ amount = math.random(1, math.floor(2 * remain / count) - 1)
+end
+redis.call('HSET', KEYS[1], 'remain', remain - amount, 'count', count - 1)
+redis.call('HSET', KEYS[2], ARGV[1], amount)
+return amount
+```
+
+对比的写法把三步分开执行:
+
+```python
+def naive(u):
+ if c.hexists(H, u): return -1
+ a = c.lpop(L)
+ if a is None: return 0
+ c.hset(H, u, a)
+ return int(a)
+```
+
+```text
+预拆 + Lua:成功 10,已抢完 180,已经抢过 10,抢到 2 次的用户 0,发出总额 100.00,领取记录 10 条
+实时计算 + Lua:成功 10,已抢完 180,已经抢过 10,抢到 2 次的用户 0,发出总额 100.00,领取记录 10 条
+分开执行(第 1 次):成功 10,已抢完 190,已经抢过 0,抢到 2 次的用户 4,发出总额 100.00,领取记录 6 条
+分开执行(第 2 次):成功 10,已抢完 189,已经抢过 1,抢到 2 次的用户 3,发出总额 100.00,领取记录 7 条
+分开执行(第 3 次):成功 10,已抢完 190,已经抢过 0,抢到 2 次的用户 4,发出总额 100.00,领取记录 6 条
+实时计算 1 万个红包:总额或最小值不符 0 个,第一份金额有 1985 种取值
+```
+
+落库在 MySQL 8.0.44 上执行。红包表和领取记录表如下,消费者在一个事务中执行正文中的 INSERT 和 UPDATE,唯一索引冲突时回滚:
+
+```sql
+CREATE TABLE red_packet (
+ id BIGINT PRIMARY KEY, total INT NOT NULL, num INT NOT NULL,
+ remain_amount INT NOT NULL, remain_num INT NOT NULL);
+CREATE TABLE red_packet_grab (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY, packet_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
+ amount INT NOT NULL, UNIQUE KEY uk_packet_user (packet_id, user_id));
+```
+
+10 条领取消息各投递两次并打乱顺序,去掉其中一个用户的两条,剩余 18 条由 8 个线程并发消费;之后查出数据库中已有的领取者,与 Redis 中的领取记录比较,补发缺失的消息:
+
+```text
+消息 18 条:插入成功 9,唯一索引冲突 9
+ 红包剩余金额 154 分,剩余个数 1;领取记录 9 条,合计 9846 分
+对账发现缺失: [(8001, 83, 154)] 补发结果: ['ok']
+ 红包剩余金额 0 分,剩余个数 0;领取记录 10 条,合计 10000 分
+```
+
+### A17 抢券的限领与核销
+
+券 501 库存 100 张,每人限领 2 张,用户 0~79 各请求 5 次,共 400 个请求由 64 个线程并发执行:
+
+```lua
+-- KEYS[1]:库存,KEYS[2]:领取记录(Hash,用户 ID → 已领张数),ARGV[1]:用户 ID,ARGV[2]:每人限领张数
+local n = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
+if n >= tonumber(ARGV[2]) then return -1 end
+if tonumber(redis.call('GET', KEYS[1])) <= 0 then return 0 end
+redis.call('DECR', KEYS[1])
+return redis.call('HINCRBY', KEYS[2], ARGV[1], 1) -- 返回第几张,落库时作为 seq
+```
+
+```text
+发出 100 张,库存剩余 0,领到张数分布 [(2, 50)],超过 2 张的用户 0,(用户, seq) 重复 0
+```
+
+核销在 MySQL 8.0.44 上执行,表结构与正文相同。用户 1001 有两张券:70001 未过期,70002 已于前一天过期:
+
+```text
+重复消息再插入 (1001, 501, 1):ERROR 1062 (23000): Duplicate entry '1001-501-1' for key 'uk_user_coupon'
+订单 9001 锁定 70001:影响 1 行
+订单 9002 再锁定 70001:影响 0 行
+订单 9002 锁定已过期的 70002:影响 0 行
+订单 9001 取消,释放 70001(WHERE order_id = 9001 AND status = 1):影响 1 行
+```
+
## 参考资料
- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)
@@ -1174,3 +1607,9 @@ SELECT id, amount FROM sys_order WHERE id = 2;
- Hu V C, Ferraiolo D, Kuhn R, et al. Guide to Attribute Based Access Control (ABAC) Definition and Considerations. NIST SP 800-162, 2014. [https://doi.org/10.6028/NIST.SP.800-162](https://doi.org/10.6028/NIST.SP.800-162)
- [Authorization Cheat Sheet,OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [Row Security Policies,PostgreSQL 文档](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
+- [Redis: INCR(Pattern: Rate limiter)](https://redis.io/docs/latest/commands/incr/)
+- [Nginx ngx_http_limit_req_module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)
+- [Guava RateLimiter](https://github.com/google/guava/blob/master/guava/src/com/google/common/util/concurrent/RateLimiter.java)、[SmoothRateLimiter](https://github.com/google/guava/blob/master/guava/src/com/google/common/util/concurrent/SmoothRateLimiter.java)
+- [Spring Cloud Gateway request_rate_limiter.lua](https://github.com/spring-cloud/spring-cloud-gateway/blob/4.1.x/spring-cloud-gateway-server/src/main/resources/META-INF/scripts/request_rate_limiter.lua)
+- [Sentinel:流量控制](https://sentinelguard.io/zh-cn/docs/flow-control.html)
+- [RFC 6585: Additional HTTP Status Codes](https://www.rfc-editor.org/rfc/rfc6585)
diff --git a/notes/缓存.md b/notes/缓存.md
index dfc4c069..cbeaa115 100644
--- a/notes/缓存.md
+++ b/notes/缓存.md
@@ -67,7 +67,7 @@
-Caffeine 读取一次约 21 纳秒;用单个连接逐条向 Redis 发送 GET,每次约 68 微秒,相差三千多倍(JDK 21、Redis 7.4 实测,见[附录 A1](#a1-本地缓存与-redis-的读取耗时)),这还只是本机回环,跨机器访问还要加上网络往返。热点数据放在本地缓存中,既省去了网络开销,也不会让请求集中到存放这个热点键的 Redis 节点上。
+Caffeine 读取一次约 21 纳秒;用单个连接逐条向 Redis 发送 GET,每次约 68 微秒,相差三千多倍(JDK 21、Redis 7.4 实测,见[附录 A1](#a1-本地缓存与-redis-的读取耗时)),这还只是客户端与 Redis 在同一台机器上的结果,跨机器访问还要加上网络往返。热点数据放在本地缓存中,既省去了网络开销,也不会让请求集中到存放这个热点键的 Redis 节点上。
本地缓存在每个实例中各存一份,难点在于数据更新后怎样让所有实例失效: