服务端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 06月6日 20:23

Docker 容器文件系统怎么工作?分层和 Copy-on-Write 机制

容器里写了一个文件,它到底存在哪里?删掉容器后文件去哪了?为什么镜像层是只读的但容器可以写入?理解 Docker 文件系统的工作原理,这些问题就都清楚了。从镜像到容器:分层文件系统Docker 镜像不是一个大文件——它是多层只读文件系统的堆叠。每个 Dockerfile 指令产生一层:FROM ubuntu:22.04 # 层 1:操作系统基础(~70MB)RUN apt-get update # 层 2:包索引更新RUN apt-get install -y python3 # 层 3:Python 运行时COPY . /app # 层 4:应用代码CMD ["python3", "/app/main.py"] # 不产生新层(只是元数据)┌────────────────────────┐│ 可写层(容器运行时添加) │ ← 容器启动后才有├────────────────────────┤│ 层 4:应用代码 │ ← COPY 产生├────────────────────────┤│ 层 3:Python 运行时 │ ← RUN 产生├────────────────────────┤│ 层 2:包索引 │ ← RUN 产生├────────────────────────┤│ 层 1:Ubuntu 基础 │ ← FROM 产生└────────────────────────┘关键:每个只读层都是不可变的。删除了上一层的文件,并不会真的删除——只是在新层里标记为"已删除"(whiteout 文件)。Copy-on-Write:容器写入的核心机制容器启动时,Docker 在所有只读层上面加一层可写层。容器内的写操作都走 CoW:修改已有文件1. 容器要修改 /etc/config.yml(存在于镜像层 2)2. Docker 从层 2 把 config.yml 复制到可写层3. 修改发生在可写层的副本上4. 后续读取这个文件时,可写层的版本"遮盖"了镜像层的原始版本新建文件直接写在可写层,不涉及复制。删除文件在可写层创建一个 whiteout 文件(标记为已删除),不真的从只读层删除。overlay2 的实现overlay2 是 Docker 默认的存储驱动,基于 Linux 内核的 OverlayFS:容器看到的是 merged 视图:┌──────────────┐│ merged │ = upperdir + lowerdir 合并后的视图├──────────────┤│ upperdir │ = 可写层(容器修改的文件)├──────────────┤│ lowerdir │ = 镜像的所有只读层└──────────────┘在宿主机上的实际位置# Docker 的存储根目录ls /var/lib/docker/overlay2/# 每个镜像层一个目录/var/lib/docker/overlay2/<layer-id>/ diff/ # 这一层的文件内容 link # 指向层的短链接 lower # 指向下层层的链接 merged/ # 合并视图(容器运行时才出现) work/ # OverlayFS 工作目录查看容器的 overlay2 层# 找到容器的 overlay2 路径docker inspect myapp --format '{{.GraphDriver.Data.MergedDir}}'# /var/lib/docker/overlay2/abc123/merged# 查看可写层的内容ls /var/lib/docker/overlay2/abc123/diff/diff/ 目录里就是容器内所有修改过的文件——和 docker diff 命令看到的一致。为什么容器删除后数据会丢1. docker run → 创建可写层 + 启动容器2. 容器运行中 → 数据写在可写层3. docker rm → 删除可写层 + 容器元数据可写层随容器一起删除,里面的数据也消失了。这就是为什么需要 Volume:services: postgres: image: postgres:16 volumes: - pg_data:/var/lib/postgresql/data # 数据存在 Volume,不在可写层Volume 的数据存在 /var/lib/docker/volumes/pg_data/,容器删除不影响它。镜像层的共享和节省空间多个镜像共享相同的基础层:镜像 A(Node.js 应用) 镜像 B(Python 应用)┌──────────────┐ ┌──────────────┐│ 层 3:App A │ │ 层 3:App B │ ← 不同├──────────────┤ ├──────────────┤│ 层 2:Node.js│ │ 层 2:Python │ ← 不同├──────────────┤ ├──────────────┤│ 层 1:Ubuntu │ │ 层 1:Ubuntu │ ← 共享!只存一份└──────────────┘ └──────────────┘# 查看 Docker 磁盘占用docker system df -v# SHARED SIZE 列显示被多个镜像共享的大小# UNIQUE SIZE 列显示该镜像独有的层大小镜像构建优化:减少层和大小合并 RUN 指令# 差:每条 RUN 一层RUN apt-get updateRUN apt-get install -y python3RUN apt-get install -y pip# 好:合并成一层RUN apt-get update && \ apt-get install -y python3 pip && \ rm -rf /var/lib/apt/lists/*合并的好处:层数减少rm -rf /var/lib/apt/lists/ 在同一层执行——删除操作真的释放了空间,而不是在下一层标记为已删除多阶段构建FROM node:20 AS builderWORKDIR /appCOPY . .RUN npm ci && npm run buildFROM node:20-alpineCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCMD ["node", "dist/index.js"]最终镜像只有 dist/ 和 node_modules/——没有源码、没有构建工具,体积缩小 60-80%。.dockerignore 减少构建上下文# .dockerignore.gitnode_modules.env*.mdtest/.dockerignore 排除的文件不会进入构建上下文——既加快构建,又防止敏感文件进镜像。常见文件系统问题镜像越来越大# 查看每层大小docker history myapp:latest# 找出最大的层docker history --format "{{.Size}}\t{{.CreatedBy}}" myapp:latest常见的膨胀原因:apt-get update 的缓存、npm 缓存、临时文件。在同一层里清理掉。容器写入慢可写层写大量小文件时性能差——特别是 Mac/Windows 上 Docker Desktop 的文件共享。解决方案:大量写入用 Volume。inode 耗尽大量小文件会消耗 inode。df -i 查看剩余 inode。overlay2 比 overlay 的 inode 使用更少——这也是推荐 overlay2 的原因之一。
服务端阅读 06月6日 20:23

Docker 容器出问题了怎么排查?分层排查流程

容器启动失败、运行中崩溃、网络不通、数据丢失——Docker 故障排查有一套固定流程,按层级从外到内逐步缩小范围。排查流程总览容器状态异常?├── 容器没启动 → 查 docker logs├── 容器运行但行为异常 → 查 docker logs + docker exec├── 容器网络不通 → 查 docker network + 端口映射├── 容器数据问题 → 查 Volume 挂载└── 宿主机资源不足 → 查 docker stats + 系统资源第一步:确认容器状态# 查看所有容器(包括停止的)docker ps -a# 关注 STATUS 列# Up 2 hours → 正常运行# Exited (0) 5 mins → 正常退出# Exited (1) 5 mins → 错误退出# Exited (137) 1 min → 被 SIGKILL 杀掉(通常是 OOM)# Restarting → 不断重启(崩溃循环)退出码含义:| 退出码 | 含义 ||--------|------|| 0 | 正常退出 || 1 | 应用错误 || 137 | OOM Killed(内存不足) || 139 | Segmentation Fault || 143 | SIGTERM(正常停止) |第二步:查日志docker logs# 查看容器日志docker logs myapp# 实时跟踪docker logs -f myapp# 最近 100 行docker logs --tail 100 myapp# 带时间戳docker logs -t myapp# 查看某个时间段的日志docker logs --since "2024-01-01T00:00:00" --until "2024-01-01T12:00:00" myapp容器已经退出了?# 已退出的容器仍然可以查日志docker logs myapp # 只要容器没被 docker rm 删掉# 如果容器被删了,日志文件还在(json-file 驱动)ls /var/lib/docker/containers/<container-id>/# <container-id>-json.log日志里什么都没有?应用可能把日志写到了文件而不是 stdout/stderr。Docker 只捕获标准输出/错误流:# 进入容器查看日志文件docker exec -it myapp shcat /app/logs/app.log最佳实践:让应用把日志输出到 stdout/stderr,这样 docker logs 直接能看到。不要写到容器内的文件——容器删除后日志也丢了。第三步:进入容器排查# 进入容器执行命令docker exec -it myapp sh# 如果容器没有 sh,用 bashdocker exec -it myapp bash# 如果都没有(alpine 镜像可能只有 sh)docker exec -it myapp /bin/sh# 不进入容器,直接执行命令docker exec myapp ps auxdocker exec myapp cat /etc/config.yml容器里能做的事:ps aux 看进程top 看 CPU/内存cat /etc/hosts 看网络配置curl localhost:3000 测试内部端口env 看环境变量容器一启动就退出了怎么办docker exec 需要容器在运行状态。容器一启动就退出的情况,用覆盖入口点的方式:# 覆盖 CMD,保持容器运行docker run -it --entrypoint sh myapp:latest# 或者在 Dockerfile 末尾临时加# CMD ["sleep", "3600"]进入容器后手动执行原来的启动命令,观察报错。第四步:排查网络问题容器端口没暴露# 检查端口映射docker port myapp# 3000/tcp -> 0.0.0.0:3000# 如果没有输出,说明没做端口映射# 重新启动时加 -pdocker run -d -p 3000:3000 myapp容器间网络不通# 查看容器的网络docker inspect myapp --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'# 查看所有 Docker 网络docker network ls# 同一网络的容器可以互相通过容器名访问# 不同网络的容器互相隔离# 测试容器间连通性docker exec myapp ping redisdocker exec myapp curl http://api-service:8080/healthDNS 解析失败# 查看容器的 DNS 配置docker exec myapp cat /etc/resolv.conf# 自定义 DNSdocker run --dns 8.8.8.8 myapp第五步:排查 Volume 问题文件没有出现在容器内# 检查 Volume 挂载docker inspect myapp --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'# /host/path -> /container/path常见原因:宿主机路径写错了(相对路径问题)文件权限不匹配(容器内用户没读权限)SELinux 阻止访问(加 :z 或 :Z 后缀)# SELinux 环境docker run -v /host/path:/container/path:z myapp # 多容器共享docker run -v /host/path:/container/path:Z myapp # 单容器专用容器内修改没有反映到宿主机检查是不是挂载方向反了,或者挂载的是文件而不是目录:# 错误:挂载不存在的文件,Docker 会创建一个目录docker run -v ./config.yml:/app/config.yml myapp# 如果 ./config.yml 不存在,Docker 会创建 /app/config.yml 目录# 正确:确保文件先存在touch ./config.ymldocker run -v ./config.yml:/app/config.yml myapp常见故障速查| 症状 | 排查命令 | 常见原因 ||------|---------|---------|| 容器启动就退出 | docker logs | CMD 执行失败、配置错误 || 容器被杀 | docker inspect --format '{{.State.OOMKilled}}' | 内存不足 || 端口访问不到 | docker port + curl | 没映射端口、防火墙 || 容器间不通 | docker exec ping | 不在同一网络 || 磁盘满 | df -h + docker system df | 日志太多、镜像太多 || 构建慢 | docker build --progress=plain | 没用缓存、上下文太大 || 权限错误 | docker exec ls -la | UID 不匹配、SELinux || 容器不断重启 | docker logs --tail 50 | 启动脚本报错、依赖服务未就绪 |调试技巧用 debug 镜像替换生产镜像可能是 distroless 或 alpine,没有 curl、ping 等工具。临时用 debug 镜像排查:# 把镜像临时换成有工具的版本docker run -d --name myapp-debug \ --entrypoint sh \ -p 3000:3000 \ myapp:latest -c "sleep 3600"docker diff 看文件变更# 查看容器内哪些文件被修改了docker diff myapp# A /app/newfile.txt → Added# C /etc/config.yml → Changed# D /tmp/old.log → Deleteddocker events 实时监控# 监控 Docker 事件(容器启停、OOM、健康检查等)docker events --filter container=myapp
服务端阅读 06月6日 20:23

Docker 容器怎么接入 CI/CD?GitHub Actions 和 GitLab CI 集成

代码提交后自动构建镜像、跑测试、部署——这就是 CI/CD 和 Docker 的结合。Docker 让构建环境一致、部署产物标准化,CI/CD 让这一切自动运行。这篇讲 Docker 在主流 CI/CD 平台中的集成方式。Docker 在 CI/CD 中的三个角色构建环境:CI Runner 本身跑在 Docker 容器里,保证每次构建环境一致构建产物:docker build 产出镜像,推到 Registry,部署时直接拉镜像部署目标:生产环境拉镜像启动容器,不需要在服务器上装运行时GitHub Actions + Docker基本构建和推送# .github/workflows/deploy.ymlname: Build and Push Docker Imageon: push: branches: [main]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | myorg/myapp:latest myorg/myapp:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=maxcache-from: type=gha 用 GitHub Actions 的缓存加速构建——没有变化的层直接复用,不用重新构建。首次构建可能要 5 分钟,后续只要 1-2 分钟。多阶段构建减小镜像# 构建阶段FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build# 运行阶段——只有产物,没有源码和 devDependenciesFROM node:20-alpineWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCMD ["node", "dist/index.js"]多阶段构建让最终镜像只有运行时需要的文件——从 1GB+ 缩小到 200MB 以下。GitLab CI + DockerDocker-in-Docker 构建# .gitlab-ci.ymlbuild: image: docker:24 services: - docker:24-dind variables: DOCKER_TLS_CERTDIR: "/certs" before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHAdocker:24-dind 服务启动一个 Docker 守护进程,让 CI 容器里可以执行 docker build。注意要加 DOCKER_TLS_CERTDIR 确保通信安全。使用 GitLab Container RegistryGitLab 自带 Container Registry,不用额外配 Docker Hub:script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA$CI_REGISTRY_IMAGE 是 GitLab 预设变量,指向当前项目的 Registry 地址。镜像标签策略# 不推荐:只用 latestmyapp:latest# 推荐:语义化版本 + Git SHA 双标签myapp:1.2.3myapp:sha-abc1234# 回滚时指定版本docker pull myapp:1.2.2| 标签 | 用途 | 示例 ||------|------|------|| latest | 默认最新版 | 开发环境用 || 语义化版本 | 生产部署 | 1.2.3 || Git SHA | 精确追溯 | sha-abc1234 || 分支名 | 预览环境 | main, staging |生产环境永远用精确版本号或 SHA,不用 latest——latest 指向的镜像随时可能变,出了问题无法回滚。部署阶段SSH 部署到服务器# GitHub Actions- name: Deploy to server uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: deploy key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker pull myorg/myapp:${{ github.sha }} docker stop myapp || true docker rm myapp || true docker run -d --name myapp \ --restart unless-stopped \ -p 3000:3000 \ myorg/myapp:${{ github.sha }}docker-compose 部署服务器上放一个 docker-compose.yml,CI 只需要触发 docker compose pull && docker compose up -d:# 服务器上的 docker-compose.ymlservices: app: image: myorg/myapp:latest ports: - "3000:3000" restart: unless-stopped# CI 部署脚本ssh deploy@server "cd /app && docker compose pull && docker compose up -d"零停机部署上面的方式会短暂中断服务。用双容器切换实现零停机:# 1. 启动新容器在不同端口docker run -d --name myapp-new -p 3001:3000 myorg/myapp:new-version# 2. 健康检查until curl -f http://localhost:3001/health; do sleep 1; done# 3. 切换 Nginx 上游# 更新 nginx upstream 指向 3001docker exec nginx nginx -s reload# 4. 停掉旧容器docker stop myapp-old && docker rm myapp-old# 5. 重命名新容器docker rename myapp-new myapp-old安全最佳实践不要用 root 跑容器:Dockerfile 里加 USER app扫描镜像漏洞:docker scout cves myapp:latest 或 Trivy最小基础镜像:用 alpine 或 distroless 代替 ubuntuSecret 不进镜像:数据库密码等通过环境变量或 Secret 注入,不写进 Dockerfile.dockerignore 排除无关文件:.git、node_modules、.env 不应该进构建上下文# .dockerignore.gitnode_modules.env*.md.dockerignore
服务端阅读 06月6日 20:23

