React 服务器动作 Server Actions 幂等设计

React Server Actions 幂等性设计:从“魔法”到“稳健”的进阶之路

各位前端大师、React 极客、以及那些在深夜里因为一个点击按钮而担惊受怕的工程师们,大家晚上好。

欢迎来到今天的讲座。今天我们不聊那些花里胡哨的 Hooks,也不聊那些把 UI 搞得像玻璃一样的 CSS 框架。我们要聊的是 React Server Actions(简称 RSA)。这是一个在 React 19 中横空出世的概念,它就像是一个不知疲倦的实习生,直接在服务器上跑代码,没有构建步骤,没有 API 路由,直接把你的函数暴露给浏览器。

听起来很美,对吧?就像你终于找到了一个不用写样板代码的魔法棒。但是,兄弟们,魔法棒也有副作用。如果你对这个实习生管理不善,它就会变成一个灾难。

今天,我们要深入探讨的主题是:如何让你的 Server Actions 像瑞士钟表一样精准,像岩石一样稳健——也就是我们常说的“幂等性设计”。

第一部分:什么是“幂等性”?(别急着翻白眼)

在开始代码之前,我们要先搞清楚一个概念。很多同学听到“幂等性”这三个字,第一反应是:“哇,好高深,听起来像是什么魔法咒语。”

其实不然。幂等性,数学上的定义是 $f(x) = f(f(x))$。翻译成人话就是:不管你调用这个函数多少次,它产生的结果是一样的。

想象一下,你在深夜饿得发慌,给外卖小哥发了条消息:“给我送个汉堡。”

  • 第一次发: 外卖小哥接单,送来了汉堡。(结果:你有了一个汉堡)
  • 第二次发: 你手滑又点了一次。(结果:你又有了一个汉堡?不,外卖小哥会把你拉黑。)

在 Server Actions 的世界里,幂等性就是那个“外卖小哥”。如果你发起了一个创建订单的动作,结果因为网络抖动,服务器以为没收到,重试了一次。如果你没有设计幂等性,恭喜你,你的服务器数据库里现在有两个一模一样的订单,而且扣了两次款。

所以,幂等性就是防止重复执行的保镖

第二部分:Server Actions 的“甜蜜陷阱”

为什么 Server Actions 特别需要幂等性?

传统的 Web 开发,数据变更通常发生在 API 调用之后,然后通过轮询或 WebSocket 来同步。而在 Server Actions 中,数据变更是同步的。你点击按钮,等待服务器返回结果,然后更新 UI。

这带来了两个巨大的风险点:

  1. 网络重试机制: React 的客户端(浏览器)或者客户端的库(比如 React Query, TanStack Query)为了防止数据丢失,默认会进行重试。如果你的 Server Action 写得不好,重试一次就是一次灾难。
  2. 乐观更新: 现在的 UI 开发很流行“乐观更新”。用户点击按钮,界面瞬间显示成功,后台异步处理。如果后台处理失败,或者用户疯狂点击,没有幂等性保护,数据就会乱套。

第三部分:场景一——创建资源的幂等性

让我们从一个最经典的需求开始:创建一个用户。

场景: 用户注册。

❌ 错误的设计(非幂等):

// app/actions.ts
import { db } from "@/lib/db";

export async function createUser(userData: { name: string; email: string }) {
  // 这里直接创建记录
  // 如果因为网络原因,浏览器重试了这个函数,这里会报错,因为邮箱已经存在
  // 或者更糟糕,如果数据库支持,可能会插入两条记录(取决于隔离级别)
  const user = await db.user.create({
    data: {
      name: userData.name,
      email: userData.email,
    },
  });
  return user;
}

问题所在:
如果用户点击了“注册”,网络卡顿了一下,React 的自动重试机制启动了。浏览器再次发送请求。第二次请求到达服务器,db.user.create 执行。如果数据库有唯一索引,它直接抛错。如果没有,或者配置不当,你就有了两个同名同姓的用户。这简直是隐私泄露的噩梦。

