1. 为什么我们需要事务?
作为一个在数据库领域摸爬滚打多年的老手,我见过太多因为不理解事务而导致的"血案"。最典型的就是电商平台的库存扣减问题——当多个用户同时抢购同一件商品时,如果没有事务保护,很可能会出现超卖的情况。
事务(Transaction)本质上是一组不可分割的数据库操作序列。想象你在银行转账:从A账户扣钱和给B账户加钱这两个操作必须同时成功或同时失败,这就是事务的典型应用场景。
提示:事务的英文Transaction原意就是"交易",这很形象地说明了它的本质特性。
1.1 事务的四大特性(ACID)
-
原子性(Atomicity):事务是最小工作单元,要么全部成功,要么全部失败回滚。就像你网购付款,不会出现钱扣了但订单没生成的情况。
-
一致性(Consistency):事务执行前后,数据库从一个一致状态变到另一个一致状态。比如转账前后,两个账户的总额应该保持不变。
-
隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务。这就像银行窗口办理业务时的隔离带。
-
持久性(Durability):一旦事务提交,其结果就是永久性的。即使系统崩溃,数据也不会丢失。
在实际开发中,我们最常遇到的问题是隔离性。比如:
- 脏读:事务A读取了事务B未提交的数据
- 不可重复读:事务A多次读取同一数据,期间事务B修改了该数据
- 幻读:事务A读取某个范围的数据时,事务B在该范围内插入了新数据
sql复制-- MySQL默认的隔离级别是REPEATABLE READ
SELECT @@transaction_isolation;
1.2 事务的典型应用场景
- 金融交易:转账、支付、结算等
- 库存管理:商品扣减、预占库存
- 订单系统:创建订单、扣减库存、生成物流单
- 用户注册:创建用户记录、初始化账户信息
我曾在项目中遇到一个经典案例:用户领取优惠券时,如果没有事务保护,在高并发下会出现同一用户领取多张相同优惠券的情况。后来我们通过事务+唯一索引解决了这个问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL中的事务实现
2.1 MySQL事务的基本用法
MySQL中事务的使用非常简单:
sql复制START TRANSACTION; -- 开始事务
-- 执行SQL语句
UPDATE account SET balance = balance - 100 WHERE user_id = 1;
UPDATE account SET balance = balance + 100 WHERE user_id = 2;
COMMIT; -- 提交事务
-- 如果出错可以 ROLLBACK;
但实际开发中,我们更常用的是通过编程语言来控制事务。比如在Java中:
java复制Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // 开启事务
// 执行SQL操作
conn.commit();
} catch (SQLException e) {
conn.rollback();
} finally {
conn.close();
}
2.2 事务隔离级别详解
MySQL支持四种隔离级别,从低到高分别是:
- READ UNCOMMITTED(读未提交):性能最好,但会出现脏读
- READ COMMITTED(读已提交):解决脏读,但会有不可重复读
- REPEATABLE READ(可重复读):MySQL默认级别,解决不可重复读
- SERIALIZABLE(串行化):完全隔离,性能最差
设置隔离级别的方法:
sql复制SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
注意:隔离级别越高,并发性能越差。实际项目中需要根据业务需求权衡。
2.3 事务中的锁机制
MySQL通过锁来实现事务的隔离性,主要锁类型包括:
- 共享锁(S锁):读锁,多个事务可以同时持有
- 排他锁(X锁):写锁,只能由一个事务持有
- 意向锁:表级锁,表明事务准备在行上加什么锁
- 记录锁:锁定索引记录
- 间隙锁:锁定索引记录间的间隙,防止幻读
- 临键锁:记录锁+间隙锁的组合
锁的兼容性矩阵:
| 请求锁类型 \ 已存在锁类型 | X | S | IX | IS |
|---|---|---|---|---|
| X | 冲突 | 冲突 | 冲突 | 冲突 |
| S | 冲突 | 兼容 | 冲突 | 兼容 |
| IX | 冲突 | 冲突 | 兼容 | 兼容 |
| IS | 冲突 | 兼容 | 兼容 | 兼容 |
3. MyBatis中的事务管理
3.1 MyBatis事务配置
MyBatis本身不管理事务,它依赖于底层的数据源(如DBCP、HikariCP等)和事务管理器。在Spring集成环境中,通常这样配置:
xml复制<!-- 配置事务管理器 -->
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<!-- 开启注解驱动的事务管理 -->
<tx:annotation-driven transaction-manager="transactionManager"/>
然后在Service层使用@Transactional注解:
java复制@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
// 业务逻辑
}
}
3.2 @Transactional注解详解
@Transactional有几个重要属性:
-
propagation:事务传播行为,默认REQUIRED
- REQUIRED:如果当前有事务,就加入;没有就新建
- REQUIRES_NEW:新建事务,挂起当前事务
- NESTED:嵌套事务
- 等等...
-
isolation:隔离级别,默认使用数据库的默认隔离级别
-
timeout:事务超时时间(秒)
-
readOnly:是否只读事务
-
rollbackFor/rollbackForClassName:指定哪些异常触发回滚
-
noRollbackFor/noRollbackForClassName:指定哪些异常不触发回滚
3.3 MyBatis事务的常见坑点
- 自调用问题:同一个类中方法A调用方法B,即使B有@Transactional也不会生效。这是因为Spring的事务是基于AOP实现的。
java复制// 错误示例
public void methodA() {
methodB(); // 事务不会生效
}
@Transactional
public void methodB() {
// ...
}
- 异常被捕获:如果在事务方法中捕获了异常但没有重新抛出,事务不会回滚。
java复制@Transactional
public void update() {
try {
// 可能抛出异常的代码
} catch (Exception e) {
logger.error("出错", e); // 事务不会回滚
}
}
-
非public方法:@Transactional注解在非public方法上不会生效。
-
数据库引擎不支持:比如使用MyISAM引擎的表不支持事务。
4. 分布式事务挑战与解决方案
4.1 分布式事务的难点
在微服务架构下,传统的单数据库事务不再适用。比如电商系统中的创建订单流程:
- 订单服务创建订单
- 库存服务扣减库存
- 支付服务处理支付
- 物流服务生成运单
这些操作可能涉及不同的数据库,传统事务无法跨服务保证ACID特性。
4.2 常见分布式事务解决方案
-
2PC(两阶段提交):
- 阶段一:协调者询问所有参与者是否可以提交
- 阶段二:根据参与者的响应决定提交或回滚
- 优点:强一致性
- 缺点:同步阻塞、单点问题、数据不一致风险
-
TCC(Try-Confirm-Cancel):
- Try:预留资源
- Confirm:确认执行业务
- Cancel:取消业务,释放资源
- 优点:最终一致性,性能较好
- 缺点:实现复杂,需要业务改造
-
本地消息表:
- 将分布式事务拆分为多个本地事务
- 通过消息队列保证最终一致性
- 优点:实现简单
- 缺点:消息处理可能重复,需要幂等设计
-
Saga模式:
- 长事务拆分为多个短事务
- 每个短事务有对应的补偿操作
- 优点:适合长流程业务
- 缺点:补偿逻辑复杂
4.3 Seata框架实战
Seata是目前流行的分布式事务解决方案。其核心概念:
- TC(Transaction Coordinator):事务协调者
- TM(Transaction Manager):事务管理器
- RM(Resource Manager):资源管理器
使用示例:
- 首先在全局配置文件中启用Seata:
properties复制spring.cloud.alibaba.seata.tx-service-group=my_test_tx_group
- 在业务方法上添加@GlobalTransactional注解:
java复制@GlobalTransactional
public void purchase(String userId, String commodityCode, int orderCount) {
// 调用各个微服务
}
- 在每个微服务的数据库中创建undo_log表(用于回滚):
sql复制CREATE TABLE undo_log (
id bigint(20) NOT NULL AUTO_INCREMENT,
branch_id bigint(20) NOT NULL,
xid varchar(100) NOT NULL,
context varchar(128) NOT NULL,
rollback_info longblob NOT NULL,
log_status int(11) NOT NULL,
log_created datetime NOT NULL,
log_modified datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_undo_log (xid,branch_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
5. 事务性能优化实战技巧
5.1 减少事务范围
事务应该尽可能小,只包含必要的操作。比如:
java复制// 不好的做法:整个方法都在事务中
@Transactional
public void processOrder(Order order) {
validate(order); // 验证
save(order); // 保存
sendEmail(); // 发邮件
log(); // 记录日志
}
// 好的做法:只把核心操作放在事务中
public void processOrder(Order order) {
validate(order);
transactionalSave(order);
sendEmail();
log();
}
@Transactional
private void transactionalSave(Order order) {
save(order);
}
5.2 选择合适的隔离级别
根据业务需求选择最低可行的隔离级别。比如报表查询可以使用READ COMMITTED,而资金交易可能需要REPEATABLE READ。
5.3 避免长事务
长事务会占用数据库连接,可能导致连接池耗尽。解决方法:
- 分批处理大数据量操作
- 将非核心操作移出事务
- 设置合理的事务超时时间
5.4 索引优化
良好的索引设计可以减少锁的竞争:
- 为常用查询条件创建合适索引
- 避免全表扫描(会导致锁表)
- 使用覆盖索引减少回表操作
5.5 连接池配置
合适的连接池配置对事务性能至关重要:
properties复制# HikariCP配置示例
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.connection-timeout=30000
6. 事务监控与问题排查
6.1 监控关键指标
- 事务成功率:成功事务数/总事务数
- 事务平均耗时:事务执行的平均时间
- 长事务数:执行时间超过阈值的事务
- 死锁次数:发生的死锁次数
6.2 常见问题排查方法
- 查看当前运行的事务:
sql复制-- MySQL
SELECT * FROM information_schema.INNODB_TRX;
- 查看锁等待情况:
sql复制-- MySQL
SELECT * FROM performance_schema.events_waits_current;
- 分析死锁日志:
sql复制-- 查看最近死锁信息
SHOW ENGINE INNODB STATUS;
- 慢查询分析:
sql复制-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
6.3 典型问题案例
案例1:库存超卖
现象:促销活动时,库存出现了负数
原因:没有正确处理高并发下的更新操作
解决方案:
- 使用SELECT FOR UPDATE加锁
- 使用乐观锁(version字段)
- 在应用层做限流
案例2:事务超时
现象:系统偶尔报出事务超时错误
原因:事务中包含耗时操作(如远程调用)
解决方案:
- 将耗时操作移出事务
- 增加事务超时时间
- 优化慢查询
案例3:连接池耗尽
现象:系统报出无法获取数据库连接
原因:长事务占用连接时间过长
解决方案:
- 优化事务范围
- 增加连接池大小
- 设置合理的连接超时时间
在实际项目中,我通常会配置APM工具(如SkyWalking、Pinpoint)来监控事务性能,设置合理的告警阈值,这样可以在问题影响用户前及时发现并解决。
