React 逻辑挑战:在全栈架构下,一个复杂的“批量删除”操作如何通过 Actions、BFF 与后端数据库协同完成?

各位同学,大家好。

坐!都坐好。别把手机收起来,那是给不思考的人用的。今天我们不聊那个整天画 UI 的低级活儿,我们要聊点“狠活”。

想象一下这个场景:你的 React 应用里有个 10,000 个用户的表格。运营那边打电话来,说:“嘿,那帮羊毛党清理一下,把上个月注册且没买过东西的 500 个僵尸用户删了。”

这时候,如果你让前端去干这件事,那你就是在拿职业生涯开玩笑。你可能会写出这样的蠢代码:

// 千万不要这么干!这是给 React 应用来着地狱的!
users.forEach(user => {
  axios.delete(`/api/users/${user.id}`);
});

如果这 500 个用户里有 5 个 ID 命中了网络错误,你怎么办?前端怎么知道哪 495 个删成功了?怎么回滚那 5 个失败的?怎么处理重试逻辑?这时候,你就会深刻体会到,为什么我们需要一个完整的数据流水线。今天,我们就来拆解一下,在这个全栈架构下,如何优雅、安全、且充满仪式感地完成这个“批量删除”任务。

我们要过五关斩六将,顺序是:React Action 层的“指挥棒”,BFF 层的“守门员”,以及后端数据库的“执法者”。


第一章:前端的“煽动”——Redux Saga 的博弈论

首先,我们要从用户点击“删除”的那一刻说起。这不仅仅是点击,这是战争。

在 React 里,如果你想在点击按钮时做点啥,onClick 是最方便的,但如果你要处理网络请求、加载状态、甚至并发控制,onClick 这种同步回调就不够用了。我们需要把异步逻辑抽离出来,交给 Redux Saga 这种“主语”。Saga 听起来像个巫师,实际上它是一个异步任务管理器,它能在后台盯着你的 Action,像鹰一样盯着猎物。

我们的目标是:把 500 个 ID 发送出去,但不要阻塞 UI,还要告诉用户进度。

这时候,我们会定义一个 DELETE_USERS 的 Action。但在发送这个请求之前,我们必须有一个强有力的前置检查——UI 状态管理。Redux 状态里得有个 isDeleting 标志位,告诉 UI:“别给我渲染进度条了,老子正忙着跟服务器吵架呢。”

这是典型的 Redux Saga 模式。看代码,我给你们写个精华版:

// saga.ts
function* deleteUsersFlow() {
  while (true) {
    const action = yield take(DELETE_USERS_REQUEST);
    const { userIds } = action.payload;

    // 1. 开始战斗,更新 UI 状态
    yield put(updateUiState({ deleting: true, error: null }));

    try {
      // 2. 调用 API
      // 注意:这里我们只发一个请求,告诉后端我们要删谁
      const response = yield call(apiService.deleteUsers, { ids: userIds });

      // 3. 战斗胜利,清空选中状态,给用户点个赞
      yield put(clearSelectedUsers());
      yield put(updateUiState({ deleting: false, success: true }));

      // 4. 刷新列表(可选,如果后端返回了新数据的话)
      yield put(fetchUsers()); 
    } catch (error) {
      // 5. 战斗失败,别灰心,告诉用户错了,并展示错误信息
      yield put(updateUiState({ deleting: false, error: error.message }));
    }
  }
}

看到没有?这就是 Saga 的牛逼之处。它把“点击”、“等待网络”、“渲染成功/失败”全部串起来了。前端只负责两件事:触发反馈

如果这里我们不懂并发控制,比如用户手滑点击了两次“删除”,Saga 怎么办?不用担心,Saga 支持 takeLatesttakeLeadingtakeLatest 的意思是:“老弟,你刚才点的那个请求我已经在跑了,虽然你现在又点了一次,但我只认第一个,后面来的我不管,直接丢弃。” 这就像食堂大妈的勺子,打完一勺,手再伸过来,勺子已经空了。


第二章:BFF 的智慧——“聚合”的艺术

好,前端准备好了,它把 500 个 ID 打包成一个小包,扔给了我们的 BFF(Backend for Frontend)服务。

为什么要 BFF?为什么不直接让 React 调 REST API?

如果你直接让前端去调 /api/users/{id},那我们要发起 500 次 HTTP 请求。想象一下那个网络延迟的瀑布流。网络层是异步的,如果第 498 个请求挂了,前面 497 个都在那儿等着,根本拿不到返回值。这叫什么?这叫“并发地狱”。

BFF 的核心职责就是聚合。我们要在后端这层,把前端发来的 500 个 ID,通过一条 SQL 语句就干掉它。

BFF 层的角色就像是一个精心策划的特工队长。它接收前端的指令,过滤掉无效 ID,然后走到数据库面前,下达最致命的一击。

我们用 NestJS 或者 Express 来写这个 BFF 的逻辑:

