评论

收藏

[NoSQL] 淘东电商项目(21) -Redis如何与数据库状态保持一致?

数据库 数据库 发布于:2021-07-08 10:47 | 阅读数:428 | 评论:0

  
引言
  在上一节《淘东电商项目(20) -会员唯一登录》,主要讲解会员如何实现三端唯一登录。
  本文代码已提交至Github(版本号:31112e64e8bc832a1416c2fcfd064b5e45b45f32),有兴趣的同学可以下载来看看:https://github.com/ylw-github/taodong-shop
  本文讲解会员服务中数据库状态与Redis服务状态如何保持一致性。
  本文目录结构:
l____引言
l____ 1. 问题引出
l____ 2. 解决思路
l____ 3. 代码实现
l____ 4. 测试
l____ 5. 第三方框架推荐
l____总结

1. 问题引出
  下面先来贴一下登录接口的代码:
@Override
public BaseResponse<JSONObject> login(@RequestBody UserLoginInDTO userLoginInpDTO) {
// 1.验证参数
String mobile = userLoginInpDTO.getMobile();
if (StringUtils.isEmpty(mobile)) {
return setResultError("手机号码不能为空!");
}
String password = userLoginInpDTO.getPassword();
if (StringUtils.isEmpty(password)) {
return setResultError("密码不能为空!");
}
// 判断登陆类型
String loginType = userLoginInpDTO.getLoginType();
if (StringUtils.isEmpty(loginType)) {
return setResultError("登陆类型不能为空!");
}
// 目的是限制范围
if (!(loginType.equals(Constants.MEMBER_LOGIN_TYPE_ANDROID) || loginType.equals(Constants.MEMBER_LOGIN_TYPE_IOS)
|| loginType.equals(Constants.MEMBER_LOGIN_TYPE_PC))) {
return setResultError("登陆类型出现错误!");
}
// 设备信息
String deviceInfor = userLoginInpDTO.getDeviceInfor();
if (StringUtils.isEmpty(deviceInfor)) {
return setResultError("设备信息不能为空!");
}
// 2.对登陆密码实现加密
String newPassWord = MD5Util.MD5(password);
// 3.使用手机号码+密码查询数据库 ,判断用户是否存在
UserDo userDo = userMapper.login(mobile, newPassWord);
if (userDo == null) {
return setResultError("用户名称或者密码错误!");
}
// 用户登陆Token Session 区别
// 用户每一个端登陆成功之后,会对应生成一个token令牌(临时且唯一)存放在redis中作为rediskey value userid
// 4.获取userid
Long userId = userDo.getUserId();
// 5.根据userId+loginType 查询当前登陆类型账号之前是否有登陆过,如果登陆过 清除之前redistoken
UserTokenDo userTokenDo = userTokenMapper.selectByUserIdAndLoginType(userId, loginType);
if (userTokenDo != null) {
// 如果登陆过 清除之前redistoken
String token = userTokenDo.getToken();
Boolean isremoveToken = generateToken.removeToken(token);
if (isremoveToken) {
 // 把该token的状态改为1
 userTokenMapper.updateTokenAvailability(token);
}
}
// .生成对应用户令牌存放在redis中
String keyPrefix = Constants.MEMBER_TOKEN_KEYPREFIX + loginType;
String newToken = generateToken.createToken(keyPrefix, userId + "");
// 1.插入新的token
UserTokenDo userToken = new UserTokenDo();
userToken.setUserId(userId);
userToken.setLoginType(userLoginInpDTO.getLoginType());
userToken.setToken(newToken);
userToken.setDeviceInfor(deviceInfor);
userTokenMapper.insertUserToken(userToken);
JSONObject data = new JSONObject();
data.put("token", newToken);
return setResultSuccess(data);
}
  我们可以看到代码流程图是这样的:
DSC0000.png
可以注意到流程图里,Redis和数据库的操作是同步的,那如果插入Token到Redis成功了,但是插入Token到数据库的时候失败了,如何解决呢?
  这就是本文主要讲的内容了,Redis如何与数据库状态保持一致?

2. 解决思路
  可以看到上面出现的问题,很容易让我们联想起“「事务」”,事务可以保持ACID,我们知道数据库是有事务的,Redis也有事务?那能否把这两者同时使用呢?比如如下场景:
  

  • 如果redis更新操作失败时,数据库更新操作也要失败
  • 如果数据库更新操作失败时,Redis更新操作也要失败
  其实解决方案已经显露出来了,我们可以重写数据库的事务和Redis事务,把两者合成一种新的事务解决方案,满足:

  • 数据库事务开启的同时,Redis事务也要开启(begin)
  • 数据库事务提交的同时,Redis事务也要提交(commit)
  • 数据库事务回滚的同时,Redis事务也要回滚(rollback)

3. 代码实现
  1.先贴上数据库事务与Redis事务的合成工具类:
/**
 * description: Redis与 DataSource 事务封装
 * create by: YangLinWei
 * create time: 2020/3/4 3:34 下午
 */
