用Node.js实现企业文件版本管理:从乐观锁到CRDT的前端实践

1 阅读3分钟

用Node.js实现企业文件版本管理:从乐观锁到CRDT的前端实践

最近在做一个企业文件管理系统的前端,遇到一个经典问题:多人同时编辑同一个文件,怎么处理版本冲突?翻了翻技术方案,从最简单的乐观锁到分布式场景下的CRDT,把踩坑过程记录下来,希望能帮到遇到类似问题的同学。


一、问题背景

我们系统里有一个文件元数据编辑场景——多个部门负责人可以同时修改同一个项目文件夹的描述、标签、权限备注。这不像代码有Git,也不像文档有OT算法,就是个简单的结构化数据并发修改问题。

但偏偏这个"简单"问题,在生产环境翻车了好几次:

用户A:读取文件描述 "2024年项目" → 修改为 "2024年已完成项目"
用户B:读取文件描述 "2024年项目" → 修改为 "2024年项目-归档"
用户A先提交成功 → 用户B提交覆盖 → A的修改丢失 💥

二、方案演进

2.1 乐观锁(版本号机制)

最直觉的方案:给每条记录加一个 version 字段。

// 前端提交时携带当前版本号
async function updateFileMeta(fileId, updates, expectedVersion) {
  const response = await fetch(`/api/files/${fileId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      ...updates,
      expectedVersion  // 乐观锁:我读到的版本号
    })
  });

  if (response.status === 409) {
    // 版本冲突!
    const serverData = await response.json();
    showConflictDialog(serverData, updates);
    return null;
  }

  return response.json();
}

后端实现也简单:

UPDATE file_metadata 
SET description = $1, tags = $2, version = version + 1 
WHERE id = $3 AND version = $4
RETURNING *;

踩坑RETURNING 返回0行就是冲突了,前端需要展示冲突让用户选择。我们做了一个简单的diff对比弹窗:

function showConflictDialog(serverData, userChanges) {
  // 用diff算法对比服务端最新值和用户修改
  const diff = computeDiff(serverData, userChanges);
  
  return ConflictDialog.show({
    serverVersion: serverData.version,
    yourChanges: userChanges,
    serverChanges: diff.serverOnly,
    fields: diff.conflictFields.map(field => ({
      field,
      serverValue: serverData[field],
      userValue: userChanges[field],
    }))
  });
}

2.2 操作日志(Event Sourcing)

乐观锁解决不了的问题:离线编辑场景

销售在外拜访客户时改了文件备注,回到公司网络同步时发现别人也改了。这种场景下"最后写入赢"(LWW)太粗暴,需要更精细的合并策略。

思路:不只存最终状态,而是存每个操作。

// 操作日志结构
const operation = {
  type: 'UPDATE_FIELD',
  fileId: 'proj-2024-001',
  field: 'description',
  oldValue: '2024年项目',
  newValue: '2024年已完成项目',
  operator: 'user-A',
  timestamp: Date.now(),
  deviceId: 'device-001',
};

前端实现操作栈:

class FileOperationStack {
  constructor(fileId) {
    this.fileId = fileId;
    this.localOps = [];     // 本地操作
    this.syncedOps = [];    // 已同步操作
    this.pendingSync = [];  // 待同步操作
  }

  push(op) {
    this.localOps.push({
      ...op,
      timestamp: Date.now(),
      localSeq: this.localOps.length,
    });
  }

  // 离线→在线转换
  reconcile(serverOps) {
    const conflictOps = [];
    const mergedOps = [];

    for (const serverOp of serverOps) {
      const localMatch = this.localOps.find(
        l => l.field === serverOp.field && 
             l.timestamp > serverOp.timestamp
      );
      
      if (localMatch && localMatch.oldValue !== serverOp.newValue) {
        conflictOps.push({ server: serverOp, local: localMatch });
      } else {
        mergedOps.push(serverOp);
      }
    }

    return { mergedOps, conflictOps };
  }
}

2.3 CRDT(无冲突复制数据类型)

上面的方案都有一个共同问题:需要服务端仲裁。如果是纯P2P或者弱网环境呢?

CRDT的思路是:设计一种数据结构,使得不管操作以什么顺序到达,最终结果都一样

对于我们的场景(文件标签是数组,描述是字符串),最实用的是 LWW-Register(最后写入赢寄存器)和 OR-Set(观察删除集合)。

// LWW-Register:带时间戳的值
class LWWRegister {
  constructor(value = null, timestamp = 0, peerId = '') {
    this.value = value;
    this.timestamp = timestamp;
    this.peerId = peerId;  // 用于打破时间戳平局
  }

  merge(other) {
    // 比较时间戳,大的赢;时间戳相同则peerId大的赢
    if (other.timestamp > this.timestamp || 
        (other.timestamp === this.timestamp && other.peerId > this.peerId)) {
      this.value = other.value;
      this.timestamp = other.timestamp;
      this.peerId = other.peerId;
    }
    return this;
  }
}

// OR-Set:用于标签数组
class ORSet {
  constructor() {
    this.elements = new Map(); // element → Set<uniqueTag>
    this.tombstones = new Set(); // 已删除的uniqueTag
  }

  add(element, uniqueTag) {
    if (!this.elements.has(element)) {
      this.elements.set(element, new Set());
    }
    this.elements.get(element).add(uniqueTag);
  }

  remove(element) {
    if (this.elements.has(element)) {
      for (const tag of this.elements.get(element)) {
        this.tombstones.add(tag);
      }
      this.elements.delete(element);
    }
  }

  merge(other) {
    // 合并add操作
    for (const [elem, tags] of other.elements) {
      if (!this.elements.has(elem)) {
        this.elements.set(elem, new Set());
      }
      for (const tag of tags) {
        if (!this.tombstones.has(tag)) {
          this.elements.get(elem).add(tag);
        }
      }
    }
    // 合并tombstones
    for (const tag of other.tombstones) {
      this.tombstones.add(tag);
    }
    // 清理:如果某个element的所有tag都被tomb了,移除
    for (const [elem, tags] of this.elements) {
      const alive = [...tags].filter(t => !this.tombstones.has(t));
      if (alive.length === 0) {
        this.elements.delete(elem);
      }
    }
    return this;
  }

  values() {
    return [...this.elements.keys()];
  }
}

实际使用时,每个客户端生成唯一的 uniqueTag

// 前端使用示例
const fileId = 'proj-2024-001';
const description = new LWWRegister('初始描述', Date.now(), myPeerId);
const tags = new ORSet();

// 用户A添加标签
tags.add('已归档', `${myPeerId}-${Date.now()}-1`);

// 用户B在其他设备上也添加标签
tags.add('待审核', `${otherPeerId}-${Date.now()}-2`);

// 合并:两人都保留了
tags.merge(otherTags);
console.log(tags.values()); // ['已归档', '待审核'] ✅

三、三种方案对比

维度乐观锁Event SourcingCRDT
实现复杂度⭐⭐⭐⭐⭐⭐⭐⭐
冲突处理服务端拒绝,用户手动解决服务端仲裁,可回溯数学保证无冲突
离线支持⚠️ 需额外处理✅ 原生支持
存储成本低(只存最终状态)高(全量操作日志)中(元数据开销)
适用场景低并发、实时在线需要审计追踪高并发、弱网、离线
一致性模型强一致最终一致最终一致(数学保证)

四、我们最终的选择

结合实际业务场景,我们用的是乐观锁 + Event Sourcing混合方案

  • 文件描述等简单字段:乐观锁,冲突时弹窗让用户选
  • 标签/分类等结构化数据:Event Sourcing,支持离线后自动合并
  • 没有上CRDT的原因:我们的场景有服务端仲裁,不需要P2P级别的无冲突保证

做企业文件管理系统选技术方案时,不用追最新的CRDT,乐观锁+操作日志已经能覆盖90%的场景。真正需要CRDT的是纯P2P协作(如多人实时编辑同一份文档),这类场景可以参考巴别鸟的方案——它用了增量同步+底层校验机制,配合版本对比功能来解决工程文件的协作冲突。

五、小结

关键点要记的
乐观锁简单有效,适合低并发实时在线
Event Sourcing可审计可回溯,适合离线+合并
CRDT数学保证无冲突,但实现复杂
选型原则看场景选方案,不要过度工程化

企业文件管理的并发问题其实是个老问题,但每年都有新人在踩坑。希望这篇文章能帮你少走一些弯路。


相关阅读