✅ 正确的设计(幂等):

我们需要一个“检查-再检查”的逻辑,或者使用“幂等键”。

方案 A:检查-再检查

export async function createUser(userData: { name: string; email: string }) {
  // 1. 先检查是否存在
  const existingUser = await db.user.findUnique({
    where: { email: userData.email },
  });

  // 2. 如果存在,直接返回现有的
  if (existingUser) {
    return existingUser;
  }

  // 3. 如果不存在,才创建
  const newUser = await db.user.create({
    data: {
      name: userData.name,
      email: userData.email,
    },
  });

  return newUser;
}

分析:
这个设计是幂等的。无论调用多少次,只要邮箱一样,返回的都是同一个人。
缺点:
每次调用都要查数据库。如果高并发,数据库压力会倍增。而且,如果两个请求同时到达,第一个查不到(没返回),第二个也查不到(没返回),于是它们都执行了 create,导致竞态条件。所以,单靠这个还不够,我们需要更强的手段。

方案 B:利用幂等键

这是生产环境的标准做法。我们需要在客户端生成一个唯一的 ID(比如 UUID),并在请求中携带它。

// app/actions.ts
import { db } from "@/lib/db";
import { v4 as uuidv4 } from 'uuid'; // 假设你有这个库

export async function createUser(idempotencyKey: string, userData: { name: string; email: string }) {
  // 1. 检查幂等键是否存在
  const record = await db.idempotencyRecord.findUnique({
    where: { idempotencyKey },
  });

  // 2. 如果存在,直接返回之前的结果
  if (record) {
    return JSON.parse(record.responseBody);
  }

  // 3. 执行核心业务逻辑
  let result;
  try {
    const user = await db.user.create({
      data: {
        name: userData.name,
        email: userData.email,
      },
    });
    result = user;
  } catch (error) {
    // 记录错误,防止死循环
    await db.idempotencyRecord.create({
      data: {
        idempotencyKey,
        status: 'failed',
      }
    });
    throw error;
  }

  // 4. 记录结果
  await db.idempotencyRecord.create({
    data: {
      idempotencyKey,
      status: 'success',
      responseBody: JSON.stringify(result),
      createdAt: new Date(),
    },
  });

  return result;
}

客户端调用:

// app/page.tsx
"use client";

import { useState } from "react";
import { createUser } from "./actions";

export default function RegisterForm() {
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);

    // 生成幂等键
    const idempotencyKey = crypto.randomUUID();

    // 获取表单数据
    const formData = new FormData(e.currentTarget);
    const data = {
      name: formData.get("name"),
      email: formData.get("email"),
    };

    try {
      // 调用 Server Action
      await createUser(idempotencyKey, data);
      alert("注册成功!");
    } catch (error) {
      alert("出错了,可能是网络原因,请稍后重试(系统会自动去重)");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" required />
      <button type="submit" disabled={loading}>
        {loading ? "处理中..." : "注册"}
      </button>
    </form>
  );
}

深度解析:
这里我们引入了一个 idempotencyRecord 表。这是为了记录“我已经处理过这个请求了”。

  • 第一次请求: 没有记录 -> 执行创建 -> 写入记录 -> 返回结果。
  • 第二次请求(重试): 有记录 -> 跳过创建 -> 直接返回第一次的结果。

这就像给每个请求发了一张“通行证”。一旦发了,就作废了。这完美解决了重试和并发问题。

第四部分:场景二——更新数据的幂等性

创建容易,更新难。因为更新涉及到“状态变更”。

场景: 点赞一个帖子。

❌ 错误的设计:

export async function likePost(postId: string) {
  // 查询帖子
  const post = await db.post.findUnique({ where: { id: postId } });

  // 直接增加点赞数
  await db.post.update({
    where: { id: postId },
    data: { likes: { increment: 1 } }
  });
}