// bff-service.js
app.delete('/api/batch-delete', async (req, res) => {
  try {
    const { ids } = req.body; // 假设前端传了一个数组: [1, 2, 3, ..., 500]

    // 1. 基础校验
    if (!Array.isArray(ids) || ids.length === 0) {
      return res.status(400).json({ error: 'ID列表不能为空' });
    }

    // 2. 数据库事务开启 (ACID 属性保命符)
    await db.transaction(async (trx) => {

      // 3. 执行删除操作
      // 注意看这个 WHERE IN 子句,一条语句搞定 500 条记录
      const deleteResult = await trx('users')
        .whereIn('id', ids)
        .delete(); // DELETE FROM users WHERE id IN (...);

      // 4. 增加日志或审计表记录 (可选,为了以后能查到是谁删的)
      await trx('audit_logs').insert({
        action: 'BATCH_DELETE',
        affected_count: deleteResult,
        operator: req.user.id // 谁干的
      });

      // 5. 返回成功
      res.json({
        success: true,
        deletedCount: deleteResult
      });
    });
  } catch (err) {
    res.status(500).json({ error: '删除失败,服务器开小差了' });
  }
});

这里有一个关键点:事务

为什么要有事务?假设我们要删 500 个用户,但是这 500 个用户里,有 10 个用户各自拥有 1 个订单。如果数据库开启了外键约束(ON DELETE CASCADE),当你删除用户时,数据库会自动帮你把订单也删了。这本来是好事。

但是!如果事务没开启,删到第 480 个用户的时候,数据库说:“哎,第 481 个用户有个订单正在被支付接口锁定,你删不了。” 这时候,前 480 个用户已经删了,第 481 个没删,第 500 个还没开始。数据就乱套了。

BEGIN TRANSACTIONCOMMIT,就像盖房子打地基。地基(事务)打好了,砖头(SQL 语句)砌好了,最后才 COMMIT(交付)。如果中间任何一个砖头放歪了(抛出异常),ROLLBACK(回滚),我们确保数据库里的数据是完美的,要么全删,要么全不删,绝不会出现“半死不活”的数据。


第三章:数据库的“冷峻”——外键与级联的博弈

现在,我们的 SQL 语句已经到了数据库引擎面前。这是最危险、也最迷人的环节。

DELETE FROM users WHERE id IN (1, 2, ...) 的时候,我们得考虑一个哲学问题:“死人是没有灵魂的,但是他们的账单还在吗?”

如果你的用户表和订单表是这样的结构:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100)
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INT REFERENCES users(id) ON DELETE CASCADE,
  amount DECIMAL
);

当你执行上面的 BFF 代码删除用户 1 时,数据库引擎会叹一口气,自动把 orders 表里所有 user_id = 1 的记录也删了。这叫级联删除。虽然方便,但有时候太霸道了。用户删了,订单没了,以后审计数据怎么办?CRM 系统怎么知道这个用户消失了?

所以在复杂的架构里,我们通常建议不要使用 ON DELETE CASCADE

我们需要更精细的控制。在 BFF 代码里,我们不仅要删用户,还得检查一下这些用户有没有“牵连”。比如,有没有正在进行的订单?

这时候,逻辑就变成了:

// 更严谨的 BFF 逻辑
async function handleBatchDelete(ids) {
  // 1. 先查询这些用户关联的订单状态
  const activeOrders = await db('orders')
    .whereIn('user_id', ids)
    .where('status', 'active'); // 比如正在付款中

  if (activeOrders.length > 0) {
    throw new Error(`无法删除!有 ${activeOrders.length} 个订单正在进行中。`);
  }

  // 2. 没有牵连,安全了,开始事务
  await db.transaction(async (trx) => {
    // 3. 这里你可以选择做“软删除”,把用户标记为 deleted_at
    // 或者直接物理删除
    await trx('users').whereIn('id', ids).del();

    // 4. 同步删除关联表数据(如果需要)
    await trx('audit_logs').whereIn('user_id', ids).del();
  });
}

这才是真正的工程思维。我们不是盲目地执行 DELETE,而是像外科医生一样,先做术前检查,确认没有肿瘤(活跃订单),再动刀。

还有一点,幂等性

如果你的 BFF 刚执行完删除,网络断了,前端重试了一次这个请求。数据库这时候的反应是什么?

如果你用 WHERE id IN (...),数据库会检查一遍,发现这些 ID 已经没了。在 SQL 标准里,这不算错误,它只是“没删着几个”。这是安全的。

但如果你写成了 DELETE FROM users WHERE id = 1,那么当你重试时,它会报错“违反外键约束”或者“记录不存在”。如果我们的 BFF 没有做异常捕获和重试逻辑,这个错误就会变成 500 状态码扔给前端,导致前端以为删除失败了,然后疯狂重试,把数据库搞崩。

所以,BFF 层必须足够健壮,要接受“删除成功”但也包含“没删多少个”这种情况。


第四章:全栈联动的“心跳”

