Redis 學習筆記 3:黑馬點評
準備工作
需要先導入項目相關資源:
- 數據庫文件 hmdp.sql
- 后端代碼 hm-dianping.zip
- 包括前端代碼的 Nginx
啟動后端代碼和 Nginx。
短信登錄
發送驗證碼
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {// 發送短信驗證碼并保存驗證碼return userService.sendCode(phone, session);
}
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("不是合法的手機號!");}String code = RandomUtil.randomNumbers(6);session.setAttribute("code", code);// 發送短信log.debug("發送短信驗證碼:{}", code);return Result.ok();}
}
登錄
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {// 實現登錄功能return userService.login(loginForm, session);
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {// 驗證手機號和驗證碼if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {return Result.fail("手機號不合法!");}String code = (String) session.getAttribute("code");if (code == null || !code.equals(loginForm.getCode())) {return Result.fail("驗證碼不正確!");}// 檢查用戶是否存在QueryWrapper<User> qw = new QueryWrapper<>();qw.eq("phone", loginForm.getPhone());User user = this.baseMapper.selectOne(qw);if (user == null) {user = this.createUserByPhone(loginForm.getPhone());}// 將用戶信息保存到 sessionsession.setAttribute("user", user);return Result.ok();
}private User createUserByPhone(String phone) {User user = new User();user.setPhone(phone);user.setNickName("user_" + RandomUtil.randomString(5));this.baseMapper.insert(user);return user;
}
統一身份校驗
定義攔截器:
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 從 session 獲取用戶信息HttpSession session = request.getSession();User user = (User) session.getAttribute("user");if (user == null) {response.setStatus(401);return false;}// 將用戶信息保存到 ThreadLocalUserDTO userDTO = new UserDTO();userDTO.setIcon(user.getIcon());userDTO.setId(user.getId());userDTO.setNickName(user.getNickName());UserHolder.saveUser(userDTO);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
添加攔截器:
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login");}
}
使用 Redis 存儲驗證碼和用戶信息
用 Session 存儲驗證碼和用戶信息的系統,無法進行橫向擴展,因為多臺 Tomcat 無法共享 Session。如果改用 Redis 存儲就可以解決這個問題。
修改后的 UserService:
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();@Overridepublic Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("不是合法的手機號!");}String code = RandomUtil.randomNumbers(6);stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL);// 發送短信log.debug("發送短信驗證碼:{}", code);return Result.ok();}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 驗證手機號和驗證碼if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {return Result.fail("手機號不合法!");}String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());if (code == null || !code.equals(loginForm.getCode())) {return Result.fail("驗證碼不正確!");}// 檢查用戶是否存在QueryWrapper<User> qw = new QueryWrapper<>();qw.eq("phone", loginForm.getPhone());User user = this.baseMapper.selectOne(qw);if (user == null) {user = this.createUserByPhone(loginForm.getPhone());}// 將用戶信息保存到 sessionString token = UUID.randomUUID().toString(true);UserDTO userDTO = new UserDTO();BeanUtils.copyProperties(user, userDTO);try {stringRedisTemplate.opsForValue().set(LOGIN_USER_KEY + token,OBJECT_MAPPER.writeValueAsString(userDTO), LOGIN_USER_TTL);} catch (JsonProcessingException e) {e.printStackTrace();throw new RuntimeException(e);}return Result.ok(token);}private User createUserByPhone(String phone) {User user = new User();user.setPhone(phone);user.setNickName("user_" + RandomUtil.randomString(5));this.baseMapper.insert(user);return user;}
}
修改后的登錄校驗攔截器:
public class LoginInterceptor implements HandlerInterceptor {private final StringRedisTemplate stringRedisTemplate;private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 從頭信息獲取 tokenString token = request.getHeader("Authorization");if (ObjectUtils.isEmpty(token)) {// 缺少 tokenresponse.setStatus(401);return false;}// 從 Redis 獲取用戶信息String jsonUser = this.stringRedisTemplate.opsForValue().get(LOGIN_USER_KEY + token);UserDTO userDTO = OBJECT_MAPPER.readValue(jsonUser, UserDTO.class);if (userDTO == null) {response.setStatus(401);return false;}// 將用戶信息保存到 ThreadLocalUserHolder.saveUser(userDTO);// 刷新 token 有效期stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
還需要添加一個更新用戶信息有效期的攔截器:
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 如果請求頭中有 token,且 redis 中有 token 相關的用戶信息,刷新其有效期String token = request.getHeader("Authorization");if (ObjectUtils.isEmpty(token)) {return true;}if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(LOGIN_USER_KEY + token))) {stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);}return true;}
}
添加這個新的攔截器,并且確保其位于登錄驗證攔截器之前:
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login");}
}
商戶查詢
緩存
對商戶類型查詢使用 Redis 緩存以提高查詢效率:
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryTypeList() {String jsonTypeList = stringRedisTemplate.opsForValue().get(CACHE_TYPE_LIST_KEY);if (!StringUtils.isEmpty(jsonTypeList)) {List<ShopType> typeList = JSONUtil.toList(jsonTypeList, ShopType.class);return Result.ok(typeList);}List<ShopType> typeList = this.query().orderByAsc("sort").list();if (!typeList.isEmpty()){stringRedisTemplate.opsForValue().set(CACHE_TYPE_LIST_KEY, JSONUtil.toJsonStr(typeList), CACHE_TYPE_LIST_TTL);}return Result.ok(typeList);}
}
對商戶詳情使用緩存:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryById(Long id) {// 先從 Redis 中查詢String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// Redis 中沒有,從數據庫查Shop shop = this.getById(id);if (shop != null) {jsonShop = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);}return Result.ok(shop);}
}
緩存更新策略
在編輯商戶信息時,將對應的緩存刪除:
@Override
public Result update(Shop shop) {if (shop.getId() == null) {return Result.fail("商戶id不能為空");}// 更新商戶信息this.updateById(shop);// 刪除緩存stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());return Result.ok();
}
緩存穿透
緩存穿透指如果請求的數據在緩存和數據庫中都不存在,就不會生成緩存數據,每次請求都不會使用緩存,會對數據庫造成壓力。
可以通過緩存空對象的方式解決緩存穿透問題。
在查詢商鋪信息時緩存空對象:
@Override
public Result queryById(Long id) {// 先從 Redis 中查詢String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// Redis 中沒有,從數據庫查Shop shop = this.getById(id);if (shop != null) {jsonShop = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);return Result.ok(shop);} else {// 緩存空對象到緩存中stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);return Result.fail("店鋪不存在");}
}
在這里,緩存中的空對象用空字符串代替,并且將緩存存活時間設置為一個較短的值(比如說2分鐘)。
在從緩存中查詢到空對象時,返回商鋪不存在:
@Override
public Result queryById(Long id) {// 先從 Redis 中查詢String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// 如果從緩存中查詢到空對象,表示商鋪不存在if ("".equals(jsonShop)) {return Result.fail("商鋪不存在");}// ...
}
緩存擊穿
緩存擊穿問題也叫熱點Key問題,就是一個被高并發訪問并且緩存重建業務較復雜的key突然失效了,無數的請求訪問會在瞬間給數據庫帶來巨大的沖擊。
常見的解決方案有兩種:
- 互斥鎖
- 邏輯過期
可以利用 Redis 做互斥鎖來解決緩存擊穿問題:
@Override
public Result queryById(Long id) {// return queryWithCachePenetration(id);return queryWithCacheBreakdown(id);
}/*** 用 Redis 創建互斥鎖** @param name 鎖名稱* @return 成功/失敗*/
private boolean lock(String name) {Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(name, "1", Duration.ofSeconds(10));return BooleanUtil.isTrue(result);
}/*** 刪除 Redis 互斥鎖** @param name 鎖名稱*/
private void unlock(String name) {stringRedisTemplate.delete(name);
}/*** 查詢店鋪信息-緩存擊穿** @param id* @return*/
private Result queryWithCacheBreakdown(Long id) {// 先查詢是否存在緩存String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// 如果從緩存中查詢到空對象,表示商鋪不存在if ("".equals(jsonShop)) {return Result.fail("商鋪不存在");}// 緩存不存在,嘗試獲取鎖,并創建緩存final String lockName = "lock:shop:" + id;try {if (!lock(lockName)){// 獲取互斥鎖失敗,休眠一段時間后重試Thread.sleep(50);return queryWithCacheBreakdown(id);}// 獲取互斥鎖成功,創建緩存// 模擬長時間才能創建緩存Thread.sleep(100);Shop shop = this.getById(id);if (shop != null) {jsonShop = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);return Result.ok(shop);} else {// 緩存空對象到緩存中stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);return Result.fail("店鋪不存在");}} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 釋放鎖unlock(lockName);}
}
下面是用邏輯過期解決緩存擊穿問題的方式。
首先需要將熱點數據的緩存提前寫入 Redis(緩存預熱):
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {/*** 創建店鋪緩存** @param id 店鋪id* @param duration 緩存有效時長*/public void saveShopCache(Long id, Duration duration) {Shop shop = getById(id);RedisCache<Shop> redisCache = new RedisCache<>();redisCache.setExpire(LocalDateTime.now().plus(duration));redisCache.setData(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisCache));}// ...
}
@SpringBootTest
class HmDianPingApplicationTests {@Autowiredprivate ShopServiceImpl shopService;@Testpublic void testSaveShopCache(){shopService.saveShopCache(1L, Duration.ofSeconds(1));}}
@Data
public class RedisCache<T> {private LocalDateTime expire; //邏輯過期時間private T data; // 數據
}
Redis 中的緩存信息包含兩部分:過期時間和具體信息。大致如下:
{"data": {"area": "大關","openHours": "10:00-22:00","sold": 4215,// ...},"expire": 1708258021725
}
且其 TTL 是-1
,也就是永不過期。
具體的緩存讀取和重建邏輯:
/*** 用邏輯過期解決緩存擊穿問題** @return*/
private Result queryWithLogicalExpiration(Long id) {//檢查緩存是否存在String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StringUtils.isEmpty(jsonShop)) {// 緩存不存在return Result.fail("店鋪不存在");}// 緩存存在,檢查是否過期RedisCache<Shop> redisCache = JSONUtil.toBean(jsonShop, new TypeReference<RedisCache<Shop>>() {}, true);if (redisCache.getExpire().isBefore(LocalDateTime.now())) {// 如果過期,嘗試獲取互斥鎖final String LOCK_NAME = LOCK_SHOP_KEY + id;if (lock(LOCK_NAME)) {// 獲取互斥鎖后,單獨啟動線程更新緩存CACHE_UPDATE_ES.execute(() -> {try {// 模擬緩存重建的延遲Thread.sleep(200);saveShopCache(id, Duration.ofSeconds(1));} catch (Exception e) {throw new RuntimeException(e);} finally {unlock(LOCK_NAME);}});}}// 無論是否過期,返回緩存對象中的信息return Result.ok(redisCache.getData());
}
封裝 Redis 緩存工具類
可以對對 Redis 緩存相關邏輯進行封裝,可以避免在業務代碼中重復編寫相關邏輯。封裝后分別對應以下方法:
- 設置緩存數據(TTL)
- 設置緩存數據(邏輯過期時間)
- 從緩存獲取數據(用空對象解決緩存穿透問題)
- 從緩存獲取數據(用互斥鎖解決緩存擊穿問題)
- 從緩存獲取數據(用邏輯過期解決緩存擊穿問題)
工具類的完整代碼可以參考這里。
本文的完整示例代碼可以從這里獲取。
參考資料
- 黑馬程序員Redis入門到實戰教程