各位同学,大家好。
坐!都坐好。别把手机收起来,那是给不思考的人用的。今天我们不聊那个整天画 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 支持 takeLatest 或 takeLeading。takeLatest 的意思是:“老弟,你刚才点的那个请求我已经在跑了,虽然你现在又点了一次,但我只认第一个,后面来的我不管,直接丢弃。” 这就像食堂大妈的勺子,打完一勺,手再伸过来,勺子已经空了。
第二章: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 TRANSACTION 和 COMMIT,就像盖房子打地基。地基(事务)打好了,砖头(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 层必须足够健壮,要接受“删除成功”但也包含“没删多少个”这种情况。
第四章:全栈联动的“心跳”
好了,现在我们把整个流程串起来,看看这颗心脏是怎么跳动的。
- UI 层: 用户勾选了 500 个用户,点击了右上角的“批量删除”。
- Action 层 (React/Redux): 触发
DELETE_USERS_REQUEST。Saga 监听到,进入worker函数。 - 网络层: 前端发起 HTTP POST 请求到 BFF 接口,Payload 是
['id1', 'id2', ...]。 - BFF 层: 路由捕获请求,进行参数校验。
- 数据库交互: BFF 连接数据库,检查关联数据(软规则),开启事务。
- 数据库执行: 执行 SQL
DELETE ... WHERE id IN (...)。引擎删除数据,返回影响行数(比如 499 行)。 - 事务提交: BFF 执行
COMMIT。 - 响应返回: BFF 返回
{ success: true, deletedCount: 499 }给前端。 - 状态更新: React 接收到成功响应,Saga 派发
CLEAR_SELECTED_USERS和REFRESH_LIST。 - 渲染反馈: 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) -> 消息队列。
- 删除逻辑:
- 数据库物理删除。
- BFF 触发 Redis 删除(或者设置 TTL)。
- BFF 发送事件到 Kafka/消息队列,通知第三方系统(比如 CRM、分析系统)这个用户没了。
这是一个环环相扣的链条。任何一个环节断了,数据就会变成“幽灵数据”。
还记得我们最开始说的 500 个用户吗?如果我们在数据库里删了 500 个,但是消息队列里的消息没发出去,第三方的数据清洗服务还在试图处理这 500 个 ID,就会报错。
这就是全栈架构的痛苦之处:你不仅要懂 React 的 useEffect,还得懂 Kafka 的分区策略,还得懂数据库的锁机制。
总结
好了,同学们,今天的讲座就到这儿。
我们回顾一下,一个简单的“批量删除”操作,在我们这个全栈架构里,经历了怎样的九九八十一难:
- 前端: 用 Redux Saga 把异步逻辑封装起来,保证 UI 不卡顿,状态不混乱。
- BFF: 用聚合思维代替多次请求,用事务保证数据库操作的原子性。
- 后端: 用严格的业务规则(如检查活跃订单)防止数据破坏,用幂等性设计防止网络重试导致的错误。
不要觉得 React 只是画 UI 的。在真正的生产环境中,React 是你伸向服务器的手。你的代码写得越健壮,你的系统就越像一座精密的瑞士钟表。
下次当你看到那个小小的“删除”按钮,或者那个长长的“批量删除”按钮时,希望你想到的不再是 onClick={() => ...},而是 Saga 的 Worker、BFF 的路由守卫,以及数据库事务里的那一声 COMMIT。
代码要写得优雅,逻辑要走得坦荡。哪怕删除的是 500 个用户,也要像删除一条记录一样,干干净净,不留痕迹。
下课!