乐观锁

原理:

乐观锁的核心思想是:假设不会发生并发冲突,因此操作数据时不加锁,只在提交更新时检查冲突。如果在提交时发现其他线程已经修改了数据,就会拒绝这次更新,并要求重新尝试(重试机制)。

应用场景:

乐观锁适用于 读多写少数据冲突概率低 的场景。因为它假设并发冲突的几率比较低,所以不在每次操作时加锁,从而提高性能。当冲突发生时,才通过重试机制来解决。

实现方式:

实现乐观锁通常是通过以下两种方式之一:

  1. 版本号:每条数据记录会有一个版本号,每次更新时,客户端会读取当前记录的版本号。提交更新时,会检查数据的版本号是否与提交前的版本号相同。如果不同,说明数据已经被其他进程修改过了,这时就会触发重试。

  2. 时间戳:类似于版本号,每条记录都会有一个时间戳。在提交更新时,检查记录的时间戳是否和提交时读取的时间戳一致,如果不一致,则说明数据已经被修改过了,重试操作。

举个例子:

假设你有一个库存管理系统,记录商品的库存数量。假设库存数量的更新是乐观锁控制的。

  1. 客户端A和客户端B同时读取商品库存的数量(比如数量为10,版本号为1)。

  2. 客户端A更新库存,将数量减1(变为9),并提交更新时发现版本号仍然是1,提交成功,版本号更新为2。

  3. 客户端B也在这个时候更新库存,客户端B尝试提交更新时,发现版本号已经变为2,而它读取时版本号仍然是1,发现冲突。于是客户端B会重新读取库存数据,并根据新的数据和版本号再次尝试提交更新。

通过乐观锁,避免了频繁的锁操作,提高了性能,同时能够保证数据的一致性。

🔒 分布式锁的原理:

分布式锁的原理是:为了保证在多个进程或机器之间访问共享资源时能够避免冲突,我们通过一个外部协调者(通常是 Redis、etcd、Zookeeper 等)来控制访问。只有成功抢到锁的进程才能继续执行,其他进程必须等待或重试。

具体来说,分布式锁的核心思路是:

  1. 加锁时尝试获取锁:每个进程或服务通过网络请求尝试获取一个全局的锁(通过 Redis 等分布式系统进行协调)。只有一个进程能够成功获取到锁。

  2. 执行操作:锁被获取的进程可以安全地执行操作,避免与其他进程发生冲突。

  3. 释放锁:操作完成后,进程必须释放锁,让其他等待的进程有机会执行。


分布式锁的应用场景:

  • 多实例共享资源访问控制:例如,多个服务实例共享一个数据库、缓存、文件系统等资源,避免多个实例并发修改同一资源。

  • 高并发情况下的任务排队执行:例如,某个任务需要确保同一时刻只有一个实例在执行,而其他实例要等候。

  • 分布式系统中的事务一致性:保证在分布式环境中执行任务时,避免资源的冲突和不一致。

适用于以下场景:

  • 订单系统中的库存扣减

  • 秒杀系统的限流控制

  • 定时任务的唯一执行

  • 分布式事务协调


🛠 实现方式:

通常通过以下方式实现分布式锁:

  1. 基于 Redis 实现:

    • 原理:通过 Redis 的 SET key value NX EX 命令来实现加锁,其中 NX 保证只有一个客户端能够成功加锁,EX 设置锁的过期时间防止死锁。

    • 示例

      • 加锁:通过 Redis 请求设置一个唯一的锁标识(通常使用 UUID 作为值),如果锁已经存在,则返回失败。

      • 解锁:只有持有锁的进程才能解锁,通过 Lua 脚本确保只有持有锁的进程才能释放锁,避免其他进程误删锁。

  2. 基于 etcd 实现:

    • 原理:通过 etcd 的 租约机制(lease) 来设置锁超时时间,只有一个客户端能够获取到某个租约,并且自动释放锁。

    • 示例

      • 使用 compare-and-swap(CAS) 操作来确保锁的原子性。

      • 锁超时后会自动释放,避免死锁。

  3. 基于 Zookeeper 实现:

    • 原理:通过创建临时顺序节点,来实现锁的竞争。每个客户端创建一个节点,最小的节点就可以获得锁。

    • 示例

      • 客户端创建一个临时顺序节点(例如 /lock-00000001)。

      • 客户端获取锁时,检查自己是否是最小节点,如果是,则成功获取锁;否则就等待。

🔒 单机锁的原理:

单机锁指的是在单台机器(单进程或多线程环境)内使用锁机制来保护共享资源,确保同一时间只有一个线程可以访问共享资源,避免数据竞争或并发问题。

原理

  1. 加锁(Lock):在访问共享资源之前,线程首先尝试获取锁。如果当前没有其他线程持有锁,则获取锁并继续执行操作。如果锁已被其他线程持有,则当前线程必须等待锁的释放。

  2. 执行操作(Do Something):锁定的线程可以安全地访问共享资源。

  3. 释放锁(Unlock):操作完成后,线程释放锁,使得其他等待的线程可以获取锁并执行操作。


应用场景:

单机锁适用于以下场景:

  • 同一台机器内的多线程/多进程环境

  • 保证线程安全:避免多个线程同时访问共享资源(如内存中的数据、文件、数据库连接等)。

  • 程序内部的数据保护:例如缓存操作、内存数据结构的保护等。

例子

  • 控制程序中的共享变量访问。

  • 任务调度系统,确保同一时刻只有一个任务在执行。

  • 内存缓存的读写控制,避免并发修改数据。


🔧 实现方式:

  1. 互斥锁(Mutex)

    • 互斥锁是最基本的单机锁类型。在 Go 中,可以使用 sync.Mutex 来实现。

    • 原理:一个线程加锁后,其他线程不能访问被锁保护的资源,直到当前线程释放锁。

import "sync"

var mu sync.Mutex

func someFunction() {
    mu.Lock()   // 加锁
    defer mu.Unlock()  // 确保执行完后解锁
    
    // 访问共享资源
}

读写锁(RWMutex)

  • 读写锁允许多个线程同时读取共享资源,但写操作是排他性的,即在有线程进行写操作时,其他所有线程不能读取或写入。

  • 在 Go 中,使用 sync.RWMutex 实现。

var rw sync.RWMutex

func readData() {
    rw.RLock()   // 加读锁
    defer rw.RUnlock()  // 确保执行完后释放读锁
    
    // 读取共享资源
}

func writeData() {
    rw.Lock()   // 加写锁
    defer rw.Unlock()  // 确保执行完后释放写锁
    
    // 修改共享资源
}

🔒 悲观锁的原理:

悲观锁是指在访问共享资源时,假设会发生冲突,因此在每次操作资源时都会加锁,以保证同一时刻只有一个线程可以访问资源。也就是说,悲观锁在开始操作之前就会加锁,并且其他线程必须等到锁被释放后才能继续访问

这种方式称为“悲观”,因为它假设了并发冲突的发生,甚至预设了最坏的情况,所有操作都要“悲观”地加锁,认为每次资源的访问都可能发生冲突,因此不容忍并发访问。


应用场景:

悲观锁通常适用于以下场景:

  • 高并发写操作:当多个线程频繁访问或修改共享资源时,必须使用悲观锁来确保每次只有一个线程能够执行操作。

  • 业务逻辑复杂,需要确保数据一致性:例如,在银行转账、库存扣减等操作中,必须确保在并发情况下数据一致性和正确性。

  • 关键资源竞争:例如,某些资源需要保证同一时刻只能被一个线程访问,防止脏读或不可重复读等问题。

适用的场景包括:

  • 多个线程或进程同时操作数据库记录。

  • 高并发环境下的任务执行控制。

  • 确保数据一致性和完整性。


🧠 实现方式:

悲观锁通常是在数据库、缓存等系统中实现的,以下是几种常见的实现方式。

1. 数据库悲观锁

数据库中的悲观锁通常通过 SELECT ... FOR UPDATE 语句来实现,它会锁住查询的记录,直到事务结束。

  • 示例: 假设你有一个库存表 inventory,并且需要在购买时扣减库存:
BEGIN;

SELECT * FROM inventory WHERE product_id = 1 FOR UPDATE;

-- 执行库存扣减操作
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;

COMMIT;

    • FOR UPDATE:它会在查询记录时加锁,确保只有当前事务能够修改该记录。其他事务只能等待当前事务释放锁后才能继续执行。

      • 事务:在执行加锁操作后,直到事务完成(COMMIT)或者回滚(ROLLBACK)之前,其他进程无法访问该数据。

2. Redis 悲观锁

Redis 本身并不直接支持悲观锁,但可以通过 SETNX 命令(SET if Not Exists)来实现类似的悲观锁。使用这个方法,只有在某个键不存在时,才能成功设置它,这样就能保证只有一个进程获得锁。

  • 示例:
