悲观锁和乐观锁是两种常见的并发控制策略,它们用于解决多线程环境下数据的一致性问题。以下是两者的详细介绍和比较:
悲观锁 (Pessimistic Lock)
概念: 悲观锁假设所有事务都会冲突,因此对数据进行加锁以防止其他事务访问。它在读取或写入数据之前,先获取锁,确保此期间没有其他事务能够修改该数据。
使用场景:
- 数据竞争激烈的环境。
- 读多写少的场景。
- 数据一致性要求极高的场景。
实现方式:
- 数据库级别:通过数据库的锁机制,如行锁、表锁等。例如,使用 SQL 语句 SELECT … FOR UPDATE 来加锁。
- 应用程序级别:通过同步机制,如 Java 的 synchronized 关键字或 ReentrantLock 类。
优点:
- 简单易理解。
- 能够在高并发环境中确保数据的一致性。
缺点:
- 可能导致死锁。
- 性能开销较大,因为锁的获取和释放需要额外的时间。
- 并发性能差,因为其他事务必须等待锁的释放。
乐观锁 (Optimistic Lock)
概念: 乐观锁假设数据冲突的概率较低,因此不会在操作前加锁,而是在提交数据时检查数据是否被其他事务修改。如果数据未被修改,则提交成功;否则,回滚并重试。
使用场景:
- 数据竞争不激烈的环境。
- 读多写少的场景。
- 对性能要求较高的场景。
实现方式:
- 版本号机制:在数据表中添加一个版本号字段,每次更新时检查和更新版本号。
- 时间戳机制:在数据表中添加一个时间戳字段,每次更新时检查和更新时间戳。
典型实现:
- 读取数据:读取数据的同时,读取当前的版本号(或时间戳)。
- 更新数据:提交更新时,检查数据库中的当前版本号(或时间戳)是否与刚才读取的一致。
- 如果一致,说明数据没有被其他事务修改,可以提交更新,同时更新版本号(或时间戳)。
- 如果不一致,说明数据已经被其他事务修改,拒绝本次更新,并可以选择重试或报错。
优点:
- 无需加锁,性能较高。
- 避免了死锁的风险。
缺点:
- 适用于冲突较少的场景,不适合高并发写操作的环境。
- 需要额外的字段来维护版本号或时间戳。
- 可能会导致频繁的重试,影响性能。
比较
特性 | 悲观锁 | 乐观锁 |
---|---|---|
假设 | 假设会发生冲突 | 假设不会发生冲突 |
加锁时间 | 操作前加锁 | 提交时检查 |
实现复杂度 | 较简单 | 较复杂 |
并发性能 | 较差 | 较好 |
死锁风险 | 有 | 无 |
适用场景 | 数据竞争激烈,高一致性要求 | 数据竞争不激烈,性能要求高 |
具体示例
悲观锁示例(SQL)
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 进行更新操作
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;
在这个示例中,我们使用了悲观锁来确保数据的一致性。具体步骤如下:
- 开启事务:
BEGIN;
这一行代码开始一个新的事务。在事务提交或回滚之前,所有的操作都将在同一个事务上下文中执行。
- 加锁读取数据:
SELECT * FROM account WHERE id = 1 FOR UPDATE;
这一行代码从 account 表中读取 id 为 1 的记录,并对这行记录加上排他锁(Exclusive Lock)。这样在当前事务结束之前,其他事务无法读取或修改这条记录。
FOR UPDATE 子句确保在读取数据的同时对其加锁,从而防止其他事务对这一行数据进行任何修改。这种方式可以确保后续的更新操作是安全的,没有并发修改的风险。
- 更新操作:
UPDATE account SET balance = balance - 100 WHERE id = 1;
这一行代码将 id 为 1 的账户余额减少 100。由于我们已经通过 SELECT … FOR UPDATE 对这行数据加了锁,可以保证这个更新操作在安全的环境下进行,不会与其他并发事务冲突。
- 提交事务:
COMMIT;
这一行代码提交当前事务,将所有更改永久保存到数据库中。如果在事务执行过程中没有发生错误,提交事务后锁将被释放,其他事务就可以访问和修改这条记录了。
注意事项
- 锁粒度:FOR UPDATE 语句会对读取的行数据加排他锁,这种锁的粒度较细,只锁定需要更新的行,从而尽量减少对其他行的影响。如果需要锁定更多的数据,可以考虑表锁等更高级别的锁。
- 死锁风险:悲观锁的使用可能会导致死锁,因此在使用它时需要特别注意事务的顺序和锁的获取顺序,避免多个事务之间相互等待的情况。
- 性能开销:悲观锁会阻止其他事务访问被锁定的数据,可能会降低系统的并发性能。因此,应该根据实际应用场景权衡性能和数据一致性要求,选择合适的并发控制策略。
示例的实际应用场景
假设在一个银行系统中,用户A要从他们的账户中取出100元。你希望确保在读取账户余额和更新账户余额之间,不会有其他事务修改这个账户的余额。悲观锁可以确保在整个事务过程中,账户余额不会被其他事务修改,从而保证了数据的一致性和正确性。
乐观锁示例(Java)
假设 Account 类有一个 version 字段:
public void updateAccountBalance(Long accountId, BigDecimal amount) {
Account account = accountMapper.selectById(accountId);
BigDecimal newBalance = account.getBalance().subtract(amount);
int updatedRows = accountMapper.updateBalance(accountId, newBalance, account.getVersion());
if (updatedRows == 0) {
throw new RuntimeException("Update failed due to concurrent modification");
}
}
对应的 SQL:
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
这条 SQL 语句展示了乐观锁的一种实现方式,具体来说是通过版本号机制来控制并发。乐观锁假设数据冲突的概率较低,因此在提交数据时检查是否发生冲突,如果有冲突则回滚并重试。让我们详细解析这条 SQL 语句:
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
语句解析
- 更新语句:
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
- SET 子句:
SET balance = ?, version = version + 1
- balance = ?:表示要更新的余额值。在实际执行时,这个问号会被具体的数值替换。
- version = version + 1:表示版本号加一。每次更新数据时,版本号都会增加,以此来记录数据的变化次数。
- WHERE 子句:
WHERE id = ? AND version = ?
- id = ?:表示要更新的账户ID。在实际执行时,这个问号会被具体的账户ID替换。
- version = ?:表示当前的数据版本号。在实际执行时,这个问号会被读取时的版本号替换。
工作原理
- 读取数据:首先,从数据库中读取目标记录,包括其 balance 和 version 字段。例如,假设我们要读取的账户ID为1,初始余额为1000,版本号为1。
SELECT balance, version FROM account WHERE id = 1;
-- 返回 1000 和 1
- 进行业务逻辑处理:在应用程序中进行相应的业务处理,例如将余额减少100。
- 提交更新:在更新数据时,使用读取时的版本号进行条件检查。如果记录的版本号没有变化(即没有其他事务修改过),则更新成功;如果版本号已经变化,则说明有其他事务修改过数据,这时更新操作会失败。
UPDATE account
SET balance = 900, version = version + 1
WHERE id = 1 AND version = 1;
- 检查更新结果:
- 如果更新成功,SQL 语句会返回受影响的行数为1,表示更新成功,数据一致性得到保证。
- 如果更新失败(即返回受影响的行数为0),说明在读取数据和提交更新之间,数据已经被其他事务修改过,此时需要重新读取数据并重试更新操作。
优点和缺点
优点:
- 高并发性能:没有实际加锁,避免了悲观锁带来的阻塞问题,提高并发性能。
- 无死锁风险:由于不涉及实际的锁机制,避免了死锁的问题。
缺点:
- 重试开销:在数据竞争较为激烈的场景下,可能会导致频繁的重试,影响性能。
- 实现复杂:需要在表结构中加入版本号字段,并且在应用程序逻辑中处理更新失败的情况。
实际应用场景
乐观锁特别适合于以下场景:
- 读多写少:例如,用户查询远多于更新的系统,如电商网站的商品浏览。
- 数据冲突少:例如,分布式系统中不同节点处理各自的数据分片,冲突概率较低。
- 性能要求高:例如,金融系统中需要快速响应的交易处理。
通过乐观锁,可以在保持数据一致性的同时,最大限度地提高系统的并发性能。