Docker 存储驱动该选哪个?overlay2 和其他驱动的区别

Docker 镜像的每一层怎么存、容器怎么在只读层上写入、不同存储驱动有什么性能差异——理解存储驱动的工作原理,能帮你解决容器写入慢、镜像占磁盘、数据丢失这些问题。容器文件系统的分层结构Docker 镜像由多个只读层组成,容器启动时在最上面加一层可写层:┌─────────────────────┐│ 可写层(容器层) │ ← docker run 产生的可写层├─────────────────────┤│ 镜像层 3(App) │ ← COPY / RUN 指令产生的层├─────────────────────┤│ 镜像层 2(Runtime)│├─────────────────────┤│ 镜像层 1(OS 基础) │└─────────────────────┘只读层:镜像的每一层都是只读的,多个容器可以共享同一组只读层可写层:容器的所有修改(新建文件、修改配置、写日志)都写在这层容器删除后,可写层一起消失——这就是为什么容器内写的数据会丢Copy-on-Write 机制容器修改镜像中的文件时,不是直接改只读层——而是把文件复制到可写层再修改:# 镜像里有个 /etc/config.yml# 容器内修改它:echo "new value" >> /etc/config.yml# 发生了什么:# 1. 从只读层复制 config.yml 到可写层# 2. 在可写层修改# 3. 后续读这个文件时读可写层的版本(遮盖了只读层的原始文件)CoW 的性能影响:第一次修改大文件时会比较慢(需要完整复制)。频繁修改大文件(比如数据库的数据文件)不适合放在容器可写层——应该用 Volume。主流存储驱动对比| 驱动 | 文件系统 | 适用场景 | 性能 ||------|---------|---------|------|| overlay2 | OverlayFS | 默认推荐,所有 Linux 发行版 | 好 || devicemapper | devicemapper | 老系统、RHEL/CentOS 6 | 一般 || btrfs | Btrfs | 需要快照、子卷管理 | 写入好,读取一般 || zfs | ZFS | 需要数据完整性校验 | 功能强但内存需求大 |overlay2:默认且最优overlay2 是当前 Docker 的默认存储驱动,也是性能最好的:# 查看当前存储驱动docker info --format '{{.Driver}}'# overlay2overlay2 的工作方式:lowerdir(只读层)= 镜像的所有层upperdir(可写层)= 容器的修改merged = 合并视图(容器看到的文件系统)优势:只有一层 lowerdir 目录,不像老版 overlay 有多层,inode 消耗少页缓存共享——多个容器读同一个文件只占一份内存build 性能好——Docker Build 时层操作都是 O(1)99% 的场景用 overlay2 就对了,不需要换。devicemapper:老系统的选择RHEL/CentOS 6 时代内核不支持 OverlayFS,只能用 devicemapper。有两种模式:loopback(默认):用文件模拟块设备,性能差,生产环境不能用direct-lvm:直接用 LVM 块设备,性能可以// /etc/docker/daemon.json - direct-lvm 配置{ "storage-driver": "devicemapper", "storage-opts": [ "dm.directlvm_device=/dev/sdb", "dm.thinp_percent=95", "dm.thinp_metapercent=1" ]}现在大部分系统已经支持 overlay2,不需要再折腾 devicemapper。存储驱动和磁盘占用的关系每个存储驱动管理镜像层的方式不同,磁盘占用差异很大:# 查看 Docker 占用的磁盘docker system df# 详细信息docker system df -vTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 15 5 4.2GB 2.1GB (50%)Containers 8 3 120MB 80MB (66%)Local Volumes 5 3 800MB 200MB (25%)Build Cache 50 0 1.5GB 1.5GB (100%)RECLAIMABLE 是可以回收的空间。清理命令:# 清理悬空镜像(没有标签的)docker image prune# 清理所有未使用的镜像docker image prune -a# 清理构建缓存docker builder prune# 一键全清docker system prune -aVolume:数据持久化的正确方式容器可写层的数据会随容器删除而丢失。需要持久化的数据(数据库、日志、配置)用 Volume:services: postgres: image: postgres:16 volumes: - pg_data:/var/lib/postgresql/data # 命名 Volume - ./init.sql:/docker-entrypoint-initdb.d/init.sql # 绑定挂载 app: image: myapp:latest volumes: - ./config:/app/config:ro # 只读绑定挂载 - app_logs:/app/logs # 命名 Volumevolumes: pg_data: app_logs:命名 Volume vs 绑定挂载| | 命名 Volume | 绑定挂载 ||---|---|---|| 存储位置 | Docker 管理(/var/lib/docker/volumes/) | 宿主机任意目录 || 性能 | 好(Docker 优化) | Mac/Win 上可能慢 || 可移植性 | 好 | 依赖宿主机路径 || 备份 | docker volume inspect 找路径 | 直接访问宿主机目录 || 适用场景 | 数据库、应用数据 | 配置文件、代码挂载 |存储驱动选择决策| 你的情况 | 推荐驱动 ||---------|---------|| Linux 内核 4.0+ | overlay2(默认) || RHEL/CentOS 7+ | overlay2 || 需要 ZFS 快照和校验 | zfs || 需要 Btrfs 子卷管理 | btrfs || 老系统无法升级内核 | devicemapper (direct-lvm) |不确定就用 overlay2——它已经是默认选项了。除非有明确的特殊需求,否则不需要改存储驱动。
服务端阅读 06月6日 20:23

Docker 容器资源怎么监控?从 docker stats 到 Prometheus 的完整方案

容器跑着跑着内存爆了、CPU 拉满了、磁盘写满了——这些问题的根源都能通过资源监控提前发现。Docker 提供了从命令行到 API 多层级的资源监控手段,这篇按从简单到复杂的顺序讲清楚。docker stats:最快上手# 查看所有运行中容器的资源使用docker stats# 只看特定容器docker stats myapp# 只输出一次(不刷新)docker stats --no-stream# 只看特定指标docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"输出示例:NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/Omyapp 12.5% 256MiB / 4GiB 6.25% 1.2MB / 340kB 45MB / 0Bredis 0.3% 8MiB / 512MiB 1.56% 56kB / 32kB 0B / 0BMEM USAGE / LIMIT 最关键——如果 Usage 接近 Limit,说明容器快 OOM 了。BLOCK I/O 高说明磁盘读写频繁,可能是日志写入太多或数据库查询没走缓存。局限:docker stats 只能看实时数据,没有历史趋势。要看趋势得上 Prometheus + Grafana。设置资源限制:监控的前提监控资源使用的目的是发现问题,而限制资源是防止问题蔓延:# docker-compose.ymlservices: app: image: myapp:latest deploy: resources: limits: cpus: '2.0' # 最多用 2 核 memory: 4G # 内存上限 4GB reservations: cpus: '0.5' # 保底 0.5 核 memory: 1G # 保底 1GB# 命令行方式docker run -d --name myapp \ --cpus=2.0 \ --memory=4g \ --memory-swap=4g \ # 禁止 swap myapp:latest--memory-swap=4g 等于 --memory 的值意味着容器不能用 swap——全部用物理内存。OOM 时容器会被直接杀掉而不是被 swap 拖慢。CPU 限制的两种方式# 1. --cpus:限制 CPU 时间占比(推荐)docker run --cpus=1.5 myapp # 最多用 1.5 核# 2. --cpu-period + --cpu-quota:更精细的控制docker run --cpu-period=100000 --cpu-quota=50000 myapp# quota/period = 50000/100000 = 0.5 核日常用 --cpus 就够了。--cpu-period/--cpu-quota 只在需要非整数的精确控制时用。cgroups:底层数据源Docker 的资源限制和监控都基于 Linux cgroups。直接读 cgroups 文件可以拿到最原始的数据:# 容器的内存使用(字节)cat /sys/fs/cgroup/memory/docker/<container-id>/memory.usage_in_bytes# 容器的 CPU 使用(纳秒)cat /sys/fs/cgroup/cpu/docker/<container-id>/cpuacct.usage# 内存限制cat /sys/fs/cgroup/memory/docker/<container-id>/memory.limit_in_bytes一般不需要直接读 cgroups——docker stats 和 Prometheus 已经做了封装。但如果容器内的监控工具和宿主机的 docker stats 数据对不上,可能是 cgroups 版本差异(cgroups v1 vs v2)导致的。Docker API:程序化监控# 获取容器资源使用统计(JSON 格式)curl --unix-socket /var/run/docker.sock \ http://localhost/containers/<container-id>/stats?stream=false返回的 JSON 包含 CPU、内存、网络、磁盘 IO 的详细数据。适合自建监控脚本:import dockerclient = docker.from_env()for container in client.containers.list(): stats = container.stats(stream=False) cpu_delta = stats['cpu_stats']['cpu_usage']['total_usage'] - \ stats['precpu_stats']['cpu_usage']['total_usage'] system_delta = stats['cpu_stats']['system_cpu_usage'] - \ stats['precpu_stats']['system_cpu_usage'] cpu_percent = (cpu_delta / system_delta) * 100 if system_delta > 0 else 0 mem_usage = stats['memory_stats']['usage'] / 1024 / 1024 mem_limit = stats['memory_stats']['limit'] / 1024 / 1024 print(f"{container.name}: CPU={cpu_percent:.1f}%, MEM={mem_usage:.0f}/{mem_limit:.0f}MB")cAdvisor + Prometheus:生产级监控docker stats 和 API 只适合临时查看。需要历史趋势和告警时,用 cAdvisor 采集 + Prometheus 存储 + Grafana 展示:services: cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro ports: - "8080:8080" prometheus: image: prom/prometheus:latest volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - "9090:9090"cAdvisor 自动采集所有容器的 CPU、内存、网络、磁盘指标,Prometheus 每 15 秒拉取一次。Grafana 里导入 Dashboard ID 11600 即可看到完整的容器资源监控面板。OOM 处理:内存爆了怎么办容器因内存不足被杀时,docker inspect 会记录原因:docker inspect <container-id> --format '{{.State.OOMKilled}}'# true 说明是 OOM 杀的处理步骤:查看内存限制是否太小——docker stats 看 MEM USAGE 是否经常顶到 LIMIT分析内存泄漏——进入容器 docker exec -it <id> sh,用 top 或 ps aux --sort=-%mem 找内存大户临时解决:加大内存限制 docker update --memory=8g <container-id>根本解决:修复应用的内存泄漏监控方案选择| 场景 | 方案 | 成本 ||------|------|------|| 临时排查 | docker stats | 零成本 || 自建脚本监控 | Docker API + Python | 低 || 小团队日常监控 | cAdvisor + Prometheus + Grafana | 中 || 生产环境 | 完整监控栈 + Alertmanager | 高 |起步建议:先用 docker stats 日常看一眼,发现问题了再搭 Prometheus 长期监控。
服务端阅读 06月5日 22:29

Docker Desktop 怎么用?安装配置和常见问题

