项目需要实现的功能介绍
项目架构
前端登录 ,使用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);
基于session实现发送验证码登录
接口:@PostMapping("/user/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
service层实现流程
@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();
}
接口 : @PostMapping("/user/login")
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return userService.login(loginForm,session);
}
service层实现
@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;
}
在拦截器中是实现校验功能
流程
在拦截器中我们就可以实现我们需要的登录流程
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();
}
}
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/**"
);
}
}
@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
对象, 这样就避免用户信息在传入前端时出现信息泄露的风险。
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
存储的信息是在单线程中的,所以多台Tomcat并不能共享session的存储空间 ,当请求切换到不同的tomcat服务器导致数据丢失的问题 ——session共享问题
当用户第一次进入系统时,tomcat服务器①接收到请求,然后进行处理用户的请求(登录注册等)。
当用户第二次进入系统时 ,被负载均衡到了tomca服务器② 。用户的信息其实是已经注册了的,但是这里却无法获取。导致用户还得注册…这会造成用户体验感很差,所以我们需要继续处理。
这就是Session共享的问题
解决办法:
实现session共享。
Redis实现解决Session共享问题
使用redis代替session解决。
【手机号为 key : 验证码为value】
保存到reids中 【以随机的token为key : 用户信息为value】
。ThreadLocal
中,然后放行该请求。
//1. 生成验证码
String code = RandomUtil.randomNumbers(6);
/**-----------------
* 保存验证码到redis中
* 添加业务前缀
* 设置验证码的有效期2分钟
*/
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
@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();
}
@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.保存用户信息到 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的设置
*/
重置token的时常,实现30分钟为点击删除token 以及**一旦点击某个请求就重置token的时间
**
方法: 新增一个拦截器,只处理点击请求就重置token时间的问题
两个不同作用的拦截器
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;
}
}
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数字越小优先级越高,越先执行
@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);
}
}
数据交换的缓存区,是存储数据的临时地方,一般读写效率高
web应用中:
缓存在web应用中,缓存可以降低后端的负载、提高读写效率、降低响应时间
成本 : 数据的一致性成本、代码维护成本、运维成本…..
业务流程分析 与 模型
接口:
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
Service层
@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);
}
}
service层
@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);
}
}
解决数据同步的问题
解决策略
场景:
低一致性需求: 使用内存淘汰机制,例如店铺类型的查询缓存
高一致性需求: 主动更新,并且使用超时剔除作为兜底方案。例如店铺的详情查询
由缓存的调用者,在更新数据库的同时更新缓存
(常用!)删除缓存还是更新缓存?
如何保证缓存与数据库的操作的同时成功或失败?
分布式事务方案先操作缓存还是先操作数据库?
低一致性需求:
高一致性需求:
查询商户时设置超时删除策略
// 4.2数据库中 商户如果存在就将商户信息写入redis ,超时删除30min
stringRedisTemplate.opsForValue().set(key,shopN.toString(),30, TimeUnit.MINUTES);
每次更新数据时,就会先删除缓存,然后再从次查询时会先从数据库中查出更新过的数据保存到缓存中去,然后再回显
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
//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();
}
}
缓存穿透
指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
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("店铺不存在!");
}
整个逻辑的代码实现
@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集群提高服务的可用性、给缓存业务添加降级限流策略、给业务添加多级缓存
(后面的三个暂未实现)
String key = CACHE_SHOP_KEY + id + RandomUtil.randomInt(4);
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
优点 :
缺点:
优点:
缺点:
互斥锁的方式:
@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);
}
//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并存储在String类型的key中,并且能够设置TTL过期时间
public void set(String key, Object value, Long time , TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
将Java对象序列化为json并存储在String类型的key中,并且能够设置TTL过期时间,用于处理缓存击穿问题
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查询缓存,并转为指定类型,利用缓存空值的方式解决缓存穿透问题
/**
* 封装 解决缓存穿透问题
* @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;
}
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
它是一种在分布式系统下用来生成全局唯一id的工具,(也称分布式唯一id)。
特性: 唯一性、高性能、高可用、安全性、递增性
ID的自增: 不使用redis自增的数值,而是拼接一些其他的信息 :
ID的组成 :
/**
* 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构造是 时间戳 + 计数器
背景
下单时需要判断两点:
//1. 提交优惠卷id
//2. 查询优惠卷信息
//3. 判断秒杀是否开启
//否 返回异常, 结束
//4. 是,判断库存是否充足
//否 返回异常, 结束
//5. 是,扣减库存,创建订单,返回订单信息
@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);
}
}
问题描述:
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
乐观锁 和悲观锁!!!
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种: 版本号法、CAS法…
//5. 是,扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()) //对乐观锁的判断
.update()
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
@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);
}
}
优惠卷秒杀持续ing…..
多线程状态下实现同步的锁。
之前我们设置的锁,只是相对于同一jvm下的,如果部署在集群模式下那么这种情况就会出现危险,还是会引起插麦问题,或者说超问题下单。所以这里就需要用到分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
功能
分布式锁的核心是多进程之间互斥
, 满足这一点,常见的有三种
互斥,确保只有一个线程获取锁
//如果获取锁失败,直接返回false
//成功则执行下面的业务逻辑
SETNX lock thread1
EXPIRE lock 10
添加过期时间,防止服务延机导致服务挂了。
业务逻辑
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);
}
}
//手动释放
//超时自动释放:根据上面设置的过期时间
//del lock
//todo 释放锁
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
实现业务
/**
* 获取互斥锁,只允许一个进入
*/
//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();
}
/**
* 实现优惠卷秒杀下单
* @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);
}
防止因为业务阻塞而引起的误删除其他人的锁。
在执行释放锁的时候需要再次确认是否是自己的锁
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的分布式锁实现思路:
特性:
之前实现的分布式锁存在的问题
根据项目实现思路一步步排查,依旧出现无法秒杀的问题,以及一人一单的问题依旧无法准确实现
暂未解决!!!
上传文件的请求
@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);
}
}
对于保存文件的地址
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
这个需要我们自己去修改成为本地的地址
public static final String IMAGE_UPLOAD_DIR = "N:\\hepre\\nginx-1.18.0\\html\\hmdp\\imgs\\";
然后就可以点击发布博客【接口: /blog
】
@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());
}
}
接口:【点击文章就是显示文章的id】
然后根据文章的id查询文章的详细信息,最后返回
@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);
}
}
接口
需求分析
接口:
//todo 点赞功能实现
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
return blogService.likeBlog(id);
}
service层实现功能 :
@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及其作品
当我们再次按点赞按钮的时候,它的点赞数就会减一,同时redis中保存的数据也会同步的删除
仿微信实现早点赞的先排在前面
需求:按照点赞时间先后排序,返回Top5的用户
/**
* 点赞用户列表查询
* @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);
}
实现接口 :
实现关注和取关的接口,对于博主和关注者之间使用一张关联表
@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层实现
@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就代表关注
}
}
点击用户头像查看个人主页
/**
* 根据当前用户查询它的博客
* @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);
}
/**
* 根据用户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集合的交集
首先将关注的用户存入Redis中
@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();
}
}
实现接口:
/**
* 查找目标用户和当前用户(此时登录的)的(共同关注)交集
* @param id
* @return
*/
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id")Long id){
return followService.followCommons(id);
}
service层实现
/**
* 查询当前用户和目标用户的共同关注
* @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);
}
关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
Feed流模式
张三、李四、王五三个人假设都发了微博,赵六关注了李四和张三,当赵六刷新信息的时候。它的收件箱就会拉去他关注的人发的微博
然后再收件箱中对赵六关注的人发的微博按照时间戳进行排序,最终得到按照时间的微博
如果赵六关注的人比较多,那么拉去微博就会很慢,非常耗内存
将微博直接全部推送给每一个粉丝,然后获取
但是这样对于粉丝多的来说就会非常耗内存
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
使用两种结合的方式,就可以完美解决两种冲突的问题
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
service层实现
收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
//如果数据有变化的话,最好不要使用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());
}
实现接口 :
两个重点:
例题: 搜索天安门( 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数据结构用于解决附近商户查询的问题,但是由于项目目前使用的数据都是虚假数据。对于真实业务的逻辑及其实现思路都不太清除,所以次模块未完成
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
service层实现
/**
* 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();
}
@PostMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
service层
@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);
}