聊聊并发场景
背景
在多用户协作的管理系统中,经常会遇到这样的场景:
场景举例:Bush 分类管理
Bush & Jungle 是面向鲜切花相关商品的管理系统,在分类管理中:
- 用户 A 打开分类编辑弹窗,准备修改"玫瑰"分类的名称(改为"鲜切玫瑰")
- 同时,用户 B 也打开同一个分类,准备把名称改为"成品玫瑰"
- 两人几乎同时点击保存
- 如果没有并发控制,后提交的用户会覆盖先提交用户的修改
这就是典型的并发场景:如果不做并发控制,就可能出现更新丢失,导致数据不一致。
问题分析
并发与并行
并发不是指“同一毫秒同时发生”,而是指:两个或多个独立的操作在同一时间段内读写同一份数据,执行顺序不确定。
为了避免概念混淆,这里再区分两个词:
- 并发(Concurrency):多个操作在时间上有重叠,执行可能交错,顺序不可预期。即使单核 CPU 通过时间片切换也会发生并发。
- 并行(Parallelism):多个操作在同一时刻真的同时运行(通常依赖多核 / 多线程 / 多进程)。并行一定是并发的一种情况,但并发不一定并行。
常见的并发场景包括(不限于):
- 多用户同时操作同一数据(如上述例子)
- 单用户多窗口操作(用户打开两个浏览器标签页,同时编辑)
- 用户 + 系统定时任务同时操作(如库存扣减:用户下单扣减 + 系统定时同步/补货入库)
- 重复提交 / 网络重试(用户连点保存、前端重试、网关重放请求)
更新丢失-时间轴展示
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 级时间线(两步法如何发现冲突)
- T1:A 读取(带 optimistic 校验)
sql
SELECT * FROM category WHERE id = 1 AND version = 1; -- 命中才算版本匹配- T2:B 在并发窗口内先更新
sql
UPDATE category
SET name = 'B 修改', version = version + 1
WHERE id = 1 AND version = 1; -- 命中 1 行,version 变成 2- 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 }