天天看點

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

引言

在上一節​​《果然新鮮電商項目(25)- 會員唯一登入》​​​,主要講解會員如何實作三端唯一登入。

本文講解會員服務中資料庫狀态與Redis服務狀态如何保持一緻性。

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);
}      

我們可以看到代碼流程圖是這樣的:

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

可以注意到流程圖裡,Redis和資料庫的操作是同步的,那如果插入​​

​Token​

​​到​

​Redis​

​​成功了,但是插入​

​Token​

​到資料庫的時候失敗了,如何解決呢?

這就是本文主要講的内容了,​

​Redis​

​如何與資料庫狀态保持一緻?

2.解決思路

可以看到上面出現的問題,很容易讓我們聯想起“「事務」”,事務可以保持ACID,我們知道資料庫是有事務的,Redis也有事務?那能否把這兩者同時使用呢?比如如下場景:

如果redis更新操作失敗時,資料庫更新操作也要失敗

如果資料庫更新操作失敗時,Redis更新操作也要失敗

其實解決方案已經顯露出來了,我們可以重寫資料庫的事務和Redis事務,把兩者合成一種新的事務解決方案,滿足:

  1. 資料庫事務開啟的同時,Redis事務也要開啟(​

    ​begin​

    ​)
  2. 資料庫事務送出的同時,Redis事務也要送出(​

    ​commit​

    ​)
  3. 資料庫事務復原的同時,Redis事務也要復原(​

    ​rollback​

    ​)

3.代碼實作

  1. 先貼上資料庫事務與​

    ​Redis​

    ​​事務的合成工具類:
    《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?
<!-- mybatis相關依賴 -->
 <dependency>
     <groupId>org.mybatis.spring.boot</groupId>
     <artifactId>mybatis-spring-boot-starter</artifactId>
     <version>1.1.1</version>
 </dependency>

 <!-- mysql 依賴 -->
 <dependency>
     <groupId>mysql</groupId>
     <artifactId>mysql-connector-java</artifactId>
     <version>8.0.25</version>
 </dependency>      
package com.guoranxinxian.util;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

@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();
    }

}      
  1. 重新寫登入接口代碼,完整代碼如下:
package com.guoranxinxian.impl;

import com.alibaba.fastjson.JSONObject;
import com.guoranxinxian.api.BaseResponse;
import com.guoranxinxian.constants.Constants;
import com.guoranxinxian.entity.BaseApiService;
import com.guoranxinxian.entity.UserDo;
import com.guoranxinxian.entity.UserTokenDo;
import com.guoranxinxian.mapper.UserMapper;
import com.guoranxinxian.mapper.UserTokenMapper;
import com.guoranxinxian.member.dto.input.UserLoginInDTO;
import com.guoranxinxian.service.MemberLoginService;
import com.guoranxinxian.util.GenerateToken;
import com.guoranxinxian.util.MD5Util;
import com.guoranxinxian.util.RedisDataSoureceTransaction;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.TransactionStatus;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@Slf4j
public class MemberLoginServiceImpl extends BaseApiService<JSONObject> implements MemberLoginService {

    @Resource
    private UserMapper userMapper;

    @Autowired
    private GenerateToken generateToken;

    @Resource
    private UserTokenMapper userTokenMapper;

    /**
     * 手動事務工具類
     */
    @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("系統錯誤!");
        }

    }
}      

4. 測試

首先,可以看到資料庫和Redis裡面都沒有内容:

資料庫内容

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

Redis内容

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

啟動會員項目後,使用swagger通路登入接口,斷點走過redis插入後,可以看到Redis裡面沒有内容,因為事務還沒有送出:

斷點位置

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

Redis資料

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

斷點繼續走到資料庫插入資料,可以看到資料庫裡面還是沒有内容,因為事務也沒有送出:

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

資料庫資料

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

最後斷點走過送出,可以看到,資料庫可Redis裡面均有内容了:

Redis資料

《果然新鮮》電商項目(26)- Redis如何與資料庫狀态保持一緻?

5.總結