问题:
如果用户手抖,或者网络卡顿,疯狂点击“点赞”按钮,React 的乐观更新会让界面瞬间变成“1000个赞”。然后服务器处理完第一个请求,变成“1001个赞”。此时,乐观更新的 UI 还是“1000个赞”。用户看到数据不一致,以为坏了,又点了一次。
第二次请求来了,服务器又 increment: 1。变成“1002个赞”。

✅ 正确的设计:

我们需要知道“这个用户是否已经点赞过”。

export async function likePost(userId: string, postId: string) {
  // 1. 检查是否已经点赞
  const existingLike = await db.like.findUnique({
    where: {
      userId_postId: {
        userId,
        postId,
      },
    },
  });

  if (existingLike) {
    // 已经点赞了,别动,直接返回
    return existingLike;
  }

  // 2. 使用事务,保证原子性
  return await db.$transaction(async (tx) => {
    // 更新帖子点赞数
    await tx.post.update({
      where: { id: postId },
      data: { likes: { increment: 1 } },
    });

    // 创建点赞记录
    await tx.like.create({
      data: {
        userId,
        postId,
      },
    });
  });
}

这里有个玄学:
如果两个用户同时点赞同一个帖子,或者同一个用户在极短时间内(毫秒级)连续点赞,上面的 findUnique 可能会失效。因为第一个请求查到了(false),还没来得及 update,第二个请求也查到了(false)。

终极方案:数据库唯一约束

这是最硬核、最可靠的方案。

在数据库层面,我们给 likes 表加一个唯一索引:UNIQUE(userId, postId)

export async function likePost(userId: string, postId: string) {
  try {
    await db.like.create({
      data: {
        userId,
        postId,
      },
    });

    await db.post.update({
      where: { id: postId },
      data: { likes: { increment: 1 } },
    });
  } catch (error) {
    // 数据库会抛出唯一约束冲突错误
    // 这意味着用户已经点赞过了,或者并发导致重复
    // 我们需要捕获这个错误,并优雅地处理
    console.error("Duplicate like detected", error);

    // 这里可以重定向到详情页,或者什么都不做
    throw new Error("Already liked");
  }
}

为什么这是最好的?
因为数据库是系统的“真理之源”。只要数据库唯一约束生效,无论你在代码里怎么写,或者网络怎么抖,数据库都会把重复的请求拒之门外。代码里的逻辑反而是多余的,但保留它作为防御性编程依然是好的。

第五部分:并发竞态条件与乐观 UI 的爱恨情仇

在 React Server Actions 的世界里,乐观 UI 是一把双刃剑。

乐观 UI 的逻辑:

  1. 用户点击。
  2. 客户端立即更新 UI(显示“已点赞”)。
  3. 发送请求到服务器。
  4. 服务器处理。
  5. 服务器返回成功/失败。
  6. 如果失败,回滚 UI。

如果没有幂等性:
假设用户点击了“购买”。乐观 UI 显示“购买成功”。
服务器处理需要 2 秒。这期间,用户因为太高兴,又点了 10 次。
服务器处理完第一次,返回成功。
客户端收到成功,UI 保持“购买成功”。
此时,数据库里可能已经有了 10 个订单(或者因为幂等性拦截,只生成了 1 个,但服务器返回了 10 次成功)。

解决方案:幂等性是乐观 UI 的基石。

如果你的 Server Action 是幂等的,那么即使客户端发了 10 次请求,服务器只处理一次。乐观 UI 只需要确保如果收到错误,就回滚。至于成功,无论来几次,都稳如泰山。

实战代码:结合 React 的 useOptimistic

"use client";

import { useState } from "react";
import { likePost } from "./actions";

