Redis 缓存架构与高并发问题终极解法

缓存,大多数人对于这个词想必熟悉又陌生;熟悉是因为缓存用在我们当今电子设备当中的方方面面,陌生是因为我们不熟悉其存在的意义和工作方式;

缓存简介

缓存是用来暂时存储对应的数据资料,作为数据交换的缓冲区,一般读写性能比较高;

在我们平常人的认知当中,一般认为缓存只有一层,但实际上,在当今的互联网大厂中,特别是双11时的上亿流量的冲击中,一般会设置多级缓存来减轻服务器的压力:

  1. 一般会将一些访问量比较高的数据先保存在浏览器的缓存当中,如果浏览器的缓存中找不到对应的资料;
  2. 那么会接着到tomcat应用层缓存中去寻找,如果再找不到对应的资料
  3. 接着到数据库的缓存,->CPU缓存 ->磁盘缓存 里面去查找对应的数据

每个事物都难免存在两面性,redis缓存也是同理,也是一把双刃剑

作用:

  1. 降低了后端的压力/负载:如果数据可以直接从缓存里面获得的话,那么就不需要去查数据库,所以对应的数据库压力也就没有那么大了
  2. 可以提高读写的效率,降低访问时间:这是由于redis缓存是存储在系统内存当中的,内存就相当于我们工作时的工作台上的数据/资料,不需要到磁盘【书房】去取资料,会增加系统的读写效率

成本:

  1. 数据一致性成本:如果数据库的数据发生了修改,应该如何保证数据的同步性和一致性?这是需要考虑的一个重要问题
  2. 代码的维护成本 :如果引入了缓存进行数据的处理,整个的处理流程的逻辑是相对比较复杂的
  3. 运维成本:需要考虑到人力成本的问题,以及硬件的配置的成本

不加数据库的工作流程:

客户端发送的请求直接打到服务器的数据库上,在访问量比较小的时候,数据库是可以正常的工作 的;如果访问量增大,比如碰到双11这种大活动,那么对数据库的压力是非常大的,因此需要考虑加入缓存来缓解数据库的压力。

添加缓存之后:

工作流程

在客户端和数据库之间加上一层缓存,当客户当需要查询对应的数据时:

  1. 先到缓存当中去寻找对应的数据,如果能找到对应的数据,数据直接返回到客户端
  2. 如果数据不存在的话,就需要到数据库当中去寻找对应的数据
  3. 如果能找到数据,需要将当前的数据返回到客户端,并且在缓存里面保存一份数据
  4. 如果传递过来的是无效索引,那么就有可能引发一些问题,后面会提到涉及的问题以及解决思路

根据id查询商铺的流程

代码开发:

一般controller层中不进行业务代码的开发

复制代码
@Resource
public IShopService shopService;

/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {

    return Result.ok(shopService.queryById(id));
}

业务层代码:

  1. 严格遵循流程图,重要的是要知道整个架构的数据流转和处理的过程

  2. 先是需要到缓存当中去查找对应的数据,如果找到了对应的数据,则将数据进行返回即可

  3. 如果找不到对应的数据,则需要考虑到对应的数据库当中去进行数据的查找

  4. 找到了对应的数据,将数据进行返回;需要将对应的数据保存到缓存当中,保证下次访问同样的一份数据时,到时候可以直接在缓存当中找到对应的数据/资料

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Object queryById(Long id) {
    String key=CACHE_SHOP_KEY+id;
    String shopJson=stringRedisTemplate.opsForValue().get(key);
    if(StrUtil.isNotBlank(shopJson)){
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return Result.ok(shop);
    }
    //缓存中没有,需要访问数据库
    Shop shop=getById(id);
    //数据库中也没有
    if(shop==null){
    return Result.fail("店铺不存在");
    }
    //存在,存入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
    return Result.ok(shop);
    }

这是我们在缓存代码开发后,打开一个网页的时间花销

从一开始的1.71s【缓存里面没有这个商户的数据】到后面的15ms【缓存里面加入了对应的数据】,提高了数据访问的效率

