黑马点评项目的学习日志

项目需要实现的功能介绍

项目架构

image-20230218101531022

前端登录 ,使用nginx启动前端项目 ,然后访问8080端口,必须是在后端项目启动的情况下

数据转换

Bean --- > String : Bean.toString()

String ----> Bean : Shop shop = JSONUtil.toBean(shopJson, Shop.class);

Bean ---->hashMap : Map<String, Object> map1 = BeanUtil.beanToMap(userDTO);

hash ---->Bean : UserDTO userDTO1 = BeanUtil.mapToBean(map, UserDTO.class, true);

2023.2.18—–短信登录

基于session实现发送验证码登录

流程图的分析

发送短信验证码

image-20230218111418914

接口:@PostMapping("/user/code")

1
2
3
4
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}

service层实现流程

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
@Override
public Result sendCode(String phone, HttpSession session) {
// TODO 实现发送验证码方法
/**
* 1. 校验手机号是否合格
* 2. 不合格怎么做
//合格..........
* 1. 生成验证码
* 2. 保存验证码到 session
* 3. 发送验证码
*/
//1. 校验手机号是否合格(一般使用正则表达式去校验) 这里我们封装到RefexUtils.isPhoneInvalid是否是无效手机号
boolean isNotNumber = RegexUtils.isPhoneInvalid(phone);
//不是手机号
if(isNotNumber){
return Result.fail("手机号格式错误 !!!");
}
//合格,是手机号
//1. 生成验证码
String code = RandomUtil.randomNumbers(6);
session.setAttribute("code",code);
log.debug("发送短信验证码成功 !"); //需要调用阿里云的测试,暂时不是重点 ,无需实现
//返回ok就行了
return Result.ok();
}

短信验证码登录

image-20230218111427520

  1. 提交验证码和手机号,并且进行判断是否正确
  2. 正确 : 就继续 ,错误 : 返回验证码错误
  3. 调用数据库查询用户是否存在
  4. 存在的话 : 保存用户信息到session,不存在 : 就跳转到注册页面 ,注册并保存到数据库

接口 : @PostMapping("/user/login")

1
2
3
4
5
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return userService.login(loginForm,session);
}

service层实现

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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//1. 提交验证码和手机号,并且进行判断是否正确
String phone = (String) session.getAttribute("phone");
String code = (String) session.getAttribute("code");
//2. **正确 : **就继续 ,**错误** : 返回验证码错误
if(!loginForm.getPhone().equals(phone) || !loginForm.getCode().equals(code)){
return Result.fail("手机号/验证码错误!");
}
//3. 调用数据库查询用户是否存在
User user = query().eq("phone", phone).one();

//4. **存在的话** : 保存用户信息到session,**不存在** : 就跳转到注册页面 ,注册并保存到数据库
if(user == null){
user = createUserWithPhone(phone);
}
//保存
session.setAttribute("user",user);
return Result.ok();
}

private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
//生成随机的用户名
user.setNickName("user_" + RandomUtil.randomString(10));
return user;
}

校验登录状态

在拦截器中是实现校验功能

​ 流程

  1. 获取请求携带的cookie
  2. 获取用户
  3. 判断用户是否存在,存在 : 保存该线程 ,**不存在 :**拦截
首先实现拦截器

在拦截器中我们就可以实现我们需要的登录流程

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
package com.hmdp.config.Handler;

import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.utils.UserHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

/**
* 配置登录校验拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
/**
* 拦截请求
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求携带的cookie
HttpSession session = request.getSession();
//2. 获取用户
Object user = session.getAttribute("user");
//3. 判断用户是否存在,**存在 :** 保存该线程 ,**不存在 :**拦截
if(user == null){
response.setStatus(401);
return false;
}
//4. 保存信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
//放行
return true;
}


/**
* 销毁用户的信息,避免信息的泄露 ,防止内存泄露
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户 ,清除的是线程中的
UserHolder.removeUser();
}
}
在mvc配置类中添加拦截器
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
package com.hmdp.config;

import com.hmdp.config.Handler.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
//排除不需要拦截的路径
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
}
}
登录
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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//1. 提交验证码和手机号,并且进行判断是否正确
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误!");
}
String code = (String) session.getAttribute("code");
//2. **正确 : **就继续 ,**错误** : 返回验证码错误
if(phone == null || !loginForm.getCode().equals(code)){
return Result.fail("手机号/验证码错误!");
}
//3. 调用数据库查询用户是否存在
User user = query().eq("phone", phone).one();

//4. **存在的话** : 保存用户信息到session,**不存在** : 就跳转到注册页面 ,注册并保存到数据库
if(user == null){
user = createUserWithPhone( phone);
}
//保存
/**
* 因为这样会将所有的用户的信息都传过来,这样不利于保护用户隐私 ,所以我们映射到前端的时候不能将全部的信息都返回
* 仅仅返回一些简单的信息即可,所以我们就用到了UserDTO
* 使用Bean.copyProperties就可以将user中的属性的值拷贝一份给userDto
* 然后存储到session的就是我们的UserDTo对象
*/
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
session.setAttribute("user",userDTO);
return Result.ok();
}

对于返回的信息,因为我们登录时会设置密码等 ,一系列隐私属性,如果我们返回前端这些属性的话,那么势必会造成信息泄露。所以为例用户安全着想 我们映射到前端的时候不能将全部的信息都返回 。 仅仅返回一些简单的信息即可,所以我们就用到了UserDTO 。 使用Bean.copyProperties就可以将user中的属性的值拷贝一份给userDto。 然后存储到session的就是我们的UserDTo对象, 这样就避免用户信息在传入前端时出现信息泄露的风险。

获取当前用户并返回
1
2
3
4
5
6
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}

集群的Session共享问题

存储的信息是在单线程中的,所以多台Tomcat并不能共享session的存储空间 ,当请求切换到不同的tomcat服务器导致数据丢失的问题 ——session共享问题

image-20230218145140010

当用户第一次进入系统时,tomcat服务器①接收到请求,然后进行处理用户的请求(登录注册等)。

当用户第二次进入系统时 ,被负载均衡到了tomca服务器② 。用户的信息其实是已经注册了的,但是这里却无法获取。导致用户还得注册…这会造成用户体验感很差,所以我们需要继续处理。

