<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>三水 | 个人博客</title>
    <link>https://sanshui-blog.pages.dev</link>
    <description>记录技术思考、生活感悟与创作灵感</description>
    <language>zh-cn</language>
    <lastBuildDate>Fri, 31 Jul 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://sanshui-blog.pages.dev/feed.xml" rel="self" type="application/rss+xml" />
        <item>
      <title>CSS 容器查询实战：从媒体查询到组件级响应式</title>
      <link>https://sanshui-blog.pages.dev/posts/css-%E5%AE%B9%E5%99%A8%E6%9F%A5%E8%AF%A2%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/css-%E5%AE%B9%E5%99%A8%E6%9F%A5%E8%AF%A2%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate>
      <description>媒体查询基于视口，容器查询基于父容器。本文从 @container 语法讲到 polyfill 兼容方案，再给出 6 个真实组件的容器查询改造案例。</description>
      <content:encoded><![CDATA[
# CSS 容器查询实战：从媒体查询到组件级响应式

容器查询（Container Queries）在 2023 年正式进入所有主流浏览器。但两年过去了，我看到的真实生产代码里用它的依然不多——很多人知道语法，但不知道什么场景下该用容器查询替代媒体查询。这篇文章会从最基础的语法讲到 6 个真实组件的改造案例。

## 一、为什么媒体查询不够用

媒体查询的问题在于：**它基于视口尺寸，而不是组件所在容器的尺寸**。

考虑一个 Card 组件，它同时被用在两个地方：

1. 首页 Hero 旁边的右栏，容器宽度 300px
2. 文章列表的主区域，容器宽度 800px

用媒体查询写：

```css
.card {
  display: flex;
  flex-direction: column;
}
@media (min-width: 768px) {
  .card {
    flex-direction: row;
  }
}
```

问题：用户在 iPad 上看，视口 768px，两个地方的 Card 都变成 row 布局。但右栏只有 300px 宽，row 布局把图片挤成 100×50 的缩略图，丑得没法看。

容器查询要解决的就是这个：**让组件根据自己父容器的尺寸调整布局，而不是根据整个视口**。

## 二、@container 语法三步走

```css
/* 第一步：声明一个容器 */
.card-wrapper {
  container-type: inline-size; /* 横向尺寸作为容器查询的依据 */
  container-name: card;        /* 可选，给容器起个名字 */
}

/* 第二步：用 @container 写查询 */
@container card (min-width: 500px) {
  .card {
    flex-direction: row;
  }
}

/* 第三步：组件内部样式照常 */
.card { ... }
```

关键概念：

- `container-type: inline-size` —— 以 inline 方向（通常是横向）的尺寸作为查询依据。这是最常用的，因为大多数响应式布局关心的是宽度。
- `container-type: size` —— 同时考虑 width 和 height，但会让容器脱离正常文档流计算，慎用。
- `container-name` —— 给容器起名，避免多个容器查询互相干扰。

## 三、踩坑 1：container-type 会影响子元素的尺寸计算

```css
.sidebar {
  container-type: inline-size;
  width: 300px;
}
.sidebar > .content {
  width: 100%; /* 这里的 100% 是相对于 .sidebar 的 content-box */
}
```

这看起来没问题。但如果你把 `container-type` 设成 `size`，子元素的 `width: 100%` 会变成相对于**容器自身**，导致循环依赖。**这就是为什么 90% 的场景应该用 `inline-size` 而不是 `size`**。

## 四、踩坑 2：容器内的 fixed 定位会「失效」

```css
.modal-wrapper {
  container-type: inline-size;
}
.modal-wrapper .modal {
  position: fixed;
  top: 50%;
  transform: translateY(-50%);
}
```

直觉上 `position: fixed` 应该相对视口定位。但如果某个祖先元素有 `transform` / `filter` / `will-change` / `container-type: size`（注意 `inline-size` 不在此列）等属性，fixed 会变成相对于那个祖先定位。

**`container-type: size` 会触发 containing block 改变**，`container-type: inline-size` 不会。所以**优先用 `inline-size`**，除非你真的需要查询高度。

## 五、踩坑 3：容器查询里的 vh/vw 单位语义没变

```css
@container (min-width: 500px) {
  .hero {
    height: 80vh;
  }
}
```

`vh` / `vw` 依然是视口单位，**不会**因为你在容器查询里就变成容器单位。如果你想用容器的高度，得用 `cqh`（容器高度百分比）、`cqw`（容器宽度百分比）、`cqi`（容器 inline 尺寸百分比）等容器单位。

```css
.hero {
  height: 80cqh;
} /* 容器高度的 80% */
```

## 六、实战案例 1：Card 组件三态布局

```html
<div class="card-grid">
  <div class="card-wrapper">
    <article class="card">
      <img class="card-image" src="..." />
      <div class="card-body">
        <h3>文章标题</h3>
        <p>摘要...</p>
      </div>
    </article>
  </div>
</div>
```

```css
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

.card-wrapper {
  container-type: inline-size;
}

.card {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.card-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

/* 容器宽度大于 500px：横向布局，图片在左 */
@container (min-width: 500px) {
  .card {
    flex-direction: row;
    align-items: center;
  }
  .card-image {
    width: 200px;
    height: 200px;
    flex-shrink: 0;
  }
}

/* 容器宽度大于 700px：图片变大，标题字号增加 */
@container (min-width: 700px) {
  .card-image {
    width: 280px;
    height: 280px;
  }
  .card-body h3 {
    font-size: 1.5rem;
  }
}
```

效果：同一个 Card 组件放在窄栏（300px）时是纵向小图布局，放在主区（800px）时是横向大图布局，**完全由容器尺寸驱动**，与视口无关。

## 七、实战案例 2：Navigation 自动横竖切换

```css
.nav-wrapper {
  container-type: inline-size;
}

.nav-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

@container (min-width: 600px) {
  .nav-list {
    flex-direction: row;
    justify-content: space-around;
  }
}
```

把导航放在 sidebar 时容器窄，纵向；放在 header 时容器宽，横向。一个组件代码搞定两种场景。

## 八、实战案例 3：Table 在窄容器退化为卡片

数据表格在窄屏下挤成一团。传统方案是 `overflow-x: auto`，但用户体验差。用容器查询可以让表格在窄容器自动变成卡片列表：

```css
.table-wrapper {
  container-type: inline-size;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
}

@container (max-width: 600px) {
  .data-table thead {
    display: none;
  }
  .data-table tr {
    display: block;
    margin-bottom: 1rem;
    border: 1px solid #ddd;
    border-radius: 8px;
    padding: 1rem;
  }
  .data-table td {
    display: flex;
    justify-content: space-between;
    padding: 0.5rem 0;
  }
  .data-table td::before {
    content: attr(data-label);
    font-weight: bold;
    margin-right: 1rem;
  }
}
```

HTML 里每个 `td` 加 `data-label` 属性：

```html
<td data-label="姓名">张三</td>
```

这样窄容器下每行变成卡片，左 label 右值。

## 九、实战案例 4：Hero 区背景图随容器尺寸切换

```css
.hero-wrapper {
  container-type: inline-size;
}

.hero-bg {
  background-image: url('/hero-mobile.jpg');
  background-size: cover;
}

@container (min-width: 768px) {
  .hero-bg {
    background-image: url('/hero-desktop.jpg');
  }
}
```

视口宽度变化时，容器宽度也跟着变，自动切换合适的图片。比媒体查询更精准——比如把 Hero 嵌入一个 Dialog 里，容器宽度只有 400px，即使视口是 1920px 也会用 mobile 版本。

## 十、实战案例 5：Side-by-side Editor 在窄容器堆叠

代码编辑器组件默认左右分栏（代码 + 预览）。窄容器下自动堆叠：

```css
.editor-wrapper {
  container-type: inline-size;
}

.editor-split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

@container (max-width: 800px) {
  .editor-split {
    grid-template-columns: 1fr;
  }
}
```

## 十一、实战案例 6：Sidebar 折叠按钮的容器查询 + prefers-reduced-motion

```css
.sidebar-wrapper {
  container-type: inline-size;
}

.sidebar {
  transition: width 0.3s ease;
}

@container (max-width: 200px) {
  .sidebar-label {
    display: none;
  }
  .sidebar-icon {
    margin: 0 auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sidebar {
    transition: none;
  }
}
```

容器查询和媒体查询可以共存——容器查询管布局，媒体查询管偏好。

## 十二、兼容性方案

容器查询在 2023 年后所有主流浏览器都原生支持。但如果你要支持更老的浏览器，有两条路：

**方案 A：CSS 容器查询 polyfill**

```html
<script src="https://cdn.jsdelivr.net/npm/container-query-polyfill@1.0.2/dist/cqfill.min.js"></script>
```

**方案 B：渐进增强**

```css
/* 老浏览器：媒体查询兜底 */
@media (min-width: 768px) {
  .card {
    flex-direction: row;
  }
}

/* 新浏览器：容器查询覆盖 */
@supports (container-type: inline-size) {
  .card-wrapper {
    container-type: inline-size;
  }
  .card {
    flex-direction: column;
  }
  @container (min-width: 500px) {
    .card {
      flex-direction: row;
    }
  }
}
```

`@supports` 检测原生支持，如果有就覆盖媒体查询的样式。

## 十三、容器查询与 Subgrid 的配合

容器查询解决「组件根据容器尺寸切换布局」，Subgrid 解决「子元素继承父元素的网格轨道」。两者结合可以让嵌套组件的列对齐完美：

```css
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}

.card-wrapper {
  container-type: inline-size;
  display: grid;
  grid-template-rows: auto 1fr auto;
}

@container (min-width: 500px) {
  .card-wrapper {
    grid-template-columns: 200px 1fr;
    grid-template-rows: auto;
  }
  .card-image {
    grid-column: 1;
  }
  .card-body {
    grid-column: 2;
  }
}
```

## 十四、总结

容器查询不是媒体查询的替代品，而是补充：

- **视口级布局变化**：用媒体查询（Hero 区在不同视口尺寸下的布局）
- **组件级布局变化**：用容器查询（同一个组件在不同容器宽度下的布局）

记住三件事：

1. `container-type: inline-size` 是 90% 场景的选择
2. 容器查询里的 `vh` / `vw` 依然是视口单位，容器单位是 `cqh` / `cqw` / `cqi`
3. 老浏览器用 `@supports` 渐进增强

容器查询让组件真正变成了可复用的「独立单元」——它不关心自己被放在哪里，只关心父容器给了它多大的空间。这是响应式设计的下一步进化。
]]></content:encoded>
      <category>CSS</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>Docker 多阶段构建与 BuildKit 缓存优化</title>
      <link>https://sanshui-blog.pages.dev/posts/docker-%E5%A4%9A%E9%98%B6%E6%AE%B5%E6%9E%84%E5%BB%BA%E4%BC%98%E5%8C%96/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/docker-%E5%A4%9A%E9%98%B6%E6%AE%B5%E6%9E%84%E5%BB%BA%E4%BC%98%E5%8C%96/</guid>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
      <description>一个 Go 服务镜像从 1.2GB 优化到 18MB，构建时间从 180s 降到 25s。本文讲多阶段构建、distroless / scratch 基础镜像、BuildKit cache mount、Buildx 多架构构建。</description>
      <content:encoded><![CDATA[
# Docker 多阶段构建与 BuildKit 缓存优化

线上一个 Go 微服务，Docker 镜像 1.2GB，CI 构建要 3 分钟。优化后镜像降到 18MB，构建 25 秒。这篇文章记录完整链路。

## 一、为什么镜像这么大

传统的 Dockerfile：

```dockerfile
FROM golang:1.22

WORKDIR /app
COPY . .
RUN go build -o myservice .

CMD ["./myservice"]
```

问题：

1. 基础镜像 `golang:1.22` 是 850MB（包含完整 Go 工具链、Linux 发行版）
2. `COPY . .` 把所有源码、依赖、测试数据都复制进去
3. build 产物 + 源码都在最终镜像里
4. 没用 `.dockerignore`，`.git`、`node_modules` 全被复制

## 二、多阶段构建：分离构建环境与运行环境

```dockerfile
# 阶段 1：构建
FROM golang:1.22 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myservice .

# 阶段 2：运行
FROM gcr.io/distroless/static-debian12

COPY --from=builder /app/myservice /myservice

EXPOSE 8080
CMD ["/myservice"]
```

**关键优化**：

1. **`COPY go.mod go.sum` 先于 `COPY . .`**：依赖未变时，`go mod download` 缓存命中
2. **`CGO_ENABLED=0`**：纯静态二进制，不需要 glibc
3. **`-ldflags="-s -w"`**：去除调试符号，二进制减小 30%
4. **`distroless/static`**：只有 ca-certificates 和 timezone，无 shell、无包管理器

镜像大小：1.2GB → 25MB（distroless 7MB + 二进制 18MB）。

## 三、distroless vs scratch vs alpine

| 基础镜像            | 大小  | 适合场景               | 限制                 |
| ------------------- | ----- | ---------------------- | -------------------- |
| `scratch`           | 0 MB  | 静态二进制 + 自带 CA   | 无 shell，调试困难   |
| `distroless/static` | 2 MB  | Go / Rust 静态二进制   | 无 shell，无包管理器 |
| `distroless/base`   | 20 MB | C/C++ 动态链接二进制   | 需要 glibc           |
| `alpine`            | 5 MB  | 动态二进制 + musl libc | musl 与 glibc 不兼容 |
| `ubuntu:22.04`      | 80 MB | 通用场景               | 大                   |

**生产推荐**：`distroless/static-debian12`，安全且小。

## 四、踩坑 1：CGO_ENABLED=1 的场景

如果 Go 代码用了 sqlite3、net 包等需要 CGO 的库：

```dockerfile
# ❌ CGO_ENABLED=0 时 net 包 DNS 解析行为改变，可能慢
RUN CGO_ENABLED=0 go build ...

# ✅ 用 distroless/base（含 glibc）
FROM gcr.io/distroless/base-debian12
```

## 五、BuildKit 缓存优化

### 启用 BuildKit

```bash
# 临时
DOCKER_BUILDKIT=1 docker build .

# 永久（/etc/docker/daemon.json）
{
  "features": { "buildkit": true }
}
```

### Cache Mount：跨构建复用依赖缓存

```dockerfile
# syntax=docker/dockerfile:1.6

FROM golang:1.22 AS builder

WORKDIR /app
COPY go.mod go.sum ./

# Go module 缓存
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o myservice .
```

**关键点**：

1. **`--mount=type=cache,target=...`**：把缓存目录挂载到构建层
2. **缓存与构建层解耦**：缓存更新不影响构建层 hash
3. **跨构建复用**：第二次构建时 `go mod download` 直接命中缓存

### npm 缓存示例

```dockerfile
FROM node:20 AS builder

WORKDIR /app
COPY package*.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build
```

## 六、踩坑 2：cache mount 不共享给 build stage

```dockerfile
# ❌ 阶段 1 的 cache mount 在阶段 2 看不到
FROM golang:1.22 AS builder
RUN --mount=type=cache,target=/go/pkg/mod go mod download

FROM golang:1.22 AS tester
RUN --mount=type=cache,target=/go/pkg/mod go test ./...
# 这里 cache mount 是独立的，不会复用 builder 的
```

**修复**：用 `id` 显式共享：

```dockerfile
RUN --mount=type=cache,id=gomod,target=/go/pkg/mod ...
```

## 七、踩坑 3：cache mount 与 CI 并行构建冲突

GitLab CI / GitHub Actions 中多个 job 并行构建，cache mount 共享时可能冲突。

**修复**：每个 job 用独立 cache key：

```dockerfile
RUN --mount=type=cache,id=gomod-${CI_JOB_ID},target=/go/pkg/mod ...
```

或用 Buildx 的 `--cache-from` / `--cache-to` 推到 registry：

```bash
docker buildx build \
  --cache-from type=registry,ref=myrepo/cache \
  --cache-to type=registry,ref=myrepo/cache,mode=max \
  -t myrepo/app:v1 .
```

## 八、Secret Mount：安全传递凭证

传统方式：

```dockerfile
# ❌ npm token 泄露到 image layer
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
RUN npm ci
```

BuildKit 方式：

```dockerfile
# syntax=docker/dockerfile:1.6

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
```

```bash
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc .
```

Secret 不会进入 image layer，也不会出现在 build history。

## 九、SSH Mount：拉私有 Git 仓库

```dockerfile
# syntax=docker/dockerfile:1.6

FROM golang:1.22 AS builder

RUN --mount=type=ssh \
    GOPRIVATE=github.com/myorg/* \
    go mod download
```

```bash
docker build --ssh default=$SSH_AUTH_SOCK .
```

## 十、多架构构建：Buildx

```bash
# 创建 buildx builder
docker buildx create --name multiarch --use

# 同时构建 amd64 + arm64
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myrepo/app:v1 \
  --push .
```

**关键**：Dockerfile 必须用 `TARGETARCH` 自动选择架构：

```dockerfile
FROM --platform=$BUILDPLATFORM golang:1.22 AS builder

ARG TARGETOS=linux
ARG TARGETARCH=amd64

RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build ...
```

## 十一、踩坑 4：Alpine 镜像 DNS 解析慢

```dockerfile
FROM alpine:3.18
RUN apk add --no-cache curl
```

Alpine 用 musl libc，DNS 解析行为与 glibc 不同。某些场景下 DNS 解析会超时。

**修复**：用 `distroless` 替代 `alpine`，或在 alpine 里装 glibc 兼容层。

## 十二、踩坑 5：层数过多导致镜像变大

```dockerfile
# ❌ 每条 RUN 一层
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ 合并 RUN
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*
```

**关键**：`rm -rf` 必须与 `install` 在同一 RUN，否则 install 的文件依然在前面的层里。

## 十三、.dockerignore 必备

```text
.git
.gitignore
node_modules
dist
build
*.log
.env
.vscode
.idea
coverage
test
__tests__
docs
README.md
Dockerfile
.dockerignore
```

没有 `.dockerignore`，`COPY . .` 会把 `.git`（几百 MB）、`node_modules`（几百 MB）全复制进去。

## 十四、实战案例：完整优化 Dockerfile

```dockerfile
# syntax=docker/dockerfile:1.6

# === 阶段 1：构建 ===
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder

ARG TARGETOS=linux
ARG TARGETARCH=amd64

WORKDIR /app

# 先复制依赖描述，利用 layer 缓存
COPY go.mod go.sum ./

# 用 cache mount 加速依赖下载
RUN --mount=type=cache,id=gomod,target=/go/pkg/mod \
    --mount=type=cache,id=gobuild,target=/root/.cache/go-build \
    go mod download

# 复制源码
COPY . .

# 静态构建
RUN --mount=type=cache,id=gomod,target=/go/pkg/mod \
    --mount=type=cache,id=gobuild,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -ldflags="-s -w" -trimpath -o /myservice .

# === 阶段 2：运行 ===
FROM gcr.io/distroless/static-debian12

# 非 root 用户
USER nonroot:nonroot

WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /myservice /myservice

EXPOSE 8080
ENTRYPOINT ["/myservice"]
```

**优化效果**：

| 指标               | 优化前 | 优化后 |
| ------------------ | ------ | ------ |
| 镜像大小           | 1.2 GB | 18 MB  |
| 构建时间（无缓存） | 180 s  | 90 s   |
| 构建时间（有缓存） | 90 s   | 25 s   |
| 安全漏洞           | 47 个  | 0 个   |

## 十五、CI/CD 集成

### GitHub Actions

```yaml
name: Build and Push

on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
```

`cache-from: type=gha` 用 GitHub Actions 缓存，比 registry cache 快。

## 十六、镜像扫描

构建后用 Trivy 或 Grype 扫描：

```bash
# Trivy
trivy image myrepo/app:v1 --severity HIGH,CRITICAL --exit-code 1

# Grype
grype myrepo/app:v1 --fail-on high
```

CI 中加扫描步骤，发现高危漏洞自动 fail。

## 十七、总结

镜像优化的 7 条原则：

1. **多阶段构建**：build 环境 ≠ 运行环境
2. **distroless 基础镜像**：最小化攻击面
3. **Cache Mount 加速依赖下载**
4. **Secret Mount 安全传递凭证**
5. **合并 RUN，减少层数**
6. **`.dockerignore` 必备**
7. **Buildx 多架构构建**

投入一次优化，CI 时间从 3 分钟降到 25 秒，镜像大小从 1.2GB 降到 18MB。性价比极高。
]]></content:encoded>
      <category>Docker</category>
      <category>DevOps</category>
      <category>后端</category>
      <category>技术</category>
    </item>
    <item>
      <title>etcd 与 Zookeeper 一致性算法对比：Raft vs ZAB</title>
      <link>https://sanshui-blog.pages.dev/posts/etcd-zookeeper-%E4%B8%80%E8%87%B4%E6%80%A7%E7%AE%97%E6%B3%95%E5%AF%B9%E6%AF%94/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/etcd-zookeeper-%E4%B8%80%E8%87%B4%E6%80%A7%E7%AE%97%E6%B3%95%E5%AF%B9%E6%AF%94/</guid>
      <pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate>
      <description>同样是 CP 系统，etcd 用 Raft，Zookeeper 用 ZAB。本文讲两种算法的差异、leader 选举、日志复制、安全性证明，再到生产选型建议。</description>
      <content:encoded><![CDATA[
# etcd 与 Zookeeper 一致性算法对比：Raft vs ZAB

公司同时维护两套分布式协调系统：Kubernetes 用 etcd，Kafka 用 Zookeeper。两套系统的设计哲学完全不同——etcd 选 Raft 简单可理解，Zookeeper 选 ZAB 优化读写分离。这篇文章系统对比两种算法。

## 一、两种算法的根本差异

| 维度        | Raft (etcd)     | ZAB (Zookeeper) |
| ----------- | --------------- | --------------- |
| 主轴        | 日志复制        | 原子广播        |
| leader 选举 | 随机超时 + term | epoch + 三阶段  |
| 日志顺序    | 强一致索引      | ZXID 单调递增   |
| 成员变更    | 联合一致        | 重配置          |
| 易理解性    | 高              | 中              |

**核心共识**：两者都是 **CP 系统**（强一致性 + 分区容错），都通过 leader-based 协议保证所有副本状态一致。

## 二、Raft 的核心机制

### 1. 三种角色

```text
Follower —— 普通节点，被动接收 leader 请求
Candidate —— 选举中，竞选 leader
Leader    —— 主节点，处理所有写入
```

### 2. Term（任期）

```text
Term 1: Leader A
        ↓ A 宕机
Term 2: 选举超时，B 竞选
        B 拿到多数票，成为 Leader
        ↓
Term 3: B 宕机，C 竞选 ...
```

**Term 是单调递增的逻辑时钟**。每次选举开启新 term。所有 RPC 请求都带 term，过期 term 的请求被拒绝。

### 3. Leader 选举

```go
// 每个 follower 有随机选举超时（150-300ms）
// 超时未收到 leader 心跳，转为 candidate

func (r *raft) startElection() {
    r.currentTerm++
    r.state = Candidate
    r.votedFor = r.id

    // 向所有节点发 RequestVote
    votes := 1  // 自己一票
    for _, peer := range r.peers {
        resp := peer.RequestVote(r.currentTerm, r.lastLogIndex, r.lastLogTerm)
        if resp.VoteGranted {
            votes++
        }
    }

    if votes > len(r.peers)/2 {
        r.becomeLeader()
    }
}
```

**关键约束**：

1. **多数派**：必须拿到 > N/2 票才能当选
2. **log 完整性**：投票前检查候选人的 log 是否比自己新

```go
// 候选人的 log 至少要和自己一样新
func (r *raft) isLogUpToDate(candidateLastLogIndex, candidateLastLogTerm int) bool {
    myLastTerm := r.logs[r.lastLogIndex].Term
    if candidateLastLogTerm != myLastTerm {
        return candidateLastLogTerm > myLastTerm
    }
    return candidateLastLogIndex >= r.lastLogIndex
}
```

### 4. 日志复制

```text
Client → Leader: SET x = 5
Leader:
  1. 写本地 log (uncommitted)
  2. 并行 AppendEntries RPC 给所有 follower
  3. 收到多数派 ack → commit
  4. 应用到状态机
  5. 响应 client
```

**关键**：**commit 必须 > N/2 副本 ack**，否则不能 commit。这是 Raft 安全性的核心。

### 5. 心跳维持 leadership

```go
// Leader 每隔 50ms 发一次心跳
func (r *raft) heartbeat() {
    for r.state == Leader {
        r.broadcastAppendEntries()
        time.Sleep(50 * time.Millisecond)
    }
}
```

如果 follower 超过 election timeout 没收到心跳，会触发选举。

## 三、ZAB 的核心机制

### 1. 四种状态

```text
LOOKING     —— 正在选举 leader
FOLLOWING   —— 跟随者
LEADING     —— 领导者
OBSERVING   —— 观察者（不参与投票）
```

### 2. 三阶段选举

```text
Phase 1: Discovery
  - 各节点交换自己的 epoch
  - 选出最大的 epoch + 适合的 leader

Phase 2: Synchronization
  - follower 与 leader 同步历史事务
  - 类似 Raft 的 log 复制

Phase 3: Broadcast
  - leader 接受 client 请求，广播给 follower
  - 多数派 ack 后 commit
```

### 3. ZXID：单调递增的全局 ID

```text
ZXID = (epoch << 32) | counter
```

**epoch**：每次 leader 切换递增，类似 Raft 的 term
**counter**：每个事务递增

ZXID 保证：

1. 同一 leader 任期内事务顺序明确（counter）
2. 不同 leader 任期可比较（epoch）

### 4. 原子广播（Atomic Broadcast）

```text
Client → Leader: create /node
Leader:
  1. 分配 ZXID
  2. 发 Proposal 给所有 follower
  3. follower 写本地事务日志，ack
  4. leader 收到多数派 ack → commit
  5. leader 发 COMMIT 给 follower
  6. 应用到内存数据库
```

**与 Raft 的差异**：

1. ZAB 显式区分 Proposal / ACK / COMMIT 三个阶段
2. Raft 把这三个阶段压缩到 AppendEntries + Response

## 四、安全性对比

### Raft 的 Leader Completeness

> 如果一条日志在某个 term 被 commit，那么所有更高 term 的 leader 都包含这条日志。

证明思路：

1. 一个 entry 被 commit，意味着 > N/2 节点复制了它
2. 新 leader 必须拿到 > N/2 票
3. 投票的节点中至少有一个包含已 commit 的 entry
4. Raft 的投票规则保证「投票者 log >= 候选人 log」时才投票

**结论**：Raft 保证所有 committed entry 不会丢失。

### ZAB 的 Leader Completeness

ZAB 用 ZXID 保证新 leader 的 log 包含所有已 commit 事务：

1. 新 leader 的 epoch > 旧 leader
2. 新 leader 必须拿到多数派投票
3. 投票节点的 ZXID 不超过新 leader

**结论**：ZAB 同样保证 committed 事务不丢失。

## 五、性能对比

| 指标     | Raft (etcd)       | ZAB (Zookeeper)     |
| -------- | ----------------- | ------------------- |
| 写入吞吐 | 高                | 中                  |
| 读取延迟 | 中（leader only） | 低（follower 可读） |
| 选举速度 | 慢（150-300ms）   | 快（50-200ms）      |
| 故障恢复 | 5-10s             | 2-5s                |

**Zookeeper 的优势**：

1. **follower 可读**：读请求不需要经过 leader
2. **watch 机制**：客户端可以监听 znode 变化
3. **临时节点**：session 结束自动删除

**etcd 的优势**：

1. **MVCC**：支持事务和版本历史
2. **租约（lease）**：比临时节点更灵活
3. **HTTP/gRPC API**：比 ZK 的私有协议更开放

## 六、Raft 实现踩坑：split brain

### 场景

1. 5 节点集群
2. 网络分区为 (A, B) 和 (C, D, E)
3. (C, D, E) 多数派，选出新 leader
4. (A, B) 少数派，老 leader 继续接受写入
5. (A, B) 的写入无法 commit（多数派不在）
6. 网络恢复，老 leader 发现更高 term，降级

### 副作用

老 leader 在分区期间接收的写入请求会**一直阻塞**（无法 commit），直到网络恢复或超时。

**修复**：

1. 客户端设置合理 timeout（5s）
2. 客户端重试时带 request id，避免重复写入

## 七、ZAB 踩坑：写瓶颈在 leader

### 场景

Zookeeper 所有写请求必须经过 leader。当写 QPS 高时，leader CPU 打满。

**修复**：

1. **读写分离**：follower 承担读，leader 只写
2. **批量提交**：多个 Proposal 合并为一个 commit
3. **Observer 节点**：跨机房部署 observer，分担读流量

```java
// 客户端连接 follower
ZooKeeper zk = new ZooKeeper("follower1:2181", 30000, watcher);
```

## 八、实战案例：etcd 集群 OOM

### 故障

Kubernetes 集群 etcd OOM 重启，导致 API server 不可用 30 秒。

### 排查

```bash
# etcd 指标
etcdctl endpoint status --write-out=table

# 关键指标
etcd_mvcc_db_total_size_bytes  # DB 大小
etcd_debugging_mvcc_keys_total # key 数量
```

发现：

- DB 大小 8GB（超过默认 2GB quota）
- key 数量 1000 万
- watch callback 积压

### 修复

1. **压缩历史版本**：

```bash
etcdctl compact $(etcdctl endpoint status -w json | jq -r '.[0].Status.header.revision')
etcdctl defrag
```

2. **自动压缩配置**：

```yaml
# etcd 启动参数
--auto-compaction-retention=1  # 保留 1 小时
--auto-compaction-mode=periodic
--quota-backend-bytes=8589934592  # 8GB
```

3. **定期 defrag**：

```bash
# Cron job
0 3 * * * etcdctl defrag --command-timeout=30s
```

## 九、选型决策

| 场景            | 推荐                       |
| --------------- | -------------------------- |
| Kubernetes 集群 | etcd                       |
| Kafka 集群      | Zookeeper（或 KRaft 替代） |
| 微服务配置中心  | etcd / Apollo              |
| 分布式锁        | Zookeeper（金融）/ etcd    |
| 服务发现        | etcd / Consul              |

## 十、Raft vs ZAB：选哪个？

**简单性优先**：Raft。它的设计目标就是「比 Paxos 更易理解」，论文有 20+ 页专门讲可理解性。

**读写分离需求**：ZAB。Zookeeper 的 follower-read 是内置特性。

**性能优先**：取决于负载模式。写多选 etcd（吞吐高），读多选 ZK（follower 可读）。

**生产运维**：etcd 更简单（单一二进制 + gRPC），ZK 需要独立 JVM 集群。

## 十一、KRaft：Kafka 摆脱 Zookeeper

Kafka 3.3+ 引入 KRaft 模式，用 Raft 替代 Zookeeper：

```bash
# config/kraft/server.properties
process.roles=broker,controller
node.id=1
controller.quorum.voters=1@localhost:9093,2@localhost:9094,3@localhost:9095
```

**优势**：

1. 单一架构，运维简单
2. 元数据操作延迟降低
3. 支持更大集群（百万分区）

KRaft 模式预计在 Kafka 4.0 完全替代 ZK。

## 十二、总结

一致性算法的 5 条原则：

1. **多数派决策**：所有 commit 需要 > N/2 副本 ack
2. **Term/epoch 单调递增**：保证旧 leader 不再发号施令
3. **log 完整性投票**：保证新 leader 包含所有已 commit 数据
4. **随机超时选举**：避免活锁
5. **预写日志（WAL）**：保证持久性

Raft 和 ZAB 各有所长。理解算法本身，比记住哪种实现更好更重要。
]]></content:encoded>
      <category>分布式</category>
      <category>后端</category>
      <category>etcd</category>
      <category>Zookeeper</category>
      <category>技术</category>
    </item>
    <item>
      <title>Go 1.24 泛型与 range-over-func 实战</title>
      <link>https://sanshui-blog.pages.dev/posts/go-1.24-%E6%B3%9B%E5%9E%8B%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/go-1.24-%E6%B3%9B%E5%9E%8B%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
      <description>Go 1.24 把 range 推广到函数和 int。本文讲 type parameters 实战、iter.Pull / iter.Seq 迭代器协议、generic slice / map 工具，再到 6 个泛型设计踩坑。</description>
      <content:encoded><![CDATA[
# Go 1.24 泛型与 range-over-func 实战

Go 1.18 引入泛型，1.23 引入 range-over-func（迭代函数），1.24 进一步完善 iter 包。但社区里很多人写泛型代码依然停留在「泛型 map / slice 工具」层面，没有触及真正的类型抽象能力。这篇文章从基础讲到高级，再到 6 个真实设计踩坑点。

## 一、泛型基础回顾

```go
// 泛型函数
func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

// 使用
doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })
uppers := Map([]string{"a", "b"}, strings.ToUpper)
```

`[T, U any]` 是类型参数声明，`any` 是 `interface{}` 的别名。

## 二、类型约束：comparable 与自定义

```go
// comparable 是内置约束：支持 == 和 !=
func Contains[T comparable](s []T, target T) bool {
    for _, v := range s {
        if v == target {
            return true
        }
    }
    return false
}

// 自定义约束
type Number interface {
    int | int64 | float32 | float64
}

func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}
```

**约束两种形态**：

1. **接口形式**（`interface { ... }`）：方法约束
2. **类型集形式**（`int | float64`）：底层类型约束

## 三、range-over-func：Go 1.23+ 的新能力

### 基础：range over function

```go
// 旧方式：自己处理 channel 或 callback
func Each[T any](s []T, f func(T)) {
    for _, v := range s {
        f(v)
    }
}

// 新方式：iter.Seq 协议
type Seq[V any] func(yield func(V) bool)

func Slice[T any](s []T) iter.Seq[T] {
    return func(yield func(T) bool) {
        for _, v := range s {
            if !yield(v) {
                return
            }
        }
    }
}

// 使用：直接 range！
for v := range Slice([]int{1, 2, 3}) {
    fmt.Println(v)
}
```

**关键**：`iter.Seq[T]` 是 `func(yield func(T) bool)` 类型。`yield` 返回 `false` 表示提前停止（break）。

### iter.Seq2：键值对迭代

```go
type Seq2[K, V any] func(yield func(K, V) bool)

func MapEntries[K comparable, V any](m map[K]V) iter.Seq2[K, V] {
    return func(yield func(K, V) bool) {
        for k, v := range m {
            if !yield(k, v) {
                return
            }
        }
    }
}

// 使用
for k, v := range MapEntries(myMap) {
    fmt.Println(k, v)
}
```

## 四、实战：泛型 LRU 缓存

```go
type LRU[K comparable, V any] struct {
    capacity int
    cache    map[K]*list.Element
    list     *list.List
}

type entry[K comparable, V any] struct {
    key K
    val V
}

func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
    return &LRU[K, V]{
        capacity: capacity,
        cache:    make(map[K]*list.Element),
        list:     list.New(),
    }
}

func (l *LRU[K, V]) Get(key K) (V, bool) {
    if elem, ok := l.cache[key]; ok {
        l.list.MoveToFront(elem)
        return elem.Value.(*entry[K, V]).val, true
    }
    var zero V
    return zero, false
}

func (l *LRU[K, V]) Put(key K, val V) {
    if elem, ok := l.cache[key]; ok {
        l.list.MoveToFront(elem)
        elem.Value.(*entry[K, V]).val = val
        return
    }
    elem := l.list.PushFront(&entry[K, V]{key, val})
    l.cache[key] = elem
    if l.list.Len() > l.capacity {
        oldest := l.list.Back()
        l.list.Remove(oldest)
        delete(l.cache, oldest.Value.(*entry[K, V]).key)
    }
}
```

使用：

```go
cache := NewLRU[string, *User](100)
cache.Put("user:1", user)
user, ok := cache.Get("user:1")
```

## 五、踩坑 1：泛型类型不能作为 map 的 key

```go
type Pair[T any] struct {
    A, B T
}

// ❌ 编译错误：Pair[T] 不满足 comparable
var m map[Pair[int]]int
```

**修复**：约束 `T` 为 `comparable`，并让 `Pair` 满足 `comparable`：

```go
type Pair[T comparable] struct {
    A, B T
}

var m map[Pair[int]]int  // OK
```

## 六、踩坑 2：泛型方法的 receiver 类型

```go
type Stack[T any] struct {
    items []T
}

// ✅ 值 receiver
func (s Stack[T]) Len() int {
    return len(s.items)
}

// ✅ 指针 receiver
func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}
```

**注意**：泛型结构体的方法定义必须带上类型参数 `Stack[T]`，不能省略为 `Stack`。

## 七、踩坑 3：泛型 + 接口的不匹配

```go
type Iterator[T any] interface {
    Next() (T, bool)
}

func Collect[T any](it Iterator[T]) []T {
    var result []T
    for {
        v, ok := it.Next()
        if !ok {
            break
        }
        result = append(result, v)
    }
    return result
}

type IntSlice []int

// ❌ IntSlice 不实现 Iterator[int]
func (s IntSlice) Next() (int, bool) { ... }
```

**修复**：泛型方法的 receiver 必须用具体类型：

```go
func (s IntSlice) Next() (int, bool) {
    // ...
}
// 然后 Collect([]int{...}) // 仍不行
```

这里其实需要更复杂的设计，因为 `Next` 是有状态的。改用 `iter.Seq` 更简洁。

## 八、踩坑 4：类型推断的边界

```go
func Merge[K comparable, V any](maps ...map[K]V) map[K]V {
    result := make(map[K]V)
    for _, m := range maps {
        for k, v := range m {
            result[k] = v
        }
    }
    return result
}

// 类型推断：从第一个 map 推断 K=string, V=int
m := Merge(map[string]int{"a": 1}, map[string]int{"b": 2})

// ❌ 类型不一致无法推断
m := Merge(map[string]int{"a": 1}, map[string]string{"b": "x"})
```

**修复**：显式指定类型参数：

```go
m := Merge[string, any](map[string]int{"a": 1}, map[string]string{"b": "x"})
```

## 九、踩坑 5：泛型零值

```go
func FirstOrDefault[T any](s []T, def T) T {
    if len(s) == 0 {
        return def
    }
    return s[0]
}

// 用零值作为 default
func FirstOrZero[T any](s []T) T {
    var zero T  // 零值
    return FirstOrDefault(s, zero)
}
```

`var zero T` 是获取泛型零值的标准方式。指针类型是 `nil`，数值类型是 `0`，string 是 `""`。

## 十、踩坑 6：泛型类型的类型断言

```go
func Print[T any](v T) {
    // ❌ 不能直接断言具体类型
    if s, ok := v.(string); ok { ... }

    // ✅ 通过 any 中转
    if s, ok := any(v).(string); ok {
        fmt.Println("string:", s)
    }
}
```

## 十一、性能：泛型 vs 接口

Go 1.18 泛型实现是 **GC shape stenciling**：

- 不同 GC shape（指针 / 值类型）会生成不同的代码版本
- 同一 GC shape 的不同类型共享代码

```go
// 性能对比（1000 万元素）
// 1. 接口版本
func EachInterface(s []interface{}, f func(interface{})) { ... }

// 2. 泛型版本
func EachGeneric[T any](s []T, f func(T)) { ... }

// 结果
// 接口版本：580 ms（box / unbox 开销）
// 泛型版本：120 ms（无 box / unbox）
```

## 十二、iter 包高级用法

### iter.Pull：拉模式迭代

```go
next, stop := iter.Pull(seq)
defer stop()

for {
    v, ok := next()
    if !ok {
        break
    }
    // 处理 v
}
```

`iter.Pull` 把 push 模式（`iter.Seq`）转换成 pull 模式（`Next()` 风格）。适合需要主动控制迭代进度的场景。

### 自定义 Seq 实现：生成斐波那契

```go
func Fibonacci() iter.Seq[int] {
    return func(yield func(int) bool) {
        a, b := 0, 1
        for {
            if !yield(a) {
                return
            }
            a, b = b, a+b
        }
    }
}

// 使用
for n := range Fibonacci() {
    if n > 100 {
        break
    }
    fmt.Println(n)
}
```

无限序列 + break 即可。

### 文件行迭代器

```go
func Lines(path string) iter.Seq2[int, string] {
    return func(yield func(int, string) bool) {
        f, err := os.Open(path)
        if err != nil {
            return
        }
        defer f.Close()

        scanner := bufio.NewScanner(f)
        lineNo := 0
        for scanner.Scan() {
            lineNo++
            if !yield(lineNo, scanner.Text()) {
                return
            }
        }
    }
}

for i, line := range Lines("/etc/passwd") {
    fmt.Printf("%d: %s\n", i, line)
}
```

## 十三、泛型 channel 工具

```go
// Fan-in：合并多个 channel
func FanIn[T any](chs ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup
    wg.Add(len(chs))
    for _, ch := range chs {
        go func() {
            defer wg.Done()
            for v := range ch {
                out <- v
            }
        }()
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

// 用法
merged := FanIn(prodCh, consCh, ctrlCh)
```

## 十四、实战：泛型 Result/Option 类型

```go
type Result[T any] struct {
    value T
    err   error
}

func Ok[T any](v T) Result[T] {
    return Result[T]{value: v}
}

func Err[T any](err error) Result[T] {
    var zero T
    return Result[T]{value: zero, err: err}
}

func (r Result[T]) Unwrap() T {
    if r.err != nil {
        panic(r.err)
    }
    return r.value
}

func (r Result[T]) UnwrapOr(def T) T {
    if r.err != nil {
        return def
    }
    return r.value
}

// 使用
func GetUser(id int) Result[*User] {
    user, err := db.FindUser(id)
    if err != nil {
        return Err[*User](err)
    }
    return Ok(user)
}

user := GetUser(123).UnwrapOr(&defaultUser)
```

## 十五、总结

Go 1.24 泛型实战要点：

1. **iter.Seq / Seq2 是迭代器的标准协议**
2. **range-over-func 让自定义集合像 slice 一样可迭代**
3. **泛型 + iter 配合能写出非常优雅的工具库**
4. **泛型方法的 receiver 必须带类型参数 `Stack[T]`**
5. **泛型类型作为 map key 需要 `comparable` 约束**

泛型让 Go 摆脱了「到处复制粘贴」的窘境，迭代器协议让 Go 终于有了像样的函数式编程能力。这两个特性合起来，是 Go 语言近 5 年最大的进化。
]]></content:encoded>
      <category>Go</category>
      <category>后端</category>
      <category>技术</category>
    </item>
    <item>
      <title>gRPC 实战：从 Protobuf 到双向流</title>
      <link>https://sanshui-blog.pages.dev/posts/grpc-%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/grpc-%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
      <description>微服务间通信从 HTTP/JSON 换到 gRPC，吞吐量提升 5 倍。本文讲 Protobuf 编码、 unary vs stream、keepalive 心跳、拦截器链、metadata 透传，再到 6 个生产踩坑。</description>
      <content:encoded><![CDATA[
# gRPC 实战：从 Protobuf 到双向流

公司一个微服务集群，服务间用 HTTP/JSON 通信。监控发现：80% 的 CPU 时间花在 JSON 序列化/反序列化上。换成 gRPC 后，吞吐量提升 5 倍，延迟降到原来的 1/3。这篇文章记录完整落地过程。

## 一、gRPC 相比 HTTP/JSON 的核心优势

| 维度     | HTTP/JSON   | gRPC            |
| -------- | ----------- | --------------- |
| 编码     | 文本 JSON   | 二进制 Protobuf |
| 多路复用 | HTTP/1.1 无 | HTTP/2 有       |
| 流式     | 不支持      | 原生支持        |
| 接口契约 | 文档        | .proto 文件     |
| 性能     | 慢          | 5-10 倍快       |

**Protobuf 编码示例**：

```protobuf
message User {
  int64 id = 1;
  string name = 2;
  repeated string tags = 3;
}
```

JSON 编码（约 80 字节）：

```json
{ "id": 1234567890, "name": "San Shui", "tags": ["dev", "blog"] }
```

Protobuf 编码（约 35 字节）：

```text
08 d2 85 d8 cc 04 12 0a 53 61 6e 20 53 68 75 69
1a 03 64 65 76 1a 04 62 6c 6f 67
```

数字用 varint 编码，字符串用 length-prefixed，比 JSON 紧凑得多。

## 二、定义 Protobuf 接口

```protobuf
// user.proto
syntax = "proto3";

package user.v1;
option go_package = "github.com/myorg/api/user/v1;userv1";

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (stream User);
  rpc WatchUsers(WatchUsersRequest) returns (stream User);
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message GetUserRequest { int64 id = 1; }
message GetUserResponse { User user = 1; }
message User { int64 id = 1; string name = 2; }
```

四种 RPC 模式：

1. **Unary**：`rpc A(Request) returns (Response)` —— 一问一答
2. **Server Stream**：`returns (stream Response)` —— 服务端流
3. **Client Stream**：`(stream Request) returns` —— 客户端流
4. **Bidirectional**：`(stream Request) returns (stream Response)` —— 双向流

## 三、生成 Go 代码

```bash
# 安装 buf
brew install bufbuild/buf/buf

# 生成代码
buf generate
```

```yaml
# buf.gen.yaml
version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative
```

## 四、服务端实现

```go
type userServer struct {
    userv1.UnimplementedUserServiceServer
    db *sql.DB
}

func (s *userServer) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
    user, err := s.db.GetUser(ctx, req.Id)
    if err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }
    return &userv1.GetUserResponse{User: user}, nil
}

func (s *userServer) ListUsers(req *userv1.ListUsersRequest, stream userv1.UserService_ListUsersServer) error {
    users, err := s.db.ListUsers(stream.Context(), req)
    if err != nil {
        return status.Error(codes.Internal, err.Error())
    }
    for _, u := range users {
        if err := stream.Send(u); err != nil {
            return err
        }
    }
    return nil
}

func main() {
    lis, _ := net.Listen("tcp", ":50051")
    server := grpc.NewServer(
        grpc.UnaryInterceptor(grpc_prometheus.UnaryServerInterceptor),
    )
    userv1.RegisterUserServiceServer(server, &userServer{})
    server.Serve(lis)
}
```

## 五、客户端实现

```go
func main() {
    conn, _ := grpc.Dial("localhost:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
    )
    client := userv1.NewUserServiceClient(conn)

    // Unary
    resp, err := client.GetUser(ctx, &userv1.GetUserRequest{Id: 1})

    // Server Stream
    stream, _ := client.ListUsers(ctx, &userv1.ListUsersRequest{Limit: 100})
    for {
        user, err := stream.Recv()
        if err == io.EOF { break }
        if err != nil { log.Fatal(err) }
        fmt.Println(user)
    }
}
```

## 六、踩坑 1：keepalive 心跳不工作

默认 gRPC 不发心跳。如果服务端有 firewall 断开空闲连接，客户端会卡住。

**修复**：

```go
// 服务端
server := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        Time:    30 * time.Second,
        Timeout: 10 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             10 * time.Second,
        PermitWithoutStream: true,
    }),
)

// 客户端
conn, _ := grpc.Dial(addr,
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                30 * time.Second,
        Timeout:             10 * time.Second,
        PermitWithoutStream: true,
    }),
)
```

**关键**：`PermitWithoutStream: true` 允许在没有活跃 RPC 时也发心跳，这是 firewall 友好的关键。

## 七、踩坑 2：metadata 透传失败

```go
// 客户端发 metadata
md := metadata.New(map[string]string{
    "x-user-id":    "123",
    "x-request-id": uuid.NewString(),
})
ctx = metadata.NewOutgoingContext(ctx, md)

// 服务端收 metadata
md, _ = metadata.FromIncomingContext(ctx)
userID := md.Get("x-user-id")  // []
```

**修复**：gRPC metadata key 必须小写。

```go
md := metadata.New(map[string]string{
    "x-user-id":    "123",
    "x-request-id": uuid.NewString(),
})
```

## 八、踩坑 3：错误码丢失

```go
// 服务端返回错误
return nil, status.Error(codes.NotFound, "user not found")

// 客户端处理
resp, err := client.GetUser(ctx, req)
if err != nil {
    if status.Code(err) == codes.NotFound {
        // 处理 404
    }
}
```

**坑点**：业务错误信息放在 `status.Error` 的 message 里。如果想传结构化错误信息，用 `status.WithDetails`：

```go
st := status.New(codes.InvalidArgument, "validation failed")
st, _ = st.WithDetails(&userv1.ValidationError{
    Field:   "email",
    Message: "invalid format",
})
return nil, st.Err()
```

## 九、拦截器链：日志、认证、限流

```go
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    start := time.Now()
    resp, err := handler(ctx, req)
    log.Printf("%s took %v err=%v", info.FullMethod, time.Since(start), err)
    return resp, err
}

func authInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    md, _ := metadata.FromIncomingContext(ctx)
    token := md.Get("authorization")
    if len(token) == 0 {
        return nil, status.Error(codes.Unauthenticated, "no token")
    }
    if err := validateToken(token[0]); err != nil {
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }
    return handler(ctx, req)
}

// 链式拦截器
server := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        loggingInterceptor,
        authInterceptor,
        ratelimitInterceptor,
    ),
)
```

**执行顺序**：

1. loggingInterceptor 入口
2. authInterceptor 入口
3. ratelimitInterceptor 入口
4. handler 执行
5. ratelimitInterceptor 出口
6. authInterceptor 出口
7. loggingInterceptor 出口

## 十、踩坑 4：客户端连接复用

```go
// ❌ 每次 RPC 都新建 conn
func GetUser(id int64) {
    conn, _ := grpc.Dial(addr, ...)
    defer conn.Close()
    client.GetUser(ctx, req)
}

// ✅ 全局复用 conn
var globalConn *grpc.ClientConn

func Init() {
    globalConn, _ = grpc.Dial(addr, ...)
}

func GetUser(id int64) {
    client := userv1.NewUserServiceClient(globalConn)
    return client.GetUser(ctx, req)
}
```

**grpc.ClientConn 是线程安全的**，所有 goroutine 共享一个连接，HTTP/2 多路复用自动处理并发。

## 十一、双向流实战：实时聊天

```protobuf
service ChatService {
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message ChatMessage {
  string user = 1;
  string text = 2;
  int64 timestamp = 3;
}
```

```go
type chatServer struct {
    userv1.UnimplementedChatServiceServer
    mu      sync.Mutex
    streams []userv1.ChatService_ChatServer
}

func (s *chatServer) Chat(stream userv1.ChatService_ChatServer) error {
    // 注册新客户端
    s.mu.Lock()
    s.streams = append(s.streams, stream)
    s.mu.Unlock()

    // 接收消息并广播
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        s.broadcast(msg)
    }
}

func (s *chatServer) broadcast(msg *userv1.ChatMessage) {
    s.mu.Lock()
    defer s.mu.Unlock()
    for _, stream := range s.streams {
        if err := stream.Send(msg); err != nil {
            // 移除断开的 stream
        }
    }
}
```

## 十二、客户端负载均衡

### DNS 服务发现

```go
conn, _ := grpc.Dial("dns:///user-service:50051",
    grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
```

gRPC 客户端会定期解析 DNS，把所有 A 记录当作后端。

### xDS 服务发现（生产推荐）

```go
import _ "google.golang.org/grpc/xds" // 注册 xDS balancer

conn, _ := grpc.Dial("xds:///user-service")
```

xDS 是 Envoy / Istio 用的服务发现协议，支持：

- 周期性端点更新
- 健康检查
- 加权负载均衡
- 故障转移

## 十三、踩坑 5：超过 max message size

```go
// 默认限制 4MB
// 大消息场景
conn, _ := grpc.Dial(addr,
    grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(64*1024*1024)),
)

// 服务端
server := grpc.NewServer(
    grpc.MaxRecvMsgSize(64*1024*1024),
    grpc.MaxSendMsgSize(64*1024*1024),
)
```

**坑点**：超过限制的报错信息是 `received message larger than max`，不太直观。生产环境推荐明确设置。

## 十四、踩坑 6：context deadline exceeded

```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

resp, err := client.GetUser(ctx, req)
// err: context deadline exceeded
```

**修复**：

1. 调大 deadline
2. 服务端优化处理速度
3. 用 stream 替代 unary，分批返回

**关键**：**永远不要用无限期 context**。生产环境推荐 5-10s deadline。

## 十五、性能对比：HTTP/JSON vs gRPC

| 指标         | HTTP/JSON | gRPC       |
| ------------ | --------- | ---------- |
| 1000 QPS CPU | 80%       | 25%        |
| P99 延迟     | 45ms      | 12ms       |
| 网络流量     | 100%      | 30%        |
| 连接数       | 多        | 单连接复用 |

**5 倍吞吐量提升**是真实数据。

## 十六、总结

gRPC 落地的 6 条原则：

1. **接口契约先行**：.proto 文件即文档
2. **keepalive 心跳必备**：防火墙友好
3. **metadata key 小写**：gRPC 规范
4. **拦截器链解耦**：日志、认证、限流分离
5. **客户端 conn 复用**：HTTP/2 多路复用
6. **deadline 永远设**：避免雪崩

gRPC 不是「换个协议」那么简单，是「服务间通信」范式的转变。
]]></content:encoded>
      <category>gRPC</category>
      <category>后端</category>
      <category>微服务</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>Kafka 消息可靠性实战：从 producer ack 到消费者幂等</title>
      <link>https://sanshui-blog.pages.dev/posts/kafka-%E6%B6%88%E6%81%AF%E5%8F%AF%E9%9D%A0%E6%80%A7%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/kafka-%E6%B6%88%E6%81%AF%E5%8F%AF%E9%9D%A0%E6%80%A7%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate>
      <description>一个 Kafka 消费者把同一笔订单处理了三次。本文讲 producer 的 acks/retries/idempotence、broker 的 ISR 与 min.insync.replicas、consumer 的手动提交与 exactly-once。</description>
      <content:encoded><![CDATA[
# Kafka 消息可靠性实战：从 producer ack 到消费者幂等

线上一个 Kafka 消费者，把同一笔订单处理了三次。日志显示：第一次成功、第二次也「成功」、第三次「订单已处理」。原因涉及 producer、broker、consumer 三层的多个坑。这篇文章系统梳理完整链路。

## 一、Kafka 可靠性的三层保障

| 层       | 关键配置                                                                                | 解决的问题             |
| -------- | --------------------------------------------------------------------------------------- | ---------------------- |
| Producer | `acks=all`、`enable.idempotence=true`、`retries`                                        | 不丢消息、不重消息     |
| Broker   | `replication.factor=3`、`min.insync.replicas=2`、`unclean.leader.election.enable=false` | 副本一致性、防止脑裂   |
| Consumer | `enable.auto.commit=false`、手动提交 offset、幂等处理                                   | 防止重复消费、消息丢失 |

## 二、Producer 的 acks 配置

`acks` 决定 producer 多久才算「发送成功」：

| acks     | 含义               | 可靠性                | 吞吐 |
| -------- | ------------------ | --------------------- | ---- |
| 0        | 不等待任何响应     | 最低，会丢消息        | 最高 |
| 1        | leader 写入即可    | 中等，leader 宕机会丢 | 中   |
| all / -1 | ISR 所有副本都写入 | 最高                  | 低   |

**生产推荐**：`acks=all`。

```java
Properties props = new Properties();
props.put("bootstrap.servers", "kafka:9092");
props.put("acks", "all");
props.put("retries", 3);
props.put("max.in.flight.requests.per.connection", 5);
props.put("enable.idempotence", true);
```

## 三、踩坑 1：retries 导致消息乱序

```text
场景：
1. Producer 发送 m1, m2, m3 到同一分区
2. m1 失败，producer 重试 m1
3. m2, m3 已经发送成功
4. 重试的 m1 后到，导致顺序变 m2, m3, m1
```

**修复**：用 idempotent producer + 限制 in-flight 请求数：

```java
props.put("enable.idempotence", true);
props.put("max.in.flight.requests.per.connection", 5);
```

启用 idempotence 后，broker 用 PID（Producer ID）+ SequenceNumber 去重。即使重试也不会写入重复消息。

## 四、Broker 的 ISR 与 min.insync.replicas

**ISR（In-Sync Replicas）**：与 leader 保持同步的副本集合。

**min.insync.replicas**：写入时至少需要多少个 ISR 副本。

```bash
# Topic 配置
kafka-configs --bootstrap-server kafka:9092 \
  --alter --entity-type topics --entity-name orders \
  --add-config min.insync.replicas=2,replication.factor=3
```

**踩坑 2**：min.insync.replicas 太大导致可用性下降

```text
replication.factor=3, min.insync.replicas=2
正常：3 副本，写 2 即可
挂 1：剩 2，写 2 即可
挂 2：剩 1，小于 2，写入失败
```

如果业务可用性要求高于一致性，把 min.insync.replicas 调到 1。但一般推荐 2，平衡两者。

## 五、踩坑 3：unclean.leader.election 导致数据丢失

```bash
# 默认值不同 Kafka 版本不同
unclean.leader.election.enable=false
```

**场景**：

1. leader A 写入 m100
2. follower B 还没同步 m100
3. A 宕机，B 成为新 leader
4. m100 丢失

`unclean.leader.election.enable=false` 防止这种场景：如果 ISR 为空，宁可不选 leader 也不让非 ISR 副本上位。

**代价**：可用性下降。如果 ISR 全挂，topic 无法读写直到副本恢复。

## 六、Consumer 的 offset 提交策略

```java
// ❌ 自动提交：会重复消费、丢失消息
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");

// ✅ 手动提交
props.put("enable.auto.commit", "false");
```

### 自动提交的两个问题

1. **重复消费**：commit interval 5s，2s 时消费者处理完一批消息但还没 commit，crash。重启后从上次 commit 的 offset 开始消费，已处理的会重复。
2. **消息丢失**：commit interval 5s，2s 时拉取了新消息并 commit，但还没处理就 crash。重启后从新 offset 开始，旧消息丢失。

### 手动提交的三种策略

**策略 A：处理完一批后 commit**

```java
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        process(record);
    }
    consumer.commitSync();  // 处理完才 commit
}
```

**问题**：如果 process 抛异常，整批消息都不会 commit，重启后会重复消费。

**策略 B：每条消息处理后 commit**

```java
for (ConsumerRecord<String, String> record : records) {
    process(record);
    consumer.commitSync(Collections.singletonMap(
        new TopicPartition(record.topic(), record.partition()),
        new OffsetAndMetadata(record.offset() + 1)
    ));
}
```

**问题**：commit 频率太高，性能差。

**策略 C：批量处理 + 失败重试**

```java
try {
    for (ConsumerRecord<String, String> record : records) {
        processWithRetry(record, 3);  // 重试 3 次
    }
    consumer.commitSync();
} catch (Exception e) {
    // 把失败的消息发到失败消息队列，避免阻塞消费
    sendToDLQ(records);
    consumer.commitSync();  // commit 包括失败消息的 offset
}
```

## 七、踩坑 4：消费者并发与分区数

```java
// 单消费者
new KafkaConsumer(props);

// 多消费者（消费者组）
for (int i = 0; i < 4; i++) {
    new Thread(() -> {
        KafkaConsumer c = new KafkaConsumer(props);
        // ...
    }).start();
}
```

**关键**：**消费者数不能超过分区数**。如果分区数是 4，启动 6 个消费者，其中 2 个永远拿不到消息（idle）。

**生产建议**：消费者数 = 分区数，或消费者数 < 分区数（让一个消费者消费多个分区）。

## 八、踩坑 5：长任务消费者

```java
// ❌ 处理慢，超过 max.poll.interval.ms，被踢出消费者组
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        Thread.sleep(60000);  // 假设处理需要 60s
    }
}
```

默认 `max.poll.interval.ms=300000`（5 分钟），超过就被踢出。

**修复**：

1. 调大 `max.poll.interval.ms`
2. 减小 `max.poll.records`，每次拉少点
3. 用 worker pool 异步处理

## 九、exactly-once 语义

exactly-once 语义（EOS）的实现：

### Producer 端：事务

```java
props.put("transactional.id", "my-transactional-id");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();

try {
    producer.beginTransaction();
    producer.send(new ProducerRecord<>("topic1", "key1", "value1"));
    producer.send(new ProducerRecord<>("topic2", "key2", "value2"));
    // 提交消费者的 offset（在同一事务内）
    producer.sendOffsetsToTransaction(offsets, consumerGroupId);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}
```

### Consumer 端：read_committed

```java
props.put("isolation.level", "read_committed");
```

只读已 commit 的事务消息，避免读到 abort 事务的「脏数据」。

## 十、踩坑 6：消费者重平衡（rebalance）

消费者加入/退出消费者组时触发 rebalance，期间消费者无法消费。

**经典问题**：

1. 消费者处理慢，超过 `session.timeout.ms`，被误认为「挂了」
2. 触发 rebalance，整个消费者组重新分配分区
3. 处理中的消息可能丢失或重复

**修复**：

```java
// 调大 session timeout
props.put("session.timeout.ms", "30000");
// 调大 heartbeat interval（不超过 session timeout 的 1/3）
props.put("heartbeat.interval.ms", "10000");
// 使用 Cooperative Rebalance 策略（Kafka 2.4+）
props.put("partition.assignment.strategy",
    "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
```

## 十一、Kafka Streams 的 exactly-once

```java
Properties props = new Properties();
props.put("application.id", "my-streams-app");
props.put("bootstrap.servers", "kafka:9092");
props.put("processing.guarantee", "exactly_once_v2");  // Kafka 2.5+
```

`exactly_once_v2` 使用新的 transaction API，性能比 `exactly_once` 更好。

## 十二、实战案例：订单重复处理三次

### 排查

1. 日志显示同一笔订单被消费三次
2. producer 用 `acks=1`，可能重试导致重复
3. consumer 用 `enable.auto.commit=true`，crash 后重新消费

### 修复方案

**Producer 端**：

```java
props.put("acks", "all");
props.put("enable.idempotence", true);
props.put("retries", 10);
```

**Broker 端**：

```bash
min.insync.replicas=2
replication.factor=3
unclean.leader.election.enable=false
```

**Consumer 端**：

```java
props.put("enable.auto.commit", "false");
props.put("isolation.level", "read_committed");
```

**业务端：幂等**

```python
def process_order(order_id, payload):
    # 检查是否已处理
    if cache.exists(f"order:{order_id}:processed"):
        return
    # 处理
    do_business(order_id, payload)
    # 标记已处理
    cache.set(f"order:{order_id}:processed", "1", ex=86400)
```

但 cache.set 可能失败。**更可靠**的方式是用数据库唯一约束：

```sql
INSERT INTO processed_orders (order_id, processed_at)
VALUES (?, NOW())
ON CONFLICT (order_id) DO NOTHING;
```

如果插入成功（affected_rows=1），说明是首次处理；如果 affected_rows=0，说明已处理过，跳过。

## 十三、监控指标

Kafka 关键监控指标：

| 指标                        | 含义                 | 告警阈值    |
| --------------------------- | -------------------- | ----------- |
| under_replicated_partitions | ISR 不足的分区数     | > 0         |
| offline_partitions          | 没有 leader 的分区数 | > 0         |
| consumer_lag                | 消费者滞后           | > 10000     |
| producer_request_latency    | producer 请求延迟    | P99 > 100ms |
| isr_shrinks_rate            | ISR 缩减速率         | 突增        |

## 十四、总结

Kafka 可靠性的核心原则：

1. **Producer 用 acks=all + idempotence**
2. **Broker 用 replication.factor=3 + min.insync.replicas=2**
3. **Consumer 关闭 auto commit，手动 commit**
4. **业务层做幂等**：数据库唯一约束或 Redis SETNX
5. **EOS 场景用 transaction API + read_committed**

消息队列从来不只是「发出去就行」，每一层都要明确语义，才能真正做到生产级可靠。
]]></content:encoded>
      <category>Kafka</category>
      <category>后端</category>
      <category>消息队列</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>Next.js 15 App Router 实战：从 RSC 到 Route Handlers</title>
      <link>https://sanshui-blog.pages.dev/posts/nextjs-15-app-router-%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/nextjs-15-app-router-%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>Next.js 15 把 params / searchParams 都变成了 Promise。本文讲 App Router 的 6 层缓存、generateStaticParams 与 ISR、Route Handlers 的 streaming，再到 10 个迁移踩坑点。</description>
      <content:encoded><![CDATA[
# Next.js 15 App Router 实战：从 RSC 到 Route Handlers

一个线上项目从 Next.js 14 升到 15，结果整个 dev server 启动后白屏。控制台报：

```text
Error: Params should be awaited before using its properties.
```

这是 Next.js 15 最大的破坏性变化——动态路由的 `params` 变成了 `Promise`。这篇文章记录完整迁移踩坑过程，并系统梳理 App Router 的核心机制。

## 一、params / searchParams 都是 Promise 了

Next.js 14：

```tsx
// app/posts/[slug]/page.tsx
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params; // 直接解构
  return <Post slug={slug} />;
}
```

Next.js 15：

```tsx
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params; // 必须先 await
  return <Post slug={slug} />;
}
```

**为什么这么改？** Next.js 15 引入 PPR（Partial Prerendering），同一个页面可以「静态部分先返回 HTML，动态部分流式注入」。params 和 searchParams 在动态渲染场景下可能尚未就绪，所以改成 Promise。

## 二、generateMetadata 同样要 await

```tsx
// ❌ Next 14 写法，15 会报错
export function generateMetadata({ params }: { params: { slug: string } }) {
  return { title: getPost(params.slug).title };
}

// ✅ Next 15
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return { title: (await getPost(slug)).title };
}
```

## 三、generateStaticParams 仍是同步

```tsx
export function generateStaticParams() {
  return getAllPosts().map((p) => ({ slug: p.slug }));
}
```

**注意**：返回的 `slug` 是原始字符串，**不要做 `encodeURIComponent`**。Next.js 内部会处理 URL 编码。如果你手动 encode，反而会导致 `getPostBySlug('%E6%B7%B1...')` 这种被双重编码的 slug。

## 四、App Router 的六层缓存

理解这六层是排查「数据不更新」的关键：

1. **Request Memo**：单次请求内 dedupe fetch（基于 URL + cache 选项）
2. **Data Cache**：fetch 的跨请求持久缓存（`fetch(url, { cache: 'force-cache' })`）
3. **Full Route Cache**：整个路由的 RSC payload + HTML 缓存（静态渲染触发）
4. **Router Cache**：客户端内存里的路由缓存（_APP Router_ 的 RSC payload）
5. **Draft Mode Cookie**：Draft Mode 的 cookie 标记
6. **bfcache**：浏览器后退/前进时的内存缓存

**典型坑**：

- 文章发布后看不到新内容 → Full Route Cache 没失效
- 后退按钮显示旧数据 → Router Cache 没刷新
- 多用户互相看到对方的数据 → 误用 `force-cache` 缓存了用户态数据

## 五、Route Handlers 的 streaming

```ts
// app/api/stream/route.ts
export async function GET(req: Request) {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(encoder.encode(`data: ${i}\n\n`));
        await new Promise((r) => setTimeout(r, 500));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  });
}
```

**踩坑**：`output: 'export'` 静态导出模式下 Route Handlers **不能是动态响应**。会报：

```text
Error: Route "/api/stream" used `req` as a dynamic API.
This is not supported when using `output: 'export'`.
```

静态导出场景下，Route Handler 必须返回静态数据。

## 六、踩坑 1：dynamic = 'force-dynamic' 与 ISR 冲突

```tsx
// app/posts/page.tsx
export const dynamic = 'force-dynamic';
export const revalidate = 60;
```

`force-dynamic` 让路由每次请求都重新渲染，**忽略 revalidate**。两者同时存在时，`force-dynamic` 胜出。

如果想「定时重新生成静态页」，只用 `revalidate`，不要加 `force-dynamic`。

## 七、踩坑 2：cookies() / headers() 强制动态渲染

```tsx
import { cookies } from 'next/headers';

export default async function Page() {
  const cookieStore = await cookies(); // Next 15 也要 await
  const token = cookieStore.get('token');
  // ...
}
```

调用 `cookies()` 或 `headers()` 会让路由自动切换到**动态渲染**，失去静态优化的好处。

如果你的页面大部分静态、只小部分用 cookie，把动态部分隔离到子 route，让父 route 保持静态。

## 八、踩坑 3：'use cache' 指令与 Next 15 的缓存语义

Next.js 15 引入 `'use cache'` 指令（实验性）：

```tsx
import { cache } from 'react';

// React 的 cache 是「单次渲染内 memo」
export const getPost = cache(async (slug: string) => {
  return db.post.findUnique({ where: { slug } });
});

// Next 15 的 'use cache' 是「跨请求持久缓存'
async function getCachedPost(slug: string) {
  'use cache';
  return db.post.findUnique({ where: { slug } });
}
```

**踩坑**：`'use cache'` 函数的参数必须是**可序列化**的。传一个对象进去会报错。

## 九、踩坑 4：image 的 basePath 处理

```tsx
import Image from 'next/image';

<Image src="/logo.svg" width={100} height={100} />;
```

`next/image` 会自动加 `basePath` 前缀。但如果你用原生 `<img>` 或 `<link>`，**必须手动加 basePath**：

```tsx
import { withBase } from '@/lib/basePath';

<link rel="icon" href={withBase('/favicon.svg')} />;
```

## 十、踩坑 5：metadataBase 与 OG 图片

```tsx
export const metadata = {
  metadataBase: new URL('https://sanshui.io'),
  openGraph: {
    images: ['/og-image.png'],
  },
};
```

`metadataBase` 用于解析相对路径。但 Next.js 15 在静态导出场景下，**metadataBase 必须是绝对 URL**，否则 OG 图片 URL 会变成 `/og-image.png`，社交平台抓不到。

## 十一、踩坑 6：parallelRoutes 拦截路由

```tsx
// app/@modal/(.)post/[slug]/page.tsx
// 拦截 /post/[slug]，渲染到 @modal slot
```

parallelRoutes + intercepting routes 是 Next.js 15 最难理解的特性。**典型坑**：

1. 静态导出场景下 intercepting 不工作（需要动态路由）
2. `@modal` slot 名字任意，但必须在 layout 里声明
3. 拦截路由的 `params` 也是 Promise（Next 15）

## 十二、踩坑 7：not-found.tsx 的层级

```text
app/
├── not-found.tsx          # 兜底 404
├── posts/
│   ├── [slug]/
│   │   └── page.tsx
│   └── not-found.tsx      # /posts/* 下的 404
```

`notFound()` 函数会向上查找最近的 `not-found.tsx`。

```tsx
import { notFound } from 'next/navigation';

export default async function Page({ params }) {
  const post = await getPost(params.slug);
  if (!post) notFound(); // 触发 not-found.tsx
}
```

**踩坑**：根 `not-found.tsx` 在静态导出场景下不会自动绑定到 404.html。需要手动配置：

```ts
// next.config.ts
export default {
  output: 'export',
  // 静态导出时确保 404.html 被生成
  exportTrailingSlash: true,
};
```

## 十三、踩坑 8：Server Actions 的 CSRF

Server Actions 默认有 CSRF 保护，但只对 same-origin 请求生效。

如果你的 Server Action 需要被 cross-origin 调用（不太推荐），必须显式配置：

```tsx
export const config = {
  allowedOrigins: ['https://api.example.com'],
};
```

## 十四、踩坑 9：useRouter 在 App Router 里变了

```tsx
'use client';
import { useRouter } from 'next/navigation';

const router = useRouter();
router.push('/posts'); // 还可以用
router.refresh(); // 重新拉取当前路由的 RSC payload

// ❌ router.events 在 App Router 里被移除
```

监听路由变化的正确方式：

```tsx
'use client';
import { usePathname } from 'next/navigation';

function RouteTracker() {
  const pathname = usePathname();
  useEffect(() => {
    analytics.track('page_view', { path: pathname });
  }, [pathname]);
}
```

## 十五、踩坑 10：viewport 和 themeColor 不能再放 metadata 里

Next 14：

```tsx
export const metadata = {
  themeColor: '#ffffff',
  viewport: 'width=device-width',
};
```

Next 15：

```tsx
export const viewport: Viewport = {
  themeColor: '#ffffff',
  width: 'device-width',
};

export const metadata: Metadata = {
  // themeColor 和 viewport 不放这里
};
```

混用会报 warning。

## 十六、迁移实战：sanshui-blog 的 14 → 15

### 第一步：升级依赖

```bash
npm install next@15 react@19 react-dom@19
```

### 第二步：批量改 params / searchParams

项目里有 5 个动态路由文件，全部要改：

```tsx
// app/posts/[slug]/page.tsx
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // ...
}

// generateMetadata 同样
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  // ...
}
```

### 第三步：检查 cookies() / headers()

Next 15 里这些也返回 Promise：

```tsx
const cookieStore = await cookies();
const headerList = await headers();
```

### 第四步：处理 layout 的 params

```tsx
// app/tags/[tag]/layout.tsx
export default async function Layout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ tag: string }>;
}) {
  const { tag } = await params;
  return <div>{children}</div>;
}
```

### 第五步：跑 lint

```bash
npm run lint
```

会发现一些「params 不再是普通对象」相关的 error，逐一修复。

## 十七、总结

Next.js 15 的核心变化是 **「async params」**，背后是 PPR 对异步数据的需要。迁移的关键点：

1. **`await params` / `await searchParams`**：所有动态路由文件都要改
2. **`await cookies()` / `await headers()`**：Next 15 里也是 Promise
3. **viewport 独立**：不再放 metadata
4. **`force-dynamic` 与 `revalidate` 互斥**：不要同时用
5. **静态导出场景下 Route Handlers 受限**：不能返回动态响应

理解这六层缓存 + async params 这两件事，App Router 就没有秘密了。
]]></content:encoded>
      <category>Next.js</category>
      <category>前端</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>PostgreSQL 索引实战：从 B-tree 到 GIN 踩坑录</title>
      <link>https://sanshui-blog.pages.dev/posts/postgresql-%E7%B4%A2%E5%BC%95%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/postgresql-%E7%B4%A2%E5%BC%95%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
      <description>一张 800 万行的表 LIKE 查询要 12 秒。本文讲 B-tree / Hash / GIN / GiST / BRIN 五种索引选型、部分索引、表达式索引、覆盖索引，再到 7 个真实生产事故的根因。</description>
      <content:encoded><![CDATA[
# PostgreSQL 索引实战：从 B-tree 到 GIN 踩坑录

线上一个日志查询接口超时。查下来发现是一张 800 万行的 `audit_log` 表，按 `path LIKE '/api/%'` 查询要 12 秒。建了 B-tree 索引也没用，因为 `LIKE` 以通配符开头时索引会失效。这篇文章系统梳理 PostgreSQL 五种索引的选型逻辑，再列出 7 个真实生产事故的根因和修复。

## 一、五种索引的本质差异

| 索引类型 | 数据结构      | 适用场景             | 不适用                  |
| -------- | ------------- | -------------------- | ----------------------- |
| B-tree   | 平衡多叉树    | 等值、范围、排序     | 数组、全文搜索          |
| Hash     | 哈希表        | 仅等值查询           | 范围、排序              |
| GIN      | 倒排索引      | 数组、JSON、全文搜索 | 高更新频率表            |
| GiST     | 平衡树 + 谓词 | 几何、范围、模糊匹配 | 等值查询性能不如 B-tree |
| BRIN     | 块范围索引    | 大表 + 物理有序数据  | 随机写入的小表          |

**默认是 B-tree**。`CREATE INDEX idx ON t(col)` 就是 B-tree。

## 二、踩坑 1：LIKE '%xxx' 让索引失效

```sql
-- ❌ 索引失效，全表扫描
SELECT * FROM audit_log WHERE path LIKE '/api/%'

-- 实际上 path LIKE 'xxx%' 可以用 B-tree
-- 但 LIKE '%xxx' 或 LIKE '/api/%' 中间有 % 的话，要看模式
```

`LIKE '/api/%'` 在默认 `C` locale 下是可以用索引的，因为 `path` 字段是按字典序排列的，`LIKE 'prefix%'` 等价于一个范围查询。

但 `LIKE '%suffix'` 必须从尾部匹配，B-tree 索引按升序排列，无法跳过中间部分。

**解决方案**：

### 方案 A：pg_trgm 扩展 + GIN 索引

```sql
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_audit_log_path_trgm ON audit_log USING gin (path gin_trgm_ops);

-- 现在任意位置的模糊匹配都能用索引
SELECT * FROM audit_log WHERE path LIKE '%user%';
```

`pg_trgm` 把字符串切成 3-gram（三字符组合），GIN 倒排索引能快速定位包含特定 trigram 的行。

### 方案 B：反序字段 + B-tree

```sql
-- 加一个反序字段
ALTER TABLE audit_log ADD COLUMN path_reverse text;
UPDATE audit_log SET path_reverse = reverse(path);
CREATE INDEX idx_audit_log_path_rev ON audit_log(path_reverse);

-- LIKE '%suffix' 等价于反序字段的 LIKE 'suffix%'
SELECT * FROM audit_log WHERE path_reverse LIKE reverse('user') || '%';
```

适合固定后缀匹配。但需要触发器维护 `path_reverse`。

## 三、踩坑 2：低选择性字段建索引没用

```sql
CREATE INDEX idx_user_active ON users(is_active);
```

`is_active` 只有 true / false 两个值，索引选择性极低。PostgreSQL 查询规划器看到「95% 的行匹配条件」时，会**直接走全表扫描**，跳过索引。

**解决方案**：

### 方案 A：部分索引

```sql
-- 只对 is_active = false 的行建索引
CREATE INDEX idx_user_inactive ON users(id) WHERE is_active = false;
```

索引大小可能从 100MB 缩到 5MB，且查询时规划器会优先用这个「小而精」的索引。

### 方案 B：组合索引

```sql
CREATE INDEX idx_user_active_email ON users(is_active, email);
```

`(is_active, email)` 组合索引可以服务 `WHERE is_active = true AND email = '...'` 查询。但**列顺序很重要**——`is_active` 在前，过滤大量行；`email` 在后，精确匹配。

## 四、踩坑 3：组合索引列顺序

```sql
CREATE INDEX idx_user_country_city ON users(country, city);

-- ✅ 能用索引
SELECT * FROM users WHERE country = 'US' AND city = 'NYC';
SELECT * FROM users WHERE country = 'US';

-- ❌ 不能用索引（city 在前跳过了 country）
SELECT * FROM users WHERE city = 'NYC';
```

**最左前缀原则**：组合索引只能从最左列开始用。如果查询条件涉及 `(country, city)`，那索引应该是 `(country, city)` 或 `(city, country)`，看哪个选择性更高。

**通用经验**：

1. 等值查询列在前，范围查询列在后
2. 选择性高的列在前
3. 排序列紧跟等值列

## 五、踩坑 4：COUNT(*) 索引不生效

```sql
SELECT COUNT(*) FROM orders WHERE status = 'pending';
```

即使 `status` 上有索引，PostgreSQL 也可能走全表扫描。原因：`COUNT(*)` 需要扫描所有匹配行确认可见性（MVCC 多版本可见性检查），索引里没有完整的可见性信息。

**解决方案**：

### 方案 A：维护计数表

```sql
CREATE TABLE order_counts (
  status text PRIMARY KEY,
  count bigint NOT NULL
);

-- 触发器维护
CREATE TRIGGER update_order_count
  AFTER INSERT OR UPDATE OR DELETE ON orders
  FOR EACH ROW EXECUTE FUNCTION update_order_count();
```

### 方案 B：估算计数

```sql
-- 用 pg_class.reltuples 估算
SELECT reltuples::bigint AS estimate
  FROM pg_class
  WHERE relname = 'orders';
```

适合分页总数显示，不需要精确值。

## 六、踩坑 5：JSON 查询的索引

```sql
CREATE TABLE events (
  id serial PRIMARY KEY,
  data jsonb
);

-- ❌ 查询不走索引
SELECT * FROM events WHERE data->>'user_id' = '123';

-- ✅ 表达式索引
CREATE INDEX idx_events_user_id ON events ((data->>'user_id'));
```

表达式索引 `(data->>'user_id')` 会把 JSON 字段提取出来做索引。但**每次 UPDATE JSON 都会重建索引**，写入性能会受影响。

### GIN 索引

```sql
CREATE INDEX idx_events_data ON events USING gin (data);

-- ✅ 支持各种 JSON 操作
SELECT * FROM events WHERE data @> '{"user_id": "123"}';
SELECT * FROM events WHERE data ? 'user_id';
```

`@>` 是包含查询，`?` 是 key 存在查询。GIN 索引同时支持这两种。

## 七、踩坑 6：索引膨胀（bloat）

PostgreSQL 的 MVCC 机制是「标记删除 + 后台 VACUUM 清理」。UPDATE 实际上是「DELETE + INSERT」，旧版本行留在数据文件里，等 VACUUM 回收。

索引膨胀问题：UPDATE 不更新的列也会在索引里留下「失效项」。

诊断：

```sql
SELECT schemaname, relname, indexrelname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
       idx_scan AS index_scans
  FROM pg_stat_user_indexes
  WHERE idx_scan < 50  -- 几乎没用的索引
  ORDER BY pg_relation_size(indexrelid) DESC;
```

**解决方案**：

### 方案 A：定期 REINDEX

```sql
-- 锁表，慎用
REINDEX INDEX idx_events_user_id;

-- 并发 REINDEX（PG 12+）
REINDEX INDEX CONCURRENTLY idx_events_user_id;
```

### 方案 B：pg_repack 在线重建

```bash
pg_repack -d mydb -t events
```

`pg_repack` 用触发器保证重建期间不丢更新，业务无感知。

## 八、踩坑 7：并发建索引的坑

```sql
-- ❌ 锁表，业务无法写入
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- ✅ 并发建索引，不阻塞写入
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
```

但 `CONCURRENTLY` 有几个坑：

1. **构建时间长**：需要扫两遍表，约 2-3 倍于普通方式
2. **失败留下 INVALID 索引**：构建中断后索引处于 invalid 状态，必须 `DROP INDEX` 后重建
3. **不能在事务里用**：

```sql
BEGIN;
CREATE INDEX CONCURRENTLY ...;  -- ❌ 报错
COMMIT;
```

## 九、覆盖索引：避免回表

```sql
-- 普通查询需要回表
SELECT user_id, order_date FROM orders WHERE user_id = 123;

-- 覆盖索引直接命中
CREATE INDEX idx_orders_user_id_date ON orders(user_id, order_date);
```

包含索引（PG 11+）：

```sql
-- INCLUDE 列不参与索引排序，但可以避免回表
CREATE INDEX idx_orders_user_id ON orders(user_id) INCLUDE (order_date, total);
```

`INCLUDE` 列对查询过滤无效，但 SELECT 时可以直接从索引返回，省一次 IO。

## 十、BRIN 索引：大表日志场景

```sql
-- 假设 audit_log 按 created_at 物理有序插入
CREATE INDEX idx_audit_log_created_brin ON audit_log USING brin (created_at);
```

BRIN（Block Range Index）只存每个数据块的「min/max」值，索引极小。

适合：

- 时间序列日志表
- 物理顺序与查询顺序一致的场景
- 行数极大（10 亿+）但查询都是范围扫描

不适合：

- 大量随机 UPDATE / DELETE 的表
- 需要等值查询的场景

## 十一、索引实战案例：12 秒 → 80ms

`audit_log` 表 800 万行，查询：

```sql
SELECT * FROM audit_log
 WHERE path LIKE '/api/users/%'
   AND created_at > now() - interval '7 days'
 ORDER BY created_at DESC
 LIMIT 50;
```

慢的原因：

1. `path LIKE '/api/users/%'` 是范围匹配，但中间的 `%` 让 B-tree 失效
2. `created_at > ...` 是范围扫描，B-tree 可以用
3. ORDER BY + LIMIT 想用 index scan，但前面的过滤条件不让用

**解决方案**：

```sql
-- 1. pg_trgm + GIN 解决 LIKE 模糊匹配
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_audit_log_path_trgm
  ON audit_log USING gin (path gin_trgm_ops);

-- 2. B-tree 索引覆盖 created_at 排序
CREATE INDEX idx_audit_log_created_desc
  ON audit_log (created_at DESC);

-- 3. 组合索引（最有效）
CREATE INDEX idx_audit_log_path_created
  ON audit_log (created_at DESC, path gin_trgm_ops);
```

效果：

```text
Before: 12.3s
After:  80ms
```

查询计划：

```text
Limit  (cost=0.43..1.95 rows=50 width=...)
  ->  Index Scan using idx_audit_log_created_desc on audit_log
        Index Cond: (created_at > (now() - '7 days'::interval))
        Filter: (path ~~ '/api/users/%'::text)
        Rows Removed by Filter: 1523
```

走 `idx_audit_log_created_desc`，按时间倒序扫，过滤 path。LIMIT 50 让扫描提前结束。

## 十二、索引维护清单

定期执行：

```sql
-- 1. 分析未使用的索引
SELECT relname, indexrelname, idx_scan
  FROM pg_stat_user_indexes
  WHERE idx_scan = 0
    AND indexrelname NOT LIKE '%_pkey';

-- 2. 检查膨胀率
SELECT schemaname, tablename, attname,
       (n_distinct >= 0 OR n_distinct < -0.5) AS is_good_selectivity
  FROM pg_stats
  WHERE schemaname = 'public';

-- 3. VACUUM ANALYZE
VACUUM ANALYZE audit_log;

-- 4. 自动 vacuum 调参
ALTER TABLE audit_log SET (autovacuum_vacuum_scale_factor = 0.05);
ALTER TABLE audit_log SET (autovacuum_analyze_scale_factor = 0.02);
```

## 十三、总结

PostgreSQL 索引的 7 条原则：

1. **B-tree 是默认**，等值/范围/排序都靠它
2. **LIKE '%xxx' 用 pg_trgm + GIN**
3. **低选择性字段用部分索引**，别建普通 B-tree
4. **组合索引按选择性排序**，等值列在前
5. **JSON 查询用表达式索引或 GIN**
6. **大表日志用 BRIN**，索引体积小
7. **生产环境建索引用 CONCURRENTLY**，避免锁表

索引不是越多越好——每个索引都是 UPDATE 时的额外开销。精准的索引策略能让查询速度提升 100 倍，同时减少磁盘占用。
]]></content:encoded>
      <category>PostgreSQL</category>
      <category>后端</category>
      <category>数据库</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>PostgreSQL 高可用实战：流复制与 Patroni</title>
      <link>https://sanshui-blog.pages.dev/posts/postgresql-%E9%AB%98%E5%8F%AF%E7%94%A8%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/postgresql-%E9%AB%98%E5%8F%AF%E7%94%A8%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <description>一次主库宕机，业务停了 40 分钟。本文讲流复制原理、同步级别选型、Patroni 自动故障转移，再到 6 个真实生产事故的根因。</description>
      <content:encoded><![CDATA[
# PostgreSQL 高可用实战：流复制与 Patroni

线上主库意外宕机。运维手动切换备库花了 40 分钟，期间业务完全停摆。这篇文章记录完整的 PG 高可用方案落地过程。

## 一、流复制的本质

PG 流复制（Streaming Replication）：

1. 主库把 WAL（Write-Ahead Log）日志流式发送给备库
2. 备库接收并应用 WAL，实现与主库的同步
3. 备库是「只读副本」，可承担读流量

### 物理复制 vs 逻辑复制

| 类型     | 机制              | 优势             | 劣势             |
| -------- | ----------------- | ---------------- | ---------------- |
| 物理复制 | 字节级 WAL 复制   | 完全一致，简单   | 必须同版本同架构 |
| 逻辑复制 | 解码 WAL 转成 SQL | 支持异构、跨版本 | 复杂、可能丢数据 |

**生产推荐**：物理复制 + Patroni 自动 failover。

## 二、配置流复制

### 主库配置

```ini
# postgresql.conf
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1024   # MB, 保留 WAL 给备库追上
hot_standby = on

# 允许备库连接
listen_addresses = '*'
```

```ini
# pg_hba.conf
host replication replicator 192.168.1.0/24 md5
```

```sql
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'xxx';
```

### 备库初始化

```bash
# 停止备库
pg_ctl -D /var/lib/postgresql/data stop

# 用 pg_basebackup 拉一份基线
pg_basebackup \
  -h primary_host \
  -U replicator \
  -D /var/lib/postgresql/data \
  -Fp -Xs -P -R

# -R 会自动创建 standby.signal 和 primary_conninfo
```

### 启动备库

```bash
pg_ctl -D /var/lib/postgresql/data start
```

备库启动后会自动连接主库，开始流式接收 WAL。

## 三、同步级别：async vs sync

```ini
# synchronous_commit = on 是默认，每次写入都等 WAL flush
synchronous_commit = on

# 同步备库列表
synchronous_standby_names = 'FIRST 2 (standby1, standby2, standby3)'
```

`synchronous_standby_names` 取值：

- `'ANY 2 (a, b, c)'`：任意 2 个备库确认即可
- `'FIRST 2 (a, b, c)'`：列表前 2 个确认
- `'*'`：所有备库确认

**生产推荐**：

- 强一致场景：`FIRST 1 (standby1)` —— 至少 1 个备库同步
- 高吞吐场景：`async` —— 不等备库

## 四、踩坑 1：备库 lag 过大

```sql
SELECT application_name, state, sync_state,
       sent_lsn, write_lsn, flush_lsn, replay_lsn,
       (sent_lsn - replay_lsn) AS lag
  FROM pg_stat_replication;
```

lag 大的常见原因：

1. **网络抖动**：检查备库到主库的带宽
2. **备库 IO 慢**：检查 `iostat -x 1`
3. **大事务**：一个 1GB 的 UPDATE 阻塞 replay

**修复**：大事务拆小批次，避免单事务超过 100MB WAL。

## 五、踩坑 2：备库查询阻塞写主库

备库的查询需要 snapshot，如果查询特别长，主库无法 vacuum 旧版本，导致 bloat。

```sql
-- 查看长查询
SELECT pid, now() - query_start AS duration, query
  FROM pg_stat_activity
  WHERE state = 'active'
  ORDER BY duration DESC;

-- 终止超长查询
SELECT pg_terminate_backend(pid);
```

**生产配置**：

```ini
max_standby_streaming_delay = 30s
max_standby_archive_delay = 30s
```

超过 30s 的备库查询会被自动取消。

## 六、Patroni 自动故障转移

Patroni 是 Zalando 开源的 PG 高可用方案：

```text
Patroni (primary) -- DCS (etcd/consul/zk)
   |
Patroni (standby1)
   |
Patroni (standby2)
```

### 配置示例

```yaml
# /etc/patroni/patroni.yml
scope: pg-cluster
name: node1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 192.168.1.10:8008

etcd:
  hosts: 192.168.1.100:2379,192.168.1.101:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576 # 1MB
    synchronous_mode: true
    postgresql:
      use_pg_rewind: true
      parameters:
        wal_level: replica
        hot_standby: 'on'
        max_wal_senders: 10
        synchronous_commit: 'on'
        synchronous_standby_names: '*'

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 192.168.1.10:5432
  data_dir: /var/lib/postgresql/data
  bin_dir: /usr/lib/postgresql/15/bin
  authentication:
    replication:
      username: replicator
      password: xxx
    superuser:
      username: postgres
      password: xxx

tags:
  nofailover: false
  noloadbalance: false
  clonefrom: false
  nosync: false
```

### 故障转移流程

1. 主库 Patroni 失去 DCS 心跳（默认 30s）
2. DCS 锁过期
3. 其他 Patroni 节点竞选 leader
4. lag 最小的备库被选为新主
5. 其他备库 reconfigure 指向新主
6. 应用通过 HAProxy / VIP 自动重连

**关键参数**：

- `ttl`：心跳周期，太小容易误判，太大 failover 慢
- `maximum_lag_on_failover`：超过此 lag 不允许 failover，避免丢数据
- `synchronous_mode: true`：要求至少一个 sync 备库

## 七、踩坑 3：split brain（脑裂）

场景：

1. 网络分区，主库与 DCS 失联
2. 备库被选为新主
3. 老主库不知道，继续接受写入
4. 网络恢复，两个主库数据冲突

**Patroni 的防护**：

1. 主库失去 DCS 心跳后，自动 demote 为 standby（停止接受写入）
2. 用 `pg_rewind` 修复数据冲突

```ini
# 关键：失去 DCS 时立即 demote
# patroni.yml
postgresql:
  use_pg_rewind: true
```

## 八、踩坑 4：VIP 漂移不及时

如果用 Keepalived + VIP 方案：

1. 主库宕机
2. Patroni 切换备库为新主
3. Keepalived 漂移 VIP 到新主节点

问题：第 2 步和第 3 步之间有空窗，应用连接 VIP 时连到老主库，报错。

**修复**：用 HAProxy 替代 VIP：

```haproxy
listen pg_cluster
  bind *:5432
  mode tcp
  option tcp-check
  tcp-check expect string primary
  server node1 192.168.1.10:5432 check port 8008
  server node2 192.168.1.11:5432 check port 8008
  server node3 192.168.1.12:5432 check port 8008
```

HAProxy 通过 Patroni REST API (`:8008`) 判断哪个节点是 primary，自动路由。

## 九、踩坑 5：备份策略与 PITR

只靠流复制不够。如果误删表（`DROP TABLE`），操作会立刻复制到备库，备库也跟着丢。

**修复**：定期 pg_basebackup + WAL 归档，支持 PITR（Point-in-Time Recovery）。

```bash
# 每天凌晨全量备份
pg_basebackup -D /backup/$(date +%Y%m%d) -X stream -c fast -P

# 归档 WAL
archive_command = 'test ! -f /backup/wal/%f && cp %p /backup/wal/%f'
```

恢复：

```bash
# 恢复到 2026-07-31 10:30:00
restore_command = 'cp /backup/wal/%f %p'
recovery_target_time = '2026-07-31 10:30:00'
recovery_target_action = 'promote'
```

## 十、踩坑 6：连接池与故障转移

应用直接连 PG 时，故障转移会有短暂连接中断。推荐用 PgBouncer：

```ini
# pgbouncer.ini
[databases]
mydb = host=haproxy port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
```

故障转移时，PgBouncer 自动重连到新主，应用几乎无感知。

## 十一、Patroni + etcd + HAProxy 完整架构

```text
Application
    ↓
HAProxy (5432)
    ↓
Patroni (primary) -- etcd (DCS) -- Patroni (standby1) -- Patroni (standby2)
                                       ↓
                                  pg_basebackup / WAL streaming
```

**3 节点最小集群**：1 主 2 备，任一节点宕机不影响服务。

## 十二、监控指标

| 指标                    | 含义            | 告警        |
| ----------------------- | --------------- | ----------- |
| replication_lag_bytes   | 备库 lag 字节数 | > 100MB     |
| replication_lag_seconds | 备库 lag 秒数   | > 30s       |
| Patroni leader          | 是否为 leader   | 0 个 leader |
| pg_up                   | PG 进程是否存活 | 0           |
| xact_commit_rate        | 事务提交速率    | 突降        |

## 十三、实战案例：40 分钟停摆

### 故障时间线

```text
T+0:00   主库 OOM，进程被 kill
T+0:01   Patroni 检测到 PG 挂了，尝试本地重启，失败
T+0:05   Patroni demote，DCS 锁释放
T+0:06   备库 1 竞选 leader 成功，开始 promote
T+0:07   备库 1 promote 失败（WAL 不连续）
T+0:10   备库 2 接手，promote 成功
T+0:11   HAProxy 健康检查通过，开始路由流量
T+0:12   应用报错，连接池里的旧连接还在
T+0:40   应用重启，连接池刷新，恢复
```

### 改进措施

1. **应用层**：连接池配置「连接失败时立即清空池」，而非重试
2. **Patroni**：调小 `loop_wait` 让检测更快
3. **备库 lag 监控**：lag > 100MB 立刻告警
4. **演练**：每月强制 failover 演练，确保自动化路径可靠

## 十四、总结

PG 高可用的 6 条原则：

1. **物理流复制为主**，逻辑复制为辅
2. **同步级别按场景选**，强一致用 sync，吞吐用 async
3. **Patroni 自动 failover**，避免手动操作延迟
4. **HAProxy 替代 VIP**，路由更可靠
5. **PgBouncer 连接池**，应用层无感知故障转移
6. **WAL 归档 + PITR**，应对人为误操作

高可用不是单一技术，是一整套架构：复制 + 故障转移 + 连接池 + 备份。每一环都不能省。
]]></content:encoded>
      <category>PostgreSQL</category>
      <category>高可用</category>
      <category>后端</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>React Native 新架构实战：Fabric 与 TurboModules</title>
      <link>https://sanshui-blog.pages.dev/posts/react-native-%E6%96%B0%E6%9E%B6%E6%9E%84%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/react-native-%E6%96%B0%E6%9E%B6%E6%9E%84%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
      <description>RN 新架构（Fabric + TurboModules + JSI）相比旧架构性能提升 3-5 倍。本文讲 JSI 直调 C++、Fabric 同步渲染、Codegen 类型生成，再到 5 个迁移踩坑点。</description>
      <content:encoded><![CDATA[
# React Native 新架构实战：Fabric 与 TurboModules

公司一个 React Native 项目，启动 1.8s、列表滚动掉帧严重。迁移到新架构（New Architecture）后，启动降到 600ms，滚动顺滑。这篇文章记录完整迁移过程。

## 一、新旧架构的根本差异

旧架构（Paper）：

1. JS 引擎通过「Bridge」与 Native 通信
2. Bridge 是异步的、消息队列式的
3. UI 操作：JS 调 `UIManager.measure()` → Bridge 序列化 → Native 反序列化 → 执行 → 结果序列化 → JS 反序列化
4. 大量跨边界通信时延迟累积

新架构：

1. JS 引擎通过 **JSI（JavaScript Interface）** 直接持有 C++ 对象引用
2. JSI 是同步的、零序列化的
3. **Fabric**：新渲染器，UI 操作同步执行
4. **TurboModules**：新原生模块系统，懒加载 + JSI 直调
5. **Codegen**：根据 TS / Flow 类型规范自动生成 C++ / Java / Obj-C 桥接代码

## 二、JSI 的本质：JS 持有 C++ 对象

旧 Bridge 通信示例：

```js
// JS
NativeModules.Database.query('SELECT * FROM users');
// → Bridge 异步序列化 → Native 解析执行 → Bridge 异步回传 → Promise resolve
```

JSI 通信：

```js
// JS
import { Database } from 'react-native-db'; // TurboModule
const db = new Database('app.db'); // 同步构造，直接持有 C++ 对象
const result = db.querySync('SELECT * FROM users'); // 同步调用 C++ 方法
```

**关键**：JS 直接调用 C++ 方法，没有序列化、没有异步等待。

## 三、启用新架构

```bash
# iOS
cd ios && RCT_NEW_ARCH_ENABLED=1 pod install

# Android
# gradle.properties 加
# newArchEnabled=true
# hermesEnabled=true
```

然后 `npx react-native build-android --mode release` 重新构建。

**踩坑 1：Hermes 是硬性要求**

新架构依赖 JSI，JSI 只在 Hermes 引擎上完整实现。如果项目用 JSC 或 V8，先迁移到 Hermes：

```ts
// android/app/build.gradle
project.ext.react = [
  enableHermes: true
]
```

**踩坑 2：第三方库必须支持新架构**

旧库会通过「Bridge Compatibility Layer」继续工作，但性能不如原生新架构库。检查每个库是否标记了 `codegenConfig` 或新架构兼容性。

## 四、TurboModules 实战：从 TS 规范到 C++ 实现

### 第一步：定义 TS 规范

```ts
// src/NativeCalculator.ts
import type { TurboModule } from 'react-native/Libraries/TurboModule/RCTExport';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  add(a: number, b: number): Promise<number>;
  addSync(a: number, b: number): number; // 同步方法
}

export default TurboModuleRegistry.getEnforcing<Spec>('Calculator');
```

### 第二步：让库暴露这个规范

```js
// package.json
{
  "name": "my-calculator",
  "codegenConfig": {
    "name": "RNCalculatorSpec",
    "type": "modules",
    "jsSrcsDir": "src"
  }
}
```

### 第三步：运行 Codegen

```bash
node node_modules/react-native/scripts/generate-codegen-artifacts.js \
  --path . \
  --outputPath ./generated
```

Codegen 会生成：

- C++ 接口（`RNCalculatorSpec.h`）
- Obj-C / Java 接口
- TS 类型（供 JS 使用）

### 第四步：实现 Native 代码

iOS（Obj-C++）：

```objc
// RCTCalculator.h
#import "RNCalculatorSpec.h"

@interface RCTCalculator : NSObject <NativeCalculatorSpec>
@end

// RCTCalculator.mm
#import "RCTCalculator.h"

@implementation RCTCalculator

RCT_EXPORT_MODULE()

- (NSNumber *)add:(double)a b:(double)b {
  return @(a + b);
}

- (double)addSync:(double)a b:(double)b {
  return a + b;
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
  return std::make_shared<NativeCalculatorSpecJSI>(params);
}

@end
```

JS 端直接调用：

```ts
import NativeCalculator from './src/NativeCalculator';

const sum = await NativeCalculator.add(1, 2); // 异步 Promise
const sync = NativeCalculator.addSync(1, 2); // 同步，直接拿值
```

**对比旧 Bridge**：

- 旧：`NativeModules.Calculator.add(...)` 返回 Promise，至少 1 个 JS tick 延迟
- 新：`addSync(...)` 同步返回，零延迟

## 五、Fabric 实战：自定义同步渲染组件

### TS 规范

```ts
// src/RNCustomViewNativeComponent.ts
import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes';
import type { HostComponent } from 'react-native';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';

export interface NativeProps extends ViewProps {
  color?: string;
  radius?: number;
}

export default codegenNativeComponent<NativeProps>('RNCustomView') as HostComponent<NativeProps>;
```

### React 组件

```tsx
import NativeCustomView from './src/RNCustomViewNativeComponent';

function CustomView({ color, radius }: { color: string; radius: number }) {
  return <NativeCustomView color={color} radius={radius} style={{ width: 100, height: 100 }} />;
}
```

### iOS 实现

```objc
// RCTCustomView.mm
#import <React/RCTViewComponentView.h>
#import <UIKit/UIKit.h>

@interface RCTCustomView : RCTViewComponentView
@property (nonatomic, strong) UIColor *color;
@property (nonatomic, assign) CGFloat radius;
@end

@implementation RCTCustomView
- (instancetype)initWithFrame:(CGRect)frame {
  if (self = [super initWithFrame:frame]) {
    self.layer.masksToBounds = YES;
  }
  return self;
}
- (void)setColor:(UIColor *)color {
  _color = color;
  self.backgroundColor = color;
}
- (void)setRadius:(CGFloat)radius {
  _radius = radius;
  self.layer.cornerRadius = radius;
}
@end

// Class 对外注册
Class<RCTComponentViewProtocol> RNCustomViewCls(void) {
  return RCTCustomView.class;
}
```

**关键**：Fabric 组件继承 `RCTViewComponentView`，状态更新是同步的。旧架构 `<View>` 是 async 渲染，新架构是 sync。

## 六、踩坑 3：useFrameCallback 替代 Animated API

新架构下，传统 `Animated` API 的 JS driver 性能依然不如新 `useFrameCallback`。

```tsx
// 新架构推荐写法
import { useFrameCallback } from 'react-native-reanimated';

function MyComponent() {
  const sv = useSharedValue(0);

  useFrameCallback((info) => {
    sv.value = info.timeSinceFirstFrame / 1000;
  });

  return <Animated.View style={{ transform: [{ rotate: `${sv.value * 360}deg` }] }} />;
}
```

`useFrameCallback` 在 UI 线程同步执行，完全绕过 JS 线程。

## 七、踩坑 4：Gradle 缓存爆炸

启用新架构后，Android 构建时间从 30s 飙升到 120s。原因是 Codegen 每次都重新生成大量 C++ 代码。

**解决方案**：

```gradle
// android/gradle.properties
android.cacheDir=../../.gradle-cache
newArchEnabled=true

// 启用 build cache
org.gradle.caching=true
org.gradle.configuration-cache=true
```

## 八、踩坑 5：iOS pod install 时 Codegen 失败

```bash
[Codegen] ERROR: Cannot find module 'react-native-codegen'
```

原因：依赖路径解析问题。**修复**：

```ruby
# ios/Podfile
# 顶部加
require_relative '../node_modules/react-native/scripts/react_native_pods'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'

# 然后
install! 'cocoapods', :disable_input_output_paths => true
```

并确保 `pod install` 时设置：

```bash
RCT_NEW_ARCH_ENABLED=1 pod install
```

## 九、性能对比：旧 vs 新

| 指标                | 旧架构 | 新架构 | 提升 |
| ------------------- | ------ | ------ | ---- |
| 冷启动时间          | 1.8s   | 600ms  | 3x   |
| 列表滚动 FPS        | 35     | 60     | 1.7x |
| Native 方法调用延迟 | 5-10ms | < 1ms  | 10x  |
| JS Bundle 大小      | 4.2MB  | 3.8MB  | 10%  |

## 十、迁移路径：渐进式而非大爆炸

不要试图一次性迁移所有代码。推荐路径：

### 阶段 1：启用新架构 + 兼容模式

```js
// ios/Podfile 加：
:fabric_enabled => true,
:bridgeless_enabled => true
```

Bridge Compatibility Layer 会让旧代码继续工作。先确保 app 能编译运行。

### 阶段 2：替换第三方库为新架构版本

```diff
- "react-native-reanimated": "2.3.0"
+ "react-native-reanimated": "3.0.0"  // 新架构版本
```

主要替换：

- `react-native-reanimated` → v3+
- `react-native-gesture-handler` → v2.10+
- `react-native-screens` → v3.20+

### 阶段 3：自研原生模块迁移到 TurboModule

逐个迁移，每个模块迁移完后跑性能测试。

### 阶段 4：自研 UI 组件迁移到 Fabric

最复杂，最后做。建议参考 `react-native-linear-gradient` 等开源库的新架构迁移 PR。

## 十一、调试新架构

### 1. 确认新架构已启用

```bash
npx react-native config | grep newArch
```

### 2. 查看 JSI 调用统计

```ts
import { performance } from 'perf_hooks';

const t0 = performance.now();
NativeModule.method();
console.log(`Call took ${performance.now() - t0}ms`);
```

如果耗时 < 1ms，说明走的是 JSI；如果 > 5ms，可能回退到 Bridge Compatibility。

### 3. Fabric 渲染调试

iOS：

```bash
RCT_DEBUG_FABRIC=1 npx react-native run-ios
```

会打印每次 Fabric 提交（commit）的耗时。

## 十二、总结

新架构不是「升级」，是「重写」。三点最关键：

1. **JSI 让 JS 直调 C++**：同步、零序列化
2. **Codegen 替代手写桥接**：TS 规范 → C++ / Java / Obj-C 接口
3. **Fabric 同步渲染**：UI 操作不再走异步 Bridge

迁移是漫长过程，但每一步都能立刻看到性能提升。RN 在新架构下终于和原生实现了性能对齐。
]]></content:encoded>
      <category>React Native</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>React Server Components 实战与踩坑：从 RSC payload 到流式渲染</title>
      <link>https://sanshui-blog.pages.dev/posts/react-server-components-%E5%AE%9E%E6%88%98%E4%B8%8E%E8%B8%A9%E5%9D%91/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/react-server-components-%E5%AE%9E%E6%88%98%E4%B8%8E%E8%B8%A9%E5%9D%91/</guid>
      <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
      <description>React Server Components 不是 SSR。本文从 RSC wire protocol 讲到 use client 边界划分，再列出 8 个生产环境踩过的坑，帮你避开 RSC 落地的所有暗礁。</description>
      
      <category>React</category>
      <category>前端</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>Redis 分布式锁实战：从 SET NX 到 Redlock 踩坑</title>
      <link>https://sanshui-blog.pages.dev/posts/redis-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/redis-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
      <description>一个支付系统的分布式锁写错了，导致同一笔订单被扣两次款。本文讲 SET NX EX 的正确姿势、锁续期看门狗、Redlock 算法的争议，再到 6 个真实生产事故。</description>
      
      <category>Redis</category>
      <category>后端</category>
      <category>分布式</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>Tailwind CSS v4 迁移实战：从 config 到 CSS-first</title>
      <link>https://sanshui-blog.pages.dev/posts/tailwind-v4-%E8%BF%81%E7%A7%BB%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/tailwind-v4-%E8%BF%81%E7%A7%BB%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate>
      <description>Tailwind v4 把配置从 JS 搬到 CSS。本文讲 @theme 令牌、@plugin、@custom-variant、@apply 的语义变化，再到 6 个真实迁移踩坑点。</description>
      
      <category>Tailwind</category>
      <category>CSS</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>TypeScript 5.x 装饰器实战：从元数据到依赖注入</title>
      <link>https://sanshui-blog.pages.dev/posts/typescript-5-%E8%A3%85%E9%A5%B0%E5%99%A8%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/typescript-5-%E8%A3%85%E9%A5%B0%E5%99%A8%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
      <description>TS 5.0 标准化装饰器，与旧 reflect-metadata 方案完全不同。本文讲新装饰器 API、metadata 提案、手写一个 IoC 容器，再到 NestJS 风格的路由装饰器实现。</description>
      
      <category>TypeScript</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>Vite 6 冷启动优化：从依赖预构建到插件缓存</title>
      <link>https://sanshui-blog.pages.dev/posts/vite-6-%E5%86%B7%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/vite-6-%E5%86%B7%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96/</guid>
      <pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate>
      <description>大型项目 Vite 冷启动 40s → 3s 的真实优化过程。讲依赖预构建、按需 optimizeDeps、插件 cacheDir、worker 池调优，以及 8 个常见冷启动慢的根因。</description>
      
      <category>Vite</category>
      <category>前端</category>
      <category>技术</category>
      <category>性能</category>
    </item>
    <item>
      <title>Web Worker 实战：OffscreenCanvas 与 SharedArrayBuffer</title>
      <link>https://sanshui-blog.pages.dev/posts/web-worker-%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/web-worker-%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
      <description>主线程长任务 800ms → 12ms。本文讲 Worker 的 6 种创建方式、Transferable 对象、OffscreenCanvas 渲染、SharedArrayBuffer 多线程共享内存，再到 Comlink RPC 封装。</description>
      
      <category>Web Worker</category>
      <category>前端</category>
      <category>技术</category>
      <category>性能</category>
    </item>
    <item>
      <title>Web 性能监控实战：从 PerformanceObserver 到 LCP 归因</title>
      <link>https://sanshui-blog.pages.dev/posts/web-%E6%80%A7%E8%83%BD%E7%9B%91%E6%8E%A7%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/web-%E6%80%A7%E8%83%BD%E7%9B%91%E6%8E%A7%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate>
      <description>LCP 1.2s 到 3.8s 的真实排查过程。本文讲 PerformanceObserver API、Web Vitals 指标体系、LCP 元素归因、CLS 预测布局偏移，再到 RUM 上报方案。</description>
      
      <category>性能</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>Zustand 与 Jotai 实战：React 状态管理选型</title>
      <link>https://sanshui-blog.pages.dev/posts/zustand-jotai-%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/zustand-jotai-%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate>
      <description>Redux 时代结束了。本文讲 Zustand 的 store 模型、Jotai 的原子模型，再到 8 个真实场景下的选型决策树。</description>
      
      <category>React</category>
      <category>状态管理</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>微服务可观测性实战：OpenTelemetry 三件套</title>
      <link>https://sanshui-blog.pages.dev/posts/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate>
      <description>12 个微服务，每天 2 亿次请求，一个 P99 延迟从 50ms 飙到 800ms 的排查。本文讲 OpenTelemetry Trace/Metrics/Logs 统一管线、Baggage 跨服务透传，再到 5 个生产踩坑。</description>
      
      <category>可观测性</category>
      <category>后端</category>
      <category>微服务</category>
      <category>技术</category>
    </item>
    <item>
      <title>浏览器原生 API 实战：View Transitions 与 Container Queries</title>
      <link>https://sanshui-blog.pages.dev/posts/%E6%B5%8F%E8%A7%88%E5%99%A8%E5%8E%9F%E7%94%9Fapi-%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/%E6%B5%8F%E8%A7%88%E5%99%A8%E5%8E%9F%E7%94%9Fapi-%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate>
      <description>不用框架、不用 GSAP，浏览器原生就能做流畅过渡动画。本文讲 View Transitions API、scroll-driven animations、popover API，再到 5 个渐进增强实战。</description>
      
      <category>浏览器API</category>
      <category>前端</category>
      <category>技术</category>
    </item>
    <item>
      <title>Vue 3.5 响应式系统实战：从 ref 到 shallowReactive</title>
      <link>https://sanshui-blog.pages.dev/posts/vue-3-5-%E5%93%8D%E5%BA%94%E5%BC%8F%E7%B3%BB%E7%BB%9F%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/vue-3-5-%E5%93%8D%E5%BA%94%E5%BC%8F%E7%B3%BB%E7%BB%9F%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Sat, 11 Jul 2026 00:00:00 GMT</pubDate>
      <description>Vue 3.5 的响应式系统经过多次重构，本文从 ref/reactive/computed 的底层实现讲到 effect 依赖追踪，再到 6 个生产环境踩坑点。</description>
      
      <category>Vue</category>
      <category>前端</category>
      <category>技术</category>
      <category>踩坑</category>
    </item>
    <item>
      <title>Vue 编译器优化实战：从静态提升到 PatchFlag</title>
      <link>https://sanshui-blog.pages.dev/posts/vue-%E7%BC%96%E8%AF%91%E5%99%A8%E4%BC%98%E5%8C%96%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/vue-%E7%BC%96%E8%AF%91%E5%99%A8%E4%BC%98%E5%8C%96%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate>
      <description>Vue 编译器在编译期做了大量优化。本文讲静态提升、PatchFlag、Block Tree、缓存事件处理器，再到 5 个真实性能优化案例。</description>
      
      <category>Vue</category>
      <category>前端</category>
      <category>技术</category>
      <category>性能</category>
    </item>
    <item>
      <title>Vue 3 状态管理实战：Pinia 与组合式 API 的选型</title>
      <link>https://sanshui-blog.pages.dev/posts/vue-3-%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86%E5%AE%9E%E6%88%98/</link>
      <guid isPermaLink="true">https://sanshui-blog.pages.dev/posts/vue-3-%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86%E5%AE%9E%E6%88%98/</guid>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <description>Vue 3 时代 Pinia 取代了 Vuex。本文讲 Pinia 的 store 模型、组合式 store、跨 store 依赖，再到与 provide/inject、useState 模式的选型决策。</description>
      
      <category>Vue</category>
      <category>前端</category>
      <category>状态管理</category>
      <category>技术</category>
    </item>
  </channel>
</rss>