export default function Post({ postId, initialLikes }: { postId: string, initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [optimisticLikes, setOptimisticLikes] = useState(initialLikes);
  const [isLiking, setIsLiking] = useState(false);

  const handleLike = async () => {
    if (isLiking) return;

    // 1. 乐观更新:先骗过用户
    setIsLiking(true);
    setOptimisticLikes(prev => prev + 1);

    try {
      // 2. 发送请求
      // 注意:这里不需要传 userId,服务器端可以获取 session
      await likePost(postId);
    } catch (error) {
      // 3. 失败回滚
      setOptimisticLikes(prev => prev - 1);
      alert("点赞失败,可能已经点过了");
    } finally {
      setIsLiking(false);
    }
  };

  return (
    <div>
      <p>当前点赞数: {optimisticLikes}</p>
      <button onClick={handleLike} disabled={isLiking}>
        {isLiking ? "处理中..." : "点赞"}
      </button>
    </div>
  );
}

再看服务器端:

// app/actions.ts
import { db } from "@/lib/db";

export async function likePost(postId: string) {
  // 简单的幂等检查
  const userId = getCurrentUserId(); // 假设你能拿到用户ID
  const existing = await db.like.findUnique({ where: { userId_postId: { userId, postId } }});
  if (existing) throw new Error("Already liked");

  await db.$transaction([
    db.like.create({ data: { userId, postId } }),
    db.post.update({ where: { id: postId }, data: { likes: { increment: 1 } } })
  ]);
}

第六部分:副作用与日志——幂等性的“盲区”

这是很多新手最容易忽略的地方。

Server Actions 通常用于处理数据变更。但是,副作用也是幂等性的一部分。

什么是副作用?
发送邮件、写入日志、调用第三方支付网关、更新搜索引擎索引。

陷阱:

export async function createUser(data: UserData) {
  const user = await db.user.create({ data });

  // 致命的副作用:发送欢迎邮件
  await sendEmail(user.email, "Welcome!"); 

  return user;
}

如果上面的代码因为网络原因重试了,会发生什么?

  1. 用户创建成功。
  2. 发送邮件成功。
  3. 重试触发。
  4. 用户创建失败(因为邮箱已存在)。
  5. 邮件发送成功。

结果:你多了一封欢迎邮件,用户没多一个。

如何修复?

  1. 不要在 Server Actions 中做副作用。 这是原则。Server Actions 应该纯粹地处理数据。发送邮件应该放在后台任务队列(如 Bull, AWS SQS)中。
  2. 如果必须做,必须幂等。
export async function createUser(data: UserData) {
  const user = await db.user.create({ data });

  // 幂等地发送邮件
  await sendEmailWithIdempotency(user.email, "Welcome!", user.id);

  return user;
}

邮件发送的幂等实现:

import { redis } from "@/lib/redis"; // 假设你用 Redis

async function sendEmailWithIdempotency(email: string, subject: string, userId: string) {
  const key = `email:sent:${userId}`;

  // 1. 检查 Redis
  const exists = await redis.exists(key);
  if (exists) {
    console.log("Email already sent, skipping.");
    return;
  }

  // 2. 发送邮件
  await sendEmail(email, subject);

  // 3. 标记已发送 (设置过期时间,比如 1 年)
  await redis.set(key, "1", "EX", 31536000);
}

第七部分:实战演练——构建一个完整的、幂等的 API 调用层

为了让大家更直观地理解,我们构建一个通用的 Server Action 包装器。

假设我们有一个第三方 API,比如 OpenAI 或者 Stripe,我们需要在 Server Actions 中调用它。

需求: 调用 API 生成内容。如果网络断开重试,不能生成两份内容。

设计思路:

  1. 接收一个 idempotencyKey
  2. 使用 Redis 检查 Key。
  3. 如果存在,返回缓存结果。
  4. 如果不存在,调用 API,保存结果到 Redis,返回结果。
// app/api/generate.ts
import { NextResponse } from "next/server";
import { redis } from "@/lib/redis";
import { client } from "@/lib/openai"; // 假设的 OpenAI 客户端

export async function POST(req: Request) {
  const { idempotencyKey, prompt } = await req.json();

  if (!idempotencyKey) {
    return NextResponse.json({ error: "Missing key" }, { status: 400 });
  }

  // 1. 检查 Redis 缓存
  const cachedResult = await redis.get(`api:gen:${idempotencyKey}`);
  if (cachedResult) {
    return NextResponse.json({ result: cachedResult });
  }

  // 2. 调用外部 API
  try {
    const response = await client.chat.completions.create({
      model: "gpt-3.5-turbo",
      messages: [{ role: "user", content: prompt }],
    });

    const content = response.choices[0].message.content;

    // 3. 缓存结果 (设置过期时间,比如 24 小时)
    await redis.setex(`api:gen:${idempotencyKey}`, 86400, content);

    return NextResponse.json({ result: content });
  } catch (error) {
    return NextResponse.json({ error: "API Error" }, { status: 500 });
  }
}

React 客户端调用:

// app/page.tsx
"use client";

import { useState } from "react";

export default function ChatComponent() {
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState("");

  const handleGenerate = async () => {
    setLoading(true);
    const idempotencyKey = crypto.randomUUID();

    try {
      const res = await fetch("/api/generate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          idempotencyKey,
          prompt: "Tell me a joke",
        }),
      });

      const data = await res.json();
      setResult(data.result);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={handleGenerate} disabled={loading}>
        {loading ? "生成中 (幂等保护中)" : "生成笑话"}
      </button>
      <p>{result}</p>
    </div>
  );
}