缓存更新策略

接着是要思考一下缓存的更新问题:

在实际的开发过程中,如果需要对数据库进行修改,那么修改之后的数据库里面的内容是不是和redis里面的内容不一致了,会引发数据不一致的问题

现在我们需要思考如何解决这个问题

redis提供的技术栈:

  1. 内存淘汰:redis会自行进行自我的维护,不需要开发人员来手动进行维护。在内存不足时,自动淘汰部分数据,下次查询时,自动地更新缓存
  2. 超时剔除:给缓存数据添加TTL,当时间超过之后,redis会自动地删除对应的缓存,下次查询时,更新对应的缓存数据
  3. 主动更新:编写对应的业务逻辑,在修改数据库里面的数据的时候,同时修改对应的缓存
这三种方法各有优缺点

需要结合业务开发的特点来进行修改:

低一致性需求:使用内存淘汰机制即可

高一致性需求:主动更新+超时剔除【这是开发人员需要重点解决的问题,需要重点关注这方面的需求】

主动更新时,还有以下三种更新方式
  1. 一般都是采用第一种方式,在更新数据库的同时更新缓存【主要采用这种方式,相对比较简单】
  2. 后面两种更新方式都是比较高效的更新方式,可以结合具体的业务需求采用相对应的更新方式
这三种方法各有优缺点
  1. 一般情况下:删除缓存【相对来说较为高效】
  2. 需要分析清楚,当前的项目是单体系统还是分布式系统,因为在后续用到锁的时候,会涉及到底层的JVM机制,JVM机制能否正常发挥作用,是业务功能能否实现的一个重要的,需要考虑的点
  3. 对于第三个问题,一般都是选择 先操作数据库,再删除缓存,后续会接着分析为什么要这么做
第三个问题的深入分析:
1. 采用先删除缓存,再操作数据库的方式

众所周知,在程序运行的过程中,是有很多的线程在同时运行的 ,需要考虑到这多个线程之间数据会不会相互影响,这些都是需要考虑的问题

理想情况下,运行情况,就是线程1运行完成之后,线程2再运行【理想是美好的,现实是残酷的】

可一旦出现下面的情况,就会出现问题【注意,缓存+数据库 都是线程1和2共享的】

  1. 线程1完成了对应的业务需求了,是不是要删除缓存
  2. 然后于此同时,线程2来了,想查询缓存,可是缓存已经被删除了,是不是要到数据库当中去查询
  3. 线程2将查询到的数据写入到缓存中
  4. 然后此时线程1把数据库给更新了,这就会导致数据不一致的问题!!!!导致出错
2. 先操作数据库,再删除缓存

理想情况下

  1. 先是操作数据库,修改对应的值
  2. 然后是删除缓存,只有当后续该数据用到的时候,才会被加入到缓存当中去!
  3. 接着是线程1查询数据库,获取对应的数据

特殊情况:

这种情况发生的频率相对比较小缓存的读写速度是非常快的 ,但是数据库的写入速度是非常慢的,然后让数据库读的操作正好夹在读写的中间,这种情况发生的记录是相对比较小的,综合来看这种方式是更优的】

  1. 线程1查询缓存,未命中,需要准备去找数据库了吧?
  2. 然后线程2这时候执行,先把数据库里面的值修改了
  3. 然后是把缓存全部删除了【这一部分的缓存数据】
  4. 然后是最后缓存的写入操作

代码实现

复制代码
/**
 * 更新商铺信息
 * @param shop 商铺数据
 * @return 无
 */
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
    // 写入数据库

    return shopService.update(shop);
}

@Override
@Transactional
public Result 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();
}

需要面对的三大问题

一、缓存穿透问题

问题来源:客户想要查找一个数据,从客户端发送一个请求到达缓存,发现缓存当中数据不存在,接着是访问到对应的数据库,同样找不到对应的数据,那么就没有办法在缓存里面保存对应的数据缓存永远不会生效所有的请求全部打到数据库,会给数据库带来巨大的压力!