这就是Session共享的问题

解决办法:

 实现session共享。

Redis实现解决Session共享问题

使用redis代替session解决。

基于Redis实现共享session登录

  1. 当我们实现生成验证码时 ,就可以直接将以 【手机号为 key : 验证码为value】 保存到reids中
  2. 输入完验证码,点击登录的时候。进行校验,我们就可以在redis中查询当前手机号保存的value与输入的进行比较。如果比较正确就下一步
  3. 根据手机号查询是否有这个用户。如果有就保存这个用户的信息到redis ,【以随机的token为key : 用户信息为value】
  4. 如果没有查到,那么就注册新的用户。然后保存到数据库(mysql),然后再回到步骤3进行

image-20230218151620667

  1. 校验登录状态时, 请求就会携带着token。

  2. 然后我们就可以通过携带得token得value属性值判断用户是否存在。如果存在,那么就保存用户到ThreadLocal中,然后放行该请求。

  3. 如果不存在,那么拦截器就会拦截请求

实现

  1. 发送短信验证码,然后将验证码保存到redis中
1
2
3
4
5
6
7
8
//1. 生成验证码
String code = RandomUtil.randomNumbers(6);
/**-----------------
* 保存验证码到redis中
* 添加业务前缀
* 设置验证码的有效期2分钟
*/
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
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
@Resource
private StringRedisTemplate stringRedisTemplate;
@Autowired
private UserMapper userMapper;
@Override
public Result sendCode(String phone, HttpSession session) {
// TODO 实现发送验证码方法
/**
* 1. 校验手机号是否合格
* 2. 不合格怎么做
//合格..........
* 1. 生成验证码
* 2. 保存验证码到 session
* 3. 发送验证码
*/
//1. 校验手机号是否合格(一般使用正则表达式去校验) 这里我们封装到RefexUtils.isPhoneInvalid是否是无效手机号
boolean isNotNumber = RegexUtils.isPhoneInvalid(phone);
//不是手机号
if(isNotNumber){
return Result.fail("手机号格式错误 !!!");
}
//合格,是手机号
//1. 生成验证码
String code = RandomUtil.randomNumbers(6);
/**-----------------
* 保存验证码到redis中
* 添加业务前缀
* 设置验证码的有效期 2分钟
*/
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);

//-----------------
log.debug("发送短信验证码成功 ! 验证码为 : [" + code + "]"); //需要调用阿里云的测试,暂时不是重点 ,无需实现
//返回ok就行了
return Result.ok();
}
  1. 点击登录的时候。进行校验,我们就可以在redis中查询当前手机号保存的value与输入的进行比较
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
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//1. 提交验证码和手机号,并且进行判断是否正确
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误!");
}
String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//2. **正确 : **就继续 ,**错误** : 返回验证码错误
if(phone == null || !loginForm.getCode().equals(code)){
return Result.fail("验证码错误!");
}
//3. 调用数据库查询用户是否存在
User user = query().eq("phone", phone).one();

//4. **存在的话** : 保存用户信息到session,**不存在** : 就跳转到注册页面 ,注册并保存到数据库
if(user == null){
user = createUserWithPhone( phone);
}
//保存
/**
* 1.保存用户信息到 redis中
* 2. 随机生成token, 作为登录令牌
* 3. 将user对象转成hashmap去存储
* 4. 存储
* 5. 返回token
*/
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).
setFieldValueEditor((filedName , fieldValue) -> fieldValue.toString())); //将对象转成map

stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
//存储完成设置有效期 30 min
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL ,TimeUnit.MINUTES);
/**
* 如果用户30分钟内一直进行访问的话,那么有效期就会不断的变化,所以我么就需要再拦截器中设置,一旦用户点击,就是有了请求
* 那么就重置30分钟,一直往复的设值,那么就实现了用户30分钟不点点击就删除token的设置
*/
return Result.ok(token);
}

重点1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//保存
/**
* 1.保存用户信息到 redis中
* 2. 随机生成token, 作为登录令牌
* 3. 将user对象转成hashmap去存储
* 4. 存储
* 5. 返回token
*/
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//3. 将user对象转成hashmap去存储
Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((filedName , fieldValue) -> fieldValue.toString()));
//上面那样写的目的是为了实现bean转换成map,因为我们的redis中的hash结构key 和 value全都是String类型,而在UserDTO中,id属性为Long类型,无法无法强转为String类型,所以就需要我们自定以hash中的存储类型为Long类型

stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
//存储完成设置有效期 30 min
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL ,TimeUnit.MINUTES);
/**
* 如果用户30分钟内一直进行访问的话,那么有效期就会不断的变化,所以我么就需要再拦截器中设置,一旦用户点击,就是有了请求
* 那么就重置30分钟,一直往复的设值,那么就实现了用户30分钟不点点击就删除token的设置
*/

重点2

重置token的时常,实现30分钟为点击删除token 以及**一旦点击某个请求就重置token的时间**

方法: 新增一个拦截器,只处理点击请求就重置token时间的问题

image-20230218164320306

两个不同作用的拦截器

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
package com.hmdp.config.Handler;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;

