Redis--缓存击穿详解及解决方案

缓存击穿

缓存击穿问题也称热点key问题,就是一个高并发访问(该key访问频率高,访问次数多)并且缓存重建业务比较复杂的key突然失效了,大量的请求访问会在瞬间给数据库带来巨大的冲击。

缓存重建业务比较复杂

缓存在redis数据库中存储,在一定时间后被清除,缓存失效,失效以后需要重新从数据库中查询写入redis,在实际开发中,从数据库中查询并且构建数据并不是查到什么就存储进redis,有些业务比较复杂,需要多表查询的,甚至是要去各种各样的表关联的运算最终得到的结果将其缓存进数据库。这样的业务耗时就比较长,在该时间段内,相当于redis中一直没有缓存,而在这一时间段内,无数请求就无法命中缓存,就直接到达数据库。

常见解决方案:

  • 互斥锁

当线程1查询缓存未命中时,去获取互斥锁,获取成功后查询数据库重建缓存数据,在写入缓存之后释放锁。这样做的话,在其他线程来发起请求后,未命中缓存则尝试去获取互斥锁,获取互斥锁失败,则让其进入自旋状态(让线程循环执行抢锁的过程),直到前一个线程释放锁之后,在发起请求,若缓存命中,则返回,若未命中,则获取互斥锁,循环往复。

如图所示:

image-20250528154729779

问题:互相等待,就比如同一时刻中有一千个线程发起请求,但只有一个线程在构建,其他线程都在进行自旋,如果构造时间过久,其他线程只能自旋,而长时间的自旋会让CPU一直在空转,CPU没有办法去执行其他任务,会浪费CPU,性能较差。

  • 逻辑过期

可以认为是永不过期,即当下往Redis中存储数据时,不设置过期时间,而是在设置value时添加一个expire字段(在当前时间基础加上一个过期时间),该字段的意义在于提醒我们何时销毁该key,即在逻辑意义上维护的过期时间,而该key在redis中没有过期时间,再加上在redis配置的合适的内存淘汰策略,只要该key写入redis,就一定可以查到,不会出现缓存未命中的情况。

适用情况:在举办活动时添加,活动结束之间将其移除即可。

注意事项:需要判断逻辑时间有无过期

使用详情:

当线程1查询缓存发现逻辑时间已经过期,则尝试获取互斥锁,获取成功,则新开一线程进行查询数据库重建缓存数据,在写入缓存重置逻辑过期时间,最后释放锁,而线程1在此之前将过期数据返回。

在重建缓存期间,如果有新线程发起请求,发现逻辑时间过期,则尝试获取互斥锁,如果获取互斥锁失败,则直接返回过期数据。

如图所示:

image-20250528163619269

方案对比

解决方案优点缺点
互斥锁(考虑数据一致性)没有额外的内存消耗 ,保证一致性,实现简单线程需要等待,性能受影响,可能有死锁风险
逻辑过期(考虑性能)线程无需等待,性能较好不保证一致性,有额外内存消耗,实现复杂

小结:这两种方案都是在解决缓存重建这一段时间内产生的并发问题。

互斥锁:在缓存重建的这段时间内让这些并发的线程串行执行或者相互等待,从而确保安全。确保数据一致性,牺牲了服务的可用性

逻辑过期:在缓存重建这段时间内保证了可用性,牺牲的是数据的一致性(可能访问的是旧数据)。

案例展示:基于互斥锁方式解决缓存击穿问题

需求:修改根据ID查询商铺的业务,基于互斥锁方式来解决缓存击穿问题。

分析业务流程变化

原先业务流程:

image-20250528164915769

修改步骤,在判断缓存是否命中之后,如果未命中,需要先去尝试获取互斥锁,判断是否拿到,如果没有拿到锁,说明已经有线程在更新,不应该继续往下执行,需要休眠一段时间,在重新尝试。如果拿到互斥锁,执行缓存重建,就可以去查询数据库,将查询到的数据写入Redis,随后释放互斥锁,最后返回结果。

新的业务流程如图所示:

image-20250528170232802

注意事项:在该业务流程中使用的所并不是我们平时使用的锁,我们平时使用synchronized或者lock,这种锁的执行逻辑就是拿到锁执行,没拿到锁等待,而该业务流程中的锁的执行逻辑是自定义的,因此要采用自定义的互斥锁(在多个线程并行执行时,只有一条线程成功,其余线程失败),而在学习redis中string类型时中 setnx命令的效果与之相近,当该key不存在的时候存入,如果存在就不存入,这就是一种互斥效果,在大量线程并发访问时,只有一条线程可以成功。因此获取锁为赋值命令,而释放锁则是删除命令(del)。

为了防止程序出故障导致迟迟没有执行删除命令,因此在设置setnx时通常都会为其设置有效期,来防止锁一直不释放,造成死锁,导致业务故障。

这与真正的互斥锁还是有所差距的,但是在这里够用了。

前置代码:

声明两个方法代表获取锁以及释放锁

// 尝试获取锁
private boolean tryLock(String key){//设置有效期时间取决于业务执行时间,一般比业务时间长一些即可。Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);//建议不要直接返回flag,防止返回空指针,因为Boolean是boolean的包装类,需要进行拆箱操作,可能导致空指针 网络问题或者键不存在但Redis未响应,可能会返回null,因此需要实用工具类判断。改成BooleanUtil.isTrue(flag)。return  BooleanUtil.isTrue(flag);
}
//  释放锁
private void unlock(String key){stringRedisTemplate.delete(key);
}

流程说明:

进行方法封装,在queryWithMutex()方法中进行缓存重建的业务代码。

代码展示:

 
public Result queryById(Long id) {//缓存穿透//        Shop shop = queryWithPassThrough(id);//互斥锁解决缓存击穿Shop shop = queryWithMutex(id);if (shop == null) {return Result.fail("店铺不存在");}//  7.返回return Result.ok(shop);}//互斥锁解决缓存穿透public Shop queryWithMutex(Long id) {//1.从redis中查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);Shop shop = JSONUtil.toBean(shopJson, Shop.class);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {//  3.存在,直接返回return shop;}//  4.判断命中的是否是空值if (shopJson != null) {//  返回错误信息return null;}//4.实现缓存重建//4.1 获取互斥锁String lock = LOCK_SHOP_KEY + id;try {boolean isLock = tryLock(lock);//4.2.判断是否获取成功if (!isLock) {//4.3.失败,则休眠并重试Thread.sleep(50);return queryWithMutex(id);}//  4.2.获取锁成功,再次检查redis缓存 判断是否为空,如果存在shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StrUtil.isNotBlank(shopJson)) {//  3.存在,直接返回shop = JSONUtil.toBean(shopJson, Shop.class);return shop;}//4.4.成功,根据id查询数据库shop = getById(id);//模拟重建的延时Thread.sleep(200);if (shop == null) {//将空值返回redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);//  5.不存在,返回错误return null;}//  6.存在,写入redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);//  7.释放互斥锁}catch (InterruptedException e) {throw new RuntimeException(e);}finally {unlock(lock);}//8.返回return shop;​}//  逻辑过期解决缓存击穿public Shop queryWithPassThrough(Long id){//1.从redis中查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)){//  3.存在,直接返回Shop shop = JSONUtil.toBean(shopJson, Shop.class);return shop;}//  4.判断命中的是否是空值if (shopJson != null){//  返回错误信息return null;}//  4.不存在,根据id查询数据库Shop shop = getById(id);if (shop == null) {//将空值返回redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);//  5.不存在,返回错误return null;}//  6.存在,写入redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);//  7.返回return shop;​}

借助Jmeter工具进行高并发环境模拟

image-20250528194853540

测试结果如下。

image-20250528194838627

案例展示:基于逻辑过期方式解决缓存击穿问题

需求:修改根据ID查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。

逻辑过期并不是真的过期,他要求存储数据到redis中的时候,额外的添加一个过期时间的字段。

key本身不需要设置TTL,他的过期时间不由redis控制,而是由业务代码判断是否过期,这样在业务上就会复杂很多。要修改业务流程。

修改详情:首先前端去提交商铺的ID到服务端,服务端在通过ID在Redis中查询缓存,而在逻辑过期方式中,缓存不会出现未命中的情况。

原因:key没有过期时间,一旦key添加在缓存中,就会永久存在,除非活动结束,在人工删除,而像这种热点key一般是参加活动的一些商品,或者是一些其他的东西,会提前加入缓存,并设置逻辑过期时间。

因此,在理论情况下,所有的热点key都会提前添加好,并一直存在,直到活动结束,人工删除。

因此可以不用判断是否命中缓存,如果缓存不存在,则说明该key并不在活动中,所以在流程中象征性的判断一下即可,若未命中就直接返回空。

而核心逻辑就在于默认命中之后,在命中后需要判断是否过期,也就是逻辑过期时间。

如果未过期,就直接返回即可。

如果数据过期,说明需要重新加载,需要去做缓存重建。

但是也不能让所有线程都去重建,因此还是需要争抢,即先尝试获取互斥锁,然后判断是否获取到,如果获取失败,说明之前已经有线程在进行更新缓存,这时可以直接返回旧数据。

如果抢夺成功,就需要去执行缓存重建,而且并不是在本线程执行,而是新建线程去执行缓存重建,而本线程先返回旧数据,由该独立线程执行数据重建,查询数据库,将数据写入缓存,并且设置逻辑过期时间,再去释放锁即可。

流程如图所示:

image-20250528202950846

代码实现:

考虑问题:

将数据写入redis的时候,我们要设置一个逻辑过期时间,那逻辑过期时间如何添加数据里?

可以直接找到实体类,在实体类中添加逻辑过期时间字段,但这种方案并不友好,因为对原来的代码和业务逻辑进行了修改,这里有两个方案,

一:在工具类中新建对象RedisData,在该类中新建LocalDatatime类型的字段expireTime,然后让实体类shop集继承redisData类,

二:在RedisData中去添加Object类型的Data,也就是说redisData自己带有过期时间并且里面带有数据,这个数据就是要存入redis的热点key,是一个万能的存储对象。第二种方案不会对原来的代码有任何的修改。

在这里选择第二种方案。而像这种热点key的数据需要提前导入进去,在实际开发中,可能会有一个后台管理系统,可以将某一些热点的数据在后台提前的添加缓存中,但是该项目没有,因此只能基于单元测试的方式,将店铺数据加入缓存中,相当于做一个缓存的预设。

代码展示:

向Redis存入热点数据以及设置逻辑过期时间

 private 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));}

