上一篇
线程访问数据库加锁,可使用synchronized关键字锁定关键代码块或方法;也可借助ReentrantLock类实现更灵活的锁定;读写锁(ReadWriteLock)能区分读写操作分别加锁,提升读多写少场景效率
多线程访问数据库时,为了保证数据的一致性和完整性,防止多个线程同时对数据库进行操作导致数据混乱或错误,需要采用合适的加锁策略,以下是几种常见的多线程访问数据库的加锁方法:
数据库自身的锁机制
- 表级锁:对整个表进行加锁,锁定粒度较大,当一个线程对某个表进行写操作(如INSERT、UPDATE、DELETE)时,其他线程无法对该表进行任何写操作,甚至部分情况下读操作也会被阻塞,直到锁被释放,例如在MySQL中,使用
LOCK TABLES table_name WRITE语句可以对指定表加上写锁,这种方式适用于对整个表进行批量操作的场景,但由于其锁定粒度大,会严重影响并发性能,在多线程频繁访问数据库时可能会导致大量线程等待,降低系统整体效率。 - 行级锁:只对表中的某一行或若干行数据进行加锁,锁定粒度较小,在MySQL的InnoDB存储引擎中,行级锁是通过给索引项加锁来实现的,当一个线程对某一行数据进行修改时,只会锁定这一行,其他线程仍然可以正常访问其他行的数据,执行
SELECT FROM table_name WHERE id = 1 FOR UPDATE语句,会对id为1的那一行数据加上行级锁,其他线程在该锁释放之前无法修改这一行数据,但可以正常访问其他行,行级锁能大大提高并发度,但在处理大量数据时,加锁和解锁的开销相对较大,而且可能会出现死锁的情况,需要合理设计事务和加锁顺序来避免。 - 页级锁:介于表级锁和行级锁之间,锁定粒度是数据页(通常为4KB或8KB),当一个线程对某个数据页中的数据进行操作时,会对整个数据页加锁,这种锁在数据库中使用相对较少,一些数据库系统可能会在某些特定情况下自动使用页级锁来优化性能,但一般开发者很少直接使用页级锁来进行多线程控制。
编程语言提供的锁机制在数据库访问中的应用
- synchronized关键字:在Java等编程语言中,
synchronized关键字可以用于方法或代码块,确保在同一时刻只有一个线程能够访问被锁定的代码,当多个线程调用被synchronized修饰的方法或代码块时,会被阻塞直到当前线程执行完毕并释放锁,在一个Java程序中,有一个访问数据库的方法public synchronized void accessDatabase() { ... },当多个线程调用这个方法时,同一时间只有一个线程能够进入方法体执行数据库访问操作,其他线程需要等待,这种方式简单易用,但灵活性不足,锁定的范围相对较大,可能会影响性能,尤其是在一些复杂的多线程场景下,可能会导致不必要的线程阻塞。 - ReentrantLock类:Java中的
ReentrantLock提供了比synchronized更灵活的锁定机制,它可以实现可重入锁,即同一个线程可以多次获取同一把锁,避免了在递归调用或某些复杂逻辑中出现死锁的情况,一个线程在执行过程中可能需要多次调用不同的方法来访问数据库,这些方法都使用同一个ReentrantLock对象进行加锁,那么该线程可以正常执行,而其他线程则需要等待锁的释放。ReentrantLock还支持超时锁、中断响应等功能,可以根据具体需求进行更精细的控制。 - 读写锁(ReadWriteLock):
ReadWriteLock允许多个读线程同时访问共享资源,但在写线程访问时,会阻止其他线程的读和写访问,在数据库访问场景中,如果某个操作主要是读取数据,那么可以使用读锁,这样多个读线程可以并发执行,提高系统的吞吐量;而当有写操作时,再使用写锁,确保数据的一致性,在一个缓存系统中,多个线程可能经常读取缓存数据,而只有少数时候需要更新缓存,此时使用读写锁可以很好地平衡读写操作的并发性和数据的正确性。
应用层面的锁设计
- 乐观锁:乐观锁假设多个线程在处理数据时不会发生冲突,每个线程在执行过程中不需要加锁,而是在提交数据时检查数据是否被其他线程修改过,如果发现数据被修改,则放弃本次操作并重新尝试,常见的实现方式是使用版本号或时间戳,每次读取数据时同时获取版本号或时间戳,在更新数据时比较版本号或时间戳,如果一致则执行更新,否则说明数据已经被其他线程修改过,需要重新读取和处理,乐观锁适用于读多写少的场景,可以减少加锁带来的性能开销,但如果数据冲突频繁发生,会导致大量的重试操作,影响系统性能。
- 悲观锁:悲观锁则认为多个线程在处理数据时很可能会发生冲突,因此在操作数据之前就先加锁,确保其他线程无法同时访问该数据,前面提到的数据库自身的锁机制以及编程语言提供的锁机制大多都属于悲观锁的范畴,悲观锁的优点是能够保证数据的一致性和完整性,但缺点是可能会导致线程阻塞,降低系统的并发性能。
下面通过一个表格来对比这几种加锁方法的特点:
| 加锁方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库自身锁机制(表级锁、行级锁、页级锁) | 由数据库管理系统直接支持,易于实现 | 锁定粒度较大时会影响并发性能,可能导致大量线程等待 | 对整个表或特定行、页进行批量操作时 |
| synchronized关键字 | 简单易用,无需额外引入库 | 灵活性不足,锁定范围较大可能导致性能问题 | 简单的多线程数据库访问场景,对性能要求不高时 |
| ReentrantLock类 | 提供更灵活的锁定机制,可重入、支持超时和中断响应 | 使用相对复杂,需要额外的代码来管理锁 | 需要更精细控制锁的场景,如递归调用、需要处理超时或中断的情况 |
| 读写锁(ReadWriteLock) | 提高读多写少场景下的并发性能 | 写操作时会阻塞读操作,需要注意合理使用 | 读操作远多于写操作的场景,如缓存系统 |
| 乐观锁 | 减少加锁带来的性能开销,适用于读多写少的场景 | 数据冲突频繁时会导致大量重试操作 | 读多写少且数据冲突概率较小的场景 |
| 悲观锁 | 保证数据的一致性和完整性 | 可能导致线程阻塞,降低并发性能 | 数据冲突概率较大,对数据一致性要求极高的场景 |
FAQs
- 问题1:在使用数据库自身锁机制时,如何避免死锁?
- 解答:要合理设计事务的执行顺序,尽量让所有线程以相同的顺序访问和锁定资源,尽量减少事务的持有时间,避免长时间占用锁,在编程时可以设置合理的超时时间,当等待锁的时间超过设定值时,主动放弃获取锁并回滚事务,避免死锁的发生,还可以使用数据库提供的死锁检测工具,及时发现和解决死锁问题。
- 问题2:乐观锁在实际应用中如何处理数据冲突?
- 解答:当使用乐观锁发现数据冲突时,通常会根据具体的业务需求进行处理,一种常见的做法是放弃本次操作,重新读取最新的数据并再次尝试提交,在重新尝试时,可以根据具体情况设置一定的重试次数和间隔时间,避免无限次重试导致系统资源浪费,如果重试多次仍然失败,可以将冲突信息记录到日志中,以便后续分析和处理,同时通知相关人员进行