/**
* 点击某个请求, 就重置token时间的拦截器
*/
public class preHandler implements HandlerInterceptor {

private StringRedisTemplate stringRedisTemplate;

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

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求携带的token
String token = request.getHeader("authorization");
String key = LOGIN_USER_KEY+ token;
//2. 基于token获取redis中用户
//3. 判断用户是否存在 ,如果不存在直接放行,不进行下面的步骤
if(StrUtil.isBlank(token)){
return true;
}
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(key);
//将redis查询到的用户信息hashmap转换成user对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//4. 保存信息到ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(key,LOGIN_USER_TTL , TimeUnit.MINUTES);
//放行
return true;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.hmdp.config.Handler;
import com.hmdp.utils.UserHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 配置登录校验拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
/**
* 拦截请求, 仅需要判断是否需要拦截,不需要做其他的事情
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(UserHolder.getUser() == null){
response.setStatus(401);
return false;
}
//由用户,放行
return true;
}

}

MVC配置文件中设置拦截器的执行顺序

通过后面的order(x); //x数字越小优先级越高,越先执行

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
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
//排除不需要拦截的路径
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);


//先执行
//拦截所有的请求 ,作用就是用户登录了就点击刷新token消失的时间
registry.addInterceptor(new preHandler(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}

2023.2.20—–商户查询缓存

什么是缓存

数据交换的缓存区,是存储数据的临时地方,一般读写效率高

web应用中:

image-20230219102223739

缓存在web应用中,缓存可以降低后端的负载、提高读写效率、降低响应时间

成本 : 数据的一致性成本、代码维护成本、运维成本…..

如何添加缓存

业务流程分析 与 模型

image-20230219102703659

  1. 从redis中查询商铺的缓存

  2. 判断redis中是否存在该id的商户

  3. 如果存在 : 返回商户的信息

  4. 如果不存在: 根据传入的id查询数据库 ,判断数据库中是否存在商户,如果不存在就返回401

  5. 数据库中 商户如果存在就将商户信息写入redis

  6. 返回商户信息

接口:

1
2
3
4
5
6
7
8
9
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}

Service层

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
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
String Iid = String.valueOf(id); //强转时会出现异常
// 1. 从redis中查询商铺的缓存
String shopJson = stringRedisTemplate.opsForValue().get(Iid);
// 2. 判断redis中是否存在该id的商户
if(StrUtil.isNotBlank(shopJson)){
// 3. 如果存在 : 返回商户的信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
Shop shopN = getById(id);
// 4. 如果不存在:
// 4.1根据传入的id查询数据库 ,判断数据库中是否存在商户
String key = CACHE_SHOP_KEY + id;
if(shopN == null){
//4.3 如果不存在就返回401
return Result.fail("店铺不存在!");
}
// 4.2数据库中 商户如果存在就将商户信息写入redis
stringRedisTemplate.opsForValue().set(key,shopN.toString());
// 6. 返回商户信息
return Result.ok(shopN);
}
}

给商品类型添加redis缓存

image-20230219115421961

service层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryTypeList() {
ShopType shopType = new ShopType();
//先查看缓存中是否存在
List<String> range = stringRedisTemplate.opsForList().range("shopTypeList", 0, -1);
//如果存在,那么就直接返回
if(!range.isEmpty()){
return Result.ok(range);
}
//如果不存在,先从数据库中查到,然后再交给redis,然后再返回
List<ShopType> sort = query().orderByAsc("sort").list();
for(ShopType item : sort){
stringRedisTemplate.opsForList().rightPush("shopTypeList", JSONUtil.toJsonStr(item));
}
List<String> typeList = stringRedisTemplate.opsForList().range("shopTypeList", 0, -1);
return Result.ok(typeList);
}
}

缓存更新策略- —双写一致性问题

解决数据同步的问题

解决策略

image-20230219115845335

场景:

低一致性需求: 使用内存淘汰机制,例如店铺类型的查询缓存

高一致性需求: 主动更新,并且使用超时剔除作为兜底方案。例如店铺的详情查询

主动更新的策略

  1. 由缓存的调用者,在更新数据库的同时更新缓存(常用!)
  2. 缓存与数据库整合为一个服务,由服务来维护一致性。,调用者无需关系缓存一致性问题
  3. 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终的一致

主动更新策略的考虑问题

删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

如何保证缓存与数据库的操作的同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务
  • 分布式系统,利用TCC等

分布式事务方案先操作缓存还是先操作数据库?

  • 先操作数据库,再删除缓存 / 反之亦可

缓存更新策略的最佳实践方案:

低一致性需求:

  • 使用Redis自带的内存淘汰机制

高一致性需求:

  • 主动更新,并以超时剔除作为兜底方案
    • 读操作:缓存命中则直接返回缓存未命中则查询数据库,并写入缓存,设定超时时间
    • 写操作:先写数据库,然后再删除缓存要确保数据库与缓存操作的原子性

操作:

查询商户时设置超时删除策略

1
2
// 4.2数据库中 商户如果存在就将商户信息写入redis ,超时删除30min
stringRedisTemplate.opsForValue().set(key,shopN.toString(),30, TimeUnit.MINUTES);

每次更新数据时,就会先删除缓存,然后再从次查询时会先从数据库中查出更新过的数据保存到缓存中去,然后再回显

1
2
3
4
5
6
7
8
9
10
/**
* 更新商铺信息
* @param shop 商铺数据
* @return
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    
//todo 更新数据库删除缓存
@Override
@Transactional //添加事务
public Result update(Shop shop) {
//1. 更新数据库
updateById(shop);
if(shop.getId() == null){
return Result.fail("店铺id不能为空!!!");
}
Long id = shop.getId();
String key = CACHE_SHOP_KEY + id;
//删除缓存
stringRedisTemplate.delete(key);
return Result.ok();
}
}

缓存穿透

指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

解决办法

  1. 缓存空对象 : 如果用户恶意多次查找数据库和缓存中都不存在的对象,我们可以给这种对象赋一个空的对象到redis,这样无论多少次恶意请求,他都不会多次访问数据库,只要一次访问不到,那么就只能到缓存中拿空对象了

    1. 优点:实现简单,维护方便
    2. 缺点:额外的内存消耗可能造成短期的不一致
    3. 控制ttl时间,可以实现短期不一致的降低

    image-20230219132600823

  2. 布隆过滤

    1. 优点 :内存占用非常小
    2. 缺点: 实现复杂、存在误判的可能

image-20230219133017662

实现

  1. 如果用户第一次查询,没有从缓存和数据库中查出数据,那么就创建一个空值(key1, “” ),存入redis
  2. 后面如果再次查询不存在的用户key1,那么就可以从缓存中查询将空值拿出来,然后直接返回,这样就可以不用操作数据库
  3. 如果2中拿出来的值为null,那么就说明店铺
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
String key = CACHE_SHOP_KEY + id;
/**
* 使用缓存穿透
* 判断是否为null ,因为如果是null的话
* ---------------需要好好理解以下逻辑---------------------
*/
/*isNotBlank : 判断某字符串是否不为空且长度不为0且不由空白符""(whitespace)构成
如果缓存中有查询需要的数据且不等于”“,那么就会在上面一步直接返回
如果查询出有需要的数据且值为空”“(不等于null) 那么就会到我们这一步进行返回,因为之前查过数据库,没有这个数据
如果之前没查过这个数据,那么就不会给他赋值为”“ 而至直接查出来的是null,就去数据库中查
*/
if(shopJson != null){
return Result.fail("店铺不存在!");
}
/**
* ---------------------------------------------------
*/
Shop shopN = getById(id);
// 4. 如果不存在:
// 4.1根据传入的id查询数据库 ,判断数据库中是否存在商户
if(shopN == null){
//4.3 如果不存在就返回401
/**使用缓存穿透
*查出数据库中也不存在,那么设置一个空值,下次(在规定时间内)再查他就不会再到数据库中查了
*/
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}

整个逻辑的代码实现

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
    @Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id; //强转时会出现异常
// 1. 从redis中查询商铺的缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断redis中是否存在该id的商户
if(StrUtil.isNotBlank(shopJson)){
// 3. 如果存在: 返回商户的信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
/**
* 使用缓存穿透
* 判断是否为null ,因为如果是null的话
* ---------------需要好好理解以下逻辑---------------------
*/
/*isNotBlank : 判断某字符串是否不为空且长度不为0且不由空白符""(whitespace)构成
如果缓存中有查询需要的数据且不等于”“,那么就会在上面一步直接返回
如果查询出有需要的数据且值为空”“(不等于null) 那么就会到我们这一步进行返回,因为之前查过数据库,没有这个数据
如果之前没查过这个数据,那么就不会给他赋值为”“ 而至直接查出来的是null,就去数据库中查
*/
if(shopJson != null){
return Result.fail("店铺不存在!");
}
/**
* ---------------------------------------------------
*/
Shop shopN = getById(id);
// 4. 如果不存在:
// 4.1根据传入的id查询数据库 ,判断数据库中是否存在商户
String key = CACHE_SHOP_KEY + id;
if(shopN == null){
//4.3 如果不存在就返回401
/**使用缓存穿透
*查出数据库中也不存在,那么设置一个空值,下次(在规定时间内)再查他就不会再到数据库中查了
*/
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 4.2数据库中 商户如果存在就将商户信息写入redis ,超时删除30min
stringRedisTemplate.opsForValue().set(key,shopN.toString(),30, TimeUnit.MINUTES);
// 6. 返回商户信息
return Result.ok(shopN);
}

缓存穿透产生的原因是什么?

用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

缓存穿透的解决方案有哪些?

缓存null值、布隆过滤、增强id的复杂度,避免被猜测id规律、做好数据的基础格式校验、加强用户权限校验、做好热点参数的限流

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机导致大量请求到达数据库,带来巨大压力。

服务延机最为可怕。

解决方案:

给不同的Key的TTL添加随机值、利用Redis集群提高服务的可用性、给缓存业务添加降级限流策略、给业务添加多级缓存

(后面的三个暂未实现)

1
String key = CACHE_SHOP_KEY + id + RandomUtil.randomInt(4);

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

解决方法(需要在一致性和可用性上做出选择)

互斥锁

优点 :

  • 没有额外的内存消耗
  • 保证了一致性
  • 实现简单

缺点:

  • 性能受影响
  • 可能有死锁风险

image-20230219165700880

  1. 从redis中查询数据,如果没查到,那么就返回null,到数据库中找,然后返回
  2. 如果找到了
  3. 判断缓存是否过期,未过期那就返回,并说明找到了
  4. 如果缓存过期了
    1. 尝试获取互斥锁,并判断是否获取到了互斥锁
    2. 如果获取到了,那么就开启独立线程,然后再从数据库中找打,然后写入redis并设置逻辑过期时间
    3. 释放互斥锁

逻辑过期

优点:

  • 线程无需等待
  • 性能优秀

缺点:

  • 不保证一致性
  • 有额外的内存消耗
  • 实现复杂

image-20230219145335330

实现

互斥锁的方式:

1
2
3
4
5
6
7
8
9
10
11
12
 @Override
public Result queryById(Long id) {
// 方法一: 缓存空对象解决缓存穿透
//Shop shop = queryWithPassThrough(id);
//方法二 : 互斥锁解决 缓存击穿
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在!!!");
}
// 3. 返回商户信息
return Result.ok(shop);
}
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
//todo 缓存
public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
System.out.println(key);
// 1. 从redis中查询商铺的缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断redis中是否存在该id的商户

if(StrUtil.isNotBlank(shopJson)){
//3. 如果存在: 返回商户的信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if(shopJson != null){
return null;
}

Shop shopN = null;
//未命中---------尝试获取互斥锁--------
String lockKey = LOCK_SHOP_KEY + id;
try {
//1. 判断获取互斥锁是否成功
boolean isLock = tryLock(lockKey);
//2.失败休眠,成功就获取
if (!isLock){
Thread.sleep(50);
queryWithMutex(id); //递归重试获取互斥锁
}
//!获取互斥锁成功
shopN = getById(id);
if(shopN == null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 4.2数据库中 商户如果存在就将商户信息写入redis ,超时删除30min
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shopN),30, TimeUnit.MINUTES);
}catch (InterruptedException e){
throw new RuntimeException(e);
}
finally {
//释放互斥锁
unLock(lockKey);
}
//6. 返回商户信息
return shopN;
}

//todo 获取锁
boolean tryLock(String key){
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return BooleanUtil.isTrue(aBoolean);
}

//todo 释放锁
void unLock(String key){
stringRedisTemplate.delete(key);
}
//todo 缓存穿透
public Shop queryWithPassThrough(Long id){
String key = CACHE_SHOP_KEY + id;
System.out.println(key);
// 1. 从redis中查询商铺的缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断redis中是否存在该id的商户
if(StrUtil.isNotBlank(shopJson)){
//3. 如果存在: 返回商户的信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}

if(shopJson != null){
return null;
}
Shop shopN = getById(id);
// 4. 如果不存在:
if(shopN == null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 4.2数据库中 商户如果存在就将商户信息写入redis ,超时删除30min
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shopN),30, TimeUnit.MINUTES);
// 6. 返回商户信息
return shopN;
}

方法二: 逻辑过期解决缓存击穿

暂未实现

缓存工具封装

方法: java转json

将Java对象序列化为json并存储在String类型的key中,并且能够设置TTL过期时间

1
2
3
public void set(String key, Object value, Long time , TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}

方法: java转json,用于处理缓存击穿

将Java对象序列化为json并存储在String类型的key中,并且能够设置TTL过期时间,用于处理缓存击穿问题

1
2
3
4
5
6
7
8
public void setWithLogicalExpire(String key, Object value, Long time , TimeUnit unit){
//设置逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(time));
//写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

方法: 根据指定key查询缓存,并转为指定类型

根据指定key查询缓存,并转为指定类型,利用缓存空值的方式解决缓存穿透问题

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
/**
* 封装 解决缓存穿透问题
* @param keyPre key的实际前缀
* @param id 需要查询的XXX的id
* @param type 查询的信息的类型
* @param dbFallback 函数式编程的方法
* @param time 缓存时间
* @param unit 时间单位(TimeUnit.MINUTES)
* @param <R> 返回值类型
* @param <ID> id的类型
* @return 返回查询到的信息
*/
public <R,ID> R queryWithPassThrough(
String keyPre, ID id , Class<R> type , Function<ID , R> dbFallback,
Long time , TimeUnit unit){
String key = keyPre + id;
// 1. 从redis中查询商铺的缓存
String Json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断redis中是否存在该id的商户
if(StrUtil.isNotBlank(Json)){
//3. 如果存在 : 返回信息
return JSONUtil.toBean(Json, type);
}
if(Json != null){
return null;
}
R r = dbFallback.apply(id);
// 4. 如果不存在:
if(r == null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
this.set(key,r,time,unit);
return r;
}


2023.2.24 —– 优惠劵秒杀

全局唯一ID

问题描述:

image-20230220174004660

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制
  • 容易造成数据泄露的问题

全局ID生成器

它是一种在分布式系统下用来生成全局唯一id的工具,(也称分布式唯一id)。

特性: 唯一性、高性能、高可用、安全性、递增性

ID的自增: 不使用redis自增的数值,而是拼接一些其他的信息 :

image-20230220175541092

ID的组成 :

  • 符号位: 1bit ,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

实现

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

/**
* id生成器
* 时间戳
* - 符号位: 1bit ,永远为0
* - 时间戳:31bit,以秒为单位,可以使用69年
* - 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
*/
@Component
public class RedisIdWorker {

@Resource
private StringRedisTemplate stringRedisTemplate;
//起始时间戳
private static final long BEGIN_TIMESTAMP = 1620995200L;
//设置序列号的位数
private static final int COUNT_BITS = 32;

public long nextId(String keyPre){
//1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSeconds = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSeconds - BEGIN_TIMESTAMP;

//2. 生成序列号 (将日期精确到天)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long aLong = stringRedisTemplate.opsForValue().increment("icr:" + keyPre + ":" + date);

//3. 拼接
return timestamp << COUNT_BITS | aLong;
}
}

全局ID的生成策略 : UUID、Redis自增、snowflake算法、数据库自增

Redis自增ID策略:每天一个key,方便统计订单量

ID构造是 时间戳 + 计数器

实现优惠卷秒杀下单

背景

image-20230220183703503

实现

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单

  • 库存是否充足,不足则无法下单

image-20230220193729761

1
2
3
4
5
6
7
8
9
10
11
//1. 提交优惠卷id

//2. 查询优惠卷信息

//3. 判断秒杀是否开启
//否 返回异常, 结束

//4. 是,判断库存是否充足
//否 返回异常, 结束

//5. 是,扣减库存,创建订单,返回订单信息
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
52
53

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private RedisIdWorker redisIdWorker;

@Resource
private ISeckillVoucherService seckillVoucherService;

/**
* 实现优惠卷秒杀下单
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//1. 提交优惠卷id
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 查询优惠卷信息
//3. 判断秒杀是否开启
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
//否 返回异常, 结束
return Result.fail("秒杀未开始!");
}
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
//4. 是,判断库存是否充足
//否 返回异常, 结束
Integer stock = voucher.getStock();
if (stock == 0){
return Result.fail("已经被抢完了!");
}
//5. 是,扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
//6. 创建订单 .返回订单信息
if(!update){
return Result.fail("库存不足!");
}

VoucherOrder order = new VoucherOrder();
//创建用户id,代金卷id ,订单id
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);

Long UserId = UserHolder.getUser().getId();
order.setUserId(UserId);
order.setVoucherId(voucherId);
save(order);
return Result.ok(orderId);
}
}

超卖问题

问题描述:

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

image-20230220201354715

实现

乐观锁 和悲观锁!!!

乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种: 版本号法、CAS法…

1
2
3
4
//5. 是,扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()) //对乐观锁的判断
.update()

一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

image-20230220203918035

  1. 提交优惠卷id

  2. 查询优惠卷信息

  3. 判断秒杀是否开启
    //否 返回异常, 结束

  4. 是,判断库存是否充足
    //否 返回异常, 结束

  5. 是,扣减库存,创建订单,返回订单信息

  6. 根据用户id和优惠卷id查询该用户是否下过单,如果下过直接返回异常,反之继续执行之前的操作

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private RedisIdWorker redisIdWorker;

@Resource
private ISeckillVoucherService seckillVoucherService;

/**
* 实现优惠卷秒杀下单
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//1. 提交优惠卷id
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 查询优惠卷信息
//3. 判断秒杀是否开启
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
//否 返回异常, 结束
return Result.fail("秒杀未开始!");
}
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
//4. 是,判断库存是否充足
//否 返回异常, 结束
Integer stock = voucher.getStock();
if (stock < 1){
return Result.fail("已经被抢完了!");
}
Long UserId = UserHolder.getUser().getId();
synchronized (UserId.toString().intern()){
IVoucherOrderService orderService = (IVoucherOrderService) AopContext.currentProxy();
return createVoucherOrder(voucherId);
}
}

/**
* 对于一人一单加安全锁
* @param voucherId
* @return
*/
@Transactional
public Result createVoucherOrder(Long voucherId){
//判断用户是否购买过
Long UserId = UserHolder.getUser().getId();
/**
* 一人一单解决,加锁
*/
Integer count = query().eq("user_id", UserId).eq("voucher_id", voucherId).count();
if(count > 0){
return Result.fail("该用户已经购买过了!");
}
//5. 库存充足,扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).eq("stock",0) //对乐观锁的判断
.update();
if(!update){
return Result.fail("库存不足!");
}

VoucherOrder order = new VoucherOrder();
//创建用户id,代金卷id ,订单id
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);

//6. 创建订单 .返回订单信息
order.setUserId(UserId);
order.setVoucherId(voucherId);
save(order);
return Result.ok(voucherId);
}
}

2023.2.28—–实现秒杀业务出现问题

优惠卷秒杀持续ing…..

分布式锁

多线程状态下实现同步的锁。

image-20230221082416027

之前我们设置的锁,只是相对于同一jvm下的,如果部署在集群模式下那么这种情况就会出现危险,还是会引起插麦问题,或者说超问题下单。所以这里就需要用到分布式锁

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

功能

image-20230221082817862

实现分布式锁

分布式锁的核心是多进程之间互斥, 满足这一点,常见的有三种

image-20230221083012392

获取锁

互斥,确保只有一个线程获取锁

1
2
3
4
//如果获取锁失败,直接返回false
//成功则执行下面的业务逻辑
SETNX lock thread1
EXPIRE lock 10

添加过期时间,防止服务延机导致服务挂了。

业务逻辑

image-20230221085225113

  1. 先尝试获取锁,返回结果。失败返回false
  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
public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";

//todo 获取分布式锁
@Override
public boolean tryLock(long timeOutSec) {
//获取线程的表示
long value = Thread.currentThread().getId();
//获取锁
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, value + "", timeOutSec, TimeUnit.MINUTES);

//注意自动拆箱出现的空指针错误
return Boolean.TRUE.equals(aBoolean);
}

//todo 释放锁
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

释放锁

1
2
3
4
5
6
7
8
9
//手动释放
//超时自动释放:根据上面设置的过期时间
//del lock

//todo 释放锁
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}

实现业务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取互斥锁,只允许一个进入
*/
//1. 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + UserId, stringRedisTemplate);
//2. 获取锁 ( 设置超时时间)
boolean isLock = lock.tryLock(1200);
//2.1 S获取锁不成功
if(!isLock){
return Result.fail("不允许重复下单!");
}
//2.2 获取锁成功
try {
IVoucherOrderService orderService = (IVoucherOrderService) AopContext.currentProxy();
return orderService.createVoucherOrder(voucherId);
} finally {
//关闭锁
lock.unLock();
}
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
 /**
* 实现优惠卷秒杀下单
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//1. 提交优惠卷id
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 查询优惠卷信息
//3. 判断秒杀是否开启
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
//否 返回异常, 结束
return Result.fail("秒杀未开始!");
}
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
//4. 是,判断库存是否充足
//否 返回异常, 结束
Integer stock = voucher.getStock();
if (stock < 1){
return Result.fail("已经被抢完了!");
}
Long UserId = UserHolder.getUser().getId();
/**
* 获取互斥锁,只允许一个进入
*/
//1. 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + UserId, stringRedisTemplate);
//2. 获取锁 ( 设置超时时间)
boolean isLock = lock.tryLock(1200);
//2.1 获取锁不成功
if(!isLock){
return Result.fail("不允许重复下单!");
}
//2.2 获取锁成功
try {
IVoucherOrderService orderService = (IVoucherOrderService) AopContext.currentProxy();
return orderService.createVoucherOrder(voucherId);
} finally {
//关闭锁
lock.unLock();
}
}

/**
* 对于一人一单加安全锁
* @param voucherId
* @return
*/
@Transactional
public Result createVoucherOrder(Long voucherId){
//判断用户是否购买过
Long UserId = UserHolder.getUser().getId();
/**
* 一人一单解决,加锁
*/
Integer count = query().eq("user_id", UserId).eq("voucher_id", voucherId).count();
if(count > 0){
return Result.fail("该用户已经购买过了!");
}
//5. 库存充足,扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).eq("stock",0) //对乐观锁的判断
.update();
if(!update){
return Result.fail("库存不足!");
}

VoucherOrder order = new VoucherOrder();
//创建用户id,代金卷id ,订单id
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);

//6. 创建订单 .返回订单信息
order.setUserId(UserId);
order.setVoucherId(voucherId);
save(order);
return Result.ok(voucherId);
}

分布式锁的改进

image-20230221091548158

防止因为业务阻塞而引起的误删除其他人的锁。

改进方法:

在执行释放锁的时候需要再次确认是否是自己的锁

  1. 在获取锁时存入线程标示(可以用UUID表示)

  2. 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

    1. 如果一致则释放锁 ; b. 如果不一致则不释放锁
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
public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"=";




//todo 获取分布式锁
@Override
public boolean tryLock(long timeOutSec) {
//获取线程的表示
String value = ID_PREFIX + Thread.currentThread().getId();
//获取锁
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, value + "", timeOutSec, TimeUnit.MINUTES);
//注意自动拆箱出现的空指针错误
return Boolean.TRUE.equals(aBoolean);
}

//todo 释放锁
@Override
public void unLock() {
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();

//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);

//判断两种标识是否一致
if(id.equals(threadId)){
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁

特性:

  • 利用set nx满足互斥性
  • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用Redis集群保证高可用和高并发特性

之前实现的分布式锁存在的问题

image-20230221110429194

总结问题

根据项目实现思路一步步排查,依旧出现无法秒杀的问题,以及一人一单的问题依旧无法准确实现

暂未解决!!!

2023.3.1 ——达人探店

实现发表文章及其照片

上传文件的请求

实现上传文件

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
  @Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {

/**
*
* @param image 接收文件的地址
* @return
*/
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}

private String createNewFileName(String originalFilename) {
// 获取后缀
String suffix = StrUtil.subAfter(originalFilename, ".", true);
// 生成目录
String name = UUID.randomUUID().toString();
int hash = name.hashCode();
int d1 = hash & 0xF;
int d2 = (hash >> 4) & 0xF;
// 判断目录是否存在
File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR, StrUtil.format("/blogs/{}/{}", d1, d2));
if (!dir.exists()) {
dir.mkdirs();
}
// 生成文件名
return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);
}
}

对于保存文件的地址

1
2
 // 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));

这个需要我们自己去修改成为本地的地址