编写单元测试:

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

运行,测试通过,查看redis数据库

image-20250528230610378

数据预热完成

开始解决缓存击穿问题

代码展示:

 public Shop queryWithLogicalExpire(Long id){//1.从redis中查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);// 2.判断是否存在if (StrUtil.isBlank(shopJson)){//  3.不存在,直接返回空return null;}//4.命中,需要把json反序列化为对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);//  4.存在,判断缓存是否过期//Data实际上是jsonObject对象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 lock = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lock);// 6.2.判断是否获取锁成功if (isLock) {//再次检测redis缓存是否过期redisData = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY +     id), RedisData.class);if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {//  6.1.未过期,直接返回。return JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);}//  6.3.成功,开启独立线程,实现缓存重建CACHE_REBUILD_EXECUTOR.submit(() -> {try {//  重建缓存this.saveShop2Redis(id, 20L);} catch (Exception e) {throw new RuntimeException(e);}finally {//  释放锁unlock(lock);}});}

注意事项:在开启新线程时建议使用线程池,新建线程经常要创建与销毁,十分浪费性能,使用线程池可以做到线程复用。

因此新建线程池执行器。

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

为了模拟缓存有一定的延迟,可以在数据存入时休眠200ms

 public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {//  1.查询店铺数据Shop shop = getById(id);//  模拟重建的延时Thread.sleep(200);//  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));}

延迟越长,越容易出现这种线程安全问题。

开始测试,在高并发的情况下,会不会出现大量线程重建的情况(并发的安全问题),以及一致性问题,在缓存重建完成之前查询到的是旧的数据还是新的数据(将数据库中的数据修改一下进行对比)

新建100个线程并发执行。

image-20250529192207335

检查成果:

image-20250529192354366

测试成功,在前一半线程中,数据为旧数据,后一半线程数据为新数据。

而在数据库只进行了一次数据查询,证明并发安全,只会有一次重建,但是数据一致性会有一些问题。

拓展知识:线程池的具体流程以及如何实现线程复用

线程核心类是ThreadPoolExecutor,它也有很多基于threadpoolexecutor包装的类, ThreadPoolExecutor的七大参数

  • corePoolSize (核心线程):来一个任务开一个线程,直到达到我们的corePoorSize,如果到了这个线程,就不能去开新的线程,将其放在队列中,即workQueue(阻塞队列)

  • workQueue(阻塞队列/任务队列):当核心线程达到以后,之后的任务就放在阻塞队列中 。

  • maximumPoolSize(最大线程数): 当阻塞队列满了以后,需要求其他的线程帮忙,所以会去开额外的线程,但是,corePoolsize加上来帮忙的线程不能超过这个最大线程数。

  • keepAliveTime(线程保持活跃时间):代表任务消费完之后,刚刚帮忙的线程可能要回收,也可以设置corePoolSize的线程要回收,KeepAliveTime就是设置要回收的时间,过多久以后回收线程。

  • TimeUnit:时间单位

  • ThreadFactory(线程工厂):线程池的核心是线程复用,要去创建线程,这个工厂就代表你如何创建线程,以及你在创建线程他的一些属性设置。

  • RejectedExecutionHandler(接口)(拒绝策略),当线程数满了以后,包括开始开启的线程,,包括后面帮你的线程 还有任务队列也满了的时候,不清楚该怎么做,就提供这个接口给你,只要实现RejectedExecution方法就可以了。

执行流程

在初始化线程以后,执行execute方法,execute方法传的是一个任务task(runnable),这个任务是抽象的(接口). 源码解析:

  
  //ctl:是一个原子类型,用于保存当前线程池的线程数以及线程状态。//有三位是来保证状态的,还有二十九位用来保存线程数。private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));private static final int COUNT_BITS = Integer.SIZE - 3;private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;​​int c = ctl.get(); //首先,先拿到当前线程池的线程数 if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) //如果说小于corePoolSize(核心线程),则调用addworker方法。(添加线程来帮助执行任务),也就是说,只要线程数小于核心线程,就会添加一个线程。return;//直接返回c = ctl.get();}if (isRunning(c) && workQueue.offer(command)) {//当大于corePoolSize时,则将添加到阻塞队列中(workqueue),如果if条件成立,则说明阻塞队列添加成功int recheck = ctl.get();if (! isRunning(recheck) && remove(command))reject(command);else if (workerCountOf(recheck) == 0)addWorker(null, false);}else if (!addWorker(command, false)) // 当我的线程数大于corePoolSize并且阻塞队列也是满的,则在调用addworker方法,增加线程,不过此次增加线程的参数为false,false的意思是这个添加的线程是非核心线程,相当于额外的线程reject(command);//如果额外的线程也没有添加成功,就直接拒绝,调用RejectedExecution方法即可(拒绝策略)}

细致解析addworker方法

 
private boolean addWorker(Runnable firstTask, boolean core) {retry:for (int c = ctl.get();;) {// Check if queue empty only if necessary.if (runStateAtLeast(c, SHUTDOWN)&& (runStateAtLeast(c, STOP)|| firstTask != null|| workQueue.isEmpty()))return false;​for (;;) {//   三元运算符 如果传入的core为true,说明要添加核心线程,那么就去和core对比,如果不为true,则和maximumPoolSize(最大线程数)对比if (workerCountOf(c)>= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))return false;if (compareAndIncrementWorkerCount(c))break retry;c = ctl.get();  // Re-read ctlif (runStateAtLeast(c, SHUTDOWN))continue retry;// else CAS failed due to workerCount change; retry inner loop}}//以上代码用于线程判断,判断线程是否关闭,以及线程状态是否正常​boolean workerStarted = false;boolean workerAdded = false;Worker w = null;try {w = new Worker(firstTask); // 开启线程,去找workerfinal Thread t = w.thread; // 开启线程if (t != null) {final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {// Recheck while holding lock.// Back out on ThreadFactory failure or if// shut down before lock acquired.int c = ctl.get();​if (isRunning(c) ||(runStateLessThan(c, STOP) && firstTask == null)) {if (t.getState() != Thread.State.NEW)throw new IllegalThreadStateException();workers.add(w);workerAdded = true;int s = workers.size();if (s > largestPoolSize)largestPoolSize = s;}} finally {mainLock.unlock();}if (workerAdded) {container.start(t);//启动线程workerStarted = true;}}} finally {if (! workerStarted)addWorkerFailed(w);}return workerStarted;}

找到worker构造器

 Worker(Runnable firstTask) {setState(-1); // inhibit interrupts until runWorkerthis.firstTask = firstTask;this.thread = getThreadFactory().newThread(this); //使用线程工厂来创建线程}public void run() {runWorker(this);// 调用run方法}

runworker方法执行任务

 
final void runWorker(Worker w) {Thread wt = Thread.currentThread();Runnable task = w.firstTask; // task 是我们传的参数 w.firstTask = null;w.unlock(); // allow interruptsboolean completedAbruptly = true;try {while (task != null || (task = getTask()) != null) {// 除了传进来的的以外,我们还要去阻塞队列中去拿,通过getTask()方法w.lock();// If pool is stopping, ensure thread is interrupted;// if not, ensure thread is not interrupted.  This// requires a recheck in second case to deal with// shutdownNow race while clearing interruptif ((runStateAtLeast(ctl.get(), STOP) ||(Thread.interrupted() &&runStateAtLeast(ctl.get(), STOP))) &&!wt.isInterrupted())wt.interrupt();try {beforeExecute(wt, task);try {task.run(); //执行我们的任务afterExecute(task, null);} catch (Throwable ex) {afterExecute(task, ex);throw ex;}} finally {task = null;w.completedTasks++;w.unlock();}}completedAbruptly = false;} finally {processWorkerExit(w, completedAbruptly);}}

getTask()方法

private Runnable getTask() {boolean timedOut = false; // Did the last poll() time out?​for (;;) {int c = ctl.get();​// Check if queue empty only if necessary.if (runStateAtLeast(c, SHUTDOWN)&& (runStateAtLeast(c, STOP) || workQueue.isEmpty())) {decrementWorkerCount();return null;}​int wc = workerCountOf(c);​// Are workers subject to culling?boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; //当允许核心线程回收的时候或线程数大于核心线程数,这是可以回收线程, 怎么回收?run方法结束,线程就回收了,run方法什么时候结束?​if ((wc > maximumPoolSize || (timed && timedOut))&& (wc > 1 || workQueue.isEmpty())) {if (compareAndDecrementWorkerCount(c))return null;continue;}​try {//当到达超时时间以后,允许核心线程回收时,就可以回收,如果不允许线程回收或者现在线程数小于核心线程数,就调用take()方法(一直阻塞)。这也是线程池做到线程复用的关键地方,线程池中的线程不会回收,只会通过阻塞队列阻塞Runnable r = timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take();// 怎么拿到的?take()方法或poll方法;poll()方法是超时阻塞,poll是一直阻塞,如果没有任务,要么就是超时阻塞,要么就是一直阻塞,如果有任务就去拿任务if (r != null)return r;timedOut = true;} catch (InterruptedException retry) {timedOut = false;}}}

以上就是源码中的关键源码。以及相应的解释

具体流程

在初始化线程以后,执行execute方法,execute方法传的是一个任务task。

当执行task时,首先判断是否大于corePoolSize(核心线程),如果是,直接丢给workqueue(阻塞队列)。

如果不是,就通过work类中的ThreadFactory(线程工厂)开启线程,执行start方法去Thread中回调run方法。

如果workqueue(阻塞队列)也满了之后,会再次经过一次判断,如果此时线程数小于maximumPoolSize(最大线程数),则再次通过work类中的ThreadFactory(线程工厂)去开启线程。

如果大于maximumPoolSize(最大线程数),则是拒绝策略(RejectedExecutorHandler接口中的RejectedExecution()方法),

此时run方法有两种选择,当前有task时,执行当前拿到的task,如果没有,自旋去workqueue(阻塞队列)中,会去执行take方法或者poll方法。

take方法是无限制的阻塞,而poll方法则是有超时时间的限制的阻塞,超时时间也可以控制。

当workqueue为空,且允许关闭核心线程 或者当前的线程数大于核心线程数boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;,可以进行线程回收(调用poll方法)。

否则调用take方法,阻塞在take方法中,等待workqueue里面存在task。

这样的话,线程就一直不会被回收,只要代码中 allowCoreThreadTimeOut为false且线程数刚好小于核心线程数

如图所示:

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

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

相关文章

UniApp X:鸿蒙原生开发的机会与DCloud的崛起之路·优雅草卓伊凡

UniApp X&#xff1a;鸿蒙原生开发的机会与DCloud的崛起之路优雅草卓伊凡 有句话至少先说&#xff0c;混开框架中目前uniapp x是率先支持了鸿蒙next的开发的&#xff0c;这点来说 先进了很多&#xff0c;也懂得审时度势。 一、UniApp X如何支持鸿蒙原生应用&#xff1f; UniAp…

域名解析怎么查询?有哪些域名解析查询方式?

在互联网的世界里&#xff0c;域名就像是我们日常生活中的门牌号&#xff0c;帮助我们快速定位到想要访问的网站。而域名解析则是将这个易记的域名转换为计算机能够识别的IP地址的关键过程。当我们想要了解一个网站的域名解析情况&#xff0c;或者排查网络问题时&#xff0c;掌…

算力卡上部署OCR文本识别服务与测试

使用modelscope上的图像文本行检测和文本识别模型进行本地部署并转为API服务。 本地部署时把代码中的检测和识别模型路径改为本地模型的路径。 关于模型和代码原理可以参见modelscope上这两个模型相关的页面&#xff1a; iic/cv_resnet18_ocr-detection-db-line-level_damo iic…

大语言模型的完整训练周期从0到1的体系化拆解

以下部分内容参考了AI。 要真正理解大语言模型&#xff08;LLM&#xff09;的创生过程&#xff0c;我们需要将其拆解为一个完整的生命周期&#xff0c;每个阶段的关键技术相互关联&#xff0c;共同支撑最终模型的涌现能力。以下是体系化的训练流程框架&#xff1a; 阶段一&am…

吃水果(贪心)

文章目录 题目描述输入格式输出格式样例输入样例输出提交链接提示 解析参考代码 题目描述 最近米咔买了 n n n 个苹果和 m m m 个香蕉&#xff0c;他每天可以选择吃掉一个苹果和一个香蕉&#xff08;必须都吃一个&#xff0c;即如果其中一种水果的数量为 0 0 0&#xff0c;则…

【FAQ】HarmonyOS SDK 闭源开放能力 —Account Kit(4)

1.问题描述&#xff1a; LoginWithHuaweiIDButton不支持深色模式下定制文字和loading样式&#xff1f; 解决方案&#xff1a; LoginWithHuaweiIDButtonParams 中的有个supportDarkMode属性&#xff0c;设置为true后&#xff0c;需要自行响应系统的变化&#xff0c;见文档&am…

【C语言】指针详解(接)

前言&#xff1a; 文接上章&#xff0c;在上章节讲解了部分指针知识点&#xff0c;在本章节为大家继续提供。 六指针与字符串&#xff1a;C 语言字符串的本质 在 C 语言中&#xff0c;字符串实际上是一个以\0结尾的字符数组。字符串常量本质上是指向字符数组首元素的指针&…

第5讲、Odoo 18 CLI 模块源码全解读

Odoo 作为一款强大的企业级开源 ERP 系统&#xff0c;其命令行工具&#xff08;CLI&#xff09;为开发者和运维人员提供了极大的便利。Odoo 18 的 odoo/cli 目录&#xff0c;正是这些命令行工具的核心实现地。本文将结合源码&#xff0c;详细解读每个 CLI 文件的功能与实现机制…

如何将 PDF 文件中的文本提取为 YAML(教程)

这篇博客文章将向你展示如何将 PDF 转换为 YAML&#xff0c;通过提取带有结构标签的标记内容来实现。 什么是结构化 PDF&#xff1f; 一些 PDF 文件包含结构化内容&#xff0c;也称为带标签&#xff08;tagged&#xff09;或标记内容&#xff08;marked content&#xff09;&…

银发团扎堆本地游,“微度假”模式如何盘活银发旅游市场?

​ 银发微度假&#xff0c;席卷江浙沪 作者 | AgeClub吕娆炜 前言 均价200-300元的两天一夜微度假产品&#xff0c;正在中老年客群中走红。 “我们属于酒店直营&#xff0c;没有中间商赚差价&#xff0c;老年人乘坐地铁到目的地站&#xff0c;会有大巴负责接送&#xff0c;半…

苹果iOS应用ipa文件进行签名后无法连接网络,我们该怎么解决

苹果iOS应用ipa文件在经过签名处理后&#xff0c;如果发现无法连接网络&#xff0c;这可能会给用户带来极大的不便。为了解决这一问题&#xff0c;可以采取一系列的排查和解决步骤&#xff0c;以确保应用能够顺利地访问互联网。 首先&#xff0c;确保你的设备已经连接到一个稳…

MySQL 中 ROW_NUMBER() 函数详解

MySQL 中 ROW_NUMBER() 函数详解 ROW_NUMBER() 是 SQL 窗口函数中的一种&#xff0c;用于为查询结果集中的每一行分配一个​​唯一的连续序号​​。与 RANK() 和 DENSE_RANK() 不同&#xff0c;ROW_NUMBER() 不会处理重复值&#xff0c;即使排序字段值相同&#xff0c;也会严格…

Leetcode百题斩-二叉树

二叉树作为经典面试系列&#xff0c;那么当然要来看看。总计14道题&#xff0c;包含大量的简单题&#xff0c;说明这确实是个比较基础的专题。快速过快速过。 先构造一个二叉树数据结构。 public class TreeNode {int val;TreeNode left;TreeNode right;TreeNode() {}TreeNode…

Asp.Net Core 如何配置在Swagger中带JWT报文头

文章目录 前言一、配置方法二、使用1、运行应用程序并导航到 /swagger2、点击右上角的 Authorize 按钮。3、输入 JWT 令牌&#xff0c;格式为 Bearer your_jwt_token。4、后续请求将自动携带 Authorization 头。 三、注意事项总结 前言 配置Swagger支持JWT 一、配置方法 在 …

MySQL 定时逻辑备份

文章目录 配置密码编写备份脚本配置权限定时任务配置检查效果如果不想保留明文密码手工配置备份密码修改备份命令 配置密码 cat >> /root/.my.cnf <<"EOF" [client] userroot passwordYourPassword EOF编写备份脚本 cat > /usr/local/bin/mysql_dum…

在qt中使用c++实现与Twincat3 PLC变量通信

这是一个只针对新手的教程&#xff0c;下载安装就不说了&#xff0c;我下的是TC31-Full-Setup.3.1.4024.66.exe是这个版本&#xff0c;其他版本应该问题不大。 先创建一个项目 选中SYSTEM&#xff0c;在右侧点击Choose Target&#xff08;接下来界面跟我不一样没关系&#xf…

云原生微服务devops项目管理英文表述详解

文章目录 1.云原生CNCF trail map云原生技术栈路线图 2. 微服务单体应用与微服务应用架构区别GraphQLKey differences: GraphQL and REST 3.容器化&编排dockerKubernetesContainers and ContainerizationContainer Basics 4. DevOps & CI/CDTerms and Definitions 5.Ag…

pyside 使用pyinstaller导出exe(含ui文件)

第一步&#xff1a;首先确保安装好pyinstall&#xff0c;终端运行 pyinstaller -w main.py 生成两个文件夹 打开exe文件报错&#xff0c;问题是ui文件找不到 第二步&#xff1a;将ui文件复制到exe所在文件夹&#xff0c;打开成功 ![在这里插入图片描述](https://i-blog.csdni…

kerberos在无痕浏览器 获取用户信息失败 如何判断是否无痕浏览器

kerberos在无痕浏览器 获取用户信息失败 如何判断是否无痕浏览器 js 代码 其他地方用直接导入js getCurrentUserId 这是自己后端获取 域账号地址 我是成功返回200 //true普通浏览器 fasle 无痕浏览器 export const checkBrowserMode async () > {try {const response a…

HTML 计算网页的PPI

HTML 计算网页的PPI vscode上安装live server插件&#xff0c;可以实时看网页预览 有个疑问&#xff1a; 鸿蒙density是按照类别写死的吗&#xff0c;手机520dpi 折叠屏426dpi 平板360dpi <html lang"en" data - overlayscrollbars - initialize><header&…