解决方案:

  1. 缓存一个空对象:
    1. 优点:实现较为简单,维护方便;当客户端请求一份数据的时候,可以考虑在内存里面保存一份空对象,然后适当的加上失效时间,减轻内存的压力
    2. 缺点
    3. 额外的内存消耗,毕竟保存一个空对象到内存里面,难免需要面对内存消耗的问题;
    4. 还有可能造成数据短期的不一致!->如果刚好添加了这条数据,但是给缓存存了一个空兑现,是有可能造成短期不一致的【可以考虑对空对象加上定时删除的功能】

工作流程:

  1. 布隆过滤:

现实中常见组合是:"B+树/LSM 树索引 + Bloom Filter"。Bloom Filter 作为"前置拦截",减少去查磁盘/查索引结构的次数,但它本身不是树】

特点:

不会漏掉真实存在的元素(无假阴性 / no false negative)

对标准 Bloom Filter 来说:如果它告诉你"肯定没有",那就真的没有。

会把不存在的元素误判成存在(有假阳性 / false positive)

也就是:它告诉你"可能有",你还得去后端真实数据结构(哈希表、B+树、LSM 等)再确认一次。

    1. 优点:内存占用的相对比较少,没有多余的key
    2. 缺点:实现较为复杂,存在误判的可能性

工作流程

  1. 一个请求来到布隆过滤器,如果这个数据存在,那么就放行;如果不存在,那么就会拒绝【一般情况下会正常拒绝】
  2. 然后是请求到缓存当中去找对应的数据,如果找到了就直接将数据返回
  3. 如果找不到就到数据库中进行数据的查找工作,找到了数据就给缓存保留一份,将数据返回到客户端

在原来的基础流程上进行改进:【主要采用添加空对象的方式】

  1. 在缓存未命中的情况下,需要根据id去查找对应的点评,如果店铺不存在的话,需要将对应的空值存入到缓存中
  2. 如果缓存命中了,需要考虑一下对应的对象是不是空值,如果是空值,则需要考虑返回错误信息,直接结束
代码实现
特别注释:
分情况解释一下
复制代码
String key=CACHE_SHOP_KEY+id;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return Result.ok(shop);
}
if(shopJson!=null){
    return Result.fail("店铺信息不存在");
}
1. 情况 A:Redis 里 没有这个 key
  • get(key) 返回:null
  • 含义:缓存未命中(还没查过/没写过)
2. 情况 B:Redis 里 有这个 key,但你存的是空串(缓存空值)
  • get(key) 返回:""(空字符串)或 " "(空白)
  • 含义:命中空值缓存(之前查库发现不存在,所以写了空串防穿透)
3. 情况 C:Redis 里 有正常 JSON
  • get(key) 返回:"{...}"(非空白)
  • 含义:命中正常缓存

那两段判断分别识别哪种情况?
1)StrUtil.isNotBlank(shopJson)

它要求:不是 null、不是 ""、不是全空格

所以只会命中 情况 C(正常 JSON)

2)if (shopJson != null)

这句会命中:情况 B + 情况 C(只要不是 null 都算)

但注意:情况 C 已经在上面 return 掉了,所以走到这里时只剩下:

  • shopJson != null 但 isNotBlank 为 false
    ⇒ 只能是 情况 B(空串/空白)
    ⇒ 说明你命中了"空值缓存"
    ⇒ 直接返回"不存在",避免去查数据库(防穿透)
用一句更直观的话解释
  • shopJson == null:Redis 根本没这个 key(没缓存过)⇒ 要查数据库
  • shopJson != null 但内容是空:Redis 有这个 key,但值是空(你自己缓存的"不存在标记")⇒ 直接返回不存在,不查数据库
  • shopJson 是正常 JSON:⇒ 直接返回数据
