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 节点上。 本地缓存在每个实例中各存一份,难点在于数据更新后怎样让所有实例失效: