在前端实现事务:概念、方案与应用场景

0 阅读9分钟

事务

什么是事务

事务(Transaction)本质上是一种“成组操作的执行单元”。它把一系列步骤打包成一个整体,并保证这些步骤在执行结果上满足一个核心原则:

要么全部成功生效,要么出现错误就全部撤销,不留下中间状态。

事务这个概念在数据库层面提到的比较多,因为数据库天然承担着“系统最终可信数据源”的角色:订单是否创建成功、库存是否扣减、余额是否减少、日志是否落库……这些关键数据一旦出现“只成功了一半”的情况,轻则造成数据脏乱,重则直接引发资损。

因此数据库事务的设计目标非常明确:保证数据在并发、故障、异常情况下仍然保持一致与可预期

在数据库中,事务有下面四个特性:

  • 原子性:事务中的操作要不全部成功,要不全部失败,不会出现部分成功部分失败的场景。
  • 一致性:事务执行前后,数据库都必须处于满足约束规则的状态。
  • 隔离性:多个事务并发执行时,一个事务的中间状态不应该被其他事务随意看到或干扰。
  • 持久性:事务一旦提交成功,结果必须被持久保存

为什么前端需要事务

但是在前端和后端相比,频繁操作数据的场景更少,那么是不是事务在前端就没有用武之地呢?

并不是。数据库事务强调 ACID(四个特性),但是前端事务更关注其原子性 + 可回滚这两个特性,即事务中的操作要不全部成功,要不全部失败。这在前端应用场景其实非常广泛,甚至你可能平时在用,只是并没有意识到。

例如下面这个例子,有一个 todo 复选框组件。

<input type="checkbox" /> <span>早睡</span>

当用户勾选时,前端发送请求,然后后端更新数据,并返回最新状态,然后前端更新 UI,使复选组件变为勾选状态。

这个实现思路很简单,它依靠数据库最新数据为 UI 依据,因此数据可靠。但是问题也很大,那就是用户的网络波动是难以预料的,如果用户当前网络卡顿,那么从发送请求到接收响应可能需要很长一段时间,这段时间内 UI 是没有变化的,用户会认为页面卡顿,甚至重复点击,从而发送重复请求。

因此,一种更加用户友好的思路是:首先乐观的认为接口是通的且整个请求没有任何问题,直接立即更新 UI,同时发送请求,如果后面返回响应结果有问题,比如由于某些原因拒绝更新,那么再回退 UI,同时提示用户。

这种思路我们称为“乐观更新”,它直接将 UI 更新成乐观的状态,等真的出现问题再进行回退。这种更新思路非常契合事务的原子性的特点。

  • 事务: [更新 UI、请求接口]
  • 执行事务
    • 更新 UI -> 成功
    • 请求接口 -> 失败
  • 单个失败即整体失败 -> 开始回滚
    • 请求接口 -> 进行补偿(例如发送反向请求,或者获取最新状态等)
    • 更新 UI -> 回滚到之前的 UI 状态

乐观更新在前端的应用场景非常广泛,在此之前,你可以会手动一个一个地处理上述逻辑,但是如果你能构建一套事务系统,能够系统性地统一执行事务并在失败时自动回退,那么会使你的代码更加健壮且更具可维护性。

因此,前端需要事务的核心原因是为了管理多步操作带来的原子性问题。当一次交互涉及多个状态更新时,我们需要保证它们要么一起成功,要么在失败时一起回滚。而“乐观更新”正是前端事务最典型、也最实用的落地场景之一。

实现事务的两种思路

多步操作数据

前面我们讨论过,事务对前端领域的多步操作一致性具有重要作用,并且这得益于事务的可回滚的特性。那么如果我们要实现一套事务系统,就至少得实现下面这些特性:

  • 原子性:要不全部成功,要不全部失败。
  • 可回滚:失败后每个操作都可以回滚到之前到状态。

接下来用另一个和乐观更新不同的例子来介绍下实现事务的两种思路。在笔者实现的一个大纲式的 APP 中,笔记本质上就是一个树,每个大纲语句就是树上的一个节点。


- 早上
  - 早起
- 晚上
  - 运动10分钟
  - 早睡

数据中的结构是这样的:


            Root
        /		\
    早上		晚上
 /			/     \
早起	   运动10分钟   早睡

然而,这是原始数据,用于与 UI 对照,但是不会将树作为实际状态来源,因为访问太慢了,而且更新也不方便,如果节点比较多的时候,性能会非常差,优化的方法通常是用一个哈希表来提升性能,结构可能是这样:


const treeRealData = {
 'id1': { text: '早上', children: [/* 子节点id */], parentId: 'xxx' }
}

这样就能以 O (1) 的时间复杂度访问和更新数据,然而在实际开发中,情况更复杂,比如插入一个节点,通常还伴随下面这些操作:

  • 插入节点
  • 更新节点属性
  • 更新父子关系
  • 更新历史栈
  • ...

这些数据操作必须要具有原子性,要不全部完成,要不就全部失败,不然会导致文档结构出问题,比如说出现孤儿节点,造成内存泄漏。

拷贝法

实现事务的第一个方法的灵感来自 V8 的垃圾回收机制。这里简略说一下,V8 设计了主副垃圾回收器来进行垃圾回收,其中副 GC通过半空间的方法来实现垃圾回收,具体思路是首先将空间一分为二,一半当作内存使用,这部分空间称为 from-space,另一半空着, 这部分空间称为 to-space,当进行垃圾回收时:

  1. 将 from-space 中的活对象复制到 to-space 中
  2. 现在 from-space 中的对象都是死对象,进行回收
  3. to-space 作为新的 from-space,原来的 from-space 作为新的 to-space

因此,我们在对一个复杂对象进行多步骤处理时,也可以借鉴这个思路:在操作一个复杂数据结构时,新建一个相同的数据结构,然后对这个数据结构进行操作,如果某个操作失败则直接丢弃这个对象,如果事务中所有的操作都成功了,则替换原来的数据结构。


function transaction(origin, fns){
	const copyData = copy(origin)
	for(let fn of fns){
	   try{
	     fn(copyData)
	   }catch(err){
	     // 一个失败就整体失败
	     throw err
	   }
	}
	
	// 返回替换原数据
	return copyData
}

这种方式由于所有操作都作用在副本上,最后一次性提交,因此具有天然的回滚能力,非常适合需要多步骤处理复杂的数据结构的场景,但是缺点也很明显,那就是拷贝性能影响可能比较大,尤其在对内存比较敏感的场景。

并且这种方法非常类似 React 中的 Render + Commit,都是先处理再提交,也就是说这种思路实现的方式是先完成所有操作,才会提交更新,中间可能存在一定的延迟,不适合需要立即更新的场景。

操作记录法

第二种方法更加通用,但是实现也会复杂一些。实现思路是:记录每个操作,当某个操作失败时,对其和之前已经执行过的操作进行逆向操作。

以前面的笔记 app 中更新数据为例,插入一个新的节点通常至少需要:

  1. 创建新节点,并插入到哈希表中
  2. 更新父节点关系(将自身 id 插入到 parent.children 中)
  3. 更新历史栈

假设最后一个操作失败,那么以操作记录的视角来看是这样的:

  1. 操作1: 哈希表中插入了一个节点(成功)
  2. 操作2: 哈希表中 parentId 节点的 children被更新(成功)
  3. 操作3: 历史栈被更新 (失败)
  4. 逆操作3: 回滚历史栈为之前的状态
  5. 逆操作2: 将原本插入到 parent.children 中的 id 删除
  6. 逆操作1: 将原本插入的节点删除

实际实现可以通过 immer 这类库辅助,当然,这种方法对于数据操作可能会比拷贝法复杂,但是它更加通用,操作粒度更细,比如最开始说的乐观更新,就非常适合使用操作记录法来实现事务。

function TodoItem(){
	const [checked, setChecked] = useState(false)
	
	async function onChange(){
		transaction.add(function updateUI(){
			setChecked(true)
			return function rollbackUpdateUI(){
				setChecked(false)
			}
		})
		transaction.add(async function requestServer(){
			await put(/* ... */)
			return function requestServerRollback(){
				// 获取最新状态
			}
		})
		const { ok } = await transaction.apply()
		// ... 省略
	}

	return <div>
		<input type="checkbox" checked={checked} onChange={onChange} /> 
		<span>早睡</span>
		</div>
}

基于操作记录的事务具体实现可以参考下面代码:

class Transaction {
  queue = [];

  add(stepFn) {
    this.queue.push(stepFn);
    return this;
  }

  async _rollback(reStack, ctx, info) {
    while (reStack.length) {
      const undo = reStack.pop();
      try {
        // 单个失败不能影响整体回滚
        await undo(ctx, info);
      } catch (e) {
        console.error("rollback step failed:", e);
      }
    }
  }

  async apply({ onError, context } = {}) {
    const reStack = [];

    // 传递一个context方便传递消息和共享状态
    const ctx =
      context ??
      {
        id: crypto?.randomUUID?.() ?? String(Date.now()),
        data: Object.create(null),
        meta: Object.create(null),
      };

	try {
	  for (const stepFn of this.queue) {
		const undo = await stepFn(ctx);
		// 允许没有回滚函数
		if (typeof undo === "function") reStack.push(undo);
	  }
	  return { ok: true, ctx };
	} catch (err) {
	  // 单个失败就是整体失败,进行回滚
	  try {
		if (typeof onError === "function") onError(err, ctx);
	  } catch (e) {
		console.error("onError failed:", e);
	  }

	  await this._rollback(reStack, ctx, { reason: "exception", error: err });
	  return { ok: false, error: err, ctx };
	} finally {
	  // 清空记录
	  this.queue = [];
	}
  }
}

上面代码实现了以下功能:

  1. 原子性:要不全部成功,要不全部失败
  2. 可回滚:失败即回滚到之前到状态
  3. 可扩展性:Context 用于共享状态和通讯

总结

首先,我们了解了什么是事务,事务常见于数据库,但是由于其拥有的原子性可回滚的特点,因此在前端也非常适用;然后我们介绍了事务在前端常见的两种应用场景:乐观更新和批量操作复杂数据结构,以及事务在其中起到的作用;最后,我们介绍了实现事务的两种思路:拷贝法和操作记录法,同时列出两者的区别。

拷贝法操作记录法
思路拷贝一个副本,直接操作副本记录每个操作,当失败时执行对应的逆操作
实现复杂度低,失败直接丢弃副本高,需要逆向操作
典型应用场景编辑器乐观更新
适用范围数据操作通用