黑马点评

yql

黑马点评是一个大量使用Redis的项目,黑马点评要完成的功能如下:

对应的表有:

短信登录

  1. 基于Session实现登录
发送验证码功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//com/hmdp/service/impl/UserServiceImpl.java
public Result sendCode(String phone, HttpSession httpsession){
//1.校验手机号是否合法
if(RegexUtils.isPhoneInvalid(phone)){
//2.若不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3. 若符合, 生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code",code);
//5.发送验证码 (要调用第三方,这里不做)
log.debug("发送短信验证码:{}",code);
return Result.ok();
}
短信登录、注册功能实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//com/hmdp/service/impl/UserServiceImpl.java

@Override
public Result login(LoginFormDTO loginForm,HttpSession session){
String phone = loginForm.getPhone();
String code = loginForm.getCode();
//校验手机号
if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误");
//校验验证码
Object cacheCode = session.getAttribute("code");
if(cacheCode == null || !code.equals(cacheCode.toString())){
return Result.fail("验证码错误");
}
//查数据库
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(StringUtils.isNotBlank(phone),User::getPhone,phone);
User user = userMapper.selectOne(queryWrapper); //判断用户是否存在,不存在则创建一个
if(user == null){
user=createUserWithPhone(phone);
}
//脱敏,剔除user中的敏感信息,保存一个UserDTO到session中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}

//创造新用户
private User createUserWithPhone(Stringphone){
User user = new User();
//USER_NICK_NAME_PREFIX = "user_";
user.setPhone(phone).setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(6));
//保存用户
userMapper.insert(user);
return user;
}

如何做到数据脱敏?

我们需要保存用户的有关信息,但是不能保存所有的信息,于是创建一个UserDTO对象,将一些不涉及敏感信息的字段放在里面

1
2
3
4
5
6
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
  1. 基于Redis实现共享session登录
发送验证码

Redis以String数据类型存储(key-手机号,验证码),其中key前缀为业务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//坐标:com/hmdp/service/impl/UserServiceImpl.java

@Resource
privateStringRedisTemplatestringRedisTemplate;

@Override
publicResultsendCode(Stringphone,HttpSessionsession){
//1.校验手机号是否合法
if(RegexUtils.isPhoneInvalid(phone)){
//2.若不符合,返回错误信息
return Result.fail("手机号格式错误"); }
//3.若符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到redis key-手机号 value-验证码 并设置过期时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL,
TimeUnit.MINUTES);
//5.发送验证码 (要调用第三方,这里不做)
log.debug("发送短信验证码:{}",code); return Result.ok();
}
1
2
3
4
publicclassRedisConstants{
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
}
短信验证码登录、注册
  • 坑:把User对象转换为map后,以hash的形式存储到Redis中会出现类型转换异常

  • 原因:使用的是StringRedis Template,只支持String类型的key-value。而user对象的id是long类型。因此需要自定义map映射规则

  • 细节:存储对象用hash的形式可以减少内存损耗,而且对单个字段的修改也很灵活

    存储对象使用 hash 类型可以减少内存损耗的主要原因是 Redis 内部的 内存优化机制。具体来说,它通过 数据压缩共享存储结构 在某些条件下显著减少了内存开销。

特性 hash 数据类型 string 数据类
灵活性 适合部分字段操作 适合整体操作
内存效率 字段较少时节省内存 字段较多时性能更佳
复杂度 操作粒度更细 需要序列化和反序列化
可读性 内部数据是字段和值的形式 需要解析后才可读
适用场景 小型对象,频繁字段操作 大型对象,频繁整体操作

Redis以Hash的数据类型存储对象,其中键为UUID生成的随机token,值为user对象转为的hashmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
String code = loginForm.getCode();
//检验手机号是否正确,不同的请求就应该再次去进行确认
if(RegexUtils.isPhoneInvalid(phone))
{
//如果无效,则直接返回
return Result.fail("手机格式不正确!!");
}
//从redis中读取验证码,并进行校验
String Cachecode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);

//不符合格式则报错
if (Cachecode==null || !code.equals(Cachecode))
{
return Result.fail("无效的验证码");
}
//如果上述都没有问题的话,就从数据库中查询该用户的信息

//select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();

//判断用户是否存在
if (user==null)
{
user = createuser(phone);
}
//保存用户信息到Redis中
String token = UUID.randomUUID().toString();

//7.2 将UserDto对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
HashMap<String, String > userMap = new HashMap<>();
userMap.put("id", String.valueOf(userDTO.getId()));
userMap.put("nickName", userDTO.getNickName());
userMap.put("icon", userDTO.getIcon());

//7.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);

//7.4 设置token有效期为30分钟
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

//7.5 登陆成功则删除验证码信息
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);

//8. 返回token
return Result.ok(token);
}
校验登录状态

坑1:由于LoginInterceptor没有交给Spring进行管理,因此StringRedisTemplate不能通过@Resource自动注

入。需要在配置文件中进行构造器注入。

坑2: 只有一个登陆拦截器A,该拦截器上进行获取token、查询Redis用户、刷新token有效期的操作。而该拦截 器只需要登陆路径进行拦截。此时就有问题了。当用户访问不需要登陆的路径时,就不会刷新token有效期,30分 钟后,token会自动过期。

优化:在这个拦截器A前再加一个拦截器B,用于拦截一切路径,把获取token、查询Redis用户、刷新token有效期 的操作放到这个拦截器B上做。而拦截需要登陆的路径的拦截器A只需要判断ThreadLocal中有没有用户即可。

  • 拦截一切拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//坐标:com/hmdp/interceptor/LoginInterceptor.java

public class RefreshTokenInterceptor implements HandlerInterceptor {
//这里并不是自动装配,因为RefreshTokenInterceptor是我们手动在WebConfig里new出来的
private StringRedisTemplate stringRedisTemplate;

public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求头中的token
String token = request.getHeader("authorization");
//2. 如果token是空,直接放行,交给LoginInterceptor处理
if (StrUtil.isBlank(token)) {
return true;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
//3. 基于token获取Redis中的用户数据
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//4. 判断用户是否存在,不存在,也放行,交给LoginInterceptor
if (userMap.isEmpty()) {
return true;
}
//5. 将查询到的Hash数据转化为UserDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6. 将用户信息保存到ThreadLocal
UserHolder.saveUser(userDTO);
//7. 刷新tokenTTL,这里的存活时间根据需要自己设置,这里的常量值我改为了30分钟
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除threadlocal用户,避免内存泄漏
UserHolder.removeUser();
}
}
  • 登录拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截
if (UserHolder.getUser()==null)
{
response.setStatus(401);
return false;
}
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除threadlocal用户,避免内存泄漏
UserHolder.removeUser();
}
}
  • 配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);

registry.addInterceptor(new LoginInterceptor()).
excludePathPatterns("/user/login",
"/user/code",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**").order(1);
}
}

为什么我的拦截器能够生效?

MvcConfig实现了WebMvcConfigurer类,并通过 addInterceptors 方法注册了拦截器,再通过@Configuration注解标明为配置类,交给Spring容器管理,Spring 会识别并调用其中的配置方法,并定制Web MVC的行为。

商户查询缓存

店铺查询缓存

缓存穿透: 非法的数据redis中没有,数据库中也没有,导致一直绕过redis来查询数据库。

Redis 以String类型存储的店铺对象, 根据商铺id查询到店铺之后,使用序列化的方式,将对象转成Json字符串的格式,并且设置ttl时间为2分钟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//坐标: com/hmdp/service/impl/ShopServiceImpl.java
public Shop querywithchuantou(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}

//如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。
// 当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息

//如果查询到的是空字符串,则说明是我们缓存的空数据
if (shopJson!=null) {
return null;
}

//否则去数据库中查
Shop shop = getById(id);

//查不到,则将空字符串写入Redis
if (shop == null) {
//这里的常量值是2分钟
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, null, CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}

//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,并设置TTL,防止存了错的缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return shop;
}
店铺查询缓存与数据库一致性的实现

根据id修改店铺信息时,先修改数据库,再删除缓存。注意:在方法前面需要加上事务注解,以保证原子性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//坐标: com/hmdp/service/impl/ShopServiceImpl.java

@Override
@Transactional
public Result update(Shop shop) {
// 首先先判一下空
if (shop.getId() == null){
return Result.fail("店铺id不能为空!!");
}
//先修改数据库
updateById(shop);
//再删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
return Result.ok();
}
解决店铺查询的缓存击穿问题

出现缓存击穿的原因是热点key过期了。因此我们可以不设置热点key的过期时间,让热点key永不过期。但为了更 新热点key,还需要给key一个过期时间,这里采用逻辑过期的方式来解决这个问题。也就是把过期时间以value的 方式存到key中。让key的过期判断交给程序员去做,而不是Redis。

如何给数据添加过期时间的字段?

  • 新建一个RedisData类,这个类有过期时间字段,同时有另外一个Object类字段,可以存放数据,相当于给数据又加了一层封装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public Shop queryWithLogicalExpire(Long id) {
//1. 从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2. 如果未命中,则返回空
if (StrUtil.isBlank(json)) {
return null;
}
//3. 命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//3.1 将data转为Shop对象
JSONObject shopJson = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
//3.2 获取过期时间
LocalDateTime expireTime = redisData.getExpireTime();
//4. 判断是否过期
if (LocalDateTime.now().isBefore(expireTime)) {
//5. 未过期,直接返回商铺信息
return shop;
}
//6. 过期,尝试获取互斥锁
boolean flag = tryLock(LOCK_SHOP_KEY + id);
//7. 获取到了锁
if (flag) {
//8. 开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);//此处的expirSeconds应该为物品的活动时间,设置为20只为测试
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(LOCK_SHOP_KEY + id);
}
});
//9. 直接返回商铺信息
return shop;
}
//10. 未获取到锁,直接返回商铺信息
return shop;
}
优惠券秒杀下单、一人一单
Redisson可重入锁

基于setnx实现的分布式锁有以下几个问题:

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但是业务执行耗时较长,也会导致锁释放,存在安全隐患

Reddison:

  • 可重入:替代setnx使用的string数据类型,使用hash类型,记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒、获取锁失败的重试机制
  • 超时续约:获取锁成功后,开启一个定时器,每隔一段时间,重置超时时间
Comments
On this page
黑马点评