在java后端开发的高并发业务场景中,数据库死锁是经常出现的性能问题,当两个或多个事务互相持有对方需要的锁资源且都不释放时,就会形成死锁,导致事务无法继续执行,严重时会影响整个服务的可用性。

什么是数据库死锁
数据库死锁指的是两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。比如事务A锁定了表的第一行数据,想要锁定第二行,而事务B锁定了第二行,想要锁定第一行,双方都不释放已有的锁,就会形成死锁。
死锁的常见成因
- 事务执行的顺序不一致,不同事务对相同资源的加锁顺序不同,容易形成互相等待的情况
- 事务持有锁的时间过长,长时间不提交或者回滚,导致其他事务等待锁的超时时间增加
- 索引使用不当,没有合适的索引时数据库可能会进行全表扫描,加锁范围扩大,增加死锁概率
- 事务隔离级别设置过高,比如使用串行化隔离级别,会大幅提升锁的竞争程度
死锁的排查方法
数据库层面排查
以常用的MySQL InnoDB引擎为例,可以通过查看死锁日志来定位问题,执行以下SQL语句可以获取最近的死锁信息:
-- 查看InnoDB引擎状态,包含死锁相关信息 SHOW ENGINE INNODB STATUS;
在返回的结果中,找到LATEST DETECTED DEADLOCK部分,里面会记录死锁发生时的两个事务的SQL语句、持有的锁信息以及等待的锁信息,通过这些内容可以定位到具体是哪部分业务代码触发了死锁。
java代码层面排查
可以在java代码中捕获死锁相关的异常,比如MySQL的死锁异常错误码是1213,当捕获到该异常时可以打印对应的业务参数和调用栈,方便定位问题:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import org.springframework.jdbc.core.JdbcTemplate;
public class DeadLockTest {
private JdbcTemplate jdbcTemplate;
public void updateData(String param1, String param2) {
String sql1 = "UPDATE user SET age = ? WHERE id = 1";
String sql2 = "UPDATE user SET age = ? WHERE id = 2";
Connection conn = null;
try {
conn = jdbcTemplate.getDataSource().getConnection();
// 关闭自动提交,手动管理事务
conn.setAutoCommit(false);
PreparedStatement ps1 = conn.prepareStatement(sql1);
ps1.setInt(1, 20);
ps1.executeUpdate();
PreparedStatement ps2 = conn.prepareStatement(sql2);
ps2.setInt(1, 25);
ps2.executeUpdate();
conn.commit();
} catch (SQLException e) {
// MySQL死锁错误码为1213
if (e.getErrorCode() == 1213) {
System.err.println("发生死锁,业务参数:param1=" + param1 + ", param2=" + param2);
e.printStackTrace();
}
if (conn != null) {
try {
conn.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
} finally {
if (conn != null) {
try {
conn.setAutoCommit(true);
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
java后端解决死锁的具体方案
统一事务内的加锁顺序
确保所有事务对相同资源的加锁顺序一致,比如所有更新用户数据的操作都按照用户id从小到大的顺序加锁,避免不同事务反向加锁形成死锁。示例如下:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import org.springframework.jdbc.core.JdbcTemplate;
public class FixDeadLock {
private JdbcTemplate jdbcTemplate;
// 统一按照id升序的顺序更新数据,避免加锁顺序不一致
public void updateUserInOrder(int id1, int age1, int id2, int age2) {
// 先排序,保证加锁顺序一致
int firstId = Math.min(id1, id2);
int firstAge = id1 == firstId ? age1 : age2;
int secondId = Math.max(id1, id2);
int secondAge = id1 == secondId ? age1 : age2;
Connection conn = null;
try {
conn = jdbcTemplate.getDataSource().getConnection();
conn.setAutoCommit(false);
// 先更新id小的记录
PreparedStatement ps1 = conn.prepareStatement("UPDATE user SET age = ? WHERE id = ?");
ps1.setInt(1, firstAge);
ps1.setInt(2, firstId);
ps1.executeUpdate();
// 再更新id大的记录
PreparedStatement ps2 = conn.prepareStatement("UPDATE user SET age = ? WHERE id = ?");
ps2.setInt(1, secondAge);
ps2.setInt(2, secondId);
ps2.executeUpdate();
conn.commit();
} catch (SQLException e) {
if (conn != null) {
try {
conn.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.setAutoCommit(true);
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
缩短事务持有锁的时间
尽量把事务中的非数据库操作移出事务范围,比如不要在事务中做远程接口调用、文件读写等耗时操作,减少锁的占用时间。同时尽量把事务拆解,避免大事务长时间持有锁。
优化索引使用
给经常作为查询条件的字段添加合适的索引,避免数据库进行全表扫描导致加锁范围扩大到整个表。比如更新用户数据时如果where条件用的是id,就给id字段添加主键索引,确保加锁只锁定对应的行。
合理设置事务隔离级别
如果不是特别需要,不要使用过高的隔离级别,比如普通的业务场景使用读已提交隔离级别就可以满足需求,避免使用串行化隔离级别大幅提升锁竞争。可以在数据库连接配置中设置隔离级别:
import java.sql.Connection;
import java.sql.SQLException;
import org.springframework.jdbc.core.JdbcTemplate;
public class IsolationConfig {
private JdbcTemplate jdbcTemplate;
public void setIsolationLevel() {
Connection conn = null;
try {
conn = jdbcTemplate.getDataSource().getConnection();
// 设置事务隔离级别为读已提交,对应值为2
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
} catch (SQLException e) {
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
死锁重试机制
当发生死锁时,数据库会回滚其中一个事务,我们可以在java代码中捕获死锁异常后,进行有限次数的重试,比如重试3次,每次重试间隔一定时间,减少业务失败的概率:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import org.springframework.jdbc.core.JdbcTemplate;
public class DeadLockRetry {
private JdbcTemplate jdbcTemplate;
// 最大重试次数
private static final int MAX_RETRY_TIMES = 3;
// 重试间隔毫秒数
private static final int RETRY_INTERVAL = 100;
public void updateWithRetry(int id, int age) {
int retryCount = 0;
while (retryCount <= MAX_RETRY_TIMES) {
Connection conn = null;
try {
conn = jdbcTemplate.getDataSource().getConnection();
conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement("UPDATE user SET age = ? WHERE id = ?");
ps.setInt(1, age);
ps.setInt(2, id);
ps.executeUpdate();
conn.commit();
// 执行成功,跳出循环
break;
} catch (SQLException e) {
if (e.getErrorCode() == 1213 && retryCount < MAX_RETRY_TIMES) {
// 死锁异常且还有重试次数,进行重试
retryCount++;
try {
Thread.sleep(RETRY_INTERVAL);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
} else {
// 其他异常或者重试次数用尽,回滚并抛出异常
if (conn != null) {
try {
conn.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
e.printStackTrace();
break;
}
} finally {
if (conn != null) {
try {
conn.setAutoCommit(true);
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
}
总结
解决java后端的数据库死锁问题需要从代码逻辑、数据库配置、索引优化多个层面入手,首先要通过死锁日志定位问题根源,然后针对性地统一加锁顺序、缩短事务时间、优化索引,同时配合合理的隔离级别和重试机制,就能大幅降低死锁发生的概率,保障后端服务的稳定运行。