复习:https://blog.youkuaiyun.com/qq_66345100/article/details/
Redis入门
Redis简介
Redis,全称Remote Dictionary Server(远程字典服务器),是一个开源的、基于内存的、高性能的key-value存储系统,同时也支持数据的持久化。它使用ANSI C语言编写,并提供了多种语言的API,是当前最热门的NoSQL数据库之一,也被人们称为数据结构服务器。以下是关于Redis的详细介绍:
一、特点
- 高性能:Redis是基于内存的数据库,因此读写速度非常快。同时,它还采用了单线程模型和非阻塞IO模型,进一步提高了系统的并发能力和稳定性。
- 丰富的数据结构:Redis支持多种数据类型,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等。这些数据类型都支持push/pop、add/remove及取交集并集和差集等丰富的操作,而且这些操作都是原子性的。
- 持久化:Redis支持两种持久化方式,分别是快照(Snapshotting)和AOF(Append-Only File)。快照方式是通过将内存中的数据以快照的形式保存到磁盘中,而AOF方式则是将所有对数据库进行的命令及其参数写入到AOF日志文件中。这两种方式都可以保证数据的持久性。
- 复制和高可用性:Redis支持主从复制和Sentinel集群管理工具,可以实现数据的备份和高可用性。主从复制功能允许一个master支持多个slave连接,slave支持其他slave的连接,且不会阻塞master和slave。而Sentinel则提供了监控、通知、自动故障转移等功能,进一步提高了Redis的高可用性。
- 发布/订阅:Redis支持发布/订阅模式,可以用于实现消息队列、实时通知等场景。
二、用途
- 缓存:Redis可以作为缓存数据库,存储一些临时数据,提高系统的访问速度。
- 排行榜:Redis的有序集合数据结构可以用于实现排行榜功能,如根据分数进行排序的排行榜。
- 计数器:Redis的字符串数据类型支持自增和自减操作,因此可以用于实现计数器功能,如网站访问量统计等。
- 分布式会话:在分布式系统中,Redis可以用于存储用户的会话信息,实现会话共享和统一管理。
- 分布式锁:Redis提供了分布式锁的实现机制,可以用于解决分布式系统中的资源竞争问题。
- 社交网络:Redis的数据结构和操作特性使其非常适合用于社交网络的场景,如用户关系链、好友列表等。
下载与安装
redis-server.exe redis.windows.conf 服务端启动
通过图形界面操作
Redis数据类型
Redis常用命令
字符型类型常用命令
- SET key value 设置指定key的值 set name jack
- GET key 获取指定key的值 get name
- SETEX key seconds value 设置指定key的值,并将key的过期时间设为seconds秒 setex code 30 1234 30秒后过期,查无此键
- SETNX key value 只有在key不存在时设置key的值
哈希操作命令
Redis hash是一个string类型的field 和 value的映射表,hash特别适合用于存储对象.
- HSET key field value 将哈希表key中的字段field的值设为value
- HGET key field 获取存储在哈希表中的指定字段
- HKEYS key 获取哈希表中所有字段
- HVALS key 获取哈希表中的所有值
列表操作命令
Redis列表式简单的字符串列表,按照插入顺序排序,常用命令:
- LPUSH key value1 [value2] 将一个或多个值插入到列表头部
- LRANGE key start stop 获取列表制定范围内的元素
- RPOP key 移除并获取列表最后一个元素
- LLEN key 获取列表长度
集合操作命令
Redis set是String类型的无需集合.集合成员是唯一的,集合中不能出现重复数据
- SADD key member1 [member2] 向集合添加一个或多个成员
- SMEMBERS key 返回集合中的所有成员
- SCARD key 获取集合的成员数
- SINTER key1 [key2] 返回给定所有集合的交集
- SUNION key1[key2]返回所有给定集合的并集
- SREM key member1 [member2] 删除集合中的一个或多个成员
有序集合操作命令
Redis 有序集合是String类型元素的集合,且不允许有重复成员.每个元素都会关联一个double类型的分数
- ZADD key score1 member1 [score2 member2] 向有序集合添加一个或多个成员
- ZRANGE key start stop [WITHSCORES] 向索引区间返回有序集合中制定区间内的成员
- ZINCRBY key increment member 有序集合中对指定成员分数加上增量increment
- ZREM key member[member...] 移除有序集合中的一个或多个成员
通用命令
不分数据类型的,都可以使用的命令
- KEYS pattern 查找所有符合给定模式的key
- EXISTS key 检查给定key是否存在
- TYPE key 返回key所存储的值的类型
- DEL key 该命令用于key存在是删除key
在java中操作Redis
Spring Data Redis使用方式
java程序中操作String型的数据
@SpringBootTest
public class SpringDataRedisTest {
@Autowired
private RedisTemplate redisTemplate;
@Test
public void testString() {
//set get setex setnx
redisTemplate.opsForValue().set("city","北京");
String city=(String)redisTemplate.opsForValue().get("city");
System.out.println(city);
redisTemplate.opsForValue().set("code","1234",3, TimeUnit.MINUTES);
redisTemplate.opsForValue().setIfAbsent("lock","1");
redisTemplate.opsForValue().setIfAbsent("lock","2");
}
}
操作哈希类型的数据
@Test
public void testHash(){
//hset hget hdel hkeys hvals
HashOperations hashOperations = redisTemplate.opsForHash();
hashOperations.put("100","name","tom");
hashOperations.put("100","age","20");
String name = (String)hashOperations.get("100","name");
System.out.println(name);
Set keys=hashOperations.keys("100");
System.out.println(keys);
List values=hashOperations.values("100");
System.out.println(values);
hashOperations.delete("100","name");
}
列表类型
集合类型
有序集合类型
通用
Redis实战-黑马点评
业务核心
项目部署
记得修改MySQL和Redis的配置信息
短信登录
基于Session实现登录
1.短信验证吗发送
进行手机号格式的校验后,生成一个随机的六位数保存到session域即可
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合进行返回
return Result.fail("手机号格式错误");
}
//3.符合生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code",code);
//5.发送验证码
log.info("发送短信验证码成功:{}",code);
//返回ok
return Result.ok();
}
2.登录
校验手机号-校验验证码-查询用户-保存用户信息
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail(MessageConstant.PHONE_ERROR);
}
//2.校验验证码
Object cacheCode = session.getAttribute(UserConstant.CODE);
String code = loginForm.getCode();
if(code==null&&!cacheCode.toString().equals(code)){
//3.不一致 报错
return Result.fail(MessageConstant.CODE_ERROR);
}
//4.一致?根据手机号查询用户 select * from where phone = ? 通过MyBatisPlus实现简单的CRUD
User user = query().eq("phone", phone).one();
if(user==null){
//5.用户不存在创建新用户并保存
user = createUserWithPhone(phone);
}
//6.保存用户信息到session中
session.setAttribute("user",user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX +RandomUtil.randomNumbers(7));
//保存用户
save(user);
return user;
}
3.实现登录校验拦截器
创建LoginInterceptor 实现 HandlerInterceptor接口,crtl+i快速实现方法
package com.hmdp.Interceptor;
import ...
/**
* @author 刘宇
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user == null) {
//不存在 拦截 设置状态码
response.setStatus(401);
return false;
}
//4.不存在 拦截 存保存用户信息到ThreadLocal
UserHolder.saveUser((User) user);
//5.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
然后在配置类中进行配置
package com.hmdp.config;
import ...
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login");
}
}
4.隐藏用户敏感信息
业务层完成创建保存用户对象到session作用域的时候保存部分信息即可
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
5.Session共享问题
多态Tomcat并不共享Session的存储空间,当请求切换到不同的Tomcat服务时导致数据丢失的问题.
解决方案:
基于Redis替代session的业务流程
key的要求
- 复杂 唯一 不可预测
- 安全,长度适中
- 客户端能够携带这个key去获取数据
通过Hash结构来存储,以随机token作为key存储和获取用户数据.不用手机号作为key是因为手机号返回给前端时有泄漏的风险.
发送验证码环节,将生成的验证码存储到Redis中,并设置有效期.
stringRedisTemplate.opsForValue().set(RedisConstant.LOGIN_CODE_KEY +phone,code, Duration.ofMinutes(RedisConstant.LOGIN_CODE_TTL));
登录接口中,校验手机号,从Redis中获取验证码并校验,查询用户是否存在,不存在就完成注册,将用户信息保存到Redis中,以一个随机生成的token,并将User对象转换为Hash进行存储,并设置有效期.
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail(MessageConstant.PHONE_ERROR);
}
//2.校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstant.LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if(code==null&&!cacheCode.equals(code)){
//3.不一致 报错
return Result.fail(MessageConstant.CODE_ERROR);
}
//4.一致?根据手机号查询用户 select * from where phone = ? 通过MyBatisPlus实现简单的CRUD
User user = query().eq("phone", phone).one();
if(user==null){
//5.用户不存在创建新用户并保存
user = createUserWithPhone(phone);
}
// 保存用户信息到Redis中
//6.1生成一个随机的token
String token = UUID.randomUUID().toString();
//6.2将User对象转为Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create()
.ignoreNullValue()
.setFieldValueEditor((fieldName,fieldValue)->fieldName.toString()));
//6.3存储
stringRedisTemplate.opsForHash().putAll(RedisConstant.LOGIN_TOKEN_KEY+token,map);
//6.4设置token有效期
stringRedisTemplate.expire(RedisConstant.LOGIN_TOKEN_KEY+token,LOGIN_TOKEN_TTL, TimeUnit.MINUTES);
return Result.ok();
}
在拦截器中,从请求头中获取token,然后基于token获取用户的map对象,将查询到的map转换为UserDTO对象保存到ThreadLocal中,方便后续的接口使用,并刷新token有效期.(请求时不进行刷新会可能导致用户还未退出token就失效了)
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
//2.基于token获取Redis中的用户 map对象 存储手机号和验证码
String tokenKey = RedisConstant.LOGIN_TOKEN_KEY+token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
//3.判断用户是否存在
if(map.isEmpty()){
//不存在 拦截 设置状态码
response.setStatus(401);
return false;
}
//5.将查询到的Hash数据转换为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(map,new UserDTO(),false);
//判断用户是否存在
if (userDTO == null) {
response.setStatus(401);
return false;
}
//6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//7.刷新token有效期
stringRedisTemplate.expire(tokenKey, Duration.ofMinutes(RedisConstant.LOGIN_TOKEN_TTL));
//8.放行
return true;
}
登录拦截器的优化
当用户登录后一直访问的是不需要进行拦截校验的界面,就不会刷新token导致过期,登录令牌消失.
解决方案:再创建一个拦截器,进行分工,在登录校验之前进行拦截
把大部分的业务放在第一个拦截器中,第二个拦截器只需要进行判断ThreadLocal有没有user对象即可
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0);//默认拦截所有请求 设置先后顺序,order(i)i越小越靠前
package com.hmdp.Interceptor;
import ...
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
//为空 放行
return true;
}
//2.基于token获取Redis中的用户 map对象 存储手机号和验证码
String key = RedisConstant.LOGIN_USER_KEY +token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(key);
//3.判断用户是否存在
if(map.isEmpty()){
return true;
}
//5.将查询到的Hash数据转换为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(map,new UserDTO(),false);
//6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//7.刷新token有效期
stringRedisTemplate.expire(key,RedisConstant.LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.放行
return true;
}
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 判断是否进行拦截即可
if(UserHolder.getUser()==null){
//没有设置状态码
response.setStatus(401);
//拦截
return false;
}
return true;
}
商户查询缓存
1.什么是缓存
缓存就是数据交换的缓冲区(称为Cache),是存贮数据的临时地方,一般读写性能较高.
2.添加Redis缓存
思路
注意存储时的格式.
商铺缓存
public Result queryById(Long id) {
//1.从Redis查询商铺缓存
String key = RedisConstant.CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
//4.不存在 根据id查数据库
Shop shop = getById(id);
//5.不存在返回错误
if(shop==null){
return Result.fail(MessageConstant.SHOP_NOT_EXIST);
}
//6.存在 写入Redis 转为json字符串存储
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
商铺列表缓存
public Result queryList() {
String key = RedisConstant.SHOP_TYPE_KEY;
//1.从redis查询商铺列表 以shop:type:加随机的UUID作为key
String shopList = stringRedisTemplate.opsForValue().get(key);
//2.存在 返回
if (shopList != null && !shopList.isEmpty()) {
List<ShopType> shopTypes = JSONUtil.toList(shopList, ShopType.class);
return Result.ok(shopTypes); // 缓存存在,直接返回
}
//3.不存在 从数据库中查询 遍历集合
List<ShopType> shopTypes = query().orderByAsc("sort").list();
//4.不存在 报错
if(shopTypes==null){
return Result.fail(MessageConstant.SHOP_TYPE_NOT_EXIST);
}
//5.存在 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopTypes));
//6.返回结果
return Result.ok(shopTypes);
}
3.缓存更新策略
采用主动更新策略,由缓存的调用者,在更新数据库的时候同时更新缓存.
操作缓存和数据库时,会面临
1.删除缓存还是更新缓存?
更新缓存:每次更新数据库都需要更新缓存 可能这期间用户不会访问 导致无效操作多
删除缓存:更新数据库时,删除原来的缓存,即让缓存失效,查询时才更新缓存.
2.如何保证缓存与数据库的操作的同时成功和失败?
单体项目,将缓存和数据库操作放在一个事务
分布式系统 利用TCC等分布式事务方案(spring cloud)
3.先操作缓存还是先更新数据库?
总结:
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:
-
- 缓存命中则直接放回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
-
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
4.实现商铺缓存与数据库的双写一致
@Transactional
public Result update(Shop shop) {
//对id进行判断
Long id = shop.getId();
if(id==null){
return Result.fail(MessageConstant.SHOP_NOT_EXIST);
}
String key = RedisConstant.CACHE_SHOP_KEY+id;
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(key);
return Result.ok();
}
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会失效,这些请求都会打到数据库.
解决缓存穿透问题
不存在时写入空值
进行判断""
public Result queryById(Long id) {
//1.从Redis查询商铺缓存
String key = RedisConstant.CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if(shopJson!=null){
return Result.fail(MessageConstant.SHOP_NOT_EXIST);
}
//4.不存在 根据id查数据库
Shop shop = getById(id);
//5.不存在返回错误 将空值写入redis
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",RedisConstant.CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail(MessageConstant.SHOP_NOT_EXIST);
}
//6.存在 写入Redis 转为json字符串存储
stringRedisTemplate.opsForValue()
.set(key,JSONUtil.toJsonStr(shop),RedisConstant.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
缓存雪崩
缓存雪崩是指在同一大量时间的缓存key出现同时失效或者redis服务宕机,导致大量请求到达数据库带来巨大压力.
缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据带来巨大的冲击.
常见的解决方案:
- 互斥锁
- 逻辑过期
-
-
利用互斥锁解决缓存击穿问题
public Shop queryWithMutex(Long id){
//1.从Redis查询商铺缓存
String key = RedisConstant.CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否是空值
if(shopJson!=null){
return null;
}
//未命中
//实现缓存重建
//4.1获取互斥锁
String lockKey = RedisConstant.LOCK_SHOP_KEY+id;
Shop shop = null;
try {
//4.2判断是否获取成功
boolean isLockKey = tryLock(lockKey);
if(!isLockKey){
//4.3失败 休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功 根据id查询数据库
shop = getById(id);
//模拟重建的延时
Thread.sleep(200);
//5.不存在返回错误 将空值写入redis
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",RedisConstant.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.存在 写入redis
stringRedisTemplate.opsForValue()
.set(key,JSONUtil.toJsonStr(shop),RedisConstant.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
//8.返回
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);
}
基于逻辑过期方式解决缓存击穿问题
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//逻辑过期解决缓存击穿问题
public Shop queryWithLogicalExpire(Long id){
//1.从Redis查询商铺缓存
String key = RedisConstant.CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//3.缓存不存在 直接返回 店铺不存在
return null;
}
//4.命中 需要将json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断缓存是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//5.1未过期 返回店铺信息
return shop;
}
//5.2已过期 需要缓存重建
//6 缓存重建
//6.1 获取互斥锁
String lockKey = RedisConstant.LOCK_SHOP_KEY+id;
//6.2判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if(isLock){
// 6.3 成功 开启独立线程 实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//6.4返回过期的商铺信息
return shop;
}
缓存工具封装
package com.hmdp.utils;
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.constant.RedisConstant;
import com.hmdp.entity.Shop;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* @author 刘宇
*/
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//将任意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中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
//设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
//根据指定的key查询缓存,并反序列化为指定类型 利用缓存空值的方式解决缓存穿透问题
public <R,ID>R queryWithPassThrough(String keyPrefix, ID id, Class<R>type, Function<ID,R> dbFallBack,Long time, TimeUnit unit){
//1.从Redis查询商铺缓存
String key = keyPrefix+id;
String Json = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(Json)) {
//3.存在 直接返回
return JSONUtil.toBean(Json, type);
}
//判断命中的是否是空值
if(Json!=null){
return null;
}
R r = dbFallBack.apply(id);
//5.不存在返回错误 将空值写入redis
if(r==null){
stringRedisTemplate.opsForValue().set(key,"",RedisConstant.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
this.set(key,r,time,unit);
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//根据指定的key查询缓存,并且反序列化为指定类型 需要利用逻辑过期解决缓存击穿问题
public <R,ID>R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R>dbFallBack,Long time, TimeUnit unit){
//1.从Redis查询商铺缓存
String key = keyPrefix+id;
String Json = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(Json)) {
//3.缓存不存在 直接返回
return null;
}
//4.命中 需要将json反序列化为对象
RedisData redisData = JSONUtil.toBean(Json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(),type);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断缓存是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//5.1未过期 返回店铺信息
return r;
}
//5.2已过期 需要缓存重建
//6 缓存重建
//6.1 获取互斥锁
String lockKey = RedisConstant.LOCK_SHOP_KEY+id;
//6.2判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if(isLock){
// 6.3 成功 开启独立线程 实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//查询数据库
R r1 = dbFallBack.apply(id);
//写入redis
this.setWithLogicalExpire(key,r,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//6.4返回过期的商铺信息
return r;
}
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);
}
}
优惠券秒杀
全局唯一ID
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具.
Redis的自增命令(INCR)可以满足,为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他的信息.
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* @author 刘宇
*/
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1704067200L;
/**
* 序列号的位数
*/
private static final long COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
public Long nextId(String keyPrefix){
//1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSeconds=now.toEpochSecond(ZoneOffset.UTC);
long timestamp=nowSeconds-BEGIN_TIMESTAMP;
//2.生成序列号
//2.1获取当前日期 精确到天 好处:1.避免超过32位的key 2.方便统计
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//2.2自增长
long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date);
//3.拼接并返回 位运算 或运算
return timestamp<<COUNT_BITS|count;
}
}
还有其他的生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是 时间戳+计数器
实现秒杀下单
下单时需注意两点:
- 判断秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
//尚未开始
return Result.fail(MessageConstant.KILL_NOT_START);
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//已经结束
return Result.fail(MessageConstant.KILL_HAS_OVER);
}
//4.判断库存是否充足
if(voucher.getStock()<1){
return Result.fail(MessageConstant.UNDER_STOCK);
}
//5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
if(!success){
//扣减失败
return Result.fail(MessageConstant.UNDER_STOCK);
}
//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);
}
超卖问题
利用jmeter进行高并发的测试
结果显示有15%的异常,
查看数据库表发现超卖了8张优惠券
导致超卖的原因:
当库存为1时,进行判断库存是否大于0,高并发的情况下,多个线程同时进行查询库存,满足条件进行扣减,导致超卖.
悲观锁和乐观锁
利用乐观锁解决超卖
解决超卖问题
通过第二种即CAS法的判断后,库存却只卖出了24单.是因为并发冲突频繁在高并发环境下,多个线程可能同时尝试更新同一商品的库存。由于乐观锁在更新时才进行冲突判断,如果并发冲突频繁,就会导致大量的更新操作失败,从而只能成功处理一小部分订单。
把条件修改为stock>0,就能完美解决啦.?
实现一人一单
基本思路:添加一个队voucher_id的判断
//一人一单查询订单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id",userId).eq("voucher_id",voucherId).count();
if(count>0){
//用户已经买过
return Result.fail(MessageConstant.USER_HAS_BOUGHT);
}
但是,高并发的情况下,还是会导致可能会同时进行判断的情况.同样的可以通过加锁的方式进行处理.
通过对用户id上锁的方式,锁的范围尽量小。
1.synchronized
尽量锁代码块,而不是方法
2.我们要锁住整个事务,而不是锁住事务内部的代码。先获取锁再进行下单操作,操作完后,事务提交后才释放锁.
3.Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional
失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。
①引入AOP依赖,动态代理是AOP的常见实现之一
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
②暴露动态代理对象,默认是关闭的,在启动类添加注解
@EnableAspectJAutoProxy(exposeProxy = true)
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
//尚未开始
return Result.fail(MessageConstant.KILL_NOT_START);
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//已经结束
return Result.fail(MessageConstant.KILL_HAS_OVER);
}
//4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail(MessageConstant.UNDER_STOCK);
}
Long userId = UserHolder.getUser().getId();
//先获取锁再进行下单操作,操作完后,事务提交后才释放锁
synchronized (userId.toString().intern()) {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//一人一单查询订单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
//用户已经买过
return Result.fail(MessageConstant.USER_HAS_BOUGHT);
}
//5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
//扣减失败
return Result.fail(MessageConstant.UNDER_STOCK);
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1订单id
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//6.2用户id
voucherOrder.setUserId(userId);
//6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
集群状态下线程并发问题
复制一份来模拟集群环境,但注意端口需要重新配置
集群模式下,或在分布式的系统下,有多个JVM存在,每个JVM下都有自己的锁,每一个锁都可以有线程获取,于是就出现了并行运行
为了解决这个问题,我们需要让一个锁能在多个JVM进程间互斥
分布式锁
满足分布式系统或集群模式下多线程可见并且互斥的锁.
- 多线程可见
- 互斥
- 高可用
- 高性能
- 安全性
- ...
Redis的分布式锁
编写工具类
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author 刘宇
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
//获取锁
String key = KEY_PREFIX+name;
Boolean success = stringRedisTemplate
.opsForValue().setIfAbsent(key, threadId+"", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
修改部分代码
Long userId = UserHolder.getUser().getId();
//先获取锁再进行下单操作,操作完后,事务提交后才释放锁
//创建锁对象
SimpleRedisLock lock =
new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(1200);
if (!isLock) {
//获取锁失败,返回错误或重试
return Result.fail(MessageConstant.USER_HAS_BOUGHT);
}
//获取代理对象(事务)
try {
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
Redis分布式锁误删问题
业务阻塞导致锁超时释放,被线程2拿到,导致其他线程也能够同时执行业务,线程1业务回复后,会删除别人的锁.
解决:在释放锁的时候进行判断.即前面锁存进去的线程标识
不用线程ID是因为多线程的情况下多个JVM维护的线程ID可能冲突.
解决Redis分布式锁误删问题
1.在获取锁时存入线程标识(可用UUID表示)
2.在释放锁时先获取锁中的线程表示,判断时是否与当前线程标识一致
一致才释放锁
修改工具类
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author 刘宇
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString()+"-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//线程表示
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//获取锁
String key = KEY_PREFIX+name;
Boolean success = stringRedisTemplate
.opsForValue().setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//获取标识
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//判断标识是否一致
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);
if(threadId.equals(id)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
}
分布式锁的原子性问题
由于判断锁标识和释放是两个动作,在释放锁之间产生了阻塞,为了解决这个问题,判断锁标识和释放锁得成一个原子性的操作,即同时执行
Lua脚本解决多条命令原子性问题
基于Lua脚本改进分布式锁的释放逻辑
改进方法,通过Lua脚本将判断和删除置于一个语句中,即保证其的原子性,避免阻塞导致的误删问题.
利用Redis的set nx ex获取锁 并设置过期时间 保存线程标识
释放锁时比较标识和自己是否一致 一致则释放锁
特性:
- 利用set nx ex 满足互斥性
- 利用set ex保证故障时锁依然能够释放,避免死锁 提高安全性
- 利用Redis集群保证高可用和高并发特性
package com.hmdp.utils;
import cn.hutool.core.io.resource.Resource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author 刘宇
*/
public class SimpleRedisLock implements ILock {
//锁名称
private String name;
private StringRedisTemplate stringRedisTemplate;
//key前缀
private static final String KEY_PREFIX = "lock:";
//ID前缀
private static final String ID_PREFIX = UUID.randomUUID().toString()+"-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/*
* 加载Lua脚本
*/
static{
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
/**
* 获取锁
* @param timeoutSec 设置超时时间
* @return 返回获取结果
*/
@Override
public boolean tryLock(long timeoutSec) {
//线程表示
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//获取锁
String key = KEY_PREFIX+name;
Boolean success = stringRedisTemplate
.opsForValue().setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock(){
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId());
}
}
基于Redis分分布式锁优化
基于setnx实现的分布式锁存在下面的问题:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只有尝试一次就返回false,没有重试机会
- 超时释放:锁超时释放虽然可以避免死锁,但如果执行业务耗时时间长,也会导致锁释放,存在安全隐患
- 主从一致性:如果redis提供了主从集群,主从同步存在延迟,当注宕机时,如果从并同步主中的锁数据,则会出现锁实现
通过Redisson提供的锁来解决这些问题
Redisson
编写配置类进行配置
@Configuration
public class RedissonConfig {
//放给容器管理
@Bean
public RedissonClient redisson() {
//配置
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setPassword("123456");
//创建RedissonClient对象
return Redisson.create(config);
}
}
修改代码,直接通过注入redissonClient获取锁对象.
// SimpleRedisLock lock =
// new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
然后通过jmeter进行测试即可.
Redisson可重入锁原理
现在我们的分布式锁就有了可重入性.Redisson内部释放锁,并不是直接执行del
命令将锁给删除,而是将锁以hash
数据结构的形式存储在Redis中,每次获取锁,都将value
的值+1,每次释放锁,都将value的值-1,只有锁的value值归0时才会真正的释放锁,从而确保锁的可重入性
由于整体逻辑较复杂,为了保证进行判断和释放锁的原子性,还是利用Lua脚本进行确保原子性.
获取锁
释放锁
Redisson的锁重试和WatchDog机制
总结:
- 可重入:利用hash结构记录线程id和重入次数
- 可重试:利用信号量和PubSub功能实现等待,唤醒,获取锁失败的重试机制
- 超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间
Redisson分布式锁主从一致性问题
由于主节点和从节点发生更新数据时,主节点向从节点进行同步,会有延时,导致主从一致性问题.
秒杀优化
异步秒杀
由于大部分的业务功能都是对数据库的操作,并且由于还要执行分布式锁,效率较低.需要进行优化
.
主要思路是将判断秒杀资格和校验一人一单放在两个不同的线程中,并且判断秒杀资格主要是对数据库的操作,影响效率,将其放在redis中进行操作,即可提升效率
将判断秒杀库存和校验一人一单都放到redis中进行操作,可以大大提升效率,将库存数量通过String-Value结构进行储存,由于一人一单,可用set集合进行存储秒杀用户的id.
基于Redis完成秒杀资格的判断
首先修改保存秒杀信息的方法,添加一个将库存信息写入Redis的逻辑.
//保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(RedisConstant.SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
编写lua脚本文件
-- 优惠券id
local voucherId = ARGV[1];
-- 用户id
local userId = ARGV[2];
-- 库存的key
local stockKey = 'seckill:stock:' .. voucherId;
-- 订单key
local orderKey = 'seckill:order:' .. voucherId;
-- 判断库存是否充足 get stockKey > 0 ?
local stock = redis.call('GET', stockKey);
if (tonumber(stock) <= 0) then
-- 库存不足,返回1
return 1;
end
-- 库存充足,判断用户是否已经下过单 SISMEMBER orderKey userId
if (redis.call('SISMEMBER', orderKey, userId) == 1) then
-- 用户已下单,返回2
return 2;
end
-- 库存充足,没有下过单,扣库存、下单
redis.call('INCRBY', stockKey, -1);
redis.call('SADD', orderKey, userId);
-- 返回0,标识下单成功
return 0;
修改下单代码
public Result seckillVoucher(Long voucherId) {
//1.执行lua脚本
Long userId = UserHolder.getUser().getId();
Long a = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString());
//2.判断结果是否为0
int result = a.intValue();
if(result!=0){
//2.1 =1 库存不足
//2.2 =2 你已经买过了
return Result.fail(result==1?MessageConstant.STOCK_NOT_ENOUGH:MessageConstant.USER_HAS_BOUGHT);
}
//2.1不为0 代表没有购买资格
long orderId = redisIdWorker.nextId("order");
// TODO 保存阻塞队列
//2.2为0 有购买资格 把下单信息保存到阻塞队列
//3.返回订单id
return Result.ok(orderId);
}
基于阻塞队列实现秒杀下单
通过lua脚本执行判断有无下单资格和库存是否充足,如果条件都满足则认为这个订单是必然成功的,已经可以给用户返回一个订单id表示下单成功.
下面就是执行异步下单:
创建一个阻塞队列和初始化一个线程池,从阻塞队列中不断去取订单然后创建订单
//创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<VoucherOrder>(1024*1024);
//创建线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//当前类初始化完毕后 线程池开始执行对线程的初始化
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandle());
}
//匿名内部类实现业务功能
private class VoucherOrderHandle implements Runnable{
//初始化完毕开始执行
@Override
public void run() {
while(true){
//1.获取队列中的订单信息 有元素才会继续执行 否则一直卡住
try {
VoucherOrder voucherOrder = orderTasks.take();
//2.创建订单
handleVoucherOrder(voucherOrder);
} catch(Exception e) {
log.error("订单异常信息处理",e);
}
}
}
}
//创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户id
Long userId = voucherOrder.getUserId();
//2.创建锁对象
RLock lock = redissonClient.getLock("lock:order:"+userId);
//3.获取锁
boolean isLock = lock.tryLock();
//4.判断是否获取锁成功
if(!isLock){
log.error("不允许重复下单");
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
//释放锁
lock.unlock();
}
}
依赖于JVM存储,内存有限...
无法持久化,数据安全性无保障...
Redis消息队列
认识消息队列(MQ message queue)
基于list结构的消息队列
优点:
- 利用redis存储,不受限于JVM内存上限
- 基于redis的持久化机制 数据安全性有保障
- 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者
基于PubSub实现消息队列
优点:
采用发布订阅模型,支持多生产多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出数据丢失
基于Stream的消息队列
单消费者模式
Stream类型消息队列的XREAD命令特点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
消费者组
达人探店
发布探店笔记
修改保存文件到本地的路径,然后进行测试.
成功保存至本地.
途中遇到了一个bug,登录成功后,上传图片时又会跳转到登录的界面,这是因为前面修改nginx.conf后只启动了一个服务器.
查看探店笔记
@Override
public Result queryBlogById(Long id) {
//1.查询blog
Blog blog = getById(id);
if(blog ==null){
return Result.fail(MessageConstant.BLOG_NOT_EXIST);
}
//2.查询blog相关用户
queryBlogUser(blog);
return Result.ok(blog);
}
点赞功能
同一个用户只能点赞一次,再次点赞则取消点赞
如果当前用户已经点赞,则点赞高亮显示(前端)
点赞属于高频变化的数据,如果使用MySQL的话需要频繁查询数据库,这对数据库造成了巨大的压力.使用redis的set类型来实现.
因为set类型:
- 不重复,符合业务的特点,一个用户只能点赞一次
- 高性能,Set集合内部实现了高效的数据结构(Hash表)
- 灵活性,Set集合可以实现一对多,一个用户可以点赞多个博客,符合实际的业务逻辑
package com.hmdp.service.impl;
import ...
@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.queryBlogUser(blog);
//3.查询blog是否被点赞
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
//1.查询blog
Blog blog = getById(id);
if(blog ==null){
return Result.fail(MessageConstant.BLOG_NOT_EXIST);
}
//2.查询blog相关用户
queryBlogUser(blog);
//3.查询blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
private void isBlogLiked(Blog blog) {
//1.获取登录用户
Long userId = UserHolder.getUser().getId();
//2.判断是否点赞过
String key = "blog:liked:"+blog.getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isMember));
}
@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());
}
}else{
//4.已点赞 取消点赞
//4.1数据库点赞数量-1
boolean isSuccess = update().setSql("liked=liked-1").eq("id", id).update();
//4.2把用户从Redis的set集合中移除
if(isSuccess){
stringRedisTemplate.opsForSet().remove(key,userId.toString());
}
}
return Result.ok();
}
private void queryBlogUser(Blog blog ){
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
点赞排行榜
点赞需要按照时间或者点赞数量等进行排序.
Set
是无需的,无法满足这个需求,虽然 List
有序,但是不唯一,查找效率也比较低,所以也不推荐使用,此时我们就可以选择使用SortedSet
这个数据结构,它完美的满足了我们所有的需求:唯一、有序、查找效率高。
利用时间戳作为分数,显示
package com.hmdp.service.impl;
import ...
@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.queryBlogUser(blog);
//3.查询blog是否被点赞
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
//1.查询blog
Blog blog = getById(id);
if(blog ==null){
return Result.fail(MessageConstant.BLOG_NOT_EXIST);
}
//2.查询blog相关用户
queryBlogUser(blog);
//3.查询blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
private void isBlogLiked(Blog blog) {
//1.获取登录用户
UserDTO user = UserHolder.getUser();
if(user==null){
//未登录
return;
}
Long userId = user.getId();
//2.判断是否点赞过
String key = "blog:liked:"+blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score!=null);
}
@Override
public Result likeBlog(Long id) {
//1.获取登录用户
Long userId = UserHolder.getUser().getId();
//2.判断是否点赞过
String key = RedisConstant.BLOG_LIKED_KEY +id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//3.如果没有点赞 可以点赞
if(score==null){
//3.1数据库点赞数+1
boolean isSuccess = update().setSql("liked=liked+1").eq("id", id).update();
//3.2保存用户到redis的set集合
if(isSuccess){
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
}
}else{
//4.已点赞 取消点赞
//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 queryBlogLikes(Long id) {
//1.查询top5的点赞用户
String key = RedisConstant.BLOG_LIKED_KEY+id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if(top5==null||top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
//2.解析出用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 根据id降序排序 select * from tb_user where id in(1,5) order by field(id, 1, 5)
List<UserDTO> userDTOList = userService.list(new LambdaQueryWrapper<User>()
.in(User::getId, ids)
.last("order by field (id," + idStr + ")"))
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
//4.返回
return Result.ok(userDTOList);
}
private void queryBlogUser(Blog blog ){
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
好友关注
关注和取关
查询是否关注,即是否在the_follow表中,关注和取关对应插入和删除(Put请求)
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//1.获取登录用户id
Long userId = UserHolder.getUser().getId();
//2.未关注
if(isFollow){
//2.1关注 插入数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
}else{
//2.2取关 删除
remove(new QueryWrapper<Follow>()
.eq("user_id",userId)
.eq("follow_user_id",followUserId));
}
return Result.ok();
}
@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);
}
}
redis实现共同关注功能
我们需要查询出两个用户的共同关注对象,这就需要用到交集,对于求交集,可以用Redis中的set集合.修改关注的代码,添加到保存到Redis中
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//1.获取登录用户id
Long userId = UserHolder.getUser().getId();
//2.未关注
if(isFollow){
//2.1关注 插入数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean save = save(follow);
if(save){
//存到redis
String key = RedisConstant.FOLLOWS +userId;
stringRedisTemplate.opsForSet().add(key,followUserId.toString());
}
}else{
//2.2取关 删除
boolean remove = remove(new QueryWrapper<Follow>()
.eq("user_id", userId)
.eq("follow_user_id", followUserId));
//从Redis中移除
if (remove) {
stringRedisTemplate.opsForSet().remove(RedisConstant.FOLLOWS +userId);
}
}
return Result.ok();
}
关注推送
关注推送也叫Feed流,即投喂.为用户持续的提供"沉浸式"的体验,通过无限下拉刷新获取新的信息.
拉模式:由于读取有延时,可能同时读取大量的消息,导致崩溃
推模式:内存占用较高
推拉结合:读写混合
基于推模式实现关注推送功能
传统分页的问题
为避免这个问题,采用滚动分页模式,查询不依赖角标而是查询最后
利用sortset来实现滚动分页
@Override
public Result saveBlog(Blog blog) {
//1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
//2.保存探店笔记
boolean isSuccess = save(blog);
if(!isSuccess){
return Result.fail(MessageConstant.CREATE_BLOG_FAIL);
}
//3.查询笔记作者的所有粉丝 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){
//4.1获取粉丝id
Long userId = follow.getUserId();
//4.2 推送
String key = RedisConstant.FEED+userId;
stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
}
//5.返回id
return Result.ok(blog.getId());
}
滚动分页查询收件箱
记住上一次查询的最小值.
- 插入数据:首先,将数据插入到zset中,其中每个数据项都会有一个唯一的分数(通常是一个时间戳或自增ID)。
- 获取分页数据:使用
ZRANGEBYSCORE
命令根据分数范围来获取分页数据。 - 记录分页游标:每次查询后,记录当前分页的结束分数(或时间戳),作为下一次查询的起始游标。
问题:当时间戳一样时,会出现问题.
滚动分页查询参数:
max: 当前时间戳 | 上一次查询最小值
min:0
offset:0 | 在上一次的结果中,与最小值一样的元素的个数
count:3
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1、查询收件箱
Long userId = UserHolder.getUser().getId();
String key = RedisConstant.FEED + userId;
// ZREVRANGEBYSCORE key Max Min LIMIT offset count
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 2、判断收件箱中是否有数据
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 3、收件箱中有数据,则解析数据: blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; // 记录当前最小值
int os = 1; // 偏移量offset,用来计数
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2
// 获取id
ids.add(Long.valueOf(tuple.getValue()));
// 获取分数(时间戳)
long time = tuple.getScore().longValue();
if (time == minTime) {
// 当前时间等于最小时间,偏移量+1
os++;
} else {
// 当前时间不等于最小时间,重置
minTime = time;
os = 1;
}
}
// 4、根据id查询blog(使用in查询的数据是默认按照id升序排序的,这里需要使用我们自己指定的顺序排序)
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id",ids).last("ORDER BY field (id," + idStr + ")").list();
// 设置blog相关的用户数据,是否被点赞等属性值
for (Blog blog : blogs) {
// 查询blog有关的用户
queryBlogUser(blog);
// 查询blog是否被点赞
isBlogLiked(blog);
}
// 5、封装并返回
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setOffset(os);
scrollResult.setMinTime(minTime);
return Result.ok(scrollResult);
}
附近商铺
GEO数据结构
将商铺写入Redis
@Test
public void loadShopListToCache() {
// 1、获取店铺数据
List<Shop> shopList = shopService.list();
// 2、根据 typeId 进行分类
// Map<Long, List<Shop>> shopMap = new HashMap<>();
// for (Shop shop : shopList) {
// Long shopId = shop.getId();
// if (shopMap.containsKey(shopId)){
// // 已存在,添加到已有的集合中
// shopMap.get(shopId).add(shop);
// }else{
// // 不存在,直接添加
// shopMap.put(shopId, Arrays.asList(shop));
// }
// }
// 使用 Lambda 表达式,更加优雅(优雅永不过时)
Map<Long, List<Shop>> shopMap = shopList.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
// 3、将分好类的店铺数据写入redis
for (Map.Entry<Long, List<Shop>> shopMapEntry : shopMap.entrySet()) {
// 3.1 获取 typeId
Long typeId = shopMapEntry.getKey();
List<Shop> values = shopMapEntry.getValue();
// 3.2 将同类型的店铺的写入同一个GEO ( GEOADD key 经度 维度 member )
String key = "shop:geo:" + typeId;
// 方式一:单个写入(这种方式,一个请求一个请求的发送,十分耗费资源,我们可以进行批量操作)
// for (Shop shop : values) {
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()),
// shop.getId().toString());
// }
// 方式二:批量写入
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for (Shop shop : values) {
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),
new Point(shop.getX(), shop.getY())));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
实现附近商户功能
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
String key = RedisConstant.SHOP_GEO_KEY+typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
//4.解析出id
if(results==null){
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
List<Long> ids = new ArrayList<>(list.size());
HashMap<Object, Object> distanceMap = new HashMap<>(list.size());
//4.1截取from~end的部分
list.stream().skip(from).forEach(result->{
//4.2获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
//获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
//5.根据id查询店铺
String idStr = StrUtil.join(",",ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shops) {
Double dis = (Double) distanceMap.get(shop.getId().toString());
shop.setDistance(dis);
}
//6.返回
return Result.ok(shops);
}
用户签到
BitMap
通过bitmap实现签到功能
@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 = RedisConstant.USER_SIGN_KEY + userId;
//4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
//5.写入redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
return Result.ok();
}
统计连续签到
@Override
public Result signCount() {
// 1、获取签到记录
// 获取当前登录用户
Long userId = ThreadLocalUtls.getUser().getId();
// 获取日期
LocalDateTime now = LocalDateTime.now();
// 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
// 2、判断签到记录是否存在
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
// 3、获取本月的签到数(List<Long>是因为BitFieldSubCommands是一个子命令,可能存在多个返回结果,这里我们知识使用了Get,
// 可以明确只有一个返回结果,即为本月的签到数,所以这里就可以直接通过get(0)来获取)
Long num = result.get(0);
if (num == null || num == 0) {
// 二次判断签到结果是否存在,让代码更加健壮
return Result.ok(0);
}
// 4、循环遍历,获取连续签到的天数(从当前天起始)
int count = 0;
while (true) {
// 让这个数字与1做与运算,得到数字的最后一个bit位,并且判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
} else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return Result.ok(count);
}
HyperLogLog
实现UV统计
/**
* 测试 HyperLogLog 实现 UV 统计的误差
*/
@Test
public void testHyperLogLog() {
String[] values = new String[1000];
// 批量保存100w条用户记录,每一批1个记录
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
values[j] = "user_" + i;
if (j == 999) {
// 发送到Redis
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("count = " + count);
}