SET my_lock_key "locked" NX EX 10

    • NX:只有在 my_lock_key 不存在时,才能设置成功,即加锁。

      • EX 10:设置锁的超时时间,防止死锁。
  • 其他客户端需要等待该锁被释放(删除)才能继续获取。

关于乐观锁与脏读

乐观锁并不能直接避免脏读,因为它主要解决的是并发更新冲突的问题,而脏读是和事务的隔离性有关的。

我们可以详细分析一下:

🧠 乐观锁的原理:

乐观锁的核心思想是:在数据更新时不加锁,而是假设不会发生冲突。只有在提交更新时,才会检查数据是否被其他事务修改过。如果发现冲突(即数据被其他事务修改),就让当前事务重试或回滚。

  • 乐观锁的常见实现:通常通过 版本号时间戳 来实现。例如,读取数据时,带上版本号或时间戳,在更新时检查版本号或时间戳是否匹配。如果匹配,则更新成功;如果不匹配,说明数据被其他事务修改过,就触发重试机制。

乐观锁并没有控制事务之间的可见性,而是专注于防止多个事务对同一数据进行并发更新时的数据冲突。

🔍 脏读的含义:

脏读是指一个事务读取了另一个事务未提交的更改,这些更改最终可能会被回滚,因此读取到的数据是无效的。这是和事务隔离级别有关的问题。

  • 在事务的隔离级别较低时(如 读未提交),允许事务读取未提交的数据,这就可能发生脏读。

🎯 乐观锁与脏读的关系:

乐观锁本质上不关心事务是否提交,它只关心数据是否被其他事务修改过。当一个事务提交更新时,乐观锁通过版本检查来检测是否有其他事务已经更新了数据。若没有冲突,事务就能提交;若有冲突,则通过重试来避免数据不一致。

脏读发生在事务还未提交的情况下,另一个事务就读取了未提交的数据,且这种数据可能会被回滚。脏读与乐观锁的工作方式没有直接关系,乐观锁的冲突检测是基于已提交数据的版本号,而不是未提交数据的状态

总结

  • 乐观锁并不能直接避免脏读。它主要解决的是多个事务对同一数据进行并发更新时的冲突检测,防止数据覆盖或不一致。

  • 脏读通常发生在事务隔离级别较低(如 读未提交)时,乐观锁并没有控制事务的隔离性,因此它不能解决脏读问题。

⚖️ 如何避免脏读?

如果你需要避免脏读,应该通过调整数据库的事务隔离级别来解决。最常见的做法是使用 读已提交 或更高的隔离级别(如 可重复读串行化)。

  • 读已提交:防止脏读,因为事务只会读取已提交的数据。

  • 串行化:提供最高的隔离级别,确保事务按照严格顺序执行,完全避免脏读、不可重复读和幻读。

🔒 结论

  • 乐观锁:解决并发更新冲突(版本号或时间戳检查)。

  • 脏读:通过事务的隔离级别控制,避免读取未提交的数据。

  • 避免脏读:调整数据库事务隔离级别,通常选择 读已提交 或更高隔离级别。

如果你的场景中既有并发更新冲突的风险,也需要避免脏读,可以结合乐观锁和合适的事务隔离级别来同时解决这两个问题。

4o

您说:

没理解,什么叫未提交的更改,我读数据时不都是读数据库吗,那么只要更改成功,那么乐观锁就能保证读取的数据一致啊,之后在修改就修改呗,有什么关系

ChatGPT 说:

理解你提的疑问了!我来逐步解释一下。

🎯 关键概念:

  1. 未提交的更改:指的是一个事务(比如事务 T1)已经对数据进行了修改,但是没有提交这些修改。此时,其他事务(比如事务 T2)可以在这个事务提交之前,读取到这些修改过的数据。如果事务 T1 后来回滚,这些修改就不再有效,原来的数据恢复了。

  2. 乐观锁:它通过 版本号时间戳 来确保在数据修改时不会发生并发冲突。乐观锁的核心是:在提交更新时,检查数据是否已经被其他事务修改过。如果没有被修改,乐观锁允许提交;如果被修改了,就通过重试机制避免冲突。

🎬 问题的关键:

你是说,“我读数据时不都是从数据库读取的吗?只要数据修改成功,那么乐观锁就能保证读取的数据一致,之后的修改也没问题啊。
这里的核心点是,乐观锁确实是确保你在数据更新时不会有并发冲突,但它并不涉及控制事务是否已经提交或者回滚的问题,而脏读正是由于另一个事务的未提交数据可能会导致读取不一致。

🚨 脏读的本质:

脏读发生的情况是这样的:

  1. 事务 T1 对数据进行了修改(例如 UPDATE 操作),但还没有提交(还在事务过程中)。

  2. 事务 T2T1 提交之前,读取了 T1 修改后的数据。此时 T2 读到了未提交的数据

  3. 如果 事务 T1 后来因为某些原因 回滚,那么 T1 对数据的修改就会被撤销,这时 T2 读取到的就是不真实的、已经被撤销的数据,这就产生了脏读。

举个例子:

  • 假设数据库中的某个表有字段 balance(账户余额),初始值为 1000。

  • 事务 T1

    • 修改余额:UPDATE accounts SET balance = 800 WHERE account_id = 1;

    • 但没有提交(只修改了数据,事务还在进行中)。

  • 事务 T2

    • 读取余额:SELECT balance FROM accounts WHERE account_id = 1;

    • 它看到的 balance 是 800(因为 T1 修改了数据)。

  • 然后 事务 T1 回滚了:

    • 所以 balance 实际上仍然是 1000。
  • 但是 事务 T2 读取到的却是未提交的数据(800),这就叫做脏读

🤔 乐观锁解决的是什么问题?

乐观锁并没有解决脏读的问题。它的目标是:

  • 防止并发修改冲突

  • 通过在更新时验证数据的版本(比如 version 字段),确保更新操作是基于最新数据的,防止多个事务同时修改相同的数据导致冲突。

举个例子:

  1. 事务 T1 获取了数据,版本号是 1

  2. 事务 T2 也获取了数据,版本号也是 1

  3. 事务 T1 提交修改数据,更新版本号变为 2

  4. 事务 T2 在更新时,发现它本地的数据版本号依然是 1,与数据库中数据的版本号不匹配。此时,乐观锁会拒绝 T2 的修改操作,防止它覆盖 T1 的修改。

但乐观锁并不关心“是否读取了未提交的数据”——它是通过版本号或时间戳来防止并发冲突的,而不是防止脏读。

🧠 乐观锁与脏读的关系:

  • 乐观锁关注的是并发修改冲突,确保只有数据的最新版本被提交,避免被旧版本覆盖。

  • 脏读关注的是事务隔离,确保读取的数据是已提交的。如果事务隔离级别是 读已提交 或更高,那么就不会发生脏读,因为事务 T2 只能读取已经提交的事务修改的数据。

🎯 如何避免脏读?

要避免脏读,必须调整事务隔离级别。例如:

  • 读已提交(Read Committed):保证一个事务只会读取其他事务已经提交的数据,避免脏读。

  • 串行化(Serializable):提供最高级别的隔离,完全避免脏读、不可重复读和幻读。

总结:

  • 乐观锁并不是为了解决脏读,而是为了防止并发修改冲突。它通过版本号或时间戳检查,确保数据在提交时没有被其他事务修改过。

  • 脏读发生在事务读取了未提交的数据,并且未提交的数据可能会回滚,导致读取到的数据是无效的。

  • 要避免脏读,必须使用适当的事务隔离级别,例如读已提交或更高。

如果你希望避免脏读和并发修改冲突,通常可以同时结合乐观锁和适当的事务隔离级别来实现更好的并发控制。
常见的隔离级别如下:

  1. 串行化(Serializable):这是最高的隔离级别,事务之间完全串行化执行。在这个级别下,不会发生脏读、不可重复读和幻读,所有数据访问都是有序的。

  2. 可重复读(Repeatable Read):在这个隔离级别下,一个事务在执行期间,如果多次读取同一数据,所读取的数据值不会改变,即使其他事务进行了提交更新。这个隔离级别能有效避免不可重复读,但仍然可能会发生幻读(即新增或删除了满足查询条件的记录)。

  3. 读已提交(Read Committed):这是默认的事务隔离级别。它保证一个事务只能读取已经提交的数据,避免脏读。但是,在这个级别下,事务多次读取同一数据时,可能会遇到不可重复读的情况,因为其他事务可以提交更新,从而导致数据变化。

  4. 读未提交(Read Uncommitted):这是最低的隔离级别,它允许读取其他事务的未提交数据,因此会发生脏读、不可重复读和幻读问题,通常不推荐使用。

⚖️ 事务隔离级别与不可重复读:

隔离级别 是否发生脏读 是否发生不可重复读 是否发生幻读
读未提交
读已提交
可重复读
串行化
posted @ 2025-04-20 15:33  塔玛希  阅读(33)  评论(0)    收藏  举报