Docker Desktop 是在 Mac 和 Windows 上用 Docker 最省事的方式——装一个应用就拥有完整的 Docker 环境,不需要折腾虚拟机或 Linux 双系统。但它不只是个安装包,里面的 WSL2 集成、Kubernetes 支持、资源管理有些门道值得了解。Docker Desktop 里装了什么Docker Desktop 不是 Docker Engine 的 GUI 包装——它是一个完整的开发环境:| 组件 | 作用 ||------|------|| Docker Engine | 容器运行时 || Docker CLI | 命令行工具 || Docker Compose | 多容器编排 || Docker BuildKit | 高性能构建引擎 || Kubernetes (可选) | 单节点 K8s 集群 || Docker Scout | 镜像漏洞扫描 || Docker Extensions | 扩展市场 |Mac 上 Docker Desktop 通过一个轻量级 Linux 虚拟机运行 Docker Engine(因为 Docker 本质上需要 Linux 内核)。Windows 上通过 WSL2 运行。安装后的关键配置资源分配默认配置经常不够用——4GB 内存跑不了几个容器:Settings → Resources CPUs: 4-6(建议宿主机的 50%) Memory: 8-12GB(建议宿主机的 50%) Swap: 2GB Disk image size: 60GB+调完后 Docker Desktop 会重启虚拟机。分配太多会导致宿主机卡顿,太少容器 OOM。建议 CPU 和内存各分一半给 Docker。WSL2 集成(Windows)Docker Desktop 在 Windows 上跑在 WSL2 里。开启后可以在 WSL2 的 Linux 发行版中直接使用 docker 命令:Settings → Resources → WSL Integration ✅ Enable integration with my default WSL distro ✅ Ubuntu (或其他发行版)这样在 Windows Terminal 的 Ubuntu 标签页里直接 docker run,不需要额外安装 Docker。文件共享性能Mac 上 Docker 挂载目录特别慢——因为文件要在 macOS 和 Linux 虚拟机之间同步。VirtioFS 是 Docker Desktop 4.x 以后的新方案,比之前的 gRPC FUSE 快很多:Settings → General ✅ Choose file sharing implementation for your containers: VirtioFS另外 node_modules 这种大量小文件的目录不要挂载——用匿名 volume 代替:services: app: volumes: - .:/app # 代码目录挂载 - /app/node_modules # node_modules 用容器内的,不走文件共享日常使用的核心功能镜像管理Docker Desktop → Images可以搜索、拉取、删除镜像,查看镜像层信息。比命令行更直观——特别是看哪个镜像占了多少磁盘。容器管理Docker Desktop → Containers启动、停止、删除容器,查看日志,进入容器终端。小技巧:点击容器的端口号可以直接在浏览器打开。Volume 管理Docker Desktop → Volumes查看所有 Docker Volume 占用的磁盘空间。容器删了 Volume 不会自动删——时间久了会积累大量废弃数据。定期清理:docker volume prune # 删除所有未被容器引用的 volume构建缓存清理docker builder prune # 清理构建缓存docker system prune -a # 一键清理所有未使用的资源(镜像、容器、网络、缓存)Docker Desktop 的 Troubleshoot 页面也有 "Clean / Purge data" 按钮——重置整个 Docker 环境。Kubernetes 支持Docker Desktop 内置了单节点 Kubernetes 集群,一键开启:Settings → Kubernetes ✅ Enable Kubernetes开启后 kubectl 直接可用:kubectl get nodes# NAME STATUS ROLES AGE VERSION# docker-desktop Ready control-plane 1m v1.28.0适合本地开发测试 K8s manifest,不需要装 minikube 或 kind。注意:开启 K8s 会额外占用 2-3GB 内存。不用时建议关掉。Docker ExtensionsDocker Desktop 支持扩展,常用的几个:| 扩展 | 功能 ||------|------|| Disk Usage | 可视化磁盘占用分析 || Trivy | 镜像安全扫描 || DDEV | 本地开发环境管理 || Tilt | 实时开发工作流 |安装方式:Extensions → Browse → InstallDocker Desktop 的替代方案Docker Desktop 对个人免费,但大企业(250+ 员工或 1000 万+ 美元年收入)需要付费订阅。如果不想付费:| 替代方案 | 平台 | 说明 ||---------|------|------|| OrbStack | Mac | 比 Docker Desktop 快 3-5 倍启动,内存占用少 || Rancher Desktop | Mac/Win/Linux | 开源免费,支持 containerd 和 dockerd || Colima | Mac | 命令行工具,轻量,基于 Lima || Podman Desktop | Mac/Win/Linux | Red Hat 出品,无守护进程 |推荐:Mac 用户优先试 OrbStack——启动快、内存省、文件共享性能好,个人免费。常见问题Docker Desktop 启动慢Mac 上首次启动需要 30-60 秒。加快方法:不要关 Docker Desktop,用 docker stop 停容器即可。Docker Desktop 自身在后台几乎不占 CPU。磁盘空间持续增长Docker 的虚拟磁盘(Docker.raw / data.vhdx)只会增大不会自动缩小。即使删了镜像,虚拟磁盘文件也不会缩小。解决:# Mac:压缩虚拟磁盘docker system prune -a# 然后重启 Docker Desktop → Troubleshoot → Clean / Purge data容器网络访问宿主机服务容器里访问宿主机(比如宿主机上的数据库):# 从容器内访问宿主机curl http://host.docker.internal:5432host.docker.internal 是 Docker Desktop 提供的特殊 DNS 名,自动解析为宿主机 IP。
服务端阅读 06月5日 22:29

Docker 反向代理该用 Nginx 还是 Traefik?部署和自动发现对比

Docker 部署反向代理的核心问题是:容器 IP 每次重启都会变,手动配 upstream 不现实。Nginx 需要手动更新配置,Traefik 能自动发现容器。选哪个取决于你的场景。Nginx:手动配置但性能最强基本反向代理# docker-compose.ymlservices: nginx: image: nginx:1.25 ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro networks: - frontend app1: image: myapp:latest networks: - frontend app2: image: another-app:latest networks: - frontendnetworks: frontend:# nginx/conf.d/default.confupstream app1 { server app1:3000;}upstream app2 { server app2:8080;}server { listen 80; server_name app1.example.com; location / { proxy_pass http://app1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}server { listen 80; server_name app2.example.com; location / { proxy_pass http://app2; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}Docker Compose 的服务名(app1、app2)会自动解析为容器 IP——不需要硬编码 IP。但容器重启后如果 upstream 数量变了(比如扩容),Nginx 不会自动感知。SSL 配置(Let's Encrypt)手动获取证书:certbot certonly --standalone -d example.comserver { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; location / { proxy_pass http://app1; }}问题:证书 90 天过期,需要 cron 定时续期 + docker exec nginx nginx -s reload。Nginx 什么时候合适流量非常大,需要极致性能配置不频繁变动团队熟悉 NginxTraefik:自动发现的反向代理Traefik 的核心卖点:容器启动/停止时自动更新路由,不需要改配置文件或重启代理。基本配置services: traefik: image: traefik:v2.10 command: - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" ports: - "80:80" - "443:443" - "8080:8080" # Dashboard volumes: - /var/run/docker.sock:/var/run/docker.sock:ro app1: image: myapp:latest labels: - "traefik.enable=true" - "traefik.http.routers.app1.rule=Host(`app1.example.com`)" - "traefik.http.routers.app1.entrypoints=web" - "traefik.http.services.app1.loadbalancer.server.port=3000"关键点:--providers.docker=true 启用 Docker Provider,自动监听容器事件exposedbydefault=false 只代理有 traefik.enable=true 标签的容器容器的路由规则通过 labels 配置,不需要单独的配置文件自动 SSL(Let's Encrypt)services: traefik: command: - "--certificatesresolvers.le.acme.email=you@example.com" - "--certificatesresolvers.le.acme.storage=/acme.json" - "--certificatesresolvers.le.acme.tlschallenge=true" volumes: - ./acme.json:/acme.json app1: labels: - "traefik.http.routers.app1.tls=true" - "traefik.http.routers.app1.tls.certresolver=le"Traefik 自动申请和续期证书——不需要 cron,不需要手动 reload。这是 Traefik 相比 Nginx 最大的优势。负载均衡同一个服务多个实例,Traefik 自动负载均衡: app1: deploy: replicas: 3 labels: - "traefik.http.services.app1.loadbalancer.server.port=3000"3 个副本自动分摊流量,扩容缩容 Traefik 实时感知。Caddy:最简配置Caddy 的卖点是一个 Caddyfile 搞定反向代理 + 自动 HTTPS:services: caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/datavolumes: caddy_data:# Caddyfileapp1.example.com { reverse_proxy app1:3000}app2.example.com { reverse_proxy app2:8080}就这样——Caddy 自动申请 Let's Encrypt 证书、自动续期、自动 HTTP→HTTPS 重定向。比 Nginx 少 90% 的配置。局限:不如 Nginx 灵活(复杂的 rewrite/条件判断不好写),不如 Traefik 自动化(不自动发现容器),适合简单的反向代理场景。选择决策| 场景 | 推荐 | 原因 ||------|------|------|| 简单反向代理 + HTTPS | Caddy | 配置最少,自动 HTTPS || 容器频繁变动 | Traefik | 自动发现、自动配置 || 流量极大 | Nginx | 性能最强 || 需要 Let's Encrypt | Traefik 或 Caddy | Nginx 需要手动续期 || 已有 Nginx 运维经验 | Nginx | 熟悉的工具不容易出问题 || Docker Compose 项目 | Traefik | labels 配置和 compose 一体 || Kubernetes 环境 | Nginx Ingress | K8s 生态标配 |起步建议:Docker Compose 项目用 Traefik,省心省力。需要极致性能或复杂路由时切换 Nginx。
服务端阅读 06月5日 22:29

Docker 容器怎么监控?Prometheus + Grafana 完整方案

