Redis 学习笔记 3:黑马点评
准备工作
需要先导入项目相关资源:
- 数据库文件 hmdp.sql
- 后端代码 hm-dianping.zip
- 包括前端代码的 Nginx
启动后端代码和 Nginx。
短信登录
发送验证码
java
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
java
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public 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();
}
}
登录
java
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// 实现登录功能
return userService.login(loginForm, session);
}
java
@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());
}
// 将用户信息保存到 session
session.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;
}
统一身份校验
定义拦截器:
java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public 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;
}
// 将用户信息保存到 ThreadLocal
UserDTO userDTO = new UserDTO();
userDTO.setIcon(user.getIcon());
userDTO.setId(user.getId());
userDTO.setNickName(user.getNickName());
UserHolder.saveUser(userDTO);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
添加拦截器:
java
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
@Override
public 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:
java
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override
public 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();
}
@Override
public 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());
}
// 将用户信息保存到 session
String 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;
}
}
修改后的登录校验拦截器:
java
public class LoginInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从头信息获取 token
String token = request.getHeader("Authorization");
if (ObjectUtils.isEmpty(token)) {
// 缺少 token
response.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;
}
// 将用户信息保存到 ThreadLocal
UserHolder.saveUser(userDTO);
// 刷新 token 有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
还需要添加一个更新用户信息有效期的拦截器:
java
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public 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;
}
}
添加这个新的拦截器,并且确保其位于登录验证拦截器之前:
java
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public 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 缓存以提高查询效率:
java
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public 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);
}
}
对商户详情使用缓存:
java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Autowired
private StringRedisTemplate stringRedisTemplate;
@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);
}
}
缓存更新策略
在编辑商户信息时,将对应的缓存删除:
java
@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();
}
缓存穿透
缓存穿透指如果请求的数据在缓存和数据库中都不存在,就不会生成缓存数据,每次请求都不会使用缓存,会对数据库造成压力。
可以通过缓存空对象的方式解决缓存穿透问题。
在查询商铺信息时缓存空对象:
java
@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分钟)。
在从缓存中查询到空对象时,返回商铺不存在:
java
@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 做互斥锁来解决缓存击穿问题:
java
@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(缓存预热):
java
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));
}
// ...
}
java
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl shopService;
@Test
public void testSaveShopCache(){
shopService.saveShopCache(1L, Duration.ofSeconds(1));
}
}
java
@Data
public class RedisCache<T> {
private LocalDateTime expire; //逻辑过期时间
private T data; // 数据
}
Redis 中的缓存信息包含两部分:过期时间和具体信息。大致如下:
json
{
"data": {
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
// ...
},
"expire": 1708258021725
}
且其 TTL 是-1
,也就是永不过期。
具体的缓存读取和重建逻辑:
java
/**
* 用逻辑过期解决缓存击穿问题
*
* @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)
- 设置缓存数据(逻辑过期时间)
- 从缓存获取数据(用空对象解决缓存穿透问题)
- 从缓存获取数据(用互斥锁解决缓存击穿问题)
- 从缓存获取数据(用逻辑过期解决缓存击穿问题)
工具类的完整代码可以参考这里。
本文的完整示例代码可以从这里获取。