@Component
@Scope(ConfigurableListableBeanFactory.SCOPE_PROTOTYPE)
public class RedisDataSoureceTransaction {
@Autowired
private RedisUtil redisUtil;
/**
 * 数据源事务管理器
 */
@Autowired
private DataSourceTransactionManager dataSourceTransactionManager;
/**
 * 开始事务 采用默认传播行为
 * 
 * @return
 */
public TransactionStatus begin() {
// 手动begin数据库事务
TransactionStatus transaction = dataSourceTransactionManager.getTransaction(new DefaultTransactionAttribute());
redisUtil.begin();
return transaction;
}
/**
 * 提交事务
 * 
 * @param transactionStatus
 *      事务传播行为
 * @throws Exception
 */
public void commit(TransactionStatus transactionStatus) throws Exception {
if (transactionStatus == null) {
throw new Exception("transactionStatus is null");
}
// 支持Redis与数据库事务同时提交
dataSourceTransactionManager.commit(transactionStatus);
//redisUtil.exec();//会出错,自动提交
}
/**
 * 回滚事务
 * 
 * @param transactionStatus
 * @throws Exception
 */
public void rollback(TransactionStatus transactionStatus) throws Exception {
if (transactionStatus == null) {
throw new Exception("transactionStatus is null");
}
dataSourceTransactionManager.rollback(transactionStatus);
redisUtil.discard();
}
}
  2.重新写登录接口代码,完整代码如下:
/**
 * 手动事务工具类
 */
@Autowired
private RedisDataSoureceTransaction manualTransaction;
@Override
public BaseResponse<JSONObject> login(@RequestBody UserLoginInDTO userLoginInpDTO) {
// 1.验证参数
String mobile = userLoginInpDTO.getMobile();
if (StringUtils.isEmpty(mobile)) {
return setResultError("手机号码不能为空!");
}
String password = userLoginInpDTO.getPassword();
if (StringUtils.isEmpty(password)) {
return setResultError("密码不能为空!");
}
// 判断登陆类型
String loginType = userLoginInpDTO.getLoginType();
if (StringUtils.isEmpty(loginType)) {
return setResultError("登陆类型不能为空!");
}
// 目的是限制范围
if (!(loginType.equals(Constants.MEMBER_LOGIN_TYPE_ANDROID) || loginType.equals(Constants.MEMBER_LOGIN_TYPE_IOS)
|| loginType.equals(Constants.MEMBER_LOGIN_TYPE_PC))) {
return setResultError("登陆类型出现错误!");
}
// 设备信息
String deviceInfor = userLoginInpDTO.getDeviceInfor();
if (StringUtils.isEmpty(deviceInfor)) {
return setResultError("设备信息不能为空!");
}
// 2.对登陆密码实现加密
String newPassWord = MD5Util.MD5(password);
// 3.使用手机号码+密码查询数据库 ,判断用户是否存在
UserDo userDo = userMapper.login(mobile, newPassWord);
if (userDo == null) {
return setResultError("用户名称或者密码错误!");
}
TransactionStatus transactionStatus = null;
try {
// 1.获取用户UserId
Long userId = userDo.getUserId();
// 2.生成用户令牌Key
String keyPrefix = Constants.MEMBER_TOKEN_KEYPREFIX + loginType;
// 5.根据userId+loginType 查询当前登陆类型账号之前是否有登陆过,如果登陆过 清除之前redistoken
UserTokenDo userTokenDo = userTokenMapper.selectByUserIdAndLoginType(userId, loginType);
transactionStatus = manualTransaction.begin();
// // ####开启手动事务
if (userTokenDo != null) {
// 如果登陆过 清除之前redistoken
String oriToken = userTokenDo.getToken();
// 移除Token
generateToken.removeToken(oriToken);
int updateTokenAvailability = userTokenMapper.updateTokenAvailability(oriToken);
if (updateTokenAvailability < 0) {
manualTransaction.rollback(transactionStatus);
return setResultError("系统错误");
}
}
// 4.将用户生成的令牌插入到Token记录表中
UserTokenDo userToken = new UserTokenDo();
userToken.setUserId(userId);
userToken.setLoginType(userLoginInpDTO.getLoginType());
String newToken = generateToken.createToken(keyPrefix, userId + "");
userToken.setToken(newToken);
userToken.setDeviceInfor(deviceInfor);
int result = userTokenMapper.insertUserToken(userToken);
if (!toDaoResult(result)) {
manualTransaction.rollback(transactionStatus);
return setResultError("系统错误!");
}
// #######提交事务
JSONObject data = new JSONObject();
data.put("token", newToken);
manualTransaction.commit(transactionStatus);
return setResultSuccess(data);
} catch (Exception e) {
try {
// 回滚事务
manualTransaction.rollback(transactionStatus);
} catch (Exception e1) {
}
return setResultError("系统错误!");
}
}
  3.核心代码:
DB/Redis插入DB/Redis更新 DSC0001.png DSC0002.png 提交抛异常(主要捕获Redis异常) DSC0003.png DSC0004.png
4. 测试
  首先,可以看到数据库和Redis里面都没有内容:
数据库内容Redis内容 DSC0005.png DSC0006.png   启动会员项目后,使用swagger访问登录接口,断点走过redis插入后,可以看到Redis里面没有内容,因为事务还没有提交:
断点位置Redis数据 DSC0007.png DSC0008.png   断点继续走到数据库插入数据,可以看到数据库里面还是没有内容,因为事务也没有提交:
断点位置数据库数据 DSC0009.png DSC00010.png   最后断点走过提交,可以看到,数据库可Redis里面均有内容了:
Redis数据库 DSC00011.png DSC00012.png
总结
  本文主要讲解了通过Redis事务与数据库事务同步的方式,来保持数据状态的一致性。

  
关注下面的标签,发现更多相似文章