《黑马点评》Redis高并发项目实战笔记(上)P1~P31

 P1 Redis企业实战课程介绍

P2 短信登录 导入黑马点评项目

首先在数据库连接下新建一个数据库hmdp,然后右键hmdp下的表,选择运行SQL文件,然后指定运行文件hmdp.sql即可(建议MySQL的版本在5.7及以上):

下面这个hm-dianping文件是项目源码。在IDEA中打开。

记得要修改数据库连接和Redis连接的密码:

运行程序后尝试访问:localhost:8081/shop-type/list 进行简单测试:

将nginx文件复制到一个没有中文路径的目录,然后点击nginx.exe运行:

在nginx所在目录打开CMD窗口,输入命令:start nginx.exe

访问:localhost:8080,选择用手机模式看,可以看到具体的页面:

P3 短信登录 基于session实现短信登录的流程

点击发送验证码可以看到验证码发送成功:

P4 短信登录 实现发送短信验证码功能

 controller/UserController中写入如下代码:

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

service/IUserService中写入如下代码:

public interface IUserService extends IService<User> {Result sendCode(String phone, HttpSession session);
}

service/impl/UserServiceImpl中写入如下代码:

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result sendCode(String phone, HttpSession session) {//校验手机号if(RegexUtils.isPhoneInvalid(phone)){//不符合return Result.fail("手机号格式错误");}//生成验证码String code = RandomUtil.randomNumbers(6);//保存验证码到sessionsession.setAttribute("code",code);//发送验证码log.debug("发送短信验证码成功,验证码:"+code);return Result.ok();}
}

P5 短信登录 实现短信验证码登录和注册功能

service/impl/UserServiceImpl的UserServiceImpl中写入如下代码:

  @Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {String phone = loginForm.getPhone();//校验手机if(RegexUtils.isPhoneInvalid(phone)){return Result.fail("手机号格式错误");}//校验验证码Object cacheCode = session.getAttribute("code");String code = loginForm.getCode();if(cacheCode==null || !cacheCode.toString().equals(code)){//不一致,报错return Result.fail("验证码错误");}//一致根据手机号查用户User user = query().eq("phone", phone).one();//判断用户是否存在if(user==null){//不存在,创建用户并保存user = createUserWithPhone(loginForm.getPhone());}//保存用户信息到sessionsession.setAttribute("user",user);return null;}private User createUserWithPhone(String phone){//1.创建用户User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));//2。保存用户save(user);return user;}

前端点击发送验证码,后端直接把验证码摘抄后输入:

 

勾选协议然后确定登录,出现如下代码:

然后看到数据库后台记录已更新:

P6 短信登录 实现登录校验拦截器

preHandle前置拦截:

postHandle后置拦截:

afterCompletion视图渲染之后返回给用户之前:

在utils下面编写一个LoginInterceptor类,实现preHandle和afterCompletion这两个方法(这里User和UserDto的问题,我推荐的是统一使用UserDto,采用BeanUtils里的copy方法即可):

public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//获取sessionHttpSession session = request.getSession();//获取用户User user = (User) session.getAttribute("user");//判断用户是否存在if(user==null){response.setStatus(401);return false;}UserDTO userDTO = new UserDTO();BeanUtils.copyProperties(user,userDTO);//存在,保存用户信息的ThreadLocalUserHolder.saveUser(userDTO);//放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户UserHolder.removeUser();}
}

在config下面创建一个MvcConfig类:

通过addInterceptors方法来添加拦截器,registry是拦截器的注册器。

用.excludePathPatterns来排除不需要拦截的路径。在这里code、login、bloghot、shop、shopType、upload和voucher等都不需要拦截。

