weblog/doc/5、整合 SaToken 实现 JWT 登录功能/5.13 编程式事务使用:更细粒度的事务控制.md
2025-02-17 10:05:44 +08:00

10 KiB
Raw Blame History

上小节中,系统自动注册用户逻辑,是需要保证其原子性的,要么所有操作全部失败,要么全部成功,绝对不允许一部分成功,一部分失败,这样会导致脏数据。我们通过在方法头上添加 @Transacational 事务注解,以实现事务的控制。

但是,这块其实是有坑的,不知道小伙伴们发现了没有。

模拟注册用户中间发生了错误

我们来模拟一下,在新增用户入库后,分配用户角色之前,手动添加一个运行时异常 —— 分母不能为零。如下图所示:

运行时异常添加完毕后,重启项目,通过 Apipost 测试一下登录接口,记得登录一个数据库中不存在的手机号,看看是个什么情况。

可以看到提示系统错误查看数据库会发现用户数据插入成功了但是角色数据、Redis 缓存都添加失败了,事务并没有回滚

声明式注解事务,为啥失效了?

声明式注解事务失效原因,主要由以下几点:

  • 方法可见性@Transactional 仅在 public 方法上生效。

  • 自调用:当类中的方法调用同一个类中的另一个 @Transactional 方法时,事务可能不会生效。这是因为事务注解是通过 AOP 实现的,而 Spring 的 AOP 代理机制在这种情况下不会被触发。

  • 异常处理:只有 RuntimeExceptionError 类型的异常会触发事务回滚。如果你抛出的是 checked exception,事务不会回滚,除非你明确指定 rollbackFor 属性。

  • 代理对象:确保你是在 Spring 管理的代理对象上调用方法。如果你直接使用 new 关键字实例化对象Spring 的 AOP 代理机制将不会被应用。

很显然,我们是自调用这种情况。

使用编程式事务

什么是编程式事务?有哪些优点?

编程式事务Programmatic Transaction是一种通过代码显式地管理事务的方式而不是依赖声明式事务Declarative Transaction中使用的注解或 XML 配置。在编程式事务中,开发人员通过编写代码来开启、提交和回滚事务,以精细控制事务的边界和行为。

使用编程式事务优点如下:

  • 精细控制:编程式事务允许开发者通过代码精细地控制事务的生命周期,包括开始、提交和回滚。可以根据具体业务需求,灵活地管理事务。

  • 动态处理:在运行时可以根据业务逻辑的不同情况动态决定事务的行为。特别适合需要在代码执行过程中,根据某些条件来开启、提交或回滚事务的场景。

  • 适用于复杂事务:在一个方法中需要多次开启和关闭事务,或需要嵌套事务的复杂场景中,编程式事务可以提供更大的灵活性和控制力。

  • 灵活性高:能够在代码中实现复杂的事务逻辑,可以精确控制事务的边界和行为。这在需要多个步骤或调用之间共享事务上下文时非常有用。

  • 性能提升:通过精细控制事务的边界,减少不必要的事务开启和提交,从而减少事务开销;通过明确控制事务的开始和结束,可以确保事务范围尽可能小,减少长时间占用数据库资源,提高系统的并发性;通过灵活的事务管理,可以在必要时才进行事务回滚,减少回滚操作带来的性能开销。

使用示例

以下是 Spring Boot 中使用编程式事务两种方式,示例代码如下:

第一种方式

@Service
public class MyService {

    @Resource
    private PlatformTransactionManager transactionManager;

    @Resource
    private MyMapper myMapper;

    public void myTransactionalMethod() {
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            // 业务逻辑代码
            myMapper.insertSomething(...);

            transactionManager.commit(status); // 提交事务
        } catch (Exception ex) {
            transactionManager.rollback(status); // 回滚事务
            throw ex; // 重新抛出异常
        }
    }
}

着重解释一下下面这行代码:

TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
  • 这行代码通过 transactionManager.getTransaction 方法获取一个新的事务状态。DefaultTransactionDefinition 用于定义事务的默认属性,例如传播行为和隔离级别。该方法会返回一个TransactionStatus对象,用于管理事务的状态。

第二种方式

TransactionTemplate是一个简化了事务管理的工具类,可以避免直接处理 TransactionStatus

@Service
public class MyService {

    @Resource
    private TransactionTemplate transactionTemplate;

    @Resource
    private MyMapper myMapper;

    public void myTransactionalMethod() {
        transactionTemplate.execute(status -> {
            try {
                // 业务逻辑代码
                myMapper.insertSomething(...);

            } catch (Exception ex) {
                status.setRollbackOnly(); // 标记事务为回滚
                throw ex; // 重新抛出异常
            }
            return null;
        });
    }
}

为项目整上编程式事务

了解如何使用后,我们为用户注册方法添加上编程式事务,代码如下:

package com.quanxiaoha.xiaohashu.auth.service.impl;

import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.quanxiaoha.framework.common.enums.DeletedEnum;
import com.quanxiaoha.framework.common.enums.StatusEnum;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.enums.LoginTypeEnum;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;

@Service
@Slf4j
public class UserServiceImpl implements UserService {

	// 省略...
	
    @Resource
    private TransactionTemplate transactionTemplate;

	// 省略...

    /**
     * 系统自动注册用户
     * @param phone
     * @return
     */
    private Long registerUser(String phone) {
        return transactionTemplate.execute(status -> {
            try {
                // 获取全局自增的小哈书 ID
                Long xiaohashuId = redisTemplate.opsForValue().increment(RedisKeyConstants.XIAOHASHU_ID_GENERATOR_KEY);

                UserDO userDO = UserDO.builder()
                        .phone(phone)
                        .xiaohashuId(String.valueOf(xiaohashuId)) // 自动生成小红书号 ID
                        .nickname("小红薯" + xiaohashuId) // 自动生成昵称, 如小红薯10000
                        .status(StatusEnum.ENABLE.getValue()) // 状态为启用
                        .createTime(LocalDateTime.now())
                        .updateTime(LocalDateTime.now())
                        .isDeleted(DeletedEnum.NO.getValue()) // 逻辑删除
                        .build();

                // 添加入库
                userDOMapper.insert(userDO);
				
				int i = 1 / 0;

                // 获取刚刚添加入库的用户 ID
                Long userId = userDO.getId();

                // 给该用户分配一个默认角色
                UserRoleDO userRoleDO = UserRoleDO.builder()
                        .userId(userId)
                        .roleId(RoleConstants.COMMON_USER_ROLE_ID)
                        .createTime(LocalDateTime.now())
                        .updateTime(LocalDateTime.now())
                        .isDeleted(DeletedEnum.NO.getValue())
                        .build();
                userRoleDOMapper.insert(userRoleDO);

                // 将该用户的角色 ID 存入 Redis 中
                List<Long> roles = Lists.newArrayList();
                roles.add(RoleConstants.COMMON_USER_ROLE_ID);
                String userRolesKey = RedisKeyConstants.buildUserRoleKey(phone);
                redisTemplate.opsForValue().set(userRolesKey, JsonUtils.toJsonString(roles));

                return userId;
            } catch (Exception e) {
                status.setRollbackOnly(); // 标记事务为回滚
                log.error("==> 系统注册用户异常: ", e);
                return null;
            }
        });
    }

}

自测一波

编写完毕后,重启项目,再次测试登录接口,看看这次分母不能为零运行时错误发生时,事务控制是否生效。不出意外,这次就没问题了,我就不截图了,小伙伴们可以自己测试一波。测试完毕后,记得将 int i = 1 / 0; 这行代码删除掉哟~

本小节源码下载

https://t.zsxq.com/JCs50