在这个例子中:

  • 网络重试: 如果用户点击两次,或者网络重试,两个请求都会带着同一个 idempotencyKey 到达服务器。
  • Redis 拦截: Redis 发现 Key 存在,直接返回第一次生成的笑话。不会浪费 API 配额,也不会生成重复内容。

第八部分:性能与权衡

讲到这里,你可能会说:“专家,这听起来很完美,但是每次都要查数据库或者 Redis,性能不是下降了吗?”

确实,幂等性引入了一定的开销。

  • 查询开销: 每次请求多一次数据库查询或 Redis 查询。
  • 存储开销: 你需要存储幂等记录。

如何优化?

  1. 选择合适的存储: Redis 的速度比数据库快得多,适合做幂等键的缓存。
  2. 利用数据库约束: 对于简单的创建操作,利用数据库的 UNIQUE 约束是最快、最省心的,不需要额外的应用层逻辑。
  3. 缓存策略: 幂等记录本身也可以缓存。比如 Redis 中缓存“用户已注册”的状态。

权衡原则:
如果你的操作是高价值的(比如扣款、发短信、生成内容),必须做幂等性设计。
如果你的操作是低价值的(比如记录用户点击了什么按钮,哪怕重复记录也无妨),可以不做幂等性设计,但通常为了代码的一致性,建议还是加上。

第九部分:总结与思考

好了,各位同学,今天的讲座接近尾声。

我们回顾一下今天的内容:

  1. React Server Actions 虽然方便,但它是同步的,容易受到网络重试和乐观更新的影响。
  2. 幂等性是防止重复操作、保证数据一致性的核心。
  3. 幂等性的实现手段包括:检查-再检查、数据库唯一约束、幂等键。
  4. 副作用(如发邮件)也必须是幂等的。

最后,留给大家一个思考题:

如果你的 Server Action 需要调用两个不同的第三方 API,比如先调用 A,成功后再调用 B。如果调用 B 失败了,A 已经成功了。这时候怎么办?

  • A:回滚 A?(如果 A 是支付,回滚很难)。
  • B:重试 B?(如果 B 是发送通知,重试没问题)。

这涉及到“分布式事务”和“最终一致性”的更高级话题。但在 RSA 的世界里,记住一点:永远不要相信客户端的“成功”状态,永远要在服务器端做“最终”校验。

希望这篇讲座能帮你把你的 Server Actions 从“魔法”变成“科学”。代码写得稳,头发才保得住。祝大家编码愉快,永远不踩坑!

(讲座结束,散会!)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注