@Configuration
public class MvcConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry){registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/code","/user/login","/upload/**","/blog/hot","/shop/**","/shop-type/**","/voucher/**");}
}

输入手机号码点击获取验证码,写入返回后端的验证码,勾选协议之后,登录会直接返回首页,此时看我的个人主页没问题:

P7 短信登录 隐藏用户敏感信息

在P6已将User转为UserDTO返回给前端。

P8 短信登录 session共享的问题分析

多台Tomcat并不共享session存储空间,当请求切换不同Tomcat服务器时会导致数据丢失的问题。

session的替代方案应该满足:1.数据共享。2.内存存储。3.key、value结构。

P9 短信登录 Redis代替session的业务流程

想要保存用户的登录信息有2种方法:1.用String类型。2.用Hash类型。

String类型是以JSON字符串格式来保存,比较简单直观,但是占用内存比较多(因为有name和age这类的json格式):

Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少:

以随机的token作为key来存储用户的数据,token是用一个随机的字符串。

P10 短信登录 基于Redis实现短信登录

在UserServiceImpl中写入如下代码(调用StringRedisTemplate中的set方法进行数据插入,最好在key的前面加入业务前缀以示区分,形成区分):

@Resource
private StringRedisTemplate stringRedisTemplate;

在sendCode这个方法里将保存验证码的代码替换为下面:

//保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);

在login这个方法里进行如下2处修改:

 首先是校验验证码:

//校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

然后是添加把用户信息添加到Redis的逻辑:

//7.保存用户信息到redis----------------
//7.1 随机生成Token作为登录令牌
String token = UUID.randomUUID().toString(true);
//7.2 将User对象转为Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
//7.3 存储
stringRedisTemplate.opsForHash().putAll("login:token:"+token,userMap);
//7.4设置token有效期
String tokenKey = LOGIN_USER_KEY+token;
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);

在MvcConfig类上有@Configuration注解,说明是由Spring来负责依赖注入。 

在MvcConfig类中要编写如下的代码:

@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry){registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/user/code","/user/login","/upload/**","/blog/hot","/shop/**","/shop-type/**","/voucher/**");}
}

 在utils下的LoginInterceptor中写入如下代码:

public class LoginInterceptor implements HandlerInterceptor {@Resourceprivate StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate){this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//TODO;1.获取请求头中的tokenString token = request.getHeader("authorization");if(StrUtil.isBlank(token)){//不存在,拦截,返回401状态码response.setStatus(401);return false;}//TODO:2.基于TOKEN获取redis的用户Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);//判断用户是否存在if(userMap.isEmpty()){//不存在,拦截,返回401状态码response.setStatus(401);return false;}//TODO:3.将查询到的Hash数据转化为UserDTO对象UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//TODO:4.存在,保存用户信息的ThreadLocalUserHolder.saveUser(userDTO);//TODO:5.刷新token有效期stringRedisTemplate.expire(LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);//放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户UserHolder.removeUser();}
}

测试:首先把Redis和数据库都启动。 原始的项目的Redis的服务器ID需要更改为自己的。点击发送验证码,redis中有记录,没问题:

但点击登录的时候会报一个无法将Long转String的错误。因为用的是stringRedisTemplate要求所有的字段都是string类型的。

需要对UserServiceImpl中如下的位置进行修改:

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));

效果如下:

P11 短信登录 解决状态登录刷新问题

现在只有在用户访问拦截器拦截的页面才会刷新页面,假如用户访问的是不需要拦截的页面则不会导致页面的刷新。

现在的解决思路是:新增一个拦截器,拦截一切路径。

复制LoginInterceptor变成一份新的RefreshTokenInterceptor,把下面几处地方改为return true即可:

LoginInterceptor的代码变成如下:

public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.判断是否需要拦截(ThreadLocal中是否有用户)if(UserHolder.getUser()==null){//没有,需要拦截,设置状态码response.setStatus(401);//拦截return false;}//放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户UserHolder.removeUser();}
}

现在还需要在MvcConfig里面对拦截器进行更新配置,需要(用order)调整拦截器的执行顺序: 

@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry){registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/code","/user/login","/upload/**","/blog/hot","/shop/**","/shop-type/**","/voucher/**").order(1);registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}

P12 什么是缓存

缓存就是数据交换的缓冲区,是存储数据的临时地方,一般读写性能较高。

缓存作用:降低后端负载;提高读写的效率,降低响应时间。

缓存成本:数据一致性成本(数据库里的数据如果发生变化,容易与缓存中的数据形成不一致)。代码维护成本高(搭建集群)。运营成本高。

P13 添加商户缓存

在ShopController类的queryShopById方法中:

@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {return Result.ok(shopService.queryById(id));
}

在IShopService接口中编写如下代码:

public interface IShopService extends IService<Shop> {Object queryById(Long id);
}

在ShopServiceImpl类的queryById方法中编写具体代码:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Object 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);}//4.不存在,根据id查询数据库Shop shop = getById(id);//5.不存在,返回错误if(shop==null){return Result.fail("店铺不存在!");}//6.存在,写入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));return Result.ok(shop);}
}

 核心是通过调用hutool工具包中的JSONUtil类来实现对象转JSON(方法:toJsonStr(对象))和JSON转对象(方法:toBean(json,Bean的类型))。

P14 缓存练习题分析

TODO:对分类进行缓存。

P15 缓存更新策略

主动更新:编写业务逻辑,在修改数据库的同时,更新缓存。

适用于高一致性的需求:主动更新,以超时剔除作为兜底方案。

主动更新策略:

1.由缓存的调用者,在更新数据库的同时更新缓存。(一般情况下使用该种方案)

2.缓存与数据库聚合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存的一致性问题。

3.调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致。

对1进行分析:

1.选择删除缓存还是更新缓存?如果是更新缓存:每次更新数据库都会更新缓存,无效的写操作比较多。删除缓存:更新数据库时让缓存失效,查询时再更新缓存。

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

单体系统:将缓存与数据库操作放在一个事务。

分布式系统:利用TCC等分布式事务方案。

3.先操作缓存还是先操作数据库?

先删缓存,再操作(写)数据库:

先操作(写)数据库,再删除缓存(出现的概率比较低)

要求线程1来查询的时候缓存恰好失效了->在写入缓存的时候突然来了线程2,对数据库的数据进行了修改->此时线程1写回缓存的是旧数据。

P16 实现商铺缓存与数据库的双写一致

给查询商铺的缓存添加超时剔除和主动更新的策略。

修改ShopController中的业务逻辑,满足下面要求:

1.根据id查询商铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。

2.根据id修改店铺时,先修改数据库,再删除缓存。

首先修改ShopServiceImpl的redis过期时间:

stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

修改ShopController中的updateShop方法:

@PutMapping
public Result updateShop(@RequestBody Shop shop) {// 写入数据库return Result.ok(shopService.update(shop));
}

向IShopService接口中添加update方法:

Object update(Shop shop);

向ShopServiceImpl类中添加update方法:

@Override
public Object update(Shop shop) {Long id = shop.getId();if(id == null){return Result.fail("商铺id不存在");}updateById(shop);stringRedisTemplate.delete(CACHE_SHOP_KEY + id);return Result.ok();
}

首先删除缓存中的数据,然后看SQL语句是否执行,是否加上了TTL过期时间。

在PostMan中访问http://localhost:8081/shop,然后修改101茶餐厅为102茶餐厅:

 注意要发送的是PUT请求,请求的内容如下:

{"area": "大关","openHours": "10:00-22:00","sold": 4215,"address": "金华路锦昌文华苑29号","comments": 3035,"avgPrice": 80,"score": 37,"name": "102茶餐厅","typeId": 1,"id": 1
}

然后去数据库看是否名称更新为102茶餐厅,然后看缓存中的数据是否被删除,用户刷新页面看到102茶餐厅,缓存中会有最新的数据。

P17 缓存穿透的解决思路

缓存穿透指的是客户端请求的数据在缓存中和数据库中都不存在,使得缓存永远不会生效,请求都会打到数据库。

2种解决方法:

1.缓存空对象。优点:实现简单,维护方便。缺点:额外的内存消耗。可能造成短期的不一致(可以设置TTL)。

2.布隆过滤。在客户端和Redis之间加个布隆过滤器(存在不一定存在,不存在一定不存在,有5%的错误率)。

优点:内存占用较少,没有多余key。缺点:实现复杂,存在误判可能。

P18 编码解决商铺查询的缓存穿透问题

下图是原始的:

下面是更改后的:

在ShopServiceImpl类里对queryById方法进行修改:

@Override
public Object 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);}//上面是有值的情况,下面是无值的2种情况:A:空字符串。B:null。if(shopJson != null){return Result.fail("店铺信息不存在!");}//4.不存在,根据id查询数据库Shop shop = getById(id);//5.不存在,返回错误if(shop==null){stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);return Result.fail("店铺不存在!");}//6.存在,写入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}

测试:

localhost:8080/api/shop/1此时是命中数据。

localhost:8080/api/shop/0此时未命中数据。打开缓存可以看到缓存的是空,并且TTL是200秒。

总结缓存穿透:用户请求的数据在缓存中和数据库中都不存在,不断发起请求,会给数据库造成巨大压力。

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

P19 缓存雪崩问题及解决思路

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

解决方案:

1.(解决大量缓存key同时失效)给不同Key的TTL添加随机值。

2.(解决Redis宕机)利用Redis集群提高服务的可用性。

3.给缓存业务添加降级限流策略。

4.给业务添加多级缓存(浏览器可以有缓存,nginx可以有缓存,redis可以有缓存,数据库可以有缓存)。

P20 缓存击穿问题及解决方案

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

解决方案:

1.互斥锁。由获取互斥锁成功的线程来查询数据库重建缓存数据。缺点:未获得互斥锁的线程需要等待,性能略差。

2.逻辑过期。设置一个逻辑时间字段,查询缓存的时候检查逻辑时间看是否已过期。如果某个线程获取到互斥锁就开启新线程,由新线程查询数据库重建缓存数据。

其它线程在获取互斥锁失败后不会等待,而是直接返回过期的数据。只有当缓存重建完毕之后释放锁,新线程才会读到最新的数据。

互斥锁优点:

互斥锁没有额外的内存消耗:因为逻辑过期需要维护一个逻辑过期的字段,有额外内存消耗。

互斥锁可以保证强一致性,所有线程拿到的是最新数据。实现也很简单。

互斥锁缺点:

线程需要等待,性能受到影响。可能会有死锁的风险。

逻辑过期优点:

线程无需等待,性能较好。

逻辑过期缺点:

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

P21 利用互斥锁解决缓存击穿问题

在ShopServiceImpl类中定义一个tryLock方法(在Redis中的setnx相当于setIfAbsent方法。)

public boolean tryLock(String key){Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);
}

在ShopServiceImpl类中定义一个unLock方法用于解锁。

public void unLock(String key){stringRedisTemplate.delete(key);
}

在ShopServiceImpl类中定义一个queryWithPassThrough方法。

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.存在,直接返回Shop shop = JSONUtil.toBean(shopJson, Shop.class);return shop;}//上面是有值的情况,下面是无值的2种情况:A:空字符串。B:null。if(shopJson != null){return null;}//4.不存在,根据id查询数据库Shop shop = getById(id);//5.不存在,返回错误if(shop==null){stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);return null;}//6.存在,写入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);return shop;
}

在ShopServiceImpl类中定义一个queryWithMutex方法:

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.存在,直接返回Shop shop = JSONUtil.toBean(shopJson, Shop.class);return shop;}//上面是有值的情况,下面是无值的2种情况:A:空字符串。B:null。if(shopJson != null){return null;}//4.实现缓存重建//4.1 获取互斥锁String lockKey = LOCK_SHOP_KEY+id;Shop shop = null;try {boolean isLock = tryLock(lockKey);//4.2 判断是否获取成功if(!isLock){//4.3 失败,则休眠并重试Thread.sleep(50);return queryWithMutex(id);}//4.4 获取互斥锁成功,根据id查询数据库shop = getById(id);//模拟重建的延时Thread.sleep(200);//5.数据库查询失败,返回错误if(shop==null){stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);return null;}//6.存在,写入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);}finally {//7.释放互斥锁unLock(lockKey);}//8.返回return shop;
}

在ShopServiceImpl类中修改queryById,调用queryWithMutex:

public Object queryById(Long id) {//缓存穿透//Shop shop = queryWithPassThrough(id);//互斥锁解决缓存击穿Shop shop = queryWithMutex(id);return Result.ok(shop);
}

测试:

定义1000个线程,Ramp-Up时间为5。

请求地址:localhost:8081/shop/1。

设置完毕后点击绿色箭头运行,此时会提示是否保存测试文件,选择不保存(我测试选择保存会报错)。

可以在结果树这里看请求是否发送成功:

先删掉缓存,然后点击绿色箭头发送并发请求,可以发现所有线程请求成功,控制台对数据库的查询只有1次(没有出现多个线程争抢查询数据库的情况),测试成功。

P22 利用逻辑过期解决缓存击穿问题

如何添加逻辑过期字段?答:可以在utils包下定义RedisData类(可以让Shop继承RedisData类),也可以在RedisData中设置一个Shop类的data属性:

@Data
public class RedisData {private LocalDateTime expireTime;private Object data;
}

在ShopServiceImpl类中定义saveShop2Redis方法:

public void saveShop2Redis(Long id,Long expireSeconds){//1.查询店铺数据Shop shop = getById(id);//2.封装逻辑过期时间RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));//3.写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}

单元测试,在test包下的HmDianPingApplicationTests中创建testSaveShop类写入测试代码(这里要注意的是输入alt+insert之后选择Test Method要选择Junit 5来进行测试方法的编写):

@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate ShopServiceImpl shopService;@Testvoid testSaveShop() {shopService.saveShop2Redis(1L,10L);}
}

可以看到redis中确实存入了数据:

在ShopServiceImpl中复制一份缓存穿透的代码,更改名称为queryWithLogicalExpire:

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)){//3.不存在,返回空return null;}//4.命中,需要先把json反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);JSONObject data = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(data, Shop.class);//5.判断是否过期//5.1 未过期直接返回店铺信息LocalDateTime expireTime = redisData.getExpireTime();if(expireTime.isAfter(LocalDateTime.now())){return shop;}//5.2 已过期重建缓存//6.缓存重建//6.1.获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);//6.2.判断是否获取互斥锁成功if(isLock){//6.3.成功,开启独立线程,实现缓存重建CACHE_REBUILD_EXECUTOR.submit(()->{try {saveShop2Redis(id,20L); //实际中应该设置为30分钟} catch (Exception e) {throw new RuntimeException(e);} finally {unLock(lockKey);}});}//6.4.失败,返回过期的商铺信息return shop;
}

测试:

先到数据库把102茶餐厅改为103茶餐厅(因为Redis之前插入了一条缓存为102茶餐厅,并且已经过期,此时数据库与缓存不一致),新的HTTP请求会将逻辑过期的数据删除,然后更新缓存。

线程数设置为100,Ramp-up时间设置为1

在查看结果树里面到中间某个HTTP请求会完成重建,响应数据会改变。

1.安全性问题:在高并发情况下是否会有很多线程来做重建。

2.一致性问题:在重建完成之前得到的是否是旧的数据。

P23 封装Redis工具类

在utils包下创建CacheClient类,先写入如下基础的代码:

@Slf4j
@Component
public class CacheClient {private final StringRedisTemplate stringRedisTemplate;public CacheClient(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}public void set(String key, Object value, Long time, TimeUnit unit){stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(value),time,unit);}public void setWithLogicalExpire(String key, Object value,Long expire,TimeUnit unit){//设置逻辑过期RedisData redisData = new RedisData();redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(expire)));redisData.setData(value);stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));}}

在CacheClient类中编写缓存穿透的共性方法queryWithPassThrough: 

public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type,Function<ID,R> dbFallBack,Long time,TimeUnit unit){String key = keyPrefix + id;//1.从Redis查询缓存String shopJson = stringRedisTemplate.opsForValue().get(key);//2.判断是否存在if(StrUtil.isNotBlank(shopJson)){//3.存在,直接返回return JSONUtil.toBean(shopJson, type);}//上面是有值的情况,下面是无值的2种情况:A:空字符串。B:null。if(shopJson != null){return null;}//4.不存在,根据id查询数据库R r = dbFallBack.apply(id);//5.不存在,返回错误if(r==null){stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);return null;}//6.存在,写入Redisthis.set(key,r,time,unit);return r;
}

编写完queryWithPassThrough之后可以到ShopServiceImpl中直接调用新的方法(记得引入CacheClient类):

@Resource
private CacheClient cacheClient;
@Override
public Object queryById(Long id) {//调用工具类解决缓存击穿Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);if(shop==null){return Result.fail("店铺不存在!");}return Result.ok(shop);
}

进行测试:成功会对不存在的店铺空值进行缓存。

 

接下来拷贝queryWithLogicalExpire的代码到CacheClient类中进行改写:

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallBack,Long time,TimeUnit unit){String key = keyPrefix + id;//1.从Redis查询缓存String shopJson = stringRedisTemplate.opsForValue().get(key);//2.判断是否存在if(StrUtil.isBlank(shopJson)){//3.不存在,返回空return null;}//4.命中,需要先把json反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);JSONObject data = (JSONObject) redisData.getData();R r = JSONUtil.toBean(data, type);//5.判断是否过期//5.1 未过期直接返回店铺信息LocalDateTime expireTime = redisData.getExpireTime();if(expireTime.isAfter(LocalDateTime.now())){return r;}//5.2 已过期重建缓存//6.缓存重建//6.1.获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);//6.2.判断是否获取互斥锁成功if(isLock){//6.3.成功,开启独立线程,实现缓存重建CACHE_REBUILD_EXECUTOR.submit(()->{try {//查询数据库R r1 = dbFallBack.apply(id);//写入redisthis.setWithLogicalExpire(key,r1,time,unit);} catch (Exception e) {throw new RuntimeException(e);} finally {unLock(lockKey);}});}//6.4.失败,返回过期的商铺信息return r;
}
public boolean tryLock(String key){Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);
}
public void unLock(String key){stringRedisTemplate.delete(key);
}

 改写test下的HmDianPingApplicationTests类:

@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate CacheClient cacheClient;@Resourceprivate ShopServiceImpl shopService;@Testvoid testSaveShop() throws InterruptedException {Shop shop = shopService.getById(1L);cacheClient.setWithLogicalExpire(CACHE_SHOP_KEY+1L,shop,10L,TimeUnit.SECONDS);}
}

测试:首先运行HmDianPingApplicationTests类里的测试方法,10秒后逻辑过期,此时运行后台程序,修改数据库1号商铺的name字段,此时访问:localhost:8080/api/shop/1 会出现效果第1次访问为缓存旧值,然后发现缓存过期开始重建,第2次访问开始就是新值。数据库也只有1次重建。

P24 缓存总结

P25 优惠券秒杀 全局唯一ID

每个店铺都可以发布优惠券,当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID会存在一些问题。

1.id的规律性太明显。

2.受单表数据量的限制(分表之后每张表都自增长,id会出现重复)。

全局ID生成器:是一种在分布式系统下用来生成全局唯一ID的工具。

要求全局唯一ID生成器满足如下几点:1.唯一性。2.高可用。3.高性能。4.递增性。5.安全性。

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息。

符号位永远为0代表整数。

31位的时间戳是以秒为单位,定义了一个起始时间,用当前时间减起始时间,预估可以使用69年。

32位的是序列号是Redis自增的值,支持每秒产生2^32个不同ID。

P26 优惠券秒杀 Redis实现全局唯一id

在utils包下定义一个RedisWorker类,是一个基于Redis的ID生成器。

如果只使用一个key来自增记录有一个坏处,最终key的自增数量会突破容量的上限,假如自增超过32位彼时便无法再存储新的数据,解决的方案是采用拼接日期。

@Component
public class RedisIdWorker {private static final long BEGIN_TIMESTAMP = 1640995200L;//序列号的位数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 timeStamp = now.toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;//2.生成序列号//2.1获取当前日期,精确到天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;}
}

在HmDianPingApplicationTests中写入如下的测试代码:

@Resource
private ShopServiceImpl shopService;
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {CountDownLatch latch = new CountDownLatch(300);Runnable task = ()->{for(int i=0;i<100;i++){long id = redisIdWorker.nextId("order");System.out.println("id="+id);}latch.countDown();};long begin = System.currentTimeMillis();for(int i=0;i<300;i++){es.submit(task);}latch.await();long end = System.currentTimeMillis();System.out.println("Result Time = " + (end-begin));
}

运行之后可以看到以十进制输出的所有编号: 

 

可以在Redis中看到自增长的结果,1次是30000: 

大概2秒可以生成3万条,速度还是可以的。

全局唯一ID生成策略:

1.UUID利用JDK自带的工具类即可生成,生成的是16进制的字符串,无单调递增的特性。

2.Redis自增(每天一个key,方便统计订单量。时间戳+计数器的格式。)

3.snowflake雪花算法(不依赖于Redis,性能更好,对于时钟依赖)

4.数据库自增

P27 优惠券秒杀 添加优惠券

每个店铺都可以发放优惠券,分为平价券和特价券。平价券可以任意抢购,特价券需要秒杀抢购。

tb_voucher:优惠券基本信息,优惠金额,使用规则等。

tb_seckill_voucher:优惠券的库存,开始抢购时间,结束抢购时间,只有特价优惠券才需要填写这些信息。

请求的信息如下可自行复制(注意beginTime和endTime需要修改):

{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五均可使用",
"rules":"全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2024-04-10T10:09:17",
"endTime":"2024-04-11T12:09:04"
}

注意要在请求头中带Authorization参数否则会报401(登录后进入“我的”页面,看网络包有Authorization的值): 

以如下格式发送请求:

首先在tb_voucher表中可以看到新增的优惠券:

在tb_seckill_voucher表中也可以看到秒杀优惠券的具体信息:

在前端也能看到新增的100元代金券,注意优惠券的时间一定要进行更改,如果不在开始和结束时间区间内优惠券会处于下架状态是看不到的。

 P28 优惠券秒杀 实现秒杀下单

首先要判断秒杀是否开始或结束,所以要先查询优惠券的信息,如果尚未开始或者已经结束无法下单。

要判断库存是否充足,如果不足则无法下单。

在VouchrOrderController类中:

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Resourceprivate IVoucherService voucherService;@PostMapping("seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherService.seckillVoucher(voucherId);}
}

在IVoucherOrderService中写入如下代码:

public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);
}

在VoucherOrderServiceImpl中写入如下代码:

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {//1.查询优惠券信息SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//2.判断秒杀是否开始//2.1秒杀尚未开始返回异常if(voucher.getBeginTime().isAfter(LocalDateTime.now())){return Result.fail("秒杀尚未开始");}//2.2秒杀已结束返回异常if(voucher.getEndTime().isBefore(LocalDateTime.now())){return Result.fail("秒杀已经结束");}//3.判断库存是否充足if(voucher.getStock()<1){//3.1库存不足返回异常return Result.fail("库存不足!");}//3.2库存充足扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();if(!success){return Result.fail("库存不足!");}//4.创建订单,返回订单idVoucherOrder voucherOrder = new VoucherOrder();long orderId = redisIdWorker.nextId("order");//订单idvoucherOrder.setId(orderId);Long userId = UserHolder.getUser().getId();//用户idvoucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);//代金券idsave(voucherOrder);return Result.ok(orderId);}
}

测试:点击限时抢购之后会提示抢购成功。

P29 优惠券秒杀 库存超卖问题分析

Jmeter的配置如下:

注意Authorization要事先登录获取:

下面是结果:

发现tb_seckill_voucher中库存为-9,在tb_voucher_order中插入了109条数据,说明出现了超卖的问题。

正常逻辑:

非正常逻辑:

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

悲观锁:认为线程安全问题一定会发送,因此在操作数据之前要先获取锁,确保线程串行执行。像Synchronized、Lock都属于悲观锁。

乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。

如果没有修改则认为是安全的,自己才更新数据。

如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。‘

乐观锁关键是判断之前查询得到的数据是否被修改过,常见的方法有2种:

1.版本号法:

2.CAS法(版本号法的简化版):查询的时候把库存查出来,更新的时候判断库存和之前查到的库存是否一致,如果一致则更新数据。

P30 优惠券秒杀 乐观锁解决超卖

只需加上下面这段代码即可:.eq("stock",voucher.getStock()) 。用于比较当前数据库的库存值和之前查询到的库存值是否相同,只有相同时才可以执行set语句。

//3.2库存充足扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1") //相当于set条件 set stock = stock - 1.eq("voucher_id", voucherId) //相当于where条件 where id = ? and stock = ?.eq("stock",voucher.getStock()).update();

但现在出现了异常值偏高的问题,正常的请求大约只占10%。 

原理是因为:假如一次有30个线程涌入,查询到库存值为100,只有1个线程能把值改为99,其它29个线程比对库存值99发现和自己查询到的库存值100不同,所以都认为数据已经被修改过,所以都失败了。

乐观锁的问题,成功率太低。

现在只需要保证stock>0即可,只要存量大于0就可以任意扣减。

boolean success = seckillVoucherService.update().setSql("stock = stock - 1") //相当于set条件 set stock = stock - 1.eq("voucher_id", voucherId) //相当于where条件 where id = ? and stock = ?.gt("stock",0).update();

乐观锁缺陷:

需要大量对数据库进行访问,容易导致数据库的崩溃。

总结:

 P31 优惠券秒杀 实现一人一单功能

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

首先不建议把锁加在方法上,因为任何一个用户来了都要加这把锁,而且是同一把锁,方法之间变成串行执行,性能很差。

因此可以把锁加在用户id上,只有当id相同时才会对锁形成竞争关系。但是因为toString的内部是new了一个String字符串,每调一次toString都是生成一个全新的字符串对象,锁对象会变。

所以可以调用intern()方法,intern()方法会优先去字符串常量池里查找与目标字符串值相同的引用返回(只要字符串一样能保证返回的结果一样)。

但是因为事务是在函数执行结束之后由Spring进行提交,如果把锁加在createVoucherOrder内部其实有点小——因为如果解锁之后,其它线程可以进入,而此时事务尚未提交,仍然会导致安全性问题。

因此最终方案是把synchronized加在createVoucherOrder的方法外部,锁住的是用户id。

关于代理对象事务的问题:通常情况下,当一个使用了@Transactional注解的方法被调用时,Spring会从上下文中获取一个代理对象来管理事务。

但是如果加@Transactional方法是被同一个类中的另一个方法调用时,Spring不会使用代理对象,而是直接调用该方法,导致事务注解失效。

为避免这种情况,可以使用AopContext.currentProxy方法获取当前的代理对象,然后通过代理对象调用被@Transactional注解修饰的方法,确保事务生效。

在VoucherOrderServiceImpl中写入如下代码(注意:ctrl+alt+m可以把含有return的代码段进行提取):

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {//1.查询优惠券信息SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//2.判断秒杀是否开始//2.1秒杀尚未开始返回异常if(voucher.getBeginTime().isAfter(LocalDateTime.now())){return Result.fail("秒杀尚未开始");}//2.2秒杀已结束返回异常if(voucher.getEndTime().isBefore(LocalDateTime.now())){return Result.fail("秒杀已经结束");}voucher = seckillVoucherService.getById(voucherId);//3.判断库存是否充足if(voucher.getStock()<1){//3.1库存不足返回异常return Result.fail("库存不足!");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()){//获取代理对象IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {//6.一人一单Long userId = UserHolder.getUser().getId();//6.1查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//6.2判断是否存在if(count>0){//用户已经购买过了return Result.fail("用户已经购买过一次!");}//3.2库存充足扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") //相当于set条件 set stock = stock - 1.eq("voucher_id", voucherId) //相当于where条件 where id = ? and stock = ?.gt("stock",0).update();if(!success){return Result.fail("库存不足!");}//4.创建订单,返回订单idVoucherOrder voucherOrder = new VoucherOrder();long orderId = redisIdWorker.nextId("order");//订单idvoucherOrder.setId(orderId);voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);//代金券idsave(voucherOrder);return Result.ok(orderId);}
}

在IVoucherOrderService接口中加入下面这个方法:

Result createVoucherOrder(Long voucherId);

在pom.xml中引入如下的依赖:

<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId>
</dependency>

 在启动类HmDianPingApplication上加如下注解:

@EnableAspectJAutoProxy(exposeProxy = true)

测试: 成功实现一名用户只能领取一张优惠券。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/805186.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Vue + keep-alive】路由缓存

一. 需求 列表页&#xff0c;n 条数据项可打开 n 个标签页&#xff0c;同时1条数据项的查看和编辑共用一个标签页。如下所示&#xff1a; 参考 // 主页面 // 解决因 路由缓存&#xff0c;导致 编辑后跳转到该页面 不能实时更新数据 onActivated(() > {getList() })二. 实现…

5.2 配置静态路由

5.2.1 实验1&#xff1a;配置IPv4静态路由 1、实验目的 通过本实验可以掌握&#xff1a; 配置带下一跳地址的IPv4静态路由的方法。配置带送出接口的IPv4静态路由的方法。配置总结IPv4静态路由的方法。配置浮动IPv4静态路由的方法。代理 ARP的作用。路由表的含义。扩展ping命…

python|sort_values()排序

sort_value()可以用来对值&#xff08;比如说年龄&#xff09;进行排序 根据 ‘Age’ 列进行升序排序&#xff0c;如果 ‘Age’ 相同则根据 ‘Name’ 列进行降序排序 df_sorted_multi df.sort_values(by[Age, Name], ascending[True, False]) print(df_sorted_multi)

一款可自动跳广告的安卓App开源项目

开放权限有风险&#xff0c;使用App需谨慎&#xff01; gkd 基于 无障碍 高级选择器 订阅规则 的自定义屏幕点击 APP 功能 基于 高级选择器 订阅规则 快照审查, 它可以实现 点击跳过任意开屏广告/点击关闭应用内部任意弹窗广告, 如关闭百度贴吧帖子广告卡片/知乎回答底…

自动泊车车位检测

作者 | 机器学习AI算法工程 编辑 | 汽车人 原文链接&#xff1a;https://mp.weixin.qq.com/s/JaPUiKv_F9RObJKimg_7dQ APA 自动泊车相关的车位检测算法。 一、背景介绍 自动泊车大体可分为4个等级&#xff1a; 第1级&#xff0c;APA 自动泊车&#xff1a;驾驶员在车内&#xff…

虚拟网络设备与Linux网络协议栈

在现代计算环境中&#xff0c;虚拟网络设备在实现灵活的网络配置和隔离方面发挥了至关重要的作用&#x1f527;&#xff0c;特别是在容器化和虚拟化技术广泛应用的今天&#x1f310;。而Linux网络协议栈则是操作系统处理网络通信的核心&#x1f4bb;&#xff0c;它支持广泛的协…

基于BP神经网络的时间序列预测模型matlab代码

整理了基于BP神经网络的时间序列预测模型matlab代码&#xff0c;包含数据集。采用了四个评价指标R2、MAE、MBE、MAPE对模型的进行评价。BP模型在数据集上表现优异。 代码获取链接&#xff1a;基于BP神经网络的时间序列预测模型matlab代码 训练效果&#xff1a; 训练集数据的R…

服务器开发 Socket 相关基础

Socket 三要素 1.通信的目的地址&#xff1b; 2.使用的端口号&#xff1b; 3.使用的传输层协议&#xff08;如 TCP、UDP&#xff09; Socket 通信模型 服务端实现 #include <iostream> #include <unistd.h> #include <stdio.h> #include <sys/types.h&…

day13-实战:商城首页(上)

个人主页&#xff1a;学习前端的小z 个人专栏&#xff1a;HTML5和CSS3悦读 本专栏旨在分享记录每日学习的前端知识和学习笔记的归纳总结&#xff0c;欢迎大家在评论区交流讨论&#xff01; 文章目录 作业 作业 .bg-backward {width: 60px; height: 60px;background: url(../ima…

LeetCode 热题 100 题解(二):双指针部分(1)

题目一&#xff1a;移动零&#xff08;No. 283&#xff09; 题目链接&#xff1a;https://leetcode.cn/problems/move-zeroes/description/?envTypestudy-plan-v2&envIdtop-100-liked 给定一个数组 nums&#xff0c;编写一个函数将所有 0 移动到数组的末尾&#xff0c;同…

Python对docx文本一些操作

文本要是docx结尾 安装 Python-docx 包 读取word from docx import Document doc Document("c:/word22.docx") 获取word中的所有表格 from docx import Document doc Document("c:/word22.docx") doc.tables # 返回所有表格的list 获取表格中的总行…

uni-admin初始化一直提示未初始化数据库问题

uni-admin初始化&#xff0c;一直提示&#xff1a; “检测到您未初始化数据库&#xff0c;请先右键uni-admin项目根目下的 uniCloud/database 目录&#xff0c;执行初始化云数据库&#xff0c;否则左侧无法显示菜单等数据” 最后清除了localStorage&#xff0c;发现就好了。

盘点6个AI绘画免费网站,第一个不仅免费还好用!

随着人工智能技术的前沿发展&#xff0c;人工智能在各个领域发挥了重要作用。人工智能的受欢迎程度不断增加&#xff0c;引起了越来越多的关注。借助动画人工智能生成器&#xff0c;用户可以通过简单的操作获得专业的动画作品&#xff0c;而无需掌握高端技术。今天我们将盘点 1…

算法学习 -- 多路归并

思想 : 抽象出来一个例子 : 合并k个长度相等升序列表 : 抽象成一张表也就是 : 做法 : 用一个小根堆来维护 &#xff0c; 首先将每个序列的第一个元素放入队列中 &#xff0c; 然后模拟&#xff0c;每次取出队头&#xff0c;作为结果序列的下一个元素 &#xff0c; 然后向堆…

系统架构最佳实践 -- 人力资源(E-HR)应用架构设计

当谈到人力资源管理时&#xff0c;电子人力资源&#xff08;E-HR&#xff09;系统已经成为现代企业不可或缺的组成部分。E-HR系统的设计与实践对于提高组织的人力资源管理效率和员工体验至关重要。本文将探讨E-HR应用架构的设计与实践&#xff0c;以及如何借助信息技术优化人力…

第 6 章 Gazebo仿真环境搭建(自学二刷笔记)

6.6.4 Gazebo仿真环境搭建 到目前为止&#xff0c;我们已经可以将机器人模型显示在 Gazebo 之中了&#xff0c;但是当前默认情况下&#xff0c;在 Gazebo 中机器人模型是在 empty world 中&#xff0c;并没有类似于房间、家具、道路、树木... 之类的仿真物&#xff0c;如何在 …

第十四届蓝桥杯C/C++大学B组题解(二)

6、岛屿个数 #include <bits/stdc.h> using namespace std; const int M51; int T,m,n; int vis[M][M],used[M][M]; int dx[]{1,-1,0,0,1,1,-1,-1}; int dy[]{0,0,1,-1,1,-1,1,-1}; string mp[M]; struct node{//记录一点坐标 int x,y; }; void bfs_col(int x,int y){ qu…

Linux安全认证隐匿插件:PAM配置探秘

Linux安全认证隐匿插件&#xff1a;PAM配置探秘 初遇PAM&#xff1a;踏入未知领域 案例&#xff1a; 现网环境升级总是报错端口已被占用&#xff0c;原因是执行升级包中的一条命令时&#xff0c;返回多了一条日志打印&#xff0c;导致升级包中解析命令执行结果错误 当时是第…

【图论】图的存储--链式前向星存图法以及深度优先遍历图

图的存储 介绍 无向图-就是一种特殊的有向图-> 只用考虑有向图的存储即可 有向图 邻接矩阵邻接表 邻接表 存储结构: (为每一个点开了一个单链表,存储这个点可以到达哪个点) 1:3->4->null2:1->4->null3:4->null4:null 插入一条新的边 比如要插一条边&am…

STM32学习和实践笔记(4): 分析和理解GPIO_InitTypeDef GPIO_InitStructure (e)

接上文&#xff0c;继续来看这个函数&#xff1a; /*** brief Initializes the GPIOx peripheral according to the specified* parameters in the GPIO_InitStruct.* param GPIOx: where x can be (A..G) to select the GPIO peripheral.* param GPIO_InitStruct:…