发送验证码,通过手机号校验,生成6位随机数,存储到redis当中,然后在发送验证码
判断登录的过程
功能实现
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 发送验证码
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
// 3.符合,生成验证码
String s = RandomUtil.randomNumbers(6);
// 4.保存验证码到 redis LOGIN_CODE_KEY等定义见RedisConstants工具类
// session.setAttribute("code",s);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,s,LOGIN_CODE_TTL,TimeUnit.MINUTES);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}",s);
// 6.返回ok
return Result.ok();
}
// 验证登录以及注册
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号请保持一致");
}
// 2.校验验证码
// Object cacheCode = session.getAttribute("code");
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)){
// 3,不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到redis中
// 7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2 将User对象转为Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 因为工具类要求key 和 value都是String类型,但是id是Long类型,需要进行转换
// 两种方法,一种是自定义个map,另一种就是CopyOptions,造一个可以更改的新map出来
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().
setIgnoreNullValue(true).
setFieldValueEditor((fildName,fileValue) -> fileValue.toString()));
// 7.3 存储
String tokenKey = LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
// 7.4 有效期
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// 返回token
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
// USER_NICK_NAME_PREFIX 为编写的code,代替user_
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
// 保存用户
save(user);
return user;
}
}
此时完成登录令牌的生成,以及信息储存,但是如果每个需要登录的模块都需要判断登录状态就很繁琐,因此考虑拦截器,但是由于实现类里面存在时效,因此需要刷新,而判断登录的拦截器只能拦截需要登录的功能,如果点击不需要登录的功能,时间一过,自动删除,判断为未登录,显然不合理,因此需要两个拦截器,一个用来判断token,并刷新时效,另一个判断是否登录,进行拦截
拦截器一
package com.hmdp.utils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
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;
/**
* @author 火云勰神
* @date 2022-09-27 16:16
* @description 拦截器
*/
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
//因为当前拦截器并没有交给spring控制,所以需要提供构造器,不能通过注解注入
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// HttpSession session = request.getSession();
// 基于token来获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// Object user = session.getAttribute("user");
// 判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 将查询到的hash数据转为hashDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 存在,保存用户信息到threadLocal
UserHolder.saveUser(userDTO);
// 刷新token有效期
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 {
// 移除用户
UserHolder.removeUser();
}
}
package com.hmdp.utils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author 火云勰神
* @date 2022-09-27 16:16
* @description 拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// RefreshTokenInterceptor拦截器拦截所有的资源并生成token
// 经过R拦截器到本拦截,只需要判断ThreadLocal是否存在用户
if (UserHolder.getUser() == null) {
// 没有,需要拦截
response.setStatus(401);
// 拦截
}
// 有用户,放行
return true;
}
}
具体的实现流程
店铺缓存:
实现思想:查询店铺信息时,先从redis查询,如果不存在,再从数据库中查询,查到以后,先保存在redis当中再返回
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (!StrUtil.isBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4,不存在,根据id查询数据库
Shop shop = getById(id);
// 4.1 不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在");
}
// 4.2 存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
// 5.返回
return Result.ok(shop);
}
分类列表缓存
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryList() {
// List shopTypes = stringRedisTemplate.opsForList();
String key = "cache:typeList";
//1.在redis中间查询
List<String> shopTypeList = stringRedisTemplate.opsForList().range(key,0,-1);
//2.判断是否缓存中了
//3.中了返回
if(!shopTypeList.isEmpty()){
List<ShopType> typeList = new ArrayList<>();
for (String s:shopTypeList) {
ShopType shopType = JSONUtil.toBean(s,ShopType.class);
typeList.add(shopType);
}
return Result.ok(typeList);
}
// 缓存中不存在,直接从数据库中查询并返回
List<ShopType> typeList = query().orderByAsc("sort").list();
//5.不存在直接返回错误
if(typeList.isEmpty()){
return Result.fail("不存在分类");
}
for(ShopType shopType : typeList){
String s = JSONUtil.toJsonStr(shopType);
shopTypeList.add(s);
}
//6.存在直接添加进缓存
stringRedisTemplate.opsForList().rightPushAll(key, shopTypeList);
return Result.ok(typeList);
}
}
当前存在多个问题,如缓存的更新问题,缓存穿透,雪崩等问题,见下面处理方案
此时存在一个缺点,如果数据库中的信息发生修改,但是redis也存在相关信息,那么,查询到的数据是保存在缓存里面的旧数据,导致内容不一致,需要进行修改
对于主动更新策略,目前最常见的存在三种
采取先更新再删除的策略
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (!StrUtil.isBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4,不存在,根据id查询数据库
Shop shop = getById(id);
// 4.1 不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在");
}
// 4.2 存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 5.返回
return Result.ok(shop);
}
// 更新方法
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete( CACHE_SHOP_KEY + id);
return Result.ok();
}
}
缓存穿透实质客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会达到数据库
数据不存在,数据库会返回空值,假如可以使用多线程访问,会导致数据库出问题
常见的两种解决方案:
缓存穿透的时间解决思路
店铺查询方法
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// isNotBlank方法只有在存在字符串才返回true,即存在商铺信息
// 所以需要多加一条判断,是否为空值,如果不加,直接访问数据库
// shopJson不等于null的情况就只剩下空字符串
// 当前情况为空字符串
if (shopJson != null) {
// 返回个错误信息
return Result.fail("店铺不存在");
}
// 4,不存在,根据id查询数据库
Shop shop = getById(id);
// 4.1 不存在,返回错误
if (shop == null) {
// 将空值写入redis,并且限制存在时间更短
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在");
}
// 4.2 存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 5.返回
return Result.ok(shop);
}
缓存雪崩是指在同意时段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库,带来巨大压力
在一瞬间大量的key值或者redis宕机失效未命中直接访问数据库
解决方案
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key(如正在做活动的某个缓存key)突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
解决方案
基于互斥锁的缓存击穿解决
自动拆箱导致空指针: https://blog.csdn.net/weixin_38106322/article/details/109712597
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
// 返回
return Result.ok(shop);
}
// 查询互斥锁
public Shop queryWithMutex(Long id) {
{
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// isNotBlank方法只有在存在字符串才返回true,即存在商铺信息
// 所以需要多加一条判断,是否为空值,如果不加,直接访问数据库
// shopJson不等于null的情况就只剩下空字符串
// 当前情况为空字符串
if (shopJson != null) {
// 返回个错误信息
return null;
}
// 4.实现缓存重建
String lockKey = LOCK_SHOP_KEY + id;
// 4.1 获取互斥锁
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断是否获取成功
if (!isLock){
// 4.3 失败,则休眠
Thread.sleep(LOCK_SHOP_TTL);
//递归调用
return queryWithMutex(id);
}
// 4.4 成功,根据id查询数据库
shop = getById(id);
if (shop == null) {
// 将空值写入redis,并且限制存在时间更短
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
unLock(lockKey);
}
// 返回
return shop;
}
}
private boolean tryLock(String key) {
// 获取锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 不能直接返回,直接返回存在拆箱操作,可能会有空指针
return BooleanUtil.isTrue(flag);
}
// 删除锁的方法
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
// 更新方法
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete( CACHE_SHOP_KEY + id);
return Result.ok();
}
}
逻辑过期
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
// 逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
// 返回
return Result.ok(shop);
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 逻辑过期解决缓存击穿
public Shop queryWithLogicalExpire(Long id){
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在 当前情况是为空
if (StrUtil.isBlank(shopJson)) {
// 返回null值
return null;
}
// 4. 命中需要判断过期时间,需要吧json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 因为RedisData类里面Data数据是object类型,反序列化以后并不知道具体是个什么类型的
// 为了方便以后还能缓存其他数据,使用JSONObject
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
// 5.1 未过期 直接返回店铺信息
return shop;
}
// 5.2 已过期 需要缓存重建
// 6 缓存重建
String lockKey = LOCK_SHOP_KEY +id;
boolean isLock = tryLock(lockKey);
// 6.1 判断是否获取锁成功
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShopRedis(id,30L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 6.2 成功 开启独立线程实现缓存重建
// 6.3返回过期的商品信息
return shop;
}
查询互斥锁
// public Shop queryWithMutex(Long id) {
// {
// String key = CACHE_SHOP_KEY + id;
//
1.从redis查询商铺缓存
// String shopJson = stringRedisTemplate.opsForValue().get(key);
2.判断是否存在
// if (StrUtil.isNotBlank(shopJson)) {
3.存在,直接返回
// return JSONUtil.toBean(shopJson, Shop.class);
// }
isNotBlank方法只有在存在字符串才返回true,即存在商铺信息
所以需要多加一条判断,是否为空值,如果不加,直接访问数据库
shopJson不等于null的情况就只剩下空字符串
当前情况为空字符串
// if (shopJson != null) {
返回个错误信息
// return null;
// }
4.实现缓存重建
// String lockKey = LOCK_SHOP_KEY + id;
4.1 获取互斥锁
// Shop shop = null;
// try {
// boolean isLock = tryLock(lockKey);
4.2 判断是否获取成功
// if (!isLock){
// // 4.3 失败,则休眠
// Thread.sleep(LOCK_SHOP_TTL);
// return queryWithMutex(id);
// }
4.4 成功,根据id查询数据库
// shop = getById(id);
// if (shop == null) {
// // 将空值写入redis,并且限制存在时间更短
// stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
// // 返回错误信息
// return null;
// }
写入redis
// stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// } catch (InterruptedException e) {
// throw new RuntimeException(e);
// } finally {
释放互斥锁
// unLock(lockKey);
// }
返回
// return shop;
// }
// }
// public Shop queryWithPassThrough(Long id){
// String key = CACHE_SHOP_KEY + id;
//
1.从redis查询商铺缓存
// String shopJson = stringRedisTemplate.opsForValue().get(key);
2.判断是否存在
// if (StrUtil.isNotBlank(shopJson)) {
3.存在,直接返回
// return JSONUtil.toBean(shopJson, Shop.class);
// }
isNotBlank方法只有在存在字符串才返回true,即存在商铺信息
所以需要多加一条判断,是否为空值,如果不加,直接访问数据库
shopJson不等于null的情况就只剩下空字符串
当前情况为空字符串
// if (shopJson != null) {
返回个错误信息
// return null;
// }
4,不存在,根据id查询数据库
// Shop shop = getById(id);
4.1 不存在,返回错误
// if (shop == null) {
将空值写入redis,并且限制存在时间更短
// stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
返回错误信息
// return null;
// }
4.2 存在,写入redis
// stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
5.返回
// return shop;
// }
private boolean tryLock(String key) {
// 获取锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 不能直接返回,直接返回存在拆箱操作,可能会有空指针
return BooleanUtil.isTrue(flag);
}
// 删除锁的方法
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
// 向redis写入店铺数据,并写入逻辑过期时间
public void saveShopRedis(Long id,Long expireSeconds) {
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}
// 更新方法
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete( CACHE_SHOP_KEY + id);
return Result.ok();
}
}
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* @author 火云勰神
* @date 2022-10-02 16:41
* @description 全局id生成器
*/
@Component
public class RedisIdWorker {
// 2022.10.2 16:51对应的秒数
// 作为开始的时间戳
private static final long BEGIN_TIMESTAMP = 1664729460L;
/*
* 序列号的位数
* */
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timesTemp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// string实现自增长,并且是因为redis独立于数据库的,该数据唯一
// 获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
// 如果简单的拼接就会变成一个字符串,要保证让第一位为符号位 后面为时间戳,最后一个为序列号
// 那么就需要吧时间戳往左移动知道留出合适的位置
return timesTemp << COUNT_BITS | count;
}
}
实现普通抢购
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){
// isAfter表示当前时间在开始之前
return Result.fail("秒杀尚未开始");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
// 结束
return Result.fail("秒杀已经结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
// 5.扣减库存
boolean success = iSeckillVoucherService.
update().
setSql("stock = stock - 1").
eq("voucher_id",voucherId).
update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
超卖问题其实就是线程不安全,在高并发的条件下,不能保证所有线程一一执行,例如:当库存只剩下1的时候,期望的情况是:线程一判断库存,执行扣除操作,线程二判断发现库存为0,执行返回操作,但是在高并发的情况下,很有可能发生线程一查询完库存,线程二查询库存,发现都为1,两个线程都执行扣除操作,导致最终库存为-1
解决线程安全问题一般有两种,一种是悲观锁,一种是乐观锁,悲观锁认为一定会发生线程安全问题,一定要锁上,此时线程一定安全,但是效率很低,乐观锁则认为线程问题不一定发生,在对数据修改的时候才进行判断
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){
// isAfter表示当前时间在开始之前
return Result.fail("秒杀尚未开始");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
// 结束
return Result.fail("秒杀已经结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
// 5.扣减库存
boolean success = iSeckillVoucherService.
update().
setSql("stock = stock - 1"). //set stock = stock - 1
eq("voucher_id",voucherId).
gt("stock",0). //where id = ? and stock = > 0
update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){
// isAfter表示当前时间在开始之前
return Result.fail("秒杀尚未开始");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
// 结束
return Result.fail("秒杀已经结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
// 根据id上锁,如果把锁加在方法上,那么效率很低下
// 希望同一个用户过来用同一把锁
// 所以userId需要需要转成字符串
// 但是普通调用toString方法,也是每次new一个对象,也打不到预期效果
// 因此调用intern从字符串常量池拿到字符串,那么保证,相同id对应唯一字符串
// 但是如果加载方法体上,方法结束,锁释放,此时,spring还未提交事务,
// 其他线程就能够进来更改事务,就会引发线程安全问题
// 因此把锁加在函数的外面
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
// 因为事务的原理是底层通过动态代理完成的,但是此时直接调用,是目标对象调用
// 因此直接调用不生效,需要用代理类来进行调用
// 当时此时并不能拿到代理对象
// 首先得添加aspectjweaver依赖
// 其次需要在启动类上暴露代理对象
// @EnableAspectJAutoProxy(exposeProxy = true)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 5.1 查询订单
int count = query().eq("user_id", userId).
eq("voucher_id", voucherId).count();
// 5.2 判断是否存在
if (count > 0) {
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = iSeckillVoucherService.
update().
setSql("stock = stock - 1"). //set stock = stock - 1
eq("voucher_id", voucherId).
gt("stock", 0). //where id = ? and stock = > 0
update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
}
synchronized锁只能保证当前环境互斥,但是如果是分布式系统就没有办法保证互斥生效,所以此时就需要分布式锁
分布式锁
满足分布式系统或集群模式下多进程可见并且互斥的锁
分布式锁的实现流程
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author 火云勰神
* @date 2022-10-06 20:11
* @description
*/
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:";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程的标识
long threadId = Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.MINUTES);
// 防止拆箱的过程中,success为空,拆箱结果为空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 释放锁 即删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
当前分布式锁存在一个问题,假如线程一获得锁之后,因为某种情况进入了阻塞状态,那么当锁过期以后,线程二就拿到锁,执行到一半,线程一开始唤醒执行,而此时线程二持有的锁就会被线程一释放掉,举个例子,自己拿钥匙开门,怎么都打不开,换了个大锤砸开了,但是发现砸开的不是自己家门
问题示例图:
解决:
并使用lua脚本保证原子性一致性
Redis使用同一个Lua解释器来执行所有命令,同时,Redis保证以一种原子性的方式来执行脚本:当lua脚本在执行的时候,不会有其他脚本和命令同时执行。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
/**
* @author 火云勰神
* @date 2022-10-06 20:11
* @description
*/
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:";
// 线程表示的前缀
// 如果只用线程id,那两个jvm的id会冲突,如果拼上线程的名称
// 可以保证,相同线程获得的锁相同,不同线程获得的锁不相同
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 读取文件
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 指定返回类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.MINUTES);
// 防止拆箱的过程中,success为空,拆箱结果为空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
// 因为需要传入集合类型,使用工具类转化 KEYS[I] 即锁
Collections.singletonList(KEY_PREFIX + name),
// ARGS[I] 即线程标识
ID_PREFIX + Thread.currentThread().getId()
);
}
// @Override
// public void unlock() {
获取线程标识
// String threadId = ID_PREFIX + Thread.currentThread().getId();
获取redis锁中的标识
// String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
判断标识是否一致
// if (threadId.equals(id)){
释放锁 即删除锁
// stringRedisTemplate.delete(KEY_PREFIX + name);
// }
// }
}
可重入锁
应该采取hash类型来进行存储,因为string类型的值并不可以拆分,也不能重复
线程标识 还有进入线程的次数
seckill.lua
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by 火云勰神.
--- DateTime: 2022/10/14 15:13
---
--参数列表 优惠券id
local voucherId = ARGV[1]
--用户id
local userId = ARGV[2]
--2.数据KEY
--2.1 库存key
--lua脚本是使用..来进行拼接的
local stockKey = 'secKill:stock:' .. voucherId
--2.2 订单key
local orderKey = 'secKill:order:' .. voucherId
--3.脚本业务
--判断库存是否充足 取出 stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
-- 库存不足
return 1
end
--判断用户是否下单
if (redis.call('sismember',orderKey,userId) == 1) then
-- 存在 说明重复下单 返回2
return 2
end
--扣减库存,下单,保存用户
--incrby就是加操作
redis.call('incrby',stockKey,-1)
--下单
redis.call('sadd',orderKey,userId)
return 0
变同步下单变为异步下单
首先利用redis完成库存余量、一人一单的判断,完成抢单业务
再讲下单业务放入阻塞队列,利用独立线程异步下单
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
*
* 服务实现类
*
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
// 读取文件
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
// 指定返回类型
SECKILL_SCRIPT.setResultType(Long.class);
}
// 阻塞队列 当一个线程尝试从队列获取元素的时候
// 如果没有元素,线程就会被阻塞,知道线程里面有元素,才会被唤醒
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
private static final ExecutorService secKill_order_executor = Executors.newSingleThreadExecutor();
// 利用spring的注解保证初始化完成就开始执行线程任务
@PostConstruct
private void init() {
secKill_order_executor.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true) {
try {
// 1. 获取队列中的订单信息
// 获取和删除改队列的头部,如果需要则等待知道元素可用
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常",e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 因为并不是主线程来完成,所以通过threadlocal来取userid是取不到的
Long userId = voucherOrder.getUserId();
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁
boolean isLocke = lock.tryLock();
if (!isLocke) {
// 虽然redis已经做了并发判断
// 但是为了以防万一
log.error("不允许重复下单");
return;
}
try {
// 获取代理对象
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
private IVoucherOrderService proxy;
@Override
public Result secKillVoucher(Long voucherId) {
// 获取用户
Long id = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
id.toString()
);
// 2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1 不为0
return Result.fail(r == 1 ? "库存不足":"不能重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
// 2.2 为0 有购买资格,把下单信息保存到阻塞队列
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(id);
// 代金券id
voucherOrder.setVoucherId(voucherId);
// 创建阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 3 返回订单id
return Result.ok(0);
}
// @Override
// public Result secKillVoucher(Long voucherId) {
1.查询优惠券
// SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
2.判断秒杀是否开始
// if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){
isAfter表示当前时间在开始之前
// return Result.fail("秒杀尚未开始");
// }
3.判断秒杀是否已经结束
// if (voucher.getEndTime().isBefore(LocalDateTime.now())){
结束
// return Result.fail("秒杀已经结束");
// }
4.判断库存是否充足
// if (voucher.getStock() < 1) {
库存不足
// return Result.fail("库存不足");
// }
根据id上锁,如果把锁加在方法上,那么效率很低下
希望同一个用户过来用同一把锁
所以userId需要需要转成字符串
但是普通调用toString方法,也是每次new一个对象,也打不到预期效果
因此调用intern从字符串常量池拿到字符串,那么保证,相同id对应唯一字符串
但是如果加载方法体上,方法结束,锁释放,此时,spring还未提交事务,
其他线程就能够进来更改事务,就会引发线程安全问题
因此把锁加在函数的外面
// Long userId = UserHolder.getUser().getId();
创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// RLock lock = redissonClient.getLock("lock:order:" + userId);
// // 获取锁
// boolean isLocke = lock.tryLock();
// if (!isLocke) {
获取锁失败 要么返回错误信息 要么重试
当前场景不允许重复下单
// return Result.fail("一个人只允许下一单");
// }
// try {
// IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// return proxy.createVoucherOrder(voucherId);
// } finally {
手动释放锁
// lock.unlock();
// }
//
// }
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
// 5.一人一单
Long userId = voucherOrder.getUserId();
// 5.1 查询订单
int count = query().eq("user_id", userId).
eq("voucher_id", voucherOrder.getVoucherId()).count();
// 5.2 判断是否存在
if (count > 0) {
log.error("用户已经购买一次");
return;
}
// 6.扣减库存
boolean success = iSeckillVoucherService.
update().
setSql("stock = stock - 1"). //set stock = stock - 1
eq("voucher_id", voucherOrder.getVoucherId()).
gt("stock", 0). //where id = ? and stock = > 0
update();
if (!success) {
// 扣减失败
log.error("库存不足");
return;
}
save(voucherOrder);
}
}
实现思路,当进行点赞的时候,给数据库的liked字段加1,当再次进行点赞时,liked字段减一,并且,如果需要对点赞的用户进行排序,set类型不能做到,需要使用scoreSet类型
package com.hmdp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static com.hmdp.utils.RedisConstants.BLOG_LIKED_KEY;
/**
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
this.queryUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
// 查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("博客不存在");
}
// 查询blog相关的用户
queryUser(blog);
// 查询blog是否备点赞
isBlogLiked(blog);
return Result.ok(blog);
}
private void isBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
if (user == null) {
return;
}
Long userId = UserHolder.getUser().getId();
// 判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null);
}
@Override
public Result likeBlog(Long id) {
// 获取登录用户
Long userId = UserHolder.getUser().getId();
// 判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + id;
// score查到分数(如果存在能查到分数,如果不存在返回null)
// 在当前情况下的score是时间戳
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 因为变量是包装类,如果直接使用可能会有空指针异常
if (score == null) {
// 如果未点赞,可以点赞,数据库点赞+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 保存用户到redis到set集合
// set可以存储很多点赞的用户并且保证用户id唯一
// 但是set类型不能进行排序,所以采用socketSet
// zadd key value score
if (isSuccess) {
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
}
}else {
// 如果已经点赞,取消点赞
// 4.1 数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2 把用户从redis的set集合移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
}
}
return Result.ok();
}
@Override
public Result queryBlogByLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 查询top5的点赞用户 zrange key 0 - 4
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 5);
if (top5 == null || top5.isEmpty()) {
return Result.ok();
}
// 解析出用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 根据用户id查询用户
List<UserDTO> userDTOS = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 返回
return Result.ok(userDTOS);
}
private void queryUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
点赞人数肯定不会只有一个,但是普通的set类型只能存储数据,并不能能够实现按照某个值来进行排序,所以采取zset,通过点赞时间来进行排序
@Override
public Result likeBlog(Long id) {
// 获取登录用户
Long userId = UserHolder.getUser().getId();
// 判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + id;
// score查到分数(如果存在能查到分数,如果不存在返回null)
// 在当前情况下的score是时间戳
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 因为变量是包装类,如果直接使用可能会有空指针异常
if (score == null) {
// 如果未点赞,可以点赞,数据库点赞+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 保存用户到redis到set集合
// set可以存储很多点赞的用户并且保证用户id唯一
// 但是set类型不能进行排序,所以采用socketSet
// zadd key value score
if (isSuccess) {
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
}
}else {
// 如果已经点赞,取消点赞
// 4.1 数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2 把用户从redis的set集合移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
}
}
return Result.ok();
}
@Override
public Result queryBlogByLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 查询top5的点赞用户 zrange key 0 - 4
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 5);
if (top5 == null || top5.isEmpty()) {
return Result.ok();
}
// 解析出用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 根据用户id查询用户
// 如果使用listByIds,因为数据库里面用in会导致查询数据的顺序与所给参数顺序不同
// 所以需要手动拼接
// 此处相当于 where id IN() ORDER BY FIELD
// 由于id后面的数据不能写死,而ids是一个集合,所以使用工具类进行拆分拼接然后拼如sql语句
List<UserDTO> userDTOS = userService.query().in("id",ids)
.last("ORDER BY FIELD(id,"+idStr+")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 返回
return Result.ok(userDTOS);
}
private void queryUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
共同关注的实现其实就是取得两个用户所关注的用户的交集,set集合可以实现
package com.hmdp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Follow;
import com.hmdp.entity.User;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
*
* @author 火云勰神
* @since 2021-12-22
*/
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private IUserService userService;
// 关注/取关
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 获取当前登录用户
// 当前id是被关注的id
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
// 判断是关注还是取关
if (isFollow){
// 关注 新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isFollow) {
// 把关注用户的id放入redis的set集合
// set类型可以求交集实现共同关注 sadd userId followUserId
stringRedisTemplate.opsForSet().add(key,followUserId.toString());
}
}else {
// 取关 删除 delete from tb_follow where userId = ? and follow_user_id=?
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
// 移除,把关注的用户id从redis集合中移除
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
// 1.查询是否关注
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0);
}
@Override
public Result followCommons(Long id) {
// 获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 当前用户的key
String key = "follows:" + userId;
// 目标用户的key
String key2 = "follows:" + id;
// 求交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 解析出id然后查询用户
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
}
拉模式,也叫做读扩散
推模式。也叫做写扩散
推拉结合模式,也叫做读写混合,间距推和啦两种模式
完成推送功能,推送到收件箱
因为随时都有可能在更新博客,那么分页根据下标来分页就不能完成需求,需要采取滚动分页来完成
@Override
public Result saveBlog(Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文 把笔记发送给粉丝 推模式
boolean isSave = save(blog);
// 只有成功才会去推送,否则就报错
if (!isSave) {
return Result.fail("新增失败");
}
// 当前方法拿到的是登录用户,也就是写这篇笔记的用户
// follow_userId是被关注的用户
// 查询粉丝 select * from tb_follow where follow_userId = userId
List<Follow> follows = iFollowService.query()
.eq("follow_user_id", user.getId())
.list();
for (Follow follow : follows) {
// 获取粉丝的id
// follow表中,userId是关注的人,followId是被关注的人
Long userId = follow.getUserId();
// 推送
String key = "feeds:" + userId;
// 推送博客的id,不要整个博客都进行推送
stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
}
// 返回id
return Result.ok(blog.getId());
}
在分页查找中,如果按照脚标来查询,就会发生混轮,如:0-3查到的是 1 2 3 ,4-5 查到的是 4 5,当时此时进来一个数据7 那么此时 0-3查到的数据为 7 1 2,而4-5查到的数据就是3 4,就会发生混乱,所以采取scoreSet的分数(score)来进行查询,如 6 5 4 3 2 1分数,上次查询了6 5 4,那么这次查询的只需要查询分数比4小的即可,滚动查询只需要关心开始以及数量
滚动分页查询:
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.找到收件箱 即获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 滚动分页查询 ZREVRANGRBYSCORE z1 MAX MIN WITHSCORES LIMIT OFFSET COUNT
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 3.解析数据:包含blogId,时间戳score,需要找到最小时间戳返回给前端
// 还有偏移量offset
List<Long> ids = new ArrayList<>(typedTuples.size());
// 在外面定义一个最小变量,每次找到都对赋值,保证最小
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
// key value类型 key就是id value就是分数
// 获取id
ids.add(Long.valueOf(typedTuple.getValue()));
// 获取时间戳
long time = typedTuple.getScore().longValue();
// 找到相同的时间戳,计算偏移量
if (time == minTime){
os++;
}else {
minTime = time;
os = 1;
}
}
// 4.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs =query().in("id",ids)
.last("ORDER BY FIELD(id,"+idStr+")").list();
// 5.封装并返回
for (Blog blog : blogs) {
// 查询blog相关的用户
queryUser(blog);
// 查询blog是否被点赞
isBlogLiked(blog);
}
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
geo数据结构的使用
GEOSEARCH BYLONLAT(地理坐标) x y BYRADIUS(半径) 10 WITHDISTANCE(距离)
默认单位是米,如果有WITHDISTANCE,那么返回结果会带上单位
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 因为前段不一定按照距离来做排序,所以坐标有可能为空
// 1.判断是否需要根据坐标查询
if (x == null || y == null) {
// 不需要坐标查询,按照数据库查询
// 根据类型分页查询
Page<Shop> page =query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2.计算分页参数
// 开始
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
// 结束
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3.查询redis,按照距离排序,分页
// 结果:shopId,distance
// GEOSEARCH BYLONLAT(地理坐标) x y BYRADIUS(半径) 10 WITHDISTANCE(距离)
// 按类型存,按类型取出
String key = SHOP_GEO_KEY+typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000), //limit是限制范围,但是只能指定结束,都是从第一条开始到结束,只能对结果手动截取
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));//默认单位为m,当前为5km,结果也是m
// 4.解析出id
if (results == null) {
return Result.ok();
}
// 当前集合从0到end,需要手动截取 list.subList() 或者stream流
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
List<Long> ids = new ArrayList<>();
Map<String,Distance> distanceMap = new HashMap<>(list.size());
if (list.size() <= from) {
// 没有下一页
return Result.ok();
}
list.stream().skip(from).forEach(result -> {
// 参见test测试单元的存储过程
// 获取店铺id
String shopId = result.getContent().getName();
// 需要把id转换为long类型的进行查询店铺信息
ids.add(Long.valueOf(shopId));
// 获取距离
Distance distance = result.getDistance();
// 保证id与distance一一对应
distanceMap.put(shopId,distance);
});
// 5.根据id查询店铺
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")").list();// 6.返回
// 在实体类,distance是只属于实体类,用于返回给前端的字段
for (Shop shop : shops) {
// 因为集合有存储,所以根据id来取出值,但是取出的是对象,需要调用getValue来转成相应的值
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}
把每一个bit为对应当月的每一天,形成映射关系,用0和1表示业务状态,这种思路就成为位图
即用java代码实现一个类似上图的签到表,如果使用数据库签到,那么一天的签到量是位图占据内存的好多倍,所以不采取数据库表的形式来存储签到,布隆过滤器的底层也是使用位图来存储
用法:
127.0.0.1:6379> setbit bm1 0 1
(integer) 0
127.0.0.1:6379> setbit bm1 1 1
(integer) 0
127.0.0.1:6379> setbit bm1 2 1
(integer) 0
127.0.0.1:6379> setbit bm1 5 1
(integer) 0
127.0.0.1:6379> setbit bm1 9 1
(integer) 0
127.0.0.1:6379> getbit bm1 3
(integer) 0
127.0.0.1:6379> getbit bm1 2
(integer) 1
127.0.0.1:6379> bitcount bm1
(integer) 5
127.0.0.1:6379> bitfield bm1 get u2 0
1) (integer) 3
127.0.0.1:6379> bitpos bm1 0
(integer) 3
127.0.0.1:6379> bitpos bm1 1
(integer) 0
post请求表示新增,key值考虑是由用户+时间构成,想把每个用户每个月的签到情况做一个统计,而当前用户和当天的时间都是可以获取到的,所以不需要前端传入数据
接口:
@PostMapping("/sign")
public Result sign() {
return userService.sign();
}
实现类:
@Override
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
// 格式化日期
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY+userId+keySuffix;
// 4.获取今天是本月的第几天 但是redis中第一天是0,即比当前数据少1
int dayOfMonth = now.getDayOfMonth();
// 5.写入redis
// stringRedisTemplate是没有单独的bitmap方法,全部整合到字符串当中
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
return Result.ok();
}
当前时间为 2022/10/24
问题一:连续签到天数?
从最后一次签到开始向前统计,知道遇到第一次未签到的位置,计算总的签到次数,就是连续签到天数
问题二:如何得到本月到今天位置的所有签到数据?
bitfield:bitfield key 操作类型(get…) 返回类型(u无符号/i有符号) 结束位置 开始位置
需要确定的参数,开始位置和结束位置,所以最终的命令应该为
bitfield key get u(dayofMonth)0
dayofMonth表示今天是本月的第几天,并且bitfield中,今天是第几天就需要多啊少个比特位
问题三:如何从后向前遍历每个bit位?
与1做与运算,就能得到最后一个bit位,随后右移一位,最后一个数字会越界被舍弃,所以下一个bit为就成为了最后一个bit为
1 0 1 1 1 右移一位 1 0 1 1 越界舍弃 1
1 -----------------------> 1
——————————— ——————————————————————
1 1 1
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
// 格式化日期
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY+userId+keySuffix;
// 4.获取今天是本月的第几天 但是redis中第一天是0,即比当前数据少1
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制数字
// bitfield sign:1010:202210 get u24 0
List<Long> result = stringRedisTemplate.opsForValue()
.bitField(
key, BitFieldSubCommands.create() //bitField需要两个参数,一个是key,另一个事子命令,通过当前方法创建
//指定为get,并且返回参数无符号
.get(
BitFieldSubCommands
.BitFieldType
.unsigned(dayOfMonth))
.valueAt(0));
// 非空判断
if (result == null || result.isEmpty()) {
return Result.ok();
}
// 因为当前只传了一个get,所以,集合里面只有一个元素
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok();
}
// 6.循环遍历
int count = 0; //计数器
while (true) {
// 6.1 让这个数字月1做与运算的刀片数字的最后一个bit位
// 6.2 判断这个bit位是否为0
if ((num & 1) == 0) {
// 6.3 如果为0,说明未签到
break;
}else {
// 6.4 如果不为0,说明已签到,计算器+1
count++;
// 6.5 把数字右移一位,抛弃最后一位数字
num >>>=1;
}
}
return Result.ok(count);
}
两个重要概念:
假如每次来访客,都直接写入redis,如果用户量达到千万甚至更多,那么将会占据很大很大的一部分内存,所以诞生了HyperLogLog
HyperLogLog,是从Loglog算法派生来的概率算法,用来确定非常大的集合的技术,而不需要存储其所有值链接:https://juejin.cn/post/6844903785744056333#heading-0
redis的HLL是基于string实现的,并且可以保证单个HLL的内存永远小于16kb,但是存在误差,因为是根据算法计算出来的,大概0.81的准确率
重复的数据并不会影响结果,适用于唯一性统计
String []values = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
// 保证数据脚标不会超过999
j = i % 1000;
values[j] = "user_" + i;
if (j == 999) {
// 发送到redis
stringRedisTemplate.opsForHyperLogLog().add("hl2",values);
}
}
// 统计数量
Long hl2 = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("hl2 = " + hl2);
}
当前笔记仅为黑马点评项目的学习笔记,图片来源于黑马ppt,如有侵权,联系我删除
如果内容描述或者知识点有误,欢迎指出
学习传送门
gitee传送门