代码实现
复制代码
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Object queryById(Long id) {
    String key=CACHE_SHOP_KEY+id;
    String shopJson=stringRedisTemplate.opsForValue().get(key);
    if(StrUtil.isNotBlank(shopJson)){
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    if(shopJson!=null){
        return Result.fail("店铺信息不存在");
    }
    
    //缓存中没有,需要访问数据库
    Shop shop=getById(id);
    //数据库中也没有
    if(shop==null){
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("店铺不存在");
    }
    //存在,存入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}
更多的解决方案:
  1. 前面提到的缓存null值+布隆过滤
  2. 增加id的复杂度【不让恶意用户轻易猜出对应的id值】
  3. 做好数据的基础格式校验【对客户传递过来的数据进行校验操作】
  4. 加强用户权限校验【对于权限不足的用户,不让其进行相对应的操作】
  5. 进行限流操作,适当的降低数据库的压力

二、缓存雪崩

缓存雪崩值得是在同一时间段大量的缓存key同时失效或者redis宕机,导致大量的请求到达数据库,带来巨大压力

当然redis宕机时比大量缓存key同时失效要更加严重的

解决方案:

  1. 给不同的key加上不同失效时间
  2. 利用redis集群提高服务的可用性,避免因为一台redis宕机了,导致整个项目罢工的问题
  3. 给缓存业务添加降级限流策略,降低redis/数据库的压力
  4. 添加多级缓存,相当于多级分流的机制,降低服务器的压力

三、缓存击穿问题

缓存击穿问题也被称作是热点Key问题,就是一个被高并发访问 并且缓存重建业务较繁杂的key失效了,很多的请求绕过了redis,直接打到数据库,给数据库带来巨大的压力!

解决方式:
(一) 采用互斥锁的方式来进行解决:
    1. 线程1先去查询对应的缓存来获取数据,发现缓存未命中
    2. 需要考虑重建缓存,那么在重建缓存之前需要加上一把互斥锁,来保证这个过程中不会被别的线程干扰
    3. 然后是线程2来访问对应的数据,发现也没有找到,然后尝试着去获取对应的互斥锁
    4. 获取互斥锁失败 ,那么线程2就会休眠,一段时间后再来尝试
    5. 线程1继续工作,将获取到的数据存入到缓存当中去,然后才是打开互斥锁
    6. 线程2到缓存中查找对应的数据,缓存命中
(二) 逻辑过期来解决缓存击穿问题
    1. 线程1来查询缓存数据,发现数据已经过期了
    2. 获取互斥锁成功,给这个数据加上对应的锁
    3. 然后注意:线程1会开启新的线程2,然后再在线程2上完成数据的查询,写入缓存等操作
    4. 这时候线程1还是接着往前走的,会先 返回过期数据
    5. 这时候线程3来了,查询缓存数据,发现数据已经过期了,获取互斥锁失败
    6. 这时候线程3不等了,直接 返回过期数据
    7. 最后线程2处理完毕之后,写入缓存,重置逻辑过期时间,互斥锁打开
    8. 线程4命中缓存,并且没有过期,返回数据

实现方式【互斥锁】:

由于需要考虑锁没有成功获取到的情况,像之前的锁是无法区分这些的

因此我们需要考虑自定义一把锁,来解决对应的问题,就需要用到redis里面的setnx关键字来自定义一把锁,但是注意要加上一个有效期,保证在服务出现故障的时候,锁也会自动的进行释放

参考案例如下:

代码开发

复制代码
@Override
public Object queryById(Long id) {
    //缓存穿透解决方式
    //Shop shop=queryWithPassThrough(id);

    //缓存击穿解决方式
    Shop shop=queryWitjMutex(id);
    if(shop==null){
        return Result.fail("店铺不存在");
    }
    return Result.ok(shop);
}


public Shop queryWitjMutex(Long id){
    String key=CACHE_SHOP_KEY+id;
    String shopJson=stringRedisTemplate.opsForValue().get(key);
    if(StrUtil.isNotBlank(shopJson)){
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    if(shopJson!=null){ //Redis 有这个 key,但值是空(你自己缓存的"不存在标记")⇒ 直接返回不存在,不查数据库
        return null;
    }

    String lockKey="lock:shop:"+id;
    Shop shop= null;
    try {
        boolean isLock=tryLock(lockKey);
        if(!isLock){//获取锁失败
            Thread.sleep(50);
            return queryWitjMutex(id);
        }

        //缓存中没有,需要访问数据库
        shop = getById(id);
        //数据库中也没有
        if(shop==null){
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //存在,存入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        unlock(lockKey);
    }
    return shop;
}

代码开发【逻辑过期】

复制代码
public Shop queryWithLogicalExpire(Long id){
    String key=CACHE_SHOP_KEY+id;
    String shopJson=stringRedisTemplate.opsForValue().get(key);
    if(StrUtil.isNotBlank(shopJson)){
        return null;
    }

    // 4. 命中,需要先把json反序列化为对象
    RedisData redisData=JSONUtil.toBean(shopJson, RedisData.class);
    JSONObject data=(JSONObject) redisData.getData();
    Shop shop=JSONUtil.toBean(data, Shop.class);       //获取商铺对象
    LocalDateTime expireTime=redisData.getExpireTime();//过期时间
    // 5. 判断是否过期
    // 5.1. 未过期,直接返回店铺信息
    if (expireTime != null && 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 {
                this.saveShop2Redis(id,30L);
            }
            catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                unlock(lockKey);
            }
        });

    }

    // 6.4. 返回过期的商铺信息
    return shop;
}

缓存工具封装

复制代码
@Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private CacheClient cacheClient;

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

    @Override
    public Result queryById(Long id) {
        //缓存穿透解决方式
//        Shop shop= cacheClient.queryWithPassThrough(
//                CACHE_SHOP_KEY,id, Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES
//        );

        //缓存击穿解决方式
        //Shop shop=queryWitjMutex(id);

        //缓存击穿解决方式 逻辑过期
        Shop shop=cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id, Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES);
        if(shop==null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(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.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;

import static com.hmdp.utils.RedisConstants.*;

@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 time, TimeUnit unit){
        RedisData redisData=new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }

    //缓存穿透
    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){

        String key=keyPrefix+id;
        String json=stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(json)){
            return JSONUtil.toBean(json,type);
        }
        if(json!=null){
            return null;
        }
        R r=dbFallback.apply(id);
        if(r==null){
            stringRedisTemplate.opsForValue().set(key,"",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);

    public <R,ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key=keyPrefix+id;
        String json=stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(json)){
            return null;
        }

        // 4. 命中,需要先把json反序列化为对象
        RedisData redisData=JSONUtil.toBean(json, RedisData.class);
        JSONObject data=(JSONObject) redisData.getData();
        R r=JSONUtil.toBean(data,type);       //获取商铺对象
        LocalDateTime expireTime=redisData.getExpireTime();//过期时间

        // 5. 判断是否过期
        // 5.1. 未过期,直接返回店铺信息
        if (expireTime != null && 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);
                    this.setWithLogicalExpire(key,r1,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);
    }
}
相关推荐
PD我是你的真爱粉1 小时前
Redis基础与数据结构
数据结构·数据库·redis
茶杯梦轩2 小时前
从零起步学习Redis || 第十一章:主从切换时的哨兵机制如何实现及项目实战
服务器·redis
火山引擎开发者社区2 小时前
飞书聊天直接生视频!OpenClaw × Seedance Skill王炸组合
后端
Mr -老鬼2 小时前
基于 Go 的脚本平台 APP 云控系统
开发语言·后端·golang
rannn_1112 小时前
【苍穹外卖|Day7】缓存菜品、缓存套餐、添加购物车、查看购物车、清空购物车
java·spring boot·redis·后端·缓存·项目
BingoGo2 小时前
“Fatal error: require(): Failed opening required...” 以及如何彻底避免它再次出现
后端·php
zhougl9962 小时前
Springboot - druid 连接池
java·spring boot·后端
全栈前端老曹2 小时前
【Redis】发布订阅模型 —— Pub/Sub 原理、消息队列、聊天系统实战
前端·数据库·redis·设计模式·node.js·全栈·发布订阅模型
JaguarJack2 小时前
“Fatal error: require(): Failed opening required...” 以及如何彻底避免它再次出现
后端·php·服务端