Skip to content

聊聊并发场景

背景

在多用户协作的管理系统中,经常会遇到这样的场景:

场景举例:Bush 分类管理

Bush & Jungle 是面向鲜切花相关商品的管理系统,在分类管理中:

  • 用户 A 打开分类编辑弹窗,准备修改"玫瑰"分类的名称(改为"鲜切玫瑰")
  • 同时,用户 B 也打开同一个分类,准备把名称改为"成品玫瑰"
  • 两人几乎同时点击保存
  • 如果没有并发控制,后提交的用户会覆盖先提交用户的修改

这就是典型的并发场景:如果不做并发控制,就可能出现更新丢失,导致数据不一致。

问题分析

并发与并行

并发不是指“同一毫秒同时发生”,而是指:两个或多个独立的操作在同一时间段内读写同一份数据,执行顺序不确定

为了避免概念混淆,这里再区分两个词:

  • 并发(Concurrency):多个操作在时间上有重叠,执行可能交错,顺序不可预期。即使单核 CPU 通过时间片切换也会发生并发。
  • 并行(Parallelism):多个操作在同一时刻真的同时运行(通常依赖多核 / 多线程 / 多进程)。并行一定是并发的一种情况,但并发不一定并行。

常见的并发场景包括(不限于):

  1. 多用户同时操作同一数据(如上述例子)
  2. 单用户多窗口操作(用户打开两个浏览器标签页,同时编辑)
  3. 用户 + 系统定时任务同时操作(如库存扣减:用户下单扣减 + 系统定时同步/补货入库)
  4. 重复提交 / 网络重试(用户连点保存、前端重试、网关重放请求)

更新丢失-时间轴展示

T1: 用户 A 读取数据 { id: 1, name: "玫瑰", order: 10 }
T2: 用户 B 读取数据 { id: 1, name: "玫瑰", order: 10 }
T3: 用户 A 修改 name → "鲜切玫瑰",提交 { id: 1, name: "鲜切玫瑰", order: 10 }
T4: 用户 B 修改 name → "成品玫瑰",提交 { id: 1, name: "成品玫瑰", order: 10 }
T5: 数据库最终状态 { id: 1, name: "成品玫瑰", order: 10 } ❌

用户 A 的修改丢失了。 这就是经典的 Lost Update(更新丢失):两个写入都基于同一个旧版本的数据,后一次写入把前一次写入覆盖了。

解决方案:乐观锁(Optimistic Locking)

原理: 不加锁,写入时做版本校验:只有“版本匹配”的更新才允许落库,不匹配则拒绝更新(通常返回 409)

实现方式:

  • 版本号(version)
  • 时间戳(updatedAt)
  • 数据快照(比较整行数据)

优点:

  • ✅ 无锁等待,性能高
  • ✅ 适合读多写少场景
  • ✅ 实现简单
  • ✅ 无死锁风险

缺点:

  • ❌ 并发写入时会有冲突,需要前后端配合处理
  • ❌ 用户体验稍差(需要处理冲突提示/刷新/重试)

适用场景:

  • ✅ 管理后台(用户操作频率低)
  • ✅ 博客编辑(单人编辑为主)
  • ✅ 配置管理(修改不频繁)

分类管理场景特点:

  • ✅ 冲突概率低(两人同时编辑同一分类的概率很小)
  • ✅ 允许“冲突后提示刷新/重试”(不影响核心业务)
  • ✅ 用户体验好(无锁等待,响应快)

P.S. 不适合乐观锁的场景:

  • ❌ 库存扣减(不能超卖)
  • ❌ 余额变更(金额必须精确)
  • ❌ 秒杀抢购(必须严格排队)

实现方案(Bush & Jungle)

1. 数据库设计 & 实体定义

categories 表添加 version 字段。

typescript
@Entity('categories') // categories 表对应的实体
export class Category {
  // Category 实体定义
  @VersionColumn() // 乐观锁版本号(save() 成功后自动 +1)
  version: number // 当前版本号
}

补充:关于@VersionColumn()

  • 在 TypeORM 的乐观锁写法里,需要 @VersionColumn() 来标记版本字段,并在 save() 时自动递增
  • 如果完全不走 save(),而是用“手写 UPDATE + WHERE version + version+1”的原子化写法,那它更像是“语义标注”,用 @Column() 也能实现同样的并发校验

2. 后端校验逻辑