1
public static final String IMAGE_UPLOAD_DIR = "N:\\hepre\\nginx-1.18.0\\html\\hmdp\\imgs\\";

然后就可以点击发布博客【接口: /blog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("/blog")
public class BlogController {

@Resource
private IBlogService blogService;
@Resource
private IUserService userService;

@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}
}

image-20230222194418221

查看文章

接口:【点击文章就是显示文章的id】

image-20230222194937433

然后根据文章的id查询文章的详细信息,最后返回

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
@RestController
@RequestMapping("/blog")
public class BlogController {

@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
// 根据用户查询
Page<Blog> page = blogService.query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog ->{
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
});
return Result.ok(records);
}

@GetMapping("/{id}")
public Result queryBlogById(@PathVariable("id") Long id){
return blogService.queryBlogById(id);
}


}

文章点赞功能实现(基于redis实现)

接口

image-20230222195249196

需求分析

  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

实现步骤:

  1. 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
  2. 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
  3. 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
  4. 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

实现点赞功能

接口:

1
2
3
4
5
//todo 点赞功能实现
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
return blogService.likeBlog(id);
}

service层实现功能 :

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
@Override
public Result likeBlog(Long id) {
//1. 获取当前用户
Long userId = UserHolder.getUser().getId();
//2. 判断当前用户是否点赞
String key = "blog:liked" + id;
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
//3.如果未点赞,可以实现点赞
if(BooleanUtil.isFalse(isMember)){
//3.1 数据库点赞数 + 1
boolean isSuccess = update()
.setSql("liked = liked + 1").eq("id", id).update();
//3.2 保存用户到Redis的set集合中
if(isSuccess){
stringRedisTemplate.opsForSet().add(key,userId.toString());
}
}
//4. 如果已经点过赞,点击的话就会 取消点赞
else {
//4.1数据库点赞数 - 1
boolean isSuccess = update()
.setSql("liked = liked - 1").eq("id", id).update();

//4.2 将用户从点赞列表中移除
if(isSuccess){
stringRedisTemplate.opsForSet().remove(key,userId.toString());
}
}
return Result.ok();
}

点赞了就会出现红色,同时redis中就会保存点赞用户的id及其作品

image-20230222205625368

image-20230222205738444

当我们再次按点赞按钮的时候,它的点赞数就会减一,同时redis中保存的数据也会同步的删除

点赞排行榜功能

仿微信实现早点赞的先排在前面

image-20230222210004796

需求:按照点赞时间先后排序,返回Top5的用户

功能实现

  1. 根据传入的blog的id查询出前几名的用户id
  2. 根据用户id查询出用户
  3. 再将user转换成为userDTO为了保护用户的信息安全
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
/**
* 点赞用户列表查询
* @param id
* @return
*/
@Override
public Result queryBlogLikes(Long id) {
//1. 查询点赞前 top5的用户
String key = "blog:liked" + id;

Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if(top5 == null || top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
//解析出用户id
List<Long> userIds = top5.stream().map(Long::valueOf).collect(Collectors.toList());
/**
* 针对点赞用户排序的问题的改进
*/
String idStr = StrUtil.join(",", userIds);
//根据用户id查询用户
//List<UserDTO> userDTOs = userService.listByIds(userIds).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
List<UserDTO> userDTOS = userService.query().in("id", userIds).last("ORDER BY FIELD(id," + idStr + ")").list().
stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
//返回
return Result.ok(userDTOS);
}

image-20230222220209567

2023.3.5 —–关注

实现接口 :

image-20230223183510487

实现关注和取关的接口,对于博主和关注者之间使用一张关联表

image-20230223183648078

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("/follow")
public class FollowController {

@Resource
private IFollowService followService;

//todo 关注还是取关
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId ,@PathVariable("isFollow") boolean isFollow){
return followService.follow(followUserId,isFollow);
}

//todo 判断是否关注
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
return followService.isFollow(followUserId);
}
}

service层实现

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
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

//todo 关注还是取关
@Override
public Result follow(Long followUserId, boolean isFollow) {
//1. 获取登录的用户
Long userId = UserHolder.getUser().getId();
//1。 判断到底是关注还是取关
if(isFollow){
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
}
//取消关注
else{
remove(new QueryWrapper<Follow>().eq("user_id",userId).eq("follow_user_id",followUserId));
}
return Result.ok();
}

/**
* 查询用户是否关注
* @param followUserId
* @return
*/
@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0); //如果大于0就代表关注
}
}

共同关注

点击用户头像查看个人主页

image-20230223185935621

点击头像查看用户笔记实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 根据当前用户查询它的博客
* @param current
* @param id
* @return
*/
@GetMapping("/of/user")
public Result queryBolgByUserId(@RequestParam(value = "current",defaultValue = "1") Integer current,
@RequestParam("id") Long id){
//根据用户查询
Page<Blog> page = blogService.query().eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
//获取当前页的数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 根据用户id查询用户
* @param userId
* @return
*/
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId){
//查询用户
User user = userService.getById(userId);
if(user == null){
return Result.ok();
}
//转换为UserDTO
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
return Result.ok(userDTO);
}

共同关注查看

需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。

两个用户的交集,也就是两个set集合的交集

image-20230223195046909

首先将关注的用户存入Redis中

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
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

@Resource
private StringRedisTemplate stringRedisTemplate;

//todo 关注还是取关
@Override
public Result follow(Long followUserId, boolean isFollow) {
//1. 获取登录的用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
//1。 判断到底是关注还是取关
/**
* 当需要关注时,加入redis
*/
if(isFollow){
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSecc = save(follow);
if(isSecc){
//把当前用户的id,放入redis的set集合中
stringRedisTemplate.opsForSet().add(key,followUserId.toString());
}
}
//取消关注
else{
boolean isSucc = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSucc){
//把关注的用户id从redis中移除
stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
}
}
return Result.ok();

}
}

实现共同关注

实现接口:

1
2
3
4
5
6
7
8
9
/**
* 查找目标用户和当前用户(此时登录的)的(共同关注)交集
* @param id
* @return
*/
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id")Long id){
return followService.followCommons(id);
}

