悲观锁和乐观锁是两种常见的并发控制机制,用于处理多线程或多进程环境中的数据访问冲突问题。它们在数据库系统、分布式系统和多线程编程中都有广泛应用。通过这篇文章我们来分析下他们的原理以及使用场景。
悲观锁
悲观锁假设在访问共享资源时会发生冲突,因此在操作资源之前,悲观锁会对资源加锁,保证在同一时刻只有一个线程或进程能访问该资源。其他线程或进程必须等待直到资源被解锁。
它的主要工作原理如下步骤所示:
-
当一个
线程/进程
请求资源时,悲观锁会阻塞其他线程/进程,直到当前线程释放锁。 -
这保证了资源在任何时刻只有一个线程在操作,避免了数据竞争,但也可能导致性能瓶颈,特别是在高并发的情况下。
在数据库中,悲观锁通常通过 SELECT FOR UPDATE
或 锁表
来实现,确保在数据库查询或更新时加锁,防止其他事务同时访问。而在操作系统中,通过操作系统提供的锁机制,如 互斥锁(mutex),可以对共享资源进行加锁和解锁。
悲观锁主要用于需要严格控制并发访问的场景,特别是在数据冲突概率较高的情况下。它通过在数据操作时加锁,防止其他事务或线程对数据进行修改,从而确保数据的一致性和完整性。常见的应用包括数据库事务处理、多线程编程中的临界区保护,以及需要严格资源控制的分布式系统。尽管可能会导致性能下降,但在数据一致性要求高的场合,悲观锁是非常有效的。
悲观锁确保数据的一致性和完整性,因为它在操作时锁定数据,防止其他事务或线程进行修改。这在高冲突环境中非常有效。而悲观锁可能导致性能下降,因为锁的使用会增加系统开销,并可能引发锁竞争和死锁问题,尤其是在高并发场景下。
在后端开发中,悲观锁通常用于数据库操作,以确保数据的一致性和完整性。在关系型数据库中,悲观锁可以通过 SQL 语句实现:
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 获取悲观锁
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 执行操作
COMMIT;
这段代码的目的是确保在更新账户余额时,其他事务无法同时修改同一条记录,从而避免数据不一致的问题。
乐观锁
乐观锁认为在大多数情况下资源不会发生冲突,因此在访问共享资源时不会立即加锁,而是在提交更新时检查是否有冲突。乐观锁允许多个线程/进程同时访问资源,但在提交时会进行冲突检测。如果没有冲突,操作会提交;如果有冲突,则会回滚或重新执行。
乐观锁的核心是通过 版本号 或 时间戳 来检测资源是否发生变化。每次读数据时,线程/进程读取当前数据并记住版本号或时间戳。当更新数据时,线程会验证当前的版本号与之前读取时的版本号是否一致。如果一致,则进行更新;如果不一致,说明其他线程已经修改了数据,当前线程的更新会失败,通常会返回错误或要求重试。
他们的实现方式一般是在数据表中增加一个版本号字段,每次更新时将版本号加一。更新操作时检查版本号是否一致。如果是使用时间戳的方式,会为每行数据添加一个时间戳字段,在读取时记录时间戳,更新时比较时间戳是否匹配。
主要的应用场景一般是资源冲突发生概率较低的场景,通常适用于读多写少的系统。比如在购物车管理、库存管理等场景中,当冲突发生时,用户可能会要求重试或手动处理。
它的优点是高并发性能好,避免了悲观锁的锁竞争和性能瓶颈,适用于高并发、低冲突的场景。缺点就是当冲突发生时,可能需要重试,增加了逻辑复杂性。可能导致大量无效的操作(即提交失败的操作),影响系统吞吐量。
如下示例所示,假设我们有一个 account
表,包含字段 version
(版本号),当我们读取数据时,会读取当前的版本号,然后进行更新操作,更新时会检查版本号是否一致。如果一致,则进行更新;如果不一致,说明其他线程已经修改了数据,当前线程的更新会失败,通常会返回错误或要求重试。
BEGIN;
SELECT * FROM account WHERE id = 1; -- 读取数据
UPDATE account SET balance = balance - 100 WHERE id = 1 AND version = ?; -- 执行操作
COMMIT;
总结
-
悲观锁:假设会发生冲突,锁住资源,直到操作完成。适用于冲突频繁的场景,但可能导致性能瓶颈。
-
乐观锁:假设不会发生冲突,操作前不加锁,提交时通过版本号或时间戳进行冲突检测。适用于高并发、低冲突的场景。