好了,现在我们把整个流程串起来,看看这颗心脏是怎么跳动的。

  1. UI 层: 用户勾选了 500 个用户,点击了右上角的“批量删除”。
  2. Action 层 (React/Redux): 触发 DELETE_USERS_REQUEST。Saga 监听到,进入 worker 函数。
  3. 网络层: 前端发起 HTTP POST 请求到 BFF 接口,Payload 是 ['id1', 'id2', ...]
  4. BFF 层: 路由捕获请求,进行参数校验。
  5. 数据库交互: BFF 连接数据库,检查关联数据(软规则),开启事务。
  6. 数据库执行: 执行 SQL DELETE ... WHERE id IN (...)。引擎删除数据,返回影响行数(比如 499 行)。
  7. 事务提交: BFF 执行 COMMIT
  8. 响应返回: BFF 返回 { success: true, deletedCount: 499 } 给前端。
  9. 状态更新: React 接收到成功响应,Saga 派发 CLEAR_SELECTED_USERSREFRESH_LIST
  10. 渲染反馈: UI 消失了,出现一个绿色的 Toast 提示“删除成功”。

这看起来很简单?哈,那是你没遇到边界情况。

边界情况 1:网络超时。
如果 BFF 执行 delete 的时候,数据库锁表了,或者网络抖了一下,BFF 会在 30 秒后抛出 Timeout 错误。前端怎么办?前端得有 retry 策略。是重试一次?还是告诉用户“网络不好,请刷新页面”?通常情况下,如果 BFF 返回了 504 Gateway Timeout,那请求其实可能已经发送到了数据库,也可能没发送。最安全的做法是,BFF 返回一个 200 但带有一个 deletedCount: 0 或者 status: PENDING,前端轮询检查状态,或者直接提示用户刷新列表。

边界情况 2:乐观更新。
这是前端性能优化的极致。你可以在 BFF 还没返回结果的时候,直接在前端把 UI 里的那 500 行删掉。

// 伪代码:乐观更新
dispatch({ type: 'DELETE_USERS_START' });
// 立即从 Redux Store 里移除数据,UI 瞬间变干净
// 然后再发起网络请求
const result = await apiService.deleteUsers(ids);
if (result.error) {
  // 如果失败了,把数据再加回来
  dispatch({ type: 'REFRESH_LIST' }); 
}

这种体验简直爽翻天。但要注意,如果用户开了两个标签页,你在 A 页删了,B 页没删,数据就会打架。这时候就需要复杂的 WebSocket 推送或者复杂的乐观锁机制了。


第五章:数据一致性的“圣经”

最后,我想谈谈数据一致性。这是全栈开发的圣杯。

我们做了所有这些事情:Saga、BFF、事务、外键检查。为什么?为了一致性

在分布式系统中,数据一致性分为强一致性和最终一致性。

对于“批量删除”这种操作,我们通常追求的是强一致性。也就是说,在删除操作的原子性范围内,你看到的数据状态必须是确定的。要么全部删完,要么一个都没删。

如果我们在删除用户的时候,顺便清空了他们的日志,而日志清空了,但 CDN 上的缓存没清空,这时候用户刷新页面,还能看到旧日志,这就是一致性灾难。

所以,架构师在设计系统时,必须画出这个流程图。

  • 数据流向: 用户 -> React -> BFF -> 数据库 -> 缓存 (Redis) -> 消息队列。
  • 删除逻辑:
    1. 数据库物理删除。
    2. BFF 触发 Redis 删除(或者设置 TTL)。
    3. BFF 发送事件到 Kafka/消息队列,通知第三方系统(比如 CRM、分析系统)这个用户没了。

这是一个环环相扣的链条。任何一个环节断了,数据就会变成“幽灵数据”。

还记得我们最开始说的 500 个用户吗?如果我们在数据库里删了 500 个,但是消息队列里的消息没发出去,第三方的数据清洗服务还在试图处理这 500 个 ID,就会报错。

这就是全栈架构的痛苦之处:你不仅要懂 React 的 useEffect,还得懂 Kafka 的分区策略,还得懂数据库的锁机制。


总结

好了,同学们,今天的讲座就到这儿。

我们回顾一下,一个简单的“批量删除”操作,在我们这个全栈架构里,经历了怎样的九九八十一难:

  1. 前端: 用 Redux Saga 把异步逻辑封装起来,保证 UI 不卡顿,状态不混乱。
  2. BFF: 用聚合思维代替多次请求,用事务保证数据库操作的原子性。
  3. 后端: 用严格的业务规则(如检查活跃订单)防止数据破坏,用幂等性设计防止网络重试导致的错误。

不要觉得 React 只是画 UI 的。在真正的生产环境中,React 是你伸向服务器的手。你的代码写得越健壮,你的系统就越像一座精密的瑞士钟表。

下次当你看到那个小小的“删除”按钮,或者那个长长的“批量删除”按钮时,希望你想到的不再是 onClick={() => ...},而是 Saga 的 Worker、BFF 的路由守卫,以及数据库事务里的那一声 COMMIT

代码要写得优雅,逻辑要走得坦荡。哪怕删除的是 500 个用户,也要像删除一条记录一样,干干净净,不留痕迹。

下课!

发表回复

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