四个特性
原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败回滚,不会只执行部分。
一致性(Consistency):事务执行前后,数据都必须处于一致的状态(满足所有业务和数据约束)。
隔离性(Isolation):并发事务之间相互隔离,一个事务的中间结果对其他事务不可见。
持久性(Durability):事务一旦提交,其结果会被永久保存,即使系统故障也不会丢失。
写事务的位置
由于事务通常涉及的是业务流程的操作,会写在service层进行使用。当在service实现层重写接口时,加上@Transactional 注解后会由Spring事务模块+AOP机制(Spring AOP是一种通过代理模式实现面向切面编程的轻量级框架)配合实现事务。
抛出异常
@Transactional 注解的参数rollbackFor决定了这个事务当异常已经抛出并向外传播时,事务要不要回滚(即回滚规则)。当我捕捉到了某个异常e并且抛出,但是没有命中回滚规则,异常会正常向上抛出,但事务仍然会继续执行。
回滚规则如何在“冗余vs保险”之间找平衡点
规则一:能用运行时异常就用运行时异常;
规则二:不要用Exception.class当默认习惯,优先精确到真正会抛出的受检异常,当业务繁琐可能会出现考虑不周全的情况才使用Exception.class进行兜底;
规则三:尽可能把外部IO放在事务边界外。
书写事务的数据库原理
在数据库中通过如下方式实现读锁、写锁
BEGIN;
SELECT *
FROM public.account
WHERE user_id = 1
FOR UPDATE; -- 当前为写锁,换成FOR SEARCH 则是读锁
COMMIT; -- 当前是提交,换成ROLLBACK则表示回滚在Postgresql中,读锁和写锁得途中都可以进行读操作,但是不可以写,因为Postgresql采用的是MVCC模式(Multi-Version Concurrency Control,多版本并发控制),所以每个SQL语句看到的是数据库在某个时间点的快照,而不是实时的数据库状态。读操作永远不会阻塞写操作,写操作永远不会阻塞读操作。
在SSM框架中,借助Spring AOP的代理/拦截器在方法调用前后决定什么时候开启、提交、回滚事务;然后由Spring的事务管理器通过JDBC调用commit/rollback,由 JDBC 驱动把请求发送给数据库,最终由数据库执行事务。
实战使用场景
@Override
@Transactional
public Map<String, Object> transfer(Long fromUserId, Long toUserId, BigDecimal amount, String txNo) {
Map<String, Object> result = new HashMap<>();
// 参数校验
validateTransferArgs(fromUserId, toUserId, amount, txNo);
// 1) 锁住两条账户记录(固定顺序,避免死锁)
List<Long> lockIds = fromUserId < toUserId ? List.of(fromUserId, toUserId) : List.of(toUserId, fromUserId);
accountMapper.selectByUserIdsForUpdate(lockIds);
// 2) 先写流水,利用 tx_no 唯一实现幂等(重复请求直接报错)
try {
accountMapper.insertTx(txNo, fromUserId, toUserId, amount, TX_TYPE_TRANSFER, "0", "practice transfer");
} catch (DuplicateKeyException e) {
result.put("code", 409);
result.put("msg", "重复请求:txNo 已存在(幂等触发)");
return result;
}
// 3) 扣款(余额不足会影响行数=0)
int debitRows = accountMapper.debit(fromUserId, amount);
if (debitRows != 1) {
// 触发回滚:包括刚插入的流水
throw new IllegalStateException("余额不足或账户不可用");
}
// 4) 入账
int creditRows = accountMapper.credit(toUserId, amount);
if (creditRows != 1) {
throw new IllegalStateException("入账失败:账户不可用或不存在");
}
// 5) 返回余额
Account fromAcc = getByUserId(fromUserId);
Account toAcc = getByUserId(toUserId);
result.put("code", 200);
result.put("msg", "转账成功");
Map<String, Object> data = new HashMap<>();
data.put("fromUserId", fromUserId);
data.put("toUserId", toUserId);
data.put("amount", amount);
data.put("txNo", txNo);
data.put("fromBalance", fromAcc == null ? null : fromAcc.getBalance());
data.put("toBalance", toAcc == null ? null : toAcc.getBalance());
result.put("data", data);
return result;
}吞掉异常也可以是正常逻辑
写流水处利用字段唯一属性产生异常,捕捉后不进行抛出,并且把异常转成业务结果返回,而不是让它冒泡变成500。也就是说这个地方是借助了唯一属性来检验插入的txNo是否重复,并且返回的响应不会是业务逻辑错误。
通过抛异常回滚
扣款处通过抛出异常来进行回滚,如果扣款失败,之前插入的流水也会被一并回滚,从而保证“流水插入+扣款+入账”要么全部成功、要么全部失败,满足事务的原子性原则。
写流水没有通过抛异常来回滚的原因
写流水处可以通过吞异常来提交,也可以通过抛异常来处理,但是此处为业务可预期失败,并且前面没有已经执行的数据库写操作。没有必要通过抛出异常给前端返回状态码为500的响应,这里更需要的是响应码为200的响应,并且告诉请求方发生了什么样的错误。
示例扣款失败返回给请求方的状态码是500还是200,该如何正确处理
Spring框架会捕捉到业务代码抛出的异常,再检查项目中是否有@ControllerAdvice/@RestControllerAdvice 标注的全局异常处理器,匹配到某一种异常对应的@ExceptionHandler 方法,由这些方法生成标准化的JSON响应,最后返回给请求方。
所以当项目中有全局异常处理器时返回给前端的就是200并且有对应的msg信息返回,没有全局处理器时就会返回500的响应状态码。
当有DuplicateKeyException的全局异常处理方法后,写流水的代码应该如何优化
当项目中有如下的对DuplicateKeyException全局异常处理的方法时
/**
* 幂等键/唯一约束冲突。
*/
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<Map<String, Object>> handleDuplicateKey(DuplicateKeyException e) {
return build(HttpStatus.CONFLICT, 409, e.getMessage());
}可将写流水处的代码做如下优化
// 2) 先写流水,利用 tx_no 唯一实现幂等(重复请求直接抛出异常)
try {
accountMapper.insertTx(txNo, fromUserId, toUserId, amount, TX_TYPE_TRANSFER, "0", "practice transfer");
} catch (DuplicateKeyException e) {
throw new DuplicateKeyException("重复请求:txNo 已存在(幂等触发)", e);
}抛出异常时的第一个参数是自定义的异常信息(message),用于给前端或日志更友好的提示,第二个参数 e 是“cause”,即原始异常对象,用于保留底层异常的详细信息和堆栈。