service层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 查询当前用户和目标用户的共同关注
* @param id 目标用户的id
* @return
*/
@Override
public Result followCommons(Long id) {
//1. 获取当前用户
Long userId = UserHolder.getUser().getId();
String key1 = "follows:" + userId;//当前用户
String key2 = "follows:" + id; //目标用户的key
//2. 求交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
//没有交集的情况
if(intersect == null || intersect.isEmpty()){
return Result.ok(Collections.emptyList());
}
//有交集
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
List<UserDTO> userDTOS = service.listByIds(ids).stream().map(user ->
BeanUtil.copyProperties(user, UserDTO.class)
).collect(Collectors.toList());
return Result.ok(userDTOS);
}

image-20230223200613390

image-20230223200706294

关注推送实现

关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

image-20230223201442598

Feed流模式

image-20230223201343539

拉模式

image-20230223202907097

张三、李四、王五三个人假设都发了微博,赵六关注了李四和张三,当赵六刷新信息的时候。它的收件箱就会拉去他关注的人发的微博

image-20230223203124382

然后再收件箱中对赵六关注的人发的微博按照时间戳进行排序,最终得到按照时间的微博

如果赵六关注的人比较多,那么拉去微博就会很慢,非常耗内存

读模式

将微博直接全部推送给每一个粉丝,然后获取

但是这样对于粉丝多的来说就会非常耗内存

image-20230223203408590

推拉混合模式

推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。

image-20230223203611083

使用两种结合的方式,就可以完美解决两种冲突的问题

image-20230223203859734

需求实现:

  1. 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
1
2
3
4
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}

service层实现

  1. 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
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
//如果数据有变化的话,最好不要使用list来做排序
/**
* 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
* @param blog
* @return
*/
@Override
public Result saveBlog(Blog blog) {
//1. 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
//2. 保存探店博文
boolean succ = save(blog);
if(!succ){
return Result.fail("笔记保存失败!");
}

//3. 查询笔记作者的所有粉丝
//sql语句 : select * from tb_follow where follow_user_id = ?
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
//4. 推送笔记id个所有的粉丝
for (Follow follow : follows) {
//获取每一个粉丝
Long userId = follow.getUserId();
//推送,收件箱 key粉丝的id
String key = "feeds:" + userId;
//推送笔记,按时间戳排序
stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
}

// 返回id
return Result.ok(blog.getId());
}

  1. 查询收件箱数据时,可以实现分页查询

image-20230223210902878

实现接口 :

两个重点:

  1. 每次查询完要分析出本次查询的最小时间戳 ,这将作为下一次查询的起始(最大值)
  2. 找到查询出的最小元素的个数,并将其跳过,从下一个开始查

2023.3.6—–实现用户签到

对于redis中的GEO数据结构

image-20230226121538319

例题: 搜索天安门( 116.397904 39.909005 )附近10km内的所有火车站,并按照距离升序排序

实现方法:

官网文档:

查询的中心点由以下强制选项之一提供:

  • FROMMEMBER:使用给定的现有在排序集中的位置。<member>
  • FROMLONLAT:使用给定的和位置。<longitude>``<latitude>

查询的形状由以下强制选项之一提供:

  • BYRADIUS:与GEORADIUS类似,根据给定的圆形区域搜索。<radius>
  • BYBOX:在轴对齐的矩形内搜索,由 和 确定。<height>``<width>

该命令可选择使用以下选项返回其他信息:

  • WITHDIST:同时返回返回的项目与指定中心点的距离。距离以为半径或高度和宽度参数指定的相同单位返回。
  • WITHCOORD:同时返回匹配项的经度和纬度。
  • WITHHASH:还以 52 位无符号整数的形式返回项目的原始地理哈希编码排序集分数。这仅对低级黑客或调试有用,否则对一般用户兴趣不大。

默认情况下,匹配项返回时未排序。要对它们进行排序,请使用以下两个选项之一:

  • ASC:相对于中心点,从最近到最远对返回的项目进行排序。
  • DESC:相对于中心点,从最远到最近对返回的项目进行排序。

默认情况下,将返回所有匹配项。若要将结果限制为前 N 个匹配项,请使用 COUNT<count> 选项。 使用该选项时,一旦找到足够的匹配项,该命令就会返回。这意味着返回的结果可能不是最接近指定点的结果,但服务器为生成它们而投入的工作量要少得多。 如果未提供,该命令将执行与指定区域匹配的项目数成比例的工作并对其进行排序, 因此,即使只返回几个结果,使用非常小的选项查询非常大的区域也可能很慢。ANY``ANY``COUNT

GEO数据结构用于解决附近商户查询的问题,但是由于项目目前使用的数据都是虚假数据。对于真实业务的逻辑及其实现思路都不太清除,所以次模块未完成

用bitMap实现用户签到

image-20230226143845401

bitMap的用法

image-20230226143938875

实现用户签到

image-20230226144954055

1
2
3
4
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}

service层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* todo 实现用户签到
* @return
*/
@Override
public Result sign() {
//1. 获取用户
Long userId = UserHolder.getUser().getId();
//2. 获取日期
LocalDateTime now = LocalDateTime.now();
//3. 拼接key
String key ="sign:" + userId + now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
//4. 获取今天是本月的第几天
int nowDayOfMonth = now.getDayOfMonth();
//5. 写入redis setbit key offset 1
stringRedisTemplate.opsForValue().setBit(key,nowDayOfMonth - 1,true);

return Result.ok();
}

统计当前用户连续签到天数

image-20230226150740763

1
2
3
4
@PostMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}

service层

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
@Override
public Result signCount(){
//1. 获取用户
Long userId = UserHolder.getUser().getId();
//2. 获取日期
LocalDateTime now = LocalDateTime.now();
//3. 拼接key
String key ="sign:" + userId + now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
//4. 获取今天是本月的第几天
int nowDayOfMonth = now.getDayOfMonth();
//5. 获取本月到今天为止所有的签到记录
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(nowDayOfMonth)).valueAt(0)
);
if(result == null || result.isEmpty()){
return Result.ok(0);
}
Long num = result.get(0);
if(num == null || num == 0){
return Result.ok(0);
}

int count = 0;
//循环遍历
while(true){
if((num & 1)==0){
//未签到,跳出循环
break;
}else{
//已签到 ,计数器+1
count++;
}
num >>>= 1;//二进制右移
}
return Result.ok(count);
}