容器出问题了,docker stats 只能看到 CPU 和内存——磁盘 IO、网络、进程状态全不知道。完整的监控系统需要指标采集、存储、可视化、告警四层。Docker 监控的四层架构容器 → 采集器 → 存储后端 → 可视化 cAdvisor Prometheus Grafana Node Loki Exporter采集层:从容器和宿主机收集指标数据存储层:时序数据库存指标,日志库存日志可视化层:Dashboard 展示趋势、图表告警层:超过阈值自动通知指标采集:cAdvisor + Node ExportercAdvisor——容器指标Google 出品,自动发现所有容器,采集 CPU、内存、网络、磁盘 IO:services: cadvisor: image: gcr.io/cadvisor/cadvisor:latest ports: - "8080:8080" volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:rocAdvisor 自带一个简单的 Web UI(http://localhost:8080),可以看每个容器的实时指标。Node Exporter——宿主机指标cAdvisor 只管容器,宿主机本身的 CPU、内存、磁盘、网络由 Node Exporter 采集:services: node-exporter: image: prom/node-exporter:latest ports: - "9100:9100" volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--path.rootfs=/rootfs'Prometheus——指标存储和查询Prometheus 定时从 cAdvisor 和 Node Exporter 拉取指标,存到自己的时序数据库:services: prometheus: image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus_data:/prometheusvolumes: prometheus_data:# prometheus/prometheus.ymlglobal: scrape_interval: 15sscrape_configs: - job_name: 'cadvisor' static_configs: - targets: ['cadvisor:8080'] - job_name: 'node' static_configs: - targets: ['node-exporter:9100'] - job_name: 'app' static_configs: - targets: ['app:8080']scrape_interval: 15s 表示每 15 秒采集一次。容器数量多时可以调大到 30s-60s 减少负载。常用 PromQL 查询# 所有容器的 CPU 使用率rate(container_cpu_usage_seconds_total{name!=""}[5m]) * 100# 容器内存使用量(MB)container_memory_usage_bytes{name!=""} / 1024 / 1024# 宿主机磁盘使用率(1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100# 容器网络流入速率rate(container_network_receive_bytes_total[5m])Grafana——可视化 DashboardPrometheus 的 UI 只适合临时查询。正式的监控面板用 Grafana:services: grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana_data:/var/lib/grafana配置步骤:打开 http://localhost:3000(默认 admin/admin)添加数据源 → Prometheus → URL 填 http://prometheus:9090导入 Dashboard:推荐 ID 11600(Docker 监控)和 1860(Node Exporter 全量)导入方式:Dashboard → Import → 输入 ID → Load → 选择 Prometheus 数据源告警:AlertmanagerPrometheus 本身只负责判断规则,告警通知由 Alertmanager 发送:# prometheus/alert_rules.ymlgroups: - name: docker_alerts rules: - alert: ContainerCpuHigh expr: rate(container_cpu_usage_seconds_total{name!=""}[5m]) * 100 > 80 for: 5m labels: severity: warning annotations: summary: "容器 {{ $labels.name }} CPU 超过 80%" - alert: ContainerMemoryHigh expr: container_memory_usage_bytes / container_spec_memory_limit_bytes * 100 > 90 for: 5m labels: severity: critical - alert: DiskSpaceLow expr: (1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 > 85 for: 10mfor: 5m 表示持续 5 分钟才告警——避免短暂波动误报。完整的 docker-compose 监控栈version: "3.8"services: prometheus: image: prom/prometheus:latest volumes: - ./prometheus:/etc/prometheus - prometheus_data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.retention.time=30d' grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000" volumes: - grafana_data:/var/lib/grafana cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro node-exporter: image: prom/node-exporter:latest volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' alertmanager: image: prom/alertmanager:latest volumes: - ./alertmanager:/etc/alertmanagervolumes: prometheus_data: grafana_data:这套组合约需 2-3GB 内存,覆盖了指标采集、存储、可视化、告警四层。监控方案选择| 规模 | 推荐方案 | 内存需求 ||------|---------|---------|| 单机开发 | docker stats + cAdvisor Web | < 500MB || 小团队(<20 容器) | Prometheus + Grafana + cAdvisor | 1-2GB || 中等规模 | 完整监控栈 + Alertmanager | 2-4GB || 大规模/生产 | Kubernetes + Prometheus Operator | 按需扩展 |起步建议:先跑 cAdvisor + Prometheus + Grafana 三件套,够用了再加告警。
服务端阅读 06月5日 22:29

Docker 容器日志怎么聚合?ELK、Loki 和 EFK 怎么选

容器一多,日志分散在各处——docker logs 只能看单个容器的输出,排查问题要一个一个翻。日志聚合把所有容器的日志集中到一个地方,搜索、过滤、告警一站搞定。Docker 日志驱动:日志的入口Docker 支持多种日志驱动,决定容器日志的去向:# 查看当前日志驱动docker info --format '{{.LoggingDriver}}'# 默认是 json-file# docker-compose.yml 全局配置services: app: logging: driver: json-file options: max-size: "10m" # 单个日志文件最大 10MB max-file: "3" # 最多保留 3 个文件json-file 默认不限制大小——跑久了磁盘会被日志撑满。务必配 max-size 和 max-file。其他日志驱动| 驱动 | 去向 | 适用场景 ||------|------|---------|| json-file | 本地文件 | 默认,简单场景 || local | 本地文件(压缩) | 省磁盘 || journald | systemd journal | CentOS/Ubuntu 系统日志 || fluentd | Fluentd | 接入日志聚合栈 || syslog | syslog 服务 | 传统运维 |生产环境推荐用 fluentd 或 local 驱动——前者直接接入日志聚合,后者比 json-file 省磁盘。ELK Stack:经典方案Elasticsearch + Logstash + Kibana,功能最全但最重:# docker-compose.ymlservices: elasticsearch: image: elasticsearch:8.12.0 environment: - discovery.type=single-node - xpack.security.enabled=false ports: - "9200:9200" volumes: - es_data:/usr/share/elasticsearch/data logstash: image: logstash:8.12.0 volumes: - ./logstash/pipeline:/usr/share/logstash/pipeline ports: - "5044:5044" kibana: image: kibana:8.12.0 ports: - "5601:5601" environment: - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 app: image: myapp:latest logging: driver: fluentd options: fluentd-address: localhost:24224 tag: myappvolumes: es_data:问题:ELK 最少需要 4GB 内存才能跑起来。小团队或开发环境用太重了。EFK Stack:轻量替代用 Fluentd 替代 Logstash,更省资源:services: fluentd: image: fluent/fluentd:v1.16 volumes: - ./fluentd/conf:/fluentd/etc ports: - "24224:24224" environment: - FLUENTD_CONF=fluent.conf# fluentd/conf/fluent.conf<source> @type forward port 24224</source><match **> @type elasticsearch host elasticsearch port 9200 logstash_format true logstash_prefix fluentd <buffer> @type file path /var/log/fluentd/buffer flush_interval 5s </buffer></match>容器配置 logging.driver: fluentd 后,所有 stdout/stderr 输出自动发到 Fluentd,Fluentd 转存到 Elasticsearch。Grafana Loki:最轻量的选择Loki 只索引标签不索引日志内容,存储成本比 Elasticsearch 低 10 倍以上:# docker-compose.ymlservices: loki: image: grafana/loki:2.9.0 ports: - "3100:3100" command: -config.file=/etc/loki/local-config.yaml promtail: image: grafana/promtail:2.9.0 volumes: - /var/log:/var/log - /var/lib/docker/containers:/var/lib/docker/containers:ro - ./promtail/config.yml:/etc/promtail/config.yml command: -config.file=/etc/promtail/config.yml grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=adminPromtail 从 Docker 容器日志目录读取日志,推送到 Loki。Grafana 查询和可视化。Loki 的查询语法(LogQL)比 Kibana 的 KQL 简单:{container_name="myapp"} |= "error" | json | line_format "{{.message}}"意思是:从 myapp 容器日志中过滤包含 "error" 的行,解析 JSON,只显示 message 字段。日志结构化:让聚合更有效非结构化日志在聚合平台里只能做全文搜索。结构化日志可以按字段过滤、聚合统计:# Python 结构化日志(JSON 格式)import loggingimport jsonclass JSONFormatter(logging.Formatter): def format(self, record): return json.dumps({ "timestamp": self.formatTime(record), "level": record.levelname, "message": record.getMessage(), "service": "user-service", "trace_id": getattr(record, "trace_id", None), })// Node.js 用 pino 直接输出 JSONconst logger = require('pino')({ level: 'info', formatters: { level: (label) => ({ level: label }) },})logger.info({ userId: 123, action: 'login' }, 'User logged in')选择决策| 场景 | 推荐方案 | 理由 ||------|---------|------|| 开发/测试 | docker logs + json-file | 够用 || 小团队(<10 服务) | Grafana Loki + Promtail | 轻量,1GB 内存够 || 中大型团队 | EFK Stack | 功能全、社区大 || 需要全文搜索 | ELK Stack | Elasticsearch 全文检索最强 || 已有 Grafana | 直接加 Loki | 不用额外装 Kibana || 日志量巨大 | Loki(只索引标签) | 存储成本最低 |起步建议:先用 Loki + Grafana,轻量够用。等日志量大到需要全文搜索时再迁移到 ELK。
服务端阅读 06月5日 22:29

Docker 容器配置怎么管理?环境变量、挂载和配置中心怎么选

Docker 容器的配置管理不是把配置写死在镜像里——那样每次改配置都得重新构建。正确做法是配置与镜像分离,容器启动时注入配置,运行时能动态更新。这篇讲清楚 Docker 环境下四种配置管理方式的适用场景和实现方法。环境变量:最简单的配置注入适合少量、扁平的配置项(数据库地址、端口、开关):# docker-compose.ymlservices: app: image: myapp:latest environment: - DB_HOST=postgres - DB_PORT=5432 - LOG_LEVEL=info - FEATURE_FLAG=true或者用 .env 文件:# .envDB_HOST=postgresDB_PORT=5432LOG_LEVEL=infoservices: app: env_file: .env优势:简单、Docker 原生支持、docker-compose 直接读取局限:只有字符串值、不能表示嵌套结构、修改需要重启容器敏感信息别放环境变量环境变量会被 docker inspect 暴露,也会出现在进程列表里。密码、密钥等用 Docker Secret 或文件挂载:# Docker Swarm Secretecho "my_password" | docker secret create db_password -services: app: secrets: - db_passwordsecrets: db_password: external: true配置文件挂载:复杂配置的首选当配置是结构化的(YAML、JSON、TOML),用 Volume 挂载比环境变量更合适:services: app: image: myapp:latest volumes: - ./config/app.yml:/app/config/app.yml:ro - ./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro:ro 表示只读挂载——容器不能修改配置文件,只能读取。防止容器内进程意外修改配置。只挂载需要的文件,别挂载整个目录# 好:只挂载需要的文件volumes: - ./config/app.yml:/app/config/app.yml:ro# 差:挂载整个目录,可能暴露无关文件volumes: - ./config:/app/config:ro配置热更新:不重启容器更新配置挂载的文件修改后,容器内立即可见——但应用是否自动重新加载取决于应用本身:Nginx:docker exec nginx nginx -s reloadSpring Boot:配合 Spring Cloud Config 自动刷新Node.js:用 chokidar 监听文件变化如果应用不支持热加载,可以配合 inotifywait 检测文件变化后发送信号:#!/bin/bashinotifywait -m -e modify /app/config/app.yml | while read event; do kill -SIGHUP 1 # 发送 HUP 信号给主进程done配置中心:分布式系统的统一配置多服务、多实例的场景,配置文件挂载管理成本太高——改一个配置要同步到所有机器。配置中心解决的就是这个问题。Consul + Consul-Template# docker-compose.ymlservices: consul: image: consul:1.15 ports: - "8500:8500" command: agent -dev -client=0.0.0.0 app: image: myapp:latest volumes: - ./templates:/templates command: > consul-template -consul-addr=consul:8500 -template="/templates/app.ctmpl:/app/config/app.yml:docker restart app"consul-template 监听 Consul KV 变化,自动重新生成配置文件并触发容器重启。Etcd + Confd和 Consul-Template 类似的模式,Etcd 做存储,Confd 做模板渲染:# 写入配置etcdctl set /myapp/db_host "postgres.prod"# confd 读取 etcd 并渲染模板confd -onetime -backend etcd -node http://etcd:2379Spring Cloud Config ServerJava 生态的标准方案:services: config-server: image: springcloud/configserver ports: - "8888:8888" environment: - SPRING_CLOUD_CONFIG_SERVER_GIT_URI=https://github.com/org/config-repo app: image: my-spring-app environment: - SPRING_CLOUD_CONFIG_URI=http://config-server:8888配置存在 Git 仓库里,有版本历史。应用启动时从 Config Server 拉取配置,配合 /actuator/refresh 端点实现热更新。Kubernetes ConfigMap 和 Secret如果跑在 K8s 上,环境变量和文件挂载都由 ConfigMap/Secret 管理:# ConfigMapapiVersion: v1kind: ConfigMapmetadata: name: app-configdata: DB_HOST: postgres app.yml: | server: port: 8080 logging: level: info---# Pod 使用 ConfigMapapiVersion: v1kind: Podspec: containers: - name: app envFrom: - configMapRef: name: app-config volumeMounts: - name: config mountPath: /app/config volumes: - name: config configMap: name: app-configSecret 和 ConfigMap 用法一样,但值是 base64 编码的,且访问可以加 RBAC 控制:kubectl create secret generic db-credentials \ --from-literal=username=admin \ --from-literal=password=s3cretConfigMap/Secret 更新后 Pod 不会自动重启——需要手动 kubectl rollout restart 或用 Reloader 之类的工具自动触发。选择决策| 场景 | 推荐方案 | 原因 ||------|---------|------|| 单容器、少量配置 | 环境变量 + .env | 最简单 || 单容器、复杂配置 | Volume 挂载配置文件 | 支持结构化配置 || 多容器、同一台主机 | docker-compose + 挂载 | compose 管理方便 || 多主机、多服务 | Consul/Etcd 配置中心 | 统一管理、动态更新 || Kubernetes 环境 | ConfigMap + Secret | K8s 原生方案 || 敏感信息 | Docker Secret / K8s Secret | 不暴露在环境变量里 |原则:配置与代码分离,敏感信息加密,变更可追溯。能用简单的就不用复杂的——别为了用 Consul 而用 Consul。
服务端阅读 06月5日 22:02

TypeORM 查询该用 find 还是 QueryBuilder?三种方式适用场景对比

TypeORM 查询数据有三条路:find 系列方法、QueryBuilder、原生 SQL。很多人上来就用 QueryBuilder,其实 80% 的查询 find 就够了——更简洁、类型安全、不容易出错。这篇把三种方式的适用边界和常见坑讲清楚。find:日常查询的首选基础查询// 查所有const users = await userRepository.find();// 按 ID 查一条const user = await userRepository.findOne({ where: { id: 1 } });// 按条件查const activeUsers = await userRepository.find({ where: { active: true },});条件组合import { And, Or, LessThan, MoreThan, Like, Between, In, IsNull } from 'typeorm';// AND 条件——多个字段自动 ANDconst users = await userRepository.find({ where: { active: true, age: MoreThan(18), },});// OR 条件——数组内自动 ORconst users = await userRepository.find({ where: [ { role: 'admin' }, { age: MoreThan(30) }, ],});// 常用 FindOperatorconst users = await userRepository.find({ where: { name: Like('%john%'), // 模糊搜索 id: In([1, 2, 3]), // IN 查询 createdAt: Between(start, end), // 范围查询 deletedAt: IsNull(), // IS NULL },});find 的 OR 有个坑:数组里每个元素是一个独立的 OR 分支,不是字段级别的 OR。如果你需要 role = 'admin' AND (age > 30 OR name LIKE '%john%') 这种混合逻辑,find 写不出来——得上 QueryBuilder。选择字段const users = await userRepository.find({ select: { id: true, name: true, email: true, // 不选 password 等敏感字段 }, where: { active: true },});排序和分页const users = await userRepository.find({ where: { active: true }, order: { createdAt: 'DESC' }, skip: 0, take: 20,});// 带总数的分页——一次查询拿到数据 + 总数const [users, total] = await userRepository.findAndCount({ where: { active: true }, order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize,});加载关联// 简单关联const users = await userRepository.find({ relations: { posts: true, profile: true },});// 嵌套关联const users = await userRepository.find({ relations: { posts: { comments: true } },});find 的 N+1 陷阱find 加载关联时会发多条 SQL——每个关联单独查一次。如果查 100 个用户且关联了 posts,可能产生几十条查询:// 可能触发 N+1const users = await userRepository.find({ relations: { posts: true },});// SQL 1: SELECT * FROM user// SQL 2: SELECT * FROM post WHERE userId IN (1, 2, 3, ... 100)TypeORM 0.3+ 已经优化了这个问题——会用 IN 批量查而不是逐个查。但如果关联层级很深(用户 → 文章 → 评论 → 作者),查询数仍然会膨胀。深层关联建议用 QueryBuilder 的 leftJoinAndSelect 一次性 JOIN。QueryBuilder:find 搞不定的场景关联查询带条件过滤find 的 relations 只能全量加载,不能过滤。QueryBuilder 可以:// 只查有已发布文章的用户const users = await userRepository .createQueryBuilder('user') .innerJoinAndSelect( 'user.posts', 'post', 'post.isPublished = :published', { published: true }, ) .getMany();第三个参数是 JOIN 条件——find 做不到这个。条件组合:AND + OR + 括号const users = await userRepository .createQueryBuilder('user') .where('user.active = :active', { active: true }) .andWhere( new Brackets((qb) => { qb.where('user.role = :admin', { admin: 'admin' }).orWhere( 'user.createdAt > :date', { date: '2024-01-01' }, ); }), ) .getMany();// WHERE active = true AND (role = 'admin' OR createdAt > '2024-01-01')Brackets 生成括号——保证 OR 的优先级正确。没有它,AND 和 OR 的优先级会让你拿到错误的结果。聚合查询const stats = await userRepository .createQueryBuilder('user') .select('user.role', 'role') .addSelect('COUNT(*)', 'count') .addSelect('AVG(user.age)', 'avgAge') .groupBy('user.role') .getRawMany();// 返回: [{ role: 'admin', count: '5', avgAge: '32.5' }, ...]聚合查询必须用 getRawMany()——返回原始数据库行,字段类型都是字符串。getMany() 返回实体对象,但聚合结果不是实体,强用会出错。子查询const users = await userRepository .createQueryBuilder('user') .where((qb) => { const subQuery = qb .subQuery() .select('post.authorId') .from(Post, 'post') .groupBy('post.authorId') .having('COUNT(post.id) > :count') .getQuery(); return `user.id IN ${subQuery}`; }) .setParameter('count', 5) .getMany();QueryBuilder 的类型安全坑find 是完全类型安全的——where 里的字段名写错了 TypeScript 直接报错。QueryBuilder 的 SQL 片段是字符串,写错了只有运行时才知道:// 字段名拼错了,TypeScript 不会报错,运行时才炸.where('user.actve = :active', { active: true })// ↑ typo: active vs actve建议:QueryBuilder 的 SQL 片段尽量短,把条件值用参数传递(:param),减少字符串拼写出错的可能。原生 SQL:最后的手段QueryBuilder 也搞不定时,直接写 SQL:// 查询const result = await dataSource.query( 'SELECT * FROM user WHERE created_at > $1', [startDate],);// 事务中执行const result = await queryRunner.query( 'UPDATE user SET last_login = NOW() WHERE id = $1', [userId],);什么时候用原生 SQL:数据库特有的函数或语法(如 PostgreSQL 的 JSONB 操作、UPSERT)复杂的窗口函数(ROW_NUMBER() OVER (...))需要极致优化的性能关键查询迁移老项目的 SQL 不想重写注意:$1 是 PostgreSQL 占位符,MySQL 用 ?。原生 SQL 没有方言抽象——换数据库要手动改。性能对比同样的查询,三种方式的性能差异:// 1. find —— 最慢(多条 SQL + 实体转换开销)const users = await userRepository.find({ where: { active: true }, relations: { posts: true } });// 2. QueryBuilder —— 中等(一条 JOIN SQL,但仍有实体转换)const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.active = :active', { active: true }) .getMany();// 3. 原生 SQL —— 最快(一条 SQL,跳过实体转换)const rows = await dataSource.query( 'SELECT u.*, p.id as post_id, p.title FROM user u LEFT JOIN post p ON p.userId = u.id WHERE u.active = $1', [true],);差距不大时(毫秒级),优先用 find 或 QueryBuilder——可维护性和类型安全比那几毫秒更有价值。只有查询确实是瓶颈时才用原生 SQL 优化。选择决策| 场景 | 用什么 | 原因 ||------|--------|------|| 单表简单查询 | find | 简洁、类型安全 || 单表条件组合 | find + FindOperator | In、Like、Between 够用 || 多表关联 + 过滤 | QueryBuilder | find 的 relations 不能加条件 || 聚合/分组 | QueryBuilder | find 不支持 GROUP BY || 子查询 | QueryBuilder | find 不支持 || 复杂 OR + AND 混合 | QueryBuilder | find 的 OR 只支持数组级 || 数据库特有语法 | 原生 SQL | TypeORM 不覆盖所有特性 || 性能关键查询 | 原生 SQL | 跳过实体转换 |经验法则:先试 find,搞不定再上 QueryBuilder,最后才用原生 SQL。层级越低灵活性越高,但可维护性和类型安全越差。
服务端阅读 06月5日 22:01

Next.js 状态管理该用哪个方案?Server Component 到 Zustand 怎么选

Next.js App Router 引入了 Server Components 后,状态管理的思路和纯 SPA 完全不同。很多数据根本不需要客户端状态——服务端直接获取、渲染、返回 HTML,客户端零 JS 开销。只有真正需要交互的数据才用客户端状态管理。先问:这个状态真的需要客户端管理吗?| 场景 | 方案 | 需要 JS 吗 ||------|------|-----------|| 页面初始数据 | Server Component 直接 fetch | 不需要 || 用户个人信息 | Server Component + cookies | 不需要 || 表单输入值 | useState | 需要 || 弹窗开关 | useState | 需要 || 跨页面共享的购物车 | URL 参数 / Cookie / 外部状态库 | 看场景 |原则:能用 Server Component 解决的不用客户端状态。每多一个客户端状态,就多一份 JS 发送到浏览器。第一选择:URL 状态搜索关键词、分页页码、筛选条件——这些状态天然属于 URL:// app/products/page.tsxexport default async function ProductsPage({ searchParams,}: { searchParams: { q?: string; page?: string }}) { const products = await searchProducts({ query: searchParams.q, page: Number(searchParams.page) || 1, }) return <ProductList products={products} />}URL 状态的优势:可分享:用户复制链接给别人,状态不丢浏览器后退/前进天然支持SEO 友好:搜索引擎能抓到分页和筛选状态不需要 JS:Server Component 直接读取 searchParamsClient Component 中更新 URL 状态'use client'import { useSearchParams, useRouter } from 'next/navigation'function SearchBar() { const searchParams = useSearchParams() const router = useRouter() function handleSearch(query: string) { const params = new URLSearchParams(searchParams) params.set('q', query) params.delete('page') // 搜索时重置分页 router.push(`/products?${params.toString()}`) } return <input defaultValue={searchParams.get('q') || ''} onChange={(e) => handleSearch(e.target.value)} />}第二选择:Server Component 传递 props父组件获取数据,通过 props 传给子组件——最简单的状态传递方式:// app/dashboard/page.tsxexport default async function DashboardPage() { const [stats, recentOrders] = await Promise.all([ fetchStats(), fetchRecentOrders(), ]) return ( <div> <StatsCard stats={stats} /> <RecentOrders orders={recentOrders} /> </div> )}Promise.all 并行获取,不需要任何状态库。子组件拿到的是渲染好的数据,不需要 loading 状态。第三选择:React Context跨组件共享数据,但不需要跨页面共享:// providers/theme-provider.tsx'use client'import { createContext, useContext, useState } from 'react'type Theme = 'light' | 'dark'const ThemeContext = createContext<{ theme: Theme toggleTheme: () => void}>({ theme: 'light', toggleTheme: () => {} })export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light') return ( <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'), }}> {children} </ThemeContext.Provider> )}export function useTheme() { return useContext(ThemeContext)}在根 layout 中注册:// app/layout.tsximport { ThemeProvider } from '@/providers/theme-provider'export default function RootLayout({ children }) { return ( <html> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> )}注意:Context Provider 必须是 Client Component,包在 Server Component 的 layout 里使用。这会导致 Provider 下面的所有子组件在客户端渲染——所以只放必要的 Provider,不要把整个应用包在一个巨大的 Provider 树里。第四选择:轻量外部状态库当 Context 不够用时(跨页面、需要 devtools、需要中间件),用 Zustand——最小最轻的 React 状态库:npm install zustand// store/cart-store.tsimport { create } from 'zustand'interface CartItem { id: string name: string price: number quantity: number}interface CartStore { items: CartItem[] addItem: (item: Omit<CartItem, 'quantity'>) => void removeItem: (id: string) => void clearCart: () => void total: () => number}export const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set((state) => { const existing = state.items.find(i => i.id === item.id) if (existing) { return { items: state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ), } } return { items: [...state.items, { ...item, quantity: 1 }] } }), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id), })), clearCart: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),}))// components/cart-button.tsx'use client'import { useCartStore } from '@/store/cart-store'export function CartButton() { const items = useCartStore((s) => s.items) const total = useCartStore((s) => s.total) return ( <button>购物车 ({items.length}) ¥{total()}</button> )}Zustand 的优势:不需要 Provider 包裹,任何 Client Component 里直接 useCartStore()。Zustand 持久化到 localStorageimport { create } from 'zustand'import { persist } from 'zustand/middleware'export const useCartStore = create( persist<CartStore>( (set, get) => ({ // ... store 定义 }), { name: 'cart-storage', // localStorage key } ))刷新页面后购物车数据不丢失。但注意 SSR hydration 不匹配的问题——首次渲染用空状态,hydration 后从 localStorage 读取:// 解决 hydration 不匹配function CartButton() { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) if (!mounted) return <button>购物车</button> const count = useCartStore((s) => s.items.length) return <button>购物车 ({count})</button>}Server Actions 替代表单状态传统方式:表单输入 → 客户端状态 → 提交 → API 调用。Server Actions 简化了这整个流程:// app/products/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function createProduct(formData: FormData) { const name = formData.get('name') as string const price = Number(formData.get('price')) await db.product.create({ data: { name, price } }) revalidateTag('products')}// app/products/page.tsximport { createProduct } from './actions'export function AddProductForm() { return ( <form action={createProduct}> <input name="name" required /> <input name="price" type="number" required /> <button type="submit">添加</button> </form> )}不需要 useState 管理表单值,不需要 fetch 调 API,提交后自动重新验证缓存。这是 App Router 推荐的表单处理方式。状态管理选择流程需要跨页面共享吗?├── 不需要 → 页面级用 Server Component,组件级用 useState└── 需要 → 能放 URL 吗? ├── 能 → URL 参数(searchParams) └── 不能 → 需要持久化吗? ├── 不需要 → React Context └── 需要 → Zustand + persist各方案对比| 方案 | 跨页面 | 持久化 | 需要 JS | 复杂度 ||------|--------|--------|---------|--------|| Server Component | 否 | - | 不需要 | 最低 || URL 参数 | 是 | 是 | 最少 | 低 || useState | 否 | 否 | 需要 | 低 || React Context | 是(同一 Provider 树) | 否 | 需要 | 中 || Zustand | 是 | 可选 | 需要 | 中 || Redux | 是 | 可选 | 需要 | 高 |Next.js 项目中,80% 的状态可以用 Server Component + URL 参数解决。只有真正需要在客户端管理交互状态时,才用 Context 或 Zustand。不要上来就装 Redux——那是 SPA 时代的思维。
服务端阅读 06月5日 21:52

Next.js 错误处理全指南:error.tsx、global-error 和 API 错误分层方案

Next.js 应用出错时,用户看到的不能是一个白屏或一堆报错代码。App Router 提供了分层错误处理机制——从组件级到全局级,每一层都有专门的文件处理。搞清这些层级,就能让错误发生时用户仍然能看到有意义的提示,而不是整个应用崩溃。错误处理层级组件内 try/catch → 处理可预期的业务错误 ↓ 未捕获error.tsx → 路由级 Error Boundary ↓ 仍未捕获global-error.tsx → 全局兜底(根 layout 也崩了)从内到外,每一层兜住上一层没处理的错误。error.tsx:路由级错误边界App Router 中,每个路由段可以有一个 error.tsx,捕获该路由段及其子组件的运行时错误:// app/dashboard/error.tsx'use client' // 必须是 Client Componentexport default function DashboardError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <div className="error-container"> <h2>出错了</h2> <p>{error.message}</p> <button onClick={reset}>重试</button> </div> )}关键点:error.tsx 必须是 Client Component('use client')——Error Boundary 是客户端概念reset 函数重新执行渲染该路由段的尝试——适合临时性错误(网络波动)error.digest 是 Next.js 生成的错误哈希,可以在日志中追踪error.tsx 捕获什么子组件的运行时错误子组件中 Server Component 的数据获取失败子路由的 page.tsx 抛出的错误error.tsx 不捕获什么同级 layout.tsx 的错误——layout 在 error boundary 外面loading.tsx 不会影响 error.tsx根 app/layout.tsx 的错误——需要 global-error.tsxglobal-error.tsx:最后的兜底当根 layout 崩溃时,error.tsx 也渲染不了(因为 layout 本身都挂了)。这时需要 global-error.tsx:// app/global-error.tsx'use client'export default function GlobalError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <html> <body> <h2>应用发生了严重错误</h2> <button onClick={reset}>重新加载</button> </body> </html> )}global-error.tsx 必须自带 <html> 和 <body>——因为根 layout 已经崩溃,什么都复用不了。这个文件应该尽量简单,不要引入任何可能也出错的组件。Server Component 中的错误处理Server Component 里用 try/catch 处理可预期的错误:// app/dashboard/page.tsxexport default async function DashboardPage() { let stats try { stats = await fetchDashboardStats() } catch { stats = { activeUsers: 0, revenue: 0 } // 降级数据 } return ( <div> <h1>仪表盘</h1> <StatsCard data={stats} /> </div> )}可预期的错误(API 超时、数据不存在)用 try/catch 降级处理,不让它冒泡到 error.tsx。只有真正无法恢复的错误才让 Error Boundary 接管。API Route 的错误处理基本错误响应// app/api/users/route.tsimport { NextResponse } from 'next/server'export async function GET(request: Request) { try { const users = await fetchUsers() return NextResponse.json(users) } catch (error) { console.error('Failed to fetch users:', error) return NextResponse.json( { error: '获取用户列表失败' }, { status: 500 } ) }}业务异常封装// lib/errors.tsexport class AppError extends Error { constructor( public statusCode: number, public code: string, message: string, ) { super(message) }}// app/api/users/route.tsexport async function POST(request: Request) { const body = await request.json() if (!body.email) { throw new AppError(400, 'MISSING_EMAIL', '邮箱不能为空') } if (await isEmailTaken(body.email)) { throw new AppError(409, 'EMAIL_TAKEN', '邮箱已被注册') } const user = await createUser(body) return NextResponse.json(user, { status: 201 })}全局错误中间件在 middleware 或单独的 error handler 中统一处理 AppError:// 在 route handler 外层包一个 wrapperfunction withErrorHandler(handler: Function) { return async (...args: any[]) => { try { return await handler(...args) } catch (error) { if (error instanceof AppError) { return NextResponse.json( { error: error.code, message: error.message }, { status: error.statusCode } ) } console.error('Unexpected error:', error) return NextResponse.json( { error: 'INTERNAL_ERROR', message: '服务器内部错误' }, { status: 500 } ) } }}// 使用export const POST = withErrorHandler(async (request: Request) => { const body = await request.json() // ... 业务逻辑,直接 throw AppError})notFound:比 Error Boundary 更优雅的"错误""找不到"不是错误,是一种正常状态。用 notFound() 比抛异常更语义化:// app/products/[id]/page.tsximport { notFound } from 'next/navigation'export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id) if (!product) { notFound() // 触发 not-found.tsx,不触发 error.tsx } return <ProductDetail product={product} />}自定义 not-found 页面:// app/not-found.tsxexport default function NotFound() { return ( <div className="not-found"> <h2>页面不存在</h2> <a href="/">返回首页</a> </div> )}notFound() 的好处:它不会在日志中记录为错误,不会触发 Error Boundary,返回 404 状态码——语义更准确。Client Component 中的错误处理Client Component 里可以用 React 的 Error Boundary class 组件做更细粒度的捕获:// components/error-boundary.tsx'use client'import { Component } from 'react'type Props = { fallback: React.ReactNode children: React.ReactNode}export class ErrorBoundary extends Component<Props, { hasError: boolean }> { state = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } render() { if (this.state.hasError) { return this.props.fallback } return this.props.children }}// 使用<ErrorBoundary fallback={<p>加载失败</p>}> <Chart data={data} /></ErrorBoundary>这比 error.tsx 更灵活——可以包在单个组件外面,不影响整个页面。错误监控和日志生产环境中,错误信息不能只在前端打印——需要上报到监控系统:// app/error.tsx'use client'import { useEffect } from 'react'export default function Error({ error, reset }: { error: Error; reset: () => void }) { useEffect(() => { // 上报错误到监控系统 reportError(error) }, [error]) return ( <div> <h2>出了点问题</h2> <button onClick={reset}>重试</button> </div> )}Server Component 中的错误上报:// app/dashboard/page.tsxexport default async function DashboardPage() { try { const data = await fetchData() return <Dashboard data={data} /> } catch (error) { reportError(error) // 上报 throw error // 让 error.tsx 处理 UI }}错误处理策略速查| 错误类型 | 处理方式 | 文件位置 ||---------|---------|---------|| 数据获取失败 | try/catch 降级 | Server Component 内 || 路由级运行时错误 | error.tsx | app/[segment]/error.tsx || 根 layout 崩溃 | global-error.tsx | app/global-error.tsx || 页面不存在 | notFound() + not-found.tsx | app/not-found.tsx || API 错误 | try/catch + 状态码 | route.ts 内 || 单组件错误 | ErrorBoundary 包裹 | 任意 Client Component || 未预期错误 | 上报监控 + 兜底 UI | error.tsx + 监控 |
服务端阅读 06月5日 21:51

Next.js 缓存全解析:四层缓存机制和失效策略实战

Next.js 的缓存机制是它的性能杀手锏,也是最容易让人困惑的部分。一个 fetch 请求可能被缓存 5 秒也可能永久缓存,取决于一行配置。搞不清缓存层级,就会出现"数据改了但页面没更新"的诡异问题。四层缓存,各有各的失效机制请求进来 → 请求记忆(同一渲染周期内的去重) → 数据缓存(fetch 结果持久化) → 路由缓存(Router Cache,客户端缓存) → 完整路由缓存(构建时静态化)从上到下,缓存粒度从细到粗。任何一层命中都不会继续往下查。第一层:请求记忆(Request Memoization)同一个 Server Component 渲染周期内,多次 fetch 同一个 URL 只会真正发一次请求:// 这两个 fetch 在同一个渲染周期只会发一次网络请求async function Layout() { const user = await fetch('/api/user') // 真正请求 // ...}async function Page() { const user = await fetch('/api/user') // 命中记忆,不请求 // ...}这是 React 的自动去重机制,不需要你做任何配置。只在 Server Component 的单次渲染内有效——渲染完成后记忆清除,下次请求重新获取。第二层:数据缓存(Data Cache)这是最容易出问题的缓存层。Next.js 扩展了 fetch API,默认行为是永久缓存:// 默认:永久缓存(等效 force-cache)const data = await fetch('https://api.example.com/data')// 和上面等价const data = await fetch('https://api.example.com/data', { cache: 'force-cache' })是的,你没看错——一次 fetch 的结果会被永久缓存,除非你主动让它失效。控制缓存行为// 不缓存,每次都请求const data = await fetch('https://api.example.com/data', { cache: 'no-store' })// 缓存 60 秒后重新验证const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 }})三种策略对比| 策略 | 配置 | 行为 | 适用场景 ||------|------|------|---------|| 静态 | cache: 'force-cache' | 构建时获取,永久缓存 | 不变的数据(配置、字典) || 动态 | cache: 'no-store' | 每次请求都获取 | 实时数据(用户信息、支付) || ISR | next: { revalidate: 60 } | 缓存 60 秒,到期后台重新验证 | 经常更新但不需实时的数据 |ISR 的工作原理revalidate: 60 不是到期立刻更新——而是到期后第一个请求仍然返回缓存旧数据,同时后台触发重新验证。下一个请求就能拿到新数据。这叫 stale-while-revalidate,用户永远不会等到过期数据的重新获取。按路由段配置 revalidate不想在每个 fetch 里写 revalidate?在页面级统一设置:// app/products/page.tsxexport const revalidate = 3600 // 整个页面 1 小时重新验证export default async function ProductsPage() { const products = await fetch('https://api.example.com/products') // ...}页面级 revalidate 会覆盖 fetch 级别的设置。手动失效缓存import { revalidatePath, revalidateTag } from 'next/cache'// 失效整个路由revalidatePath('/products')// 按 tag 失效(更精确)const data = await fetch('https://api.example.com/products', { next: { tags: ['products'] }})// 数据变更时清除 tagasync function updateProduct() { await db.updateProduct(...) revalidateTag('products') // 所有带 products tag 的缓存失效}revalidateTag 比 revalidatePath 更灵活——一个 tag 可以跨多个页面和组件,清除一次全部失效。第三层:路由缓存(Router Cache)这是客户端缓存,存在浏览器内存中。用户在页面间导航时,Next.js 会缓存已访问路由的 RSC Payload,返回时不需要重新请求服务端。// 在 layout.tsx 中配置export const experimental = { staleTimes: { dynamic: 30, // 动态路由缓存 30 秒 static: 300, // 静态路由缓存 5 分钟 },}路由缓存的失效用户刷新页面 → 缓存失效调用 router.refresh() → 缓存失效revalidatePath / revalidateTag → 对应缓存失效常见问题:用户在 A 页面修改了数据,切到 B 页面,再切回 A 页面——看到的还是旧数据。这就是路由缓存在作怪。解决:修改数据后调用 router.refresh()。'use client'import { useRouter } from 'next/navigation'function UpdateButton() { const router = useRouter() async function handleClick() { await updateData() router.refresh() // 刷新当前路由,清除客户端缓存 } return <button onClick={handleClick}>更新</button>}第四层:完整路由缓存(Full Route Cache)构建时,Next.js 会把静态路由的 RSC Payload 和 HTML 都生成好,部署后直接从 CDN 返回——根本不走 Node.js 服务器。// 默认行为:构建时静态生成export default async function Page() { const data = await fetch('https://api.example.com/static-data') // force-cache return <div>{data}</div>}什么时候路由变成动态的以下任一条件满足,路由就不会被静态生成:fetch 使用了 cache: 'no-store'fetch 使用了 next: { revalidate: 0 }使用了 cookies()、headers() 等动态 API页面导出了 export const dynamic = 'force-dynamic'// 强制动态渲染export const dynamic = 'force-dynamic'Server Actions 与缓存失效Server Actions 是触发缓存失效的最佳位置——数据变更和缓存失效在同一处代码:// app/products/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function createProduct(formData: FormData) { await db.product.create({ data: { name: formData.get('name') } }) revalidateTag('products') // 创建后立刻失效}调试缓存开发模式和生产模式的缓存行为完全不同——开发模式下几乎所有缓存都被禁用。缓存问题只在生产环境中复现。# 生产构建npm run build && npm start# 查看每个路由的渲染策略# 构建输出会显示:# ○ /products (静态)# ● /products/[id] (动态)# ƒ /api/products (动态)符号含义:○ 静态生成,● 服务端渲染,ƒ 动态路由。缓存策略速查| 数据特征 | fetch 配置 | 路由类型 ||---------|-----------|---------|| 不变数据 | cache: 'force-cache' | 静态 || 偶尔更新 | next: { revalidate: 3600, tags: ['xxx'] } | ISR || 频繁更新 | next: { revalidate: 60 } | ISR || 实时数据 | cache: 'no-store' | 动态 || 用户相关 | cookies() + cache: 'no-store' | 动态 || 问题 | 原因 | 解决 ||------|------|------|| 数据更新了页面没变 | 数据缓存未失效 | revalidateTag / revalidatePath || 返回上一页看到旧数据 | 路由缓存 | router.refresh() || 开发环境正常生产不对 | 开发模式禁用缓存 | 生产构建调试 || fetch 没有执行 | 请求记忆去重 | 正常行为,同一周期只请求一次 |
服务端阅读 06月5日 21:49

Next.js App Router vs Pages Router:核心区别和渐进式迁移指南

Next.js 13 引入的 App Router 不是 Pages Router 的替代品——它是一套全新的架构,基于 React Server Components,改变了数据获取、渲染和路由的整个思维方式。迁移不是改个目录名那么简单。核心区别一表看懂| 维度 | Pages Router | App Router ||------|-------------|------------|| 目录 | pages/ | app/ || 路由文件 | pages/about.js | app/about/page.tsx || API 路由 | pages/api/users.js | app/api/users/route.ts || 布局 | _app.js + 唯一 layout | 嵌套 layout(每个目录可以有) || 数据获取 | getServerSideProps / getStaticProps | 组件内直接 async/await || 渲染模型 | 客户端组件为主 | 服务端组件为主(RSC) || 状态 | 天然客户端 | 服务端组件无状态 |数据获取:最大的变化Pages Router 方式// pages/users.tsxexport async function getServerSideProps() { const users = await fetchUsers() return { props: { users } }}export default function UsersPage({ users }) { return <UserList users={users} />}每个页面需要单独的 getServerSideProps 或 getStaticProps 函数,数据只能在页面级获取,子组件不能自己获取。App Router 方式// app/users/page.tsxexport default async function UsersPage() { const users = await fetchUsers() // 直接在组件里 await return <UserList users={users} />}不需要特殊的 getXXXProps 函数——组件本身就是 async 函数,直接 await 获取数据。子组件也能自己获取:// components/user-stats.tsxexport default async function UserStats() { const stats = await fetchStats() // 子组件自己获取数据 return <div>活跃用户:{stats.active}</div>}这是 RSC 的核心优势:组件级数据获取,不再需要在页面顶层把所有数据攒齐再一层层传 props。布局系统:从全局到嵌套Pages Router:一个布局管所有页面// pages/_app.tsx - 全局唯一的布局export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> )}所有页面共享同一个 _app.js。想做"某些页面有侧边栏,某些没有"很别扭——要靠条件判断或把布局塞进每个页面。App Router:嵌套布局,每个目录可以有app/ layout.tsx # 根布局(必须有 html + body) page.tsx # 首页 dashboard/ layout.tsx # dashboard 专属布局(侧边栏) page.tsx # dashboard 首页 settings/ page.tsx # settings 页(继承 dashboard 布局)// app/dashboard/layout.tsxexport default function DashboardLayout({ children }) { return ( <div className="flex"> <Sidebar /> <main className="flex-1">{children}</main> </div> )}/dashboard/settings 会渲染三层布局:根 layout → dashboard layout → settings page。切换 settings 页面时,dashboard layout 不会重新渲染——侧边栏状态保留。这是 App Router 布局的核心:嵌套且持久化。Pages Router 做不到这一点——每次路由切换都重新挂载整个页面。Server Components vs Client Components这是迁移时最容易搞混的概念:Server Components(默认)// app/page.tsx - 自动是 Server Componentexport default async function Page() { const data = await fetch('https://api.example.com/data') return <div>{data.title}</div>}在服务端渲染,不会发送 JS 到客户端可以直接访问数据库、文件系统不能用 useState、useEffect、浏览器 API不能绑定事件处理函数(onClick 等)Client Components(需要显式声明)'use client' // 这行声明是 Client Componentimport { useState } from 'react'export default function Counter() { const [count, setCount] = useState(0) return <button onClick={() => setCount(count + 1)}>{count}</button>}在客户端渲染(可以 hydration)可以用所有 React Hooks可以绑定事件、使用浏览器 API不能直接 await 异步数据选择原则| 需要 | 选择 ||------|------|| 获取数据 | Server Component || 访问后端资源 | Server Component || useState/useEffect | Client Component || 事件处理(onClick) | Client Component || 浏览器 API(window) | Client Component |默认用 Server Component,只在需要交互时才用 Client Component。 这和 Pages Router 的思维完全相反——Pages Router 默认全是客户端组件。API 路由的变化Pages Router// pages/api/users.tsimport type { NextApiRequest, NextApiResponse } from 'next'export default function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { res.status(200).json({ users: [] }) }}App Router// app/api/users/route.tsimport { NextResponse } from 'next/server'export async function GET() { const users = await fetchUsers() return NextResponse.json({ users })}export async function POST(request: Request) { const body = await request.json() const user = await createUser(body) return NextResponse.json(user, { status: 201 })}变化:从单个 handler 函数 + if (req.method) 拆成独立的方法函数。每个 HTTP 方法一个导出函数,更清晰。迁移策略不要一次性全迁移——两个 Router 可以共存:1. 渐进式迁移pages/ old-page.tsx # 还在用 Pages Routerapp/ new-page/ page.tsx # 新页面用 App RouterNext.js 同时支持两种 Router。新页面用 App Router,旧页面保持不动,逐步迁移。2. 迁移优先级API 路由:最简单,改动最小纯展示页面:不需要交互,直接改成 Server Component有交互的页面:拆成 Server + Client 组件混合3. 常见迁移坑useRouter 不一样了:Pages Router 用 next/router,App Router 用 next/navigationgetServerSideProps 变成组件内 async:不再需要序列化/反序列化 props_app.js 里的 Provider:移到 app/layout.tsx,但要包在 Client Component 里图片组件:next/image API 有变化,注意 fill 属性替代了 layout="fill"什么时候还该用 Pages Router不是所有项目都需要迁移:老项目稳定运行:没有性能问题,不需要 RSC,不必迁移团队不熟悉 RSC:App Router 的思维模式完全不同,迁移成本不只是改代码重度依赖客户端状态:如果你的页面几乎全是客户端交互,RSC 的优势体现不出来App Router 的核心价值是服务端渲染优先 + 嵌套布局。如果你的项目确实受益于这两点,值得迁移;否则 Pages Router 继续用就好——Next.js 没有废弃它的计划。
服务端阅读 06月5日 21:48

Next.js App Router 国际化实战:路由、翻译、SEO 和语言切换

多语言网站不是翻译几个字符串就完事——路由怎么设计、翻译文件怎么组织、SEO 的 hreflang 怎么配、切换语言时状态怎么保持,每个环节都有坑。Next.js App Router 提供了灵活的路由机制,但国际化方案需要自己搭。这篇给出一个完整的实战方案。路由结构设计App Router 推荐用动态路由段 [lang] 承载语言前缀:app/ [lang]/ layout.tsx # 语言布局(加载翻译、设置 lang 属性) page.tsx # 首页 about/ page.tsx # 关于页 layout.tsx # 根布局URL 效果:/zh/about、/en/about、/ja/aboutlayout.tsx 加载翻译并设置语言// app/[lang]/layout.tsximport { notFound } from 'next/navigation'import { dictionaries } from '@/lib/dictionaries'export const supportedLocales = ['zh', 'en', 'ja'] as constexport type Locale = typeof supportedLocales[number]export function generateStaticParams() { return supportedLocales.map((lang) => ({ lang }))}export default async function LangLayout({ children, params: { lang },}: { children: React.ReactNode params: { lang: string }}) { if (!supportedLocales.includes(lang as Locale)) { notFound() } return ( <html lang={lang}> <body>{children}</body> </html> )}generateStaticParams 让 Next.js 在构建时为每种语言生成静态页面。notFound() 处理非法语言前缀。翻译文件组织按语言分文件dictionaries/ zh.json en.json ja.json// dictionaries/zh.json{ "nav": { "home": "首页", "about": "关于", "contact": "联系我们" }, "home": { "title": "欢迎使用我们的产品", "description": "一站式解决方案" }}加载翻译的工具函数// lib/dictionaries.tsconst dictionaries = { zh: () => import('@/dictionaries/zh.json').then((m) => m.default), en: () => import('@/dictionaries/en.json').then((m) => m.default), ja: () => import('@/dictionaries/ja.json').then((m) => m.default),}export type Dictionary = Awaited<ReturnType<typeof dictionaries.zh>>export async function getDictionary(lang: string): Promise<Dictionary> { if (!(lang in dictionaries)) { return dictionaries.zh() // 回退到默认语言 } return dictionaries[lang]()}用动态 import() 按需加载——用户访问 /en/about 时只加载 en.json,不会把所有语言的翻译都打到一个包里。在 Server Component 中使用// app/[lang]/page.tsximport { getDictionary } from '@/lib/dictionaries'export default async function Home({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return ( <main> <h1>{t.home.title}</h1> <p>{t.home.description}</p> </main> )}Server Component 里直接用 await,不需要状态管理。翻译在服务端完成,客户端拿到的就是渲染好的 HTML。语言切换组件// components/lang-switcher.tsx'use client'import { usePathname, useRouter } from 'next/navigation'import { supportedLocales, type Locale } from '@/app/[lang]/layout'const localeNames: Record<Locale, string> = { zh: '中文', en: 'English', ja: '日本語',}export function LangSwitcher({ currentLang }: { currentLang: Locale }) { const pathname = usePathname() const router = useRouter() function switchLang(newLang: Locale) { // 替换 URL 中的语言段 const segments = pathname.split('/') segments[1] = newLang router.push(segments.join('/')) } return ( <select value={currentLang} onChange={(e) => switchLang(e.target.value as Locale)}> {supportedLocales.map((locale) => ( <option key={locale} value={locale}>{localeNames[locale]}</option> ))} </select> )}切换语言就是替换 URL 的第一段——/zh/about 变 /en/about。Next.js 会重新加载对应语言的页面。SEO:hreflang 标签搜索引擎需要知道不同语言版本的对应关系:// app/[lang]/layout.tsx 中添加 metadataexport async function generateMetadata({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return { title: t.home.title, alternates: { canonical: `https://example.com/${lang}`, languages: { 'zh': 'https://example.com/zh', 'en': 'https://example.com/en', 'ja': 'https://example.com/ja', 'x-default': 'https://example.com/en', // 默认语言 }, }, }}x-default 告诉搜索引擎:无法匹配用户语言时,显示这个版本。通常选英语或主要目标语言。中间件:自动重定向到用户首选语言// middleware.tsimport { NextRequest, NextResponse } from 'next/server'import { supportedLocales } from '@/app/[lang]/layout'function getLocale(request: NextRequest): string { const acceptLanguage = request.headers.get('accept-language') if (!acceptLanguage) return 'zh' // 解析 Accept-Language 头,匹配支持的语言 const preferred = acceptLanguage .split(',') .map((lang) => lang.split(';')[0].trim().substring(0, 2).toLowerCase()) return preferred.find((lang) => supportedLocales.includes(lang as any)) || 'zh'}export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // 已有语言前缀,不处理 if (supportedLocales.some((locale) => pathname.startsWith(`/${locale}`))) { return } // 根目录访问,重定向到首选语言 const locale = getLocale(request) request.nextUrl.pathname = `/${locale}${pathname}` return NextResponse.redirect(request.nextUrl)}export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'],}用户首次访问 example.com/about 时,中间件检测到 URL 没有语言前缀,根据浏览器 Accept-Language 重定向到 /en/about 或 /zh/about。Client Component 中使用翻译Client Component 不能直接 await 翻译,需要通过 props 传入或用 Context:// providers/dictionary-provider.tsx'use client'import { createContext, useContext } from 'react'import type { Dictionary } from '@/lib/dictionaries'const DictionaryContext = createContext<Dictionary | null>(null)export function DictionaryProvider({ dictionary, children,}: { dictionary: Dictionary children: React.ReactNode}) { return ( <DictionaryContext.Provider value={dictionary}> {children} </DictionaryContext.Provider> )}export function useDictionary() { const dictionary = useContext(DictionaryContext) if (!dictionary) throw new Error('useDictionary must be used within DictionaryProvider') return dictionary}在 layout 里提供:// app/[lang]/layout.tsximport { DictionaryProvider } from '@/providers/dictionary-provider'export default async function LangLayout({ children, params: { lang } }) { const dictionary = await getDictionary(lang) return ( <DictionaryProvider dictionary={dictionary}> {children} </DictionaryProvider> )}Client Component 里直接 useDictionary() 取翻译,不需要每次 prop drilling。翻译中的插值和复数纯 JSON 翻译文件不支持插值,需要一个小工具函数:// lib/i18n.tsexport function interpolate(template: string, params: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`))}// 复数处理(简单版)export function pluralize(count: number, singular: string, plural: string): string { return count === 1 ? singular : plural}{ "cart": { "items": "购物车中有 {count} 件商品", "item_single": "1 件商品", "item_plural": "{count} 件商品" }}const text = interpolate(t.cart.items, { count: 3 }) // "购物车中有 3 件商品"复杂的复数规则(阿拉伯语、俄语等)建议用 intl-messageformat 库。常见问题翻译键找不到怎么办开发时加一个 fallback 机制:export function t(dictionary: Dictionary, path: string): string { const keys = path.split('.') let result: any = dictionary for (const key of keys) { result = result?.[key] } return result ?? path // 找不到翻译时返回键名,而不是报错}翻译文件太大怎么办按页面拆分翻译文件,按需加载:dictionaries/ zh/ common.json home.json about.json en/ common.json home.json about.jsonSEO 的 hreflang 和 canonical 同时存在冲突吗不冲突。canonical 指向当前语言版本的规范 URL,hreflang 指向其他语言版本。两者配合告诉搜索引擎:这些 URL 是同一个内容的不同语言版本。完整方案清单| 检查项 | 配置 ||--------|------|| 路由结构 | app/[lang]/ 动态路由 || 翻译加载 | 动态 import() 按需加载 || 语言切换 | 替换 URL 语言段 || 自动重定向 | middleware 检测 Accept-Language || SEO | generateMetadata 配置 hreflang + canonical || Client Component | DictionaryProvider + useDictionary || 非法语言 | notFound() 处理 || 翻译插值 | interpolate 工具函数 |
服务端阅读 06月5日 21:43

TypeORM 查询缓存实战:Redis 配置、主动失效和策略选择

数据库查询是后端应用最常见的性能瓶颈。TypeORM 内置了查询缓存,支持内存缓存和 Redis 缓存两种存储后端,能在不改动业务代码的情况下大幅降低数据库负载。这篇讲清楚怎么配、怎么用、以及缓存策略的选择。两种缓存存储:数据库表 vs Redis默认方案:数据库表缓存不配置任何东西,TypeORM 默认用数据库的一张表存缓存:const dataSource = new DataSource({ type: 'mysql', host: 'localhost', username: 'root', password: 'password', database: 'myapp', cache: true, // 开启缓存,默认 1000ms 过期})TypeORM 会自动创建 query-result-cache 表,把查询 SQL 和结果序列化后存进去。下次同样的查询直接从这张表取,不执行 SQL。问题:缓存本身也存数据库里——等于用数据库查数据库,只是从业务表换到了缓存表。单实例够用,分布式部署时每个实例有自己的缓存表,互相看不到。推荐方案:Redis 缓存const dataSource = new DataSource({ type: 'mysql', cache: { type: 'redis', options: { host: 'localhost', port: 6379, password: 'redis-password', db: 0, }, duration: 30000, // 默认缓存 30 秒 },})Redis 的优势:快:内存读取,微秒级延迟共享:多个应用实例访问同一个 Redis,缓存一致可控:Redis 的内存管理、过期策略、持久化都很成熟Redis 集群场景用 ioredis:cache: { type: 'ioredis/cluster', options: { startupNodes: [ { host: '10.0.0.1', port: 7000 }, { host: '10.0.0.2', port: 7000 }, { host: '10.0.0.3', port: 7000 }, ], },}查询级缓存:精确控制哪些查询缓存全局开缓存后,不是所有查询都会缓存——需要显式指定。Repository 方式// 缓存 30 秒const users = await userRepository.find({ cache: 30000,})// 给缓存一个 ID,方便后续清除const users = await userRepository.find({ cache: { id: 'users_list', milliseconds: 30000, },})// findAndCount 也支持const [users, count] = await userRepository.findAndCount({ cache: { id: 'users_paginated', milliseconds: 30000, },})QueryBuilder 方式const posts = await dataSource .createQueryBuilder(Post, 'post') .where('post.isPublished = :published', { published: true }) .cache('published_posts', 60000) // 缓存 ID + 过期时间 .getMany()缓存 ID 的作用缓存 ID 是手动控制缓存的关键——通过 ID 可以精确清除某类查询的缓存:// 清除指定 ID 的缓存await dataSource.queryResultCache.remove(['users_list'])// 数据变更后清除相关缓存async createUser(dto: CreateUserDto) { const user = await this.userRepo.save(dto) // 用户列表缓存失效 await this.dataSource.queryResultCache.remove(['users_list', 'users_paginated']) return user}原则:所有需要缓存的查询都应该指定 ID,否则你无法在数据变更时精确失效缓存——只能等过期。缓存策略选择按数据变化频率决定缓存时长| 数据特征 | 缓存时长 | 例子 ||---------|---------|------|| 几乎不变 | 5-30 分钟 | 省份列表、配置项 || 偶尔变化 | 30-60 秒 | 文章列表、商品分类 || 频繁变化 | 5-15 秒 | 实时排行榜、库存 || 实时性要求高 | 不缓存 | 支付状态、账户余额 |主动失效 vs 被动过期被动过期:设一个 duration,到期自动清除。简单,但数据变更后到过期前这段时间,用户看到的可能是旧数据主动失效:数据变更时手动 remove() 缓存 ID。更精确,但代码更复杂生产环境推荐两者结合:设一个较长的 duration 做兜底,数据变更时主动失效。这样即使忘了失效,缓存最多存在 duration 时间也不会永远不过期。// 查询时设较长缓存const users = await this.userRepo.find({ cache: { id: 'users_list', milliseconds: 300000 }, // 5 分钟兜底})// 变更时主动失效async updateUser(id: number, dto: UpdateUserDto) { await this.userRepo.update(id, dto) await this.dataSource.queryResultCache.remove(['users_list'])}缓存与事务TypeORM 的查询缓存在事务内不会自动失效。这可能导致一个问题:// 事务外查询 → 走缓存const user = await userRepo.findOne({ where: { id: 1 }, cache: 30000 })// 事务内更新await dataSource.transaction(async (manager) => { await manager.update(User, 1, { name: 'new name' }) // 此时缓存里还是旧数据!})// 事务提交后再查 → 可能还是缓存旧数据解决方案:事务提交后手动清除缓存:await dataSource.transaction(async (manager) => { await manager.update(User, 1, { name: 'new name' })})await dataSource.queryResultCache.remove(['user_1'])缓存清理清除指定缓存// 按 ID 清除await dataSource.queryResultCache.remove(['users_list', 'posts_list'])// 清除所有缓存await dataSource.queryResultCache.clear()命令行清除npx typeorm cache:clear自动清理Redis 的 TTL 机制会自动清理过期缓存,不需要手动管理。数据库表缓存 TypeORM 也会定期清理过期记录。ignoreErrors:缓存降级缓存不可用时不应该让业务请求也失败:cache: { type: 'redis', options: { host: 'localhost', port: 6379 }, ignoreErrors: true, // Redis 挂了不报错,直接查数据库}ignoreErrors: true 是生产环境必须加的——Redis 重启或网络抖动时,查询会降级到直接访问数据库,而不是直接 500。完整配置示例// data-source.tsimport { DataSource } from 'typeorm'export const dataSource = new DataSource({ type: 'mysql', host: process.env.DB_HOST, port: 3306, username: process.env.DB_USER, password: process.env.DB_PASS, database: 'myapp', cache: { type: 'redis', options: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD, db: 0, }, duration: 30000, // 默认 30 秒 ignoreErrors: true, // 缓存故障降级 tableName: 'query_result_cache', // 数据库表缓存的表名(如果用数据库缓存) },})缓存策略速查| 场景 | 策略 ||------|------|| 配置数据、字典表 | 长缓存(5 分钟+)+ 变更时手动失效 || 列表查询 | 中缓存(30-60 秒)+ 分页参数加入缓存 ID || 详情页 | 短缓存(15-30 秒)+ 更新时失效 || 实时数据 | 不缓存,或 5 秒极短缓存 || 多实例部署 | 必须用 Redis,数据库表缓存不共享 || 缓存故障容忍 | ignoreErrors: true || 事务内更新 | 事务提交后手动清除缓存 |
服务端阅读 06月5日 21:42

npm Scripts 进阶:生命周期钩子、参数传递和跨平台写法

npm scripts 是 Node.js 项目里最朴素的自动化工具——在 package.json 里写一行命令,npm run xxx 就能执行。但它能做的远不止 npm run dev,生命周期钩子和参数传递这两个特性,很多人不知道。基础用法{ "scripts": { "dev": "nodemon index.js", "build": "webpack --mode production", "start": "node dist/index.js", "test": "jest --coverage", "lint": "eslint src/ --ext .ts" }}npm run dev # 运行 dev 脚本npm run build # 运行 build 脚本npm test # test 是特殊脚本,不需要 runnpm start # start 也是特殊脚本,不需要 runtest、start、restart、stop 是 npm 的特殊脚本名——可以直接 npm xxx 执行,不用加 run。其他自定义脚本都要 npm run xxx。生命周期钩子:自动执行的脚本npm 为每个脚本提供了 pre 和 post 钩子——脚本执行前后自动运行同名前缀的脚本:{ "scripts": { "prebuild": "rimraf dist", "build": "tsc", "postbuild": "echo Build completed" }}执行 npm run build 时,实际执行顺序是:prebuild → build → postbuild。实用的钩子组合{ "scripts": { "pretest": "npm run lint", "test": "jest", "prebuild": "npm test", "build": "webpack --mode production", "postbuild": "npm run size" }}跑 npm run build 的完整流程:lint → test → build → size check。任何一步失败,后续步骤不会执行。注意:npm v7+ 取消了 install 的 pre/post 钩子npm v7 起,preinstall、postinstall 等钩子不再自动执行(安全原因)。如果你的脚本需要依赖安装后执行,用 prepare:{ "scripts": { "prepare": "husky install" }}prepare 在以下时机自动执行:npm install 之后npm publish 之前git clone 后执行 npm install 时内置生命周期脚本npm 定义了几个特殊脚本,在特定时机自动触发:| 脚本名 | 触发时机 | 典型用途 ||--------|----------|----------|| prepare | install 后 / publish 前 | 初始化 husky、编译 || prepublishOnly | publish 前(仅 publish) | 编译、跑测试 || prepack | npm pack 前 | 编译 || postinstall | install 后 | 原生模块编译 || version | npm version 改版本号后 | 自动 commit changelog |区分 prepublishOnly 和 prepareprepublishOnly:只在 npm publish 时执行,npm install 不执行prepare:npm publish 和 npm install 都会执行库项目用 prepublishOnly 做发布前检查(跑测试、确保编译),用 prepare 做初始化工作。传递参数# 错误写法——参数传给了 npm,不是脚本npm run test --coverage# 正确写法——用 -- 分隔npm run test -- --coveragenpm run test -- --watchAll=false-- 后面的参数会原样追加到脚本命令后面。所以 npm run test -- --coverage 等于执行 jest --coverage。脚本里也可以用 --:{ "scripts": { "test": "jest", "test:watch": "npm run test -- --watch", "test:ci": "npm run test -- --ci --coverage" }}这样不用重复写基础命令,只追加不同参数。跨平台兼容在 scripts 里写 shell 命令要注意跨平台——Windows 没有 rm -rf,也没有 && 的可靠支持。用跨平台工具替代| Unix 命令 | 跨平台替代 | 安装 ||-----------|-----------|------|| rm -rf | rimraf | npm i -D rimraf || mkdir -p | mkdirp | npm i -D mkdirp || cp -r | cpy-cli | npm i -D cpy-cli || && | npm-run-all | npm i -D npm-run-all |{ "scripts": { "clean": "rimraf dist coverage", "build": "rimraf dist && tsc", "build:safe": "npm-run-all clean build" }}npm-run-all 比 && 更可靠——它在所有平台上都能工作,还支持并行执行:{ "scripts": { "lint:js": "eslint src/", "lint:css": "stylelint src/", "lint": "npm-run-all --parallel lint:*" }}--parallel 让 lint:js 和 lint:css 同时跑,速度翻倍。环境变量npm scripts 里可以直接使用环境变量:{ "scripts": { "start": "NODE_ENV=production node dist/index.js", "dev": "NODE_ENV=development nodemon src/index.js" }}但 NODE_ENV=xxx 在 Windows 上不工作。跨平台方案用 cross-env:npm install -D cross-env{ "scripts": { "start": "cross-env NODE_ENV=production node dist/index.js" }}组合脚本的模式实际项目里的 scripts 通常这样组织:{ "scripts": { "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/ --ext .ts", "test": "jest", "test:watch": "npm test -- --watch", "test:ci": "npm test -- --ci --coverage", "clean": "rimraf dist coverage", "prebuild": "npm run clean", "prepublishOnly": "npm-run-all lint test build", "release": "npm version patch && npm publish" }}release 脚本组合了版本号更新和发布——npm version patch 自动改版本号并创建 git tag,npm publish 推到 registry。常见问题脚本里的命令找不到npm scripts 执行时会把 node_modules/.bin 加到 PATH 里——所以可以直接用 jest、eslint、webpack,不需要写 ./node_modules/.bin/jest。但如果你用 bash -c "jest" 或在某些 CI 环境里,可能找不到。解决:用 npx 前缀。脚本太长不好维护拆成独立文件:{ "scripts": { "build": "bash scripts/build.sh", "deploy": "bash scripts/deploy.sh" }}scripts/ 目录下放脚本文件,package.json 里只做调度。
服务端阅读 06月5日 21:40

npm 依赖类型全解析:dependencies、devDependencies 和 peerDependencies 怎么选

package.json 里有 dependencies、devDependencies、peerDependencies、optionalDependencies——都叫依赖,到底什么区别?该往哪个里装?装错了会怎样?这篇一次讲清楚。dependencies vs devDependencies:唯一的本质区别生产环境装不装——就这么简单。| | dependencies | devDependencies ||---|---|---|| npm install | 安装 | 安装 || npm install --production | 安装 | 不安装 || npm ci --production | 安装 | 不安装 || NODE_ENV=production npm install | 安装 | 不安装 |dependencies:应用运行时必需的包(express、axios、lodash)devDependencies:只在开发和构建时需要的包(jest、eslint、typescript、webpack)怎么判断放哪里问自己一个问题:这个包如果不在,应用还能跑吗?能跑 → devDependencies(测试框架、代码检查、构建工具)不能跑 → dependencies(Web 框架、数据库驱动、日期库)一个容易搞混的例子TypeScript 放哪?应用项目:devDependencies——运行时不需要 TypeScript,只需要编译产物库项目(npm 包):devDependencies——用户装你的包不需要 TypeScript@types/xxx 呢?也是 devDependencies——类型声明只在编译时用。peerDependencies:我需要你,但我不装你peerDependencies 是给库/插件用的,告诉宿主项目"你需要安装这个依赖,我自己不装"。// react-component-lib 的 package.json{ "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }}为什么不直接放 dependencies?因为 React 只能有一个实例。如果组件库自己装了一份 React,应用也装了一份,运行时会有两个 React 副本——hooks 会炸。npm v7 以前 vs 现在npm v6:peerDependencies 不满足只会警告,照样安装npm v7+:peerDependencies 不满足会报错,安装失败这导致很多老项目升级 npm 后突然装不上依赖了。解决方案:npm install --legacy-peer-deps # 回退到 v6 的行为常见需要 peerDependencies 的场景UI 组件库依赖 React/Vue/AngularBabel 插件依赖 @babel/coreESLint 插件依赖 eslintWebpack loader 依赖 webpack原则:你的包作为插件扩展另一个包时,被扩展的包放在 peerDependencies。optionalDependencies:装不上也没关系{ "optionalDependencies": { "fsevents": "^2.3.0" }}安装失败不会中断整个 npm install——只是这个包不可用,调用时需要自己做容错:let fsevents;try { fsevents = require('fsevents');} catch { // 回退到其他方案}典型场景:fsevents 只在 macOS 上可用,Linux/Windows 上装不了但也不影响功能——用其他文件监听方案兜底。注意:不要滥用。大部分依赖是必须的,装不上就应该报错而不是静默跳过。bundledDependencies:打包进你的发布包{ "bundledDependencies": ["my-helper-lib"]}正常情况下 npm install 你的包时,依赖会从 registry 下载。但 bundledDependencies 里的包会被直接打包到你的发布文件中,安装时不需要从 registry 下载。用途很少——主要是某些包不在公共 registry 上,又不想让用户单独配置私有源。版本号规则:^ vs ~ vs 精确版本{ "dependencies": { "express": "^4.18.0", "lodash": "~4.17.0", "react": "18.2.0" }}| 写法 | 允许的版本范围 | 例子 ||------|--------------|------|| ^4.18.0 | 兼容的次版本更新 | 4.18.0 ~ 4.x.x(不会升到 5.0) || ~4.17.0 | 兼容的修订版本更新 | 4.17.0 ~ 4.17.x(不会升到 4.18) || 4.18.0 | 精确版本 | 只能用 4.18.0 |^ 是默认行为(npm install 自动加),意味着次版本和修订版本的更新都会被接受。这通常没问题,但如果某个次版本更新引入了 bug,你的项目可能在别人那能跑在你这跑不了——这就是为什么需要 package-lock.json 锁定精确版本。实际项目中的依赖配置建议应用项目(Web 应用、后端服务)dependencies:运行时必需的包devDependencies:构建工具、测试、lint不需要 peerDependencies 和 optionalDependencies库项目(npm 包、组件库)dependencies:库运行时必需且不会被宿主重复安装的包devDependencies:构建工具、测试、文档peerDependencies:宿主项目应该提供的包(React、Webpack 等)optionalDependencies:平台特定的可选增强依赖类型选择流程这个包运行时需要吗?├── 不需要 → devDependencies├── 需要 → 宿主项目可能已经安装了吗?│ ├── 是 → peerDependencies│ └── 否 → 装不上也行吗?│ ├── 是 → optionalDependencies│ └── 否 → dependencies
服务端阅读 06月5日 21:39

npm 包发布全流程:从零发布到私有 Registry 配置

写好了一个工具库想发到 npm 上?或者公司内部需要搭建私有 npm 仓库管理通用组件?这篇讲清楚从零发布 npm 包的完整流程,以及私有 registry 的配置方式。发布前的准备1. 注册 npm 账号npm adduser# 按提示输入用户名、密码、邮箱# 验证登录npm whoami2. package.json 必填字段{ "name": "@your-scope/package-name", "version": "1.0.0", "description": "一句话描述包的功能", "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist"], "keywords": ["utility", "format", "date"], "license": "MIT", "repository": { "type": "git", "url": "https://github.com/you/package-name" }}几个容易忽略但很关键的字段:files:指定发布时包含哪些文件。不写的话 npm 会把项目根目录下几乎所有文件都打进去(包括测试文件、配置文件)。写了 ["dist"] 就只发布编译产物,安装的人不会下载到源码和测试main:CommonJS 入口,require() 时加载这个文件types:TypeScript 类型声明文件入口。没有这个字段,TypeScript 用户用你的包会没有类型提示name 里的 @your-scope/ 是作用域包——避免和别人的包名冲突,也支持发到私有 registry3. .npmignore 控制排除项src/test/.github/.eslintrctsconfig.json*.tsbuildinfo和 .gitignore 类似,但专门控制 npm 发布时排除的文件。如果同时有 .npmignore 和 files 字段,files 优先级更高。构建和发布TypeScript 项目的标准构建流程{ "scripts": { "build": "tsc", "prepublishOnly": "npm run build" }}prepublishOnly 是 npm 生命周期钩子——执行 npm publish 前自动跑 npm run build,确保发布的是编译后的代码而不是源码。发布版本# 首次发布npm publish# 作用域包默认是私有的,要公开需要加 --accessnpm publish --access public# 后续更新:先改版本号再发布npm version patch # 1.0.0 → 1.0.1(修复 bug)npm version minor # 1.0.1 → 1.1.0(新功能,向后兼容)npm version major # 1.1.0 → 2.0.0(破坏性变更)npm publishnpm version 会同时更新 package.json 的版本号并创建一个 git commit + tag——一步到位,不需要手动改版本号。不要发布的文件确保这些不会被打包发布:.env 文件(可能含密钥)node_modules/测试文件和 mock 数据IDE 配置(.vscode/、.idea/)CI 配置(.github/workflows/)用 npm pack --dry-run 可以预览将要发布的文件列表,不会真正打包:npm pack --dry-run# 输出类似:# npm notice 📦 @your-scope/utils@1.0.0# npm notice Tarball Contents# npm notice 1.2kB dist/index.js# npm notice 0.8kB dist/index.d.ts# npm notice 1.1kB package.json语义化版本(SemVer)版本号格式:主版本.次版本.修订版本(Major.Minor.Patch)Patch(修订):修复 bug,不改变 API → npm version patchMinor(次版本):新增功能,向后兼容 → npm version minorMajor(主版本):破坏性变更,不向后兼容 → npm version major原则:用户在 package.json 里写了 "^1.2.0",你发布 1.3.0 时他们自动升级,但发布 2.0.0 时不会——所以破坏性变更一定要升 Major。私有 Registry 配置企业内部不想把包发到公网,需要私有 registry。使用 Verdaccio(轻量自建方案)# 安装npm install -g verdaccio# 启动(默认 4873 端口)verdaccio# 创建配置文件 ~/.config/verdaccio/config.yaml# config.yamlstorage: ./storageplugins: ./pluginsauth: htpasswd: file: ./htpasswd max_users: 100uplinks: npmjs: url: https://registry.npmjs.org/packages: '@company/*': access: $authenticated publish: $authenticated unpublish: $authenticated '**': access: $all proxy: npmjs # 非 @company 包代理到 npm 官方源这个配置的意思是:@company/* 作用域的包只存在本地私有仓库,其他包自动代理到 npm 官方源。开发者不需要切换 registry——私有包和公共包都能装。项目级配置# 所有 @company 作用域的包走私有 registrynpm config set @company:registry http://your-registry:4873# 或在 .npmrc 文件中@company:registry=http://your-registry:4873发布到私有 registrynpm publish --registry=http://your-registry:4873或者在 package.json 中指定:{ "name": "@company/utils", "publishConfig": { "registry": "http://your-registry:4873" }}publishConfig 比命令行参数更可靠——不会因为忘了加 --registry 而误发到公网。CI 中自动发布# GitHub Actions 示例- name: Publish to npm run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}在 npm 网站上生成 Access Token(Settings → Access Tokens),添加到 GitHub Secrets 里。CI 环境不需要 npm login,靠 token 认证。本地配置 token:# .npmrc//registry.npmjs.org/:_authToken=${NPM_TOKEN}常见问题包名已被占用换成作用域包:@your-name/package-name。作用域包的命名空间归你所有,不会和别人冲突。发布后想撤回# 24 小时内可以撤回(npm 官方限制)npm unpublish @your-scope/package-name@1.0.0# 撤回整个包(慎用)npm unpublish @your-scope/package-name --force超过 24 小时就撤不回了。所以发布前用 npm pack --dry-run 确认内容,用 npm publish --tag beta 先发预览版。发错版本到生产用 dist-tag 管理:# 发布为 beta 版本npm publish --tag beta# 安装 beta 版本npm install @your-scope/package-name@beta# 正式版才用 latest(默认)npm publish # 默认 tag 是 latest这样 npm install 只会安装 latest 版本,beta 需要显式指定。