typescript
import { ConflictException, NotFoundException } from '@nestjs/common'; // 409/404 异常
import { OptimisticLockVersionMismatchError } from 'typeorm'; // 版本不匹配错误
async updateCategory(id: number, dto: UpdateCategoryDto) { // 更新分类(TypeORM 乐观锁)
  const { version, ...patch } = dto; // 拆出 version,其余字段作为 patch
  try { // 捕获版本冲突
    const category = await this.categoryRepository.findOne({ // 读取并校验版本
      where: { id }, // 查询条件
      lock: { mode: 'optimistic', version }, // 期望版本不一致就报错
    });
    if (!category) { // 未找到记录
      throw new NotFoundException('分类不存在'); // 抛出 404
    }
    Object.assign(category, patch); // 把变更写回实体
    return await this.categoryRepository.save(category); // 保存实体(自动 version+1)
  } catch (error) { // 捕获异常
    if (error instanceof OptimisticLockVersionMismatchError) { // 命中版本冲突
      throw new ConflictException('分类已被其他用户修改,请刷新后重试'); // 抛出 409
    }
    throw error; // 其他异常继续抛出
  }
}

关键点:

  • 前端提交时必须携带 version
  • 后端读取时用 TypeORM 的 optimistic lock 校验版本号(不一致直接报错)
  • save() 会基于 @VersionColumn() 生成带版本条件的 UPDATE,并在成功后自动把 version + 1
  • 版本不匹配时返回 409 冲突

SQL 级时间线(两步法如何发现冲突)

  1. T1:A 读取(带 optimistic 校验)
sql
SELECT * FROM category WHERE id = 1 AND version = 1; -- 命中才算版本匹配
  1. T2:B 在并发窗口内先更新
sql
UPDATE category
SET name = 'B 修改', version = version + 1
WHERE id = 1 AND version = 1; -- 命中 1 行,version 变成 2
  1. T3:A 随后保存
sql
UPDATE category
SET name = 'A 修改', version = version + 1
WHERE id = 1 AND version = 1; -- 命中 0 行,触发版本不匹配

结论:TypeORM 的两步法确实存在“读完到写入之间”的窗口,但冲突检测依赖 WHERE version = ?。读取时版本不匹配会直接抛错;写入时版本不匹配会更新 0 行并抛 OptimisticLockVersionMismatchError,Nest 映射为 409。

3. 前端处理

编辑弹窗提交

tsx
const handleFinish = async (values) => {
  try {
    // 正常更新流程
    await updateCategory(currentRow.id, {
      // 调用更新接口
      ...values, // 表单字段
      version: currentRow.version // 携带 version 用于乐观锁
    })
    return true
  } catch (error: any) {
    // 捕获异常
    if (error?.response?.status === 409) {
      // 命中并发冲突
      modal.confirm({
        // 提示用户处理冲突
        title: '数据冲突', // 弹窗标题
        content: '分类已被其他用户修改,是否加载最新数据?', // 弹窗内容
        onOk: async () => {
          // 用户确认后
          await actionRef.current?.reload() // 重新加载列表
          setModalVisible(false) // 关闭弹窗
        }
      })
      return false // 阻止表单继续关闭/流转
    }
    throw error // 非 409 直接抛出
  }
}

状态开关切换

tsx
const handleActiveChange = async (checked, record) => {
  // 开关切换处理函数
  try {
    // 正常更新流程
    await updateCategory(record.id, {
      // 调用更新接口
      isActive: checked, // 目标状态
      version: record.version // 携带 version 用于乐观锁
    })
    actionRef.current?.reload() // 刷新列表
  } catch (error: any) {
    // 捕获异常
    if (error?.response?.status === 409) {
      // 命中并发冲突
      modal.warning({
        // 提示用户刷新
        title: '数据冲突', // 弹窗标题
        content: '分类已被其他用户修改,请刷新页面后重试', // 弹窗内容
        onOk: () => {
          // 用户确认后
          actionRef.current?.reload() // 重新加载列表
        }
      })
    }
  }
}

4. 完整流程演示

T1: 用户 A 读取 { id: 1, name: "玫瑰", version: 1 }
T2: 用户 B 读取 { id: 1, name: "玫瑰", version: 1 }
T3: 用户 A 提交 { id: 1, name: "鲜切玫瑰", version: 1 }
    → 后端读取: optimistic lock(version=1) ✅
    → save() ✅,version = 2
T4: 用户 B 提交 { id: 1, order: 20, version: 1 }
    → 后端读取: optimistic lock(version=1) ✅
    → save() ❌(版本不匹配),返回 409 冲突
T5: 前端提示用户 B:"数据已被修改,请刷新后重试"
T6: 用户 B 重新加载,看到最新数据 { id: 1, name: "鲜切玫瑰", version: 2 }

Released under the MIT License.