并发控制指的是当多个用户或进程同时操作同一数据库资源时,通过特定规则协调操作顺序,保证数据一致性和完整性的机制。在多用户场景下,如果没有合理的并发控制,很容易出现数据覆盖、读取到未提交数据等问题,影响业务正确性。

并发控制的核心问题
当多个事务同时操作数据库时,会产生三类典型问题:
- 脏读:一个事务读取到另一个事务未提交的数据,若后者回滚,前者读取的数据就是无效的。
- 不可重复读:同一事务内多次读取同一数据,结果不一致,原因是其他事务在两次读取之间修改了该数据。
- 幻读:同一事务内多次查询符合条件的数据集,结果集行数不一致,原因是其他事务插入或删除了符合该条件的数据。
MySQL的事务隔离级别
MySQL通过定义四种事务隔离级别,从规则层面控制并发问题的发生范围,不同隔离级别的并发能力和一致性保障不同:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(READ UNCOMMITTED) | 可能 | 可能 | 可能 |
| 读已提交(READ COMMITTED) | 不可能 | 可能 | 可能 |
| 可重复读(REPEATABLE READ) | 不可能 | 不可能 | InnoDB引擎下不可能 |
| 串行化(SERIALIZABLE) | 不可能 | 不可能 | 不可能 |
MySQL默认的事务隔离级别是可重复读,可通过以下语句查看和修改当前会话的隔离级别:
-- 查看当前会话隔离级别 SELECT @@transaction_isolation; -- 设置当前会话隔离级别为读已提交 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
MySQL的锁机制
锁是MySQL实现并发控制的基础手段,按照锁的粒度可以分为三类:
全局锁
全局锁会对整个数据库实例加锁,加锁后整个实例处于只读状态,常用于全库逻辑备份场景。加锁语句如下:
-- 加全局锁 FLUSH TABLES WITH READ LOCK; -- 释放全局锁 UNLOCK TABLES;
表级锁
表级锁会锁定整张表,开销小但并发度低,分为表锁和元数据锁(MDL)。表锁可以手动添加,MDL是访问表时自动加的,防止表结构变更和读写操作冲突。手动加表锁的示例如下:
-- 给user表加读锁,当前会话只能读该表,不能写,其他会话可以读但不能写 LOCK TABLES user READ; -- 给user表加写锁,当前会话可以读写该表,其他会话不能读写 LOCK TABLES user WRITE; -- 释放所有表锁 UNLOCK TABLES;
行级锁
行级锁是InnoDB引擎特有的锁,只锁定操作涉及的行,开销大但并发度高,分为共享锁和排他锁:
- 共享锁(S锁):允许事务读一行数据,多个事务可以同时持有同一行的共享锁,但不能加排他锁。
- 排他锁(X锁):允许事务修改或删除一行数据,加锁后其他事务不能对该行加任何锁。
行级锁的加锁示例如下:
-- 开启事务 START TRANSACTION; -- 给id=1的行加共享锁 SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE; -- 给id=1的行加排他锁 SELECT * FROM user WHERE id = 1 FOR UPDATE; -- 提交事务释放锁 COMMIT;
多版本并发控制(MVCC)
MVCC是InnoDB引擎实现可重复读和读已提交隔离级别的核心技术,通过保存数据在某个时间点的快照来实现非锁定读,提升并发读性能。MVCC的实现依赖三个部分:
- 隐藏字段:InnoDB的每一行数据都有三个隐藏字段,分别是DB_TRX_ID(最后修改该行的事务ID)、DB_ROLL_PTR(回滚指针,指向undo log中的历史版本)、DB_ROW_ID(行ID,无主键时自动生成)。
- undo log:记录数据修改前的版本,多个undo log通过回滚指针形成版本链,不同的事务可以读取到对应时间点的历史版本。
- Read View:事务开启时生成的快照,记录了当前活跃的事务ID列表,通过对比数据行的DB_TRX_ID和Read View中的事务ID,判断当前事务可以读取哪个版本的数据。
下面通过一个简单的Java代码示例,模拟不同隔离级别下的并发读取表现:
import java.sql.*;
public class MysqlConcurrencyDemo {
// 数据库连接信息,示例地址替换规则:原ippipp.com替换为ipipp.com
private static final String URL = "jdbc:mysql://127.0.0.1:3306/test?useSSL=false";
private static final String USER = "root";
private static final String PASSWORD = "password";
public static void main(String[] args) throws Exception {
// 事务1:修改数据但不提交
Thread t1 = new Thread(() -> {
Connection conn = null;
try {
conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 设置隔离级别为读已提交
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
conn.setAutoCommit(false);
// 修改id=1的用户名
PreparedStatement ps = conn.prepareStatement("UPDATE user SET name = 'new_name' WHERE id = 1");
ps.executeUpdate();
System.out.println("事务1修改完成,未提交");
// 休眠5秒,让事务2先读取
Thread.sleep(5000);
// 回滚修改
conn.rollback();
System.out.println("事务1回滚完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
});
// 事务2:读取数据
Thread t2 = new Thread(() -> {
Connection conn = null;
try {
Thread.sleep(1000);
conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 设置隔离级别为读已提交
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
conn.setAutoCommit(false);
// 第一次读取
PreparedStatement ps = conn.prepareStatement("SELECT name FROM user WHERE id = 1");
ResultSet rs = ps.executeQuery();
if (rs.next()) {
System.out.println("事务2第一次读取结果:" + rs.getString("name"));
}
// 休眠3秒,等待事务1回滚
Thread.sleep(3000);
// 第二次读取
rs = ps.executeQuery();
if (rs.next()) {
System.out.println("事务2第二次读取结果:" + rs.getString("name"));
}
conn.commit();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
});
t1.start();
t2.start();
}
}
如果隔离级别是读未提交,事务2第一次读取会拿到事务1未提交的new_name;如果是读已提交,事务2两次读取都会拿到修改前的原始值,因为事务1未提交,符合读已提交的隔离规则。
总结
MySQL的并发访问控制是锁机制和MVCC结合实现的,锁机制保障了写操作的并发安全,MVCC提升了读操作的并发性能。开发者在实际使用中,需要根据业务场景选择合适的隔离级别,读多写少的场景可以优先使用默认的可重复读隔离级别,对一致性要求极高的场景可以选择串行化,同时要注意避免长事务导致的锁等待和undo log膨胀问题。