Redis学习笔记(五万字超详细,配具体代码及个人理解,包括redis在项目中的具体使用及优化查询)

IT疑难杂症诊疗室 10w+人浏览 715人参与

一. 基础入门

1. Jedis

(1)快速入门:

(2)Jedis连接池

2. SpringDataRedis

(1)快速入门:

(2)序列化器

StringRedisTemplate:

二. redis在项目中的运用

1.短信登录

1.1 基于Session实现登录

1.2 集群的Session共享问题

1.3 基于Redis实现共享session登录

1.4 拦截器配置

用户有已登录和游客状态,对于已登录的用户,需要在其进行任意操作时刷新用户的token。对于游客我们要拦截一些特定的请求。对于这个需求,设置了两个拦截器来实现。

1.RefreshTokenInterceptor

该拦截器拦截所有请求,先根据键从redis中查看是否有用户信息,有则保存到ThreadLocal并刷新token,没有则直接放行。

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中的用户
        String key = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }

        //5.将查询到的Hsah数据转换为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);

        // 7.刷新token的有效期
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

2.LoginInterceptor

该拦截器拦截特定的请求,未登陆的游客不能访问某些接口。

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            //没有,需要拦截,设置状态码
            response.setStatus(401);
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

3.MvcConfig配置类

在该配置类中注册拦截器,设置拦截器的顺序以及限制访问的接口。

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 刷新token拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);

        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                ).order(1);
    }
}

2. 缓存

1.添加redis缓存

执行流程:

2. 缓存更新策略

对于先操作缓存还是先操作数据库:

虽然两种方案都存在线程安全问题,但右边发生的概率比左边发生的概率小得多

3.缓存穿透

业务层防缓存穿透的查询方法:

public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        //缓存穿透
        Shop shop = queryWithPassThrough(id);

        // 7.返回
        return Result.ok(shop);
    }

    public Shop queryWithPassThrough(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的是否是空字符串 ""
        if (shopJson != null) {//shopJson不为空就只能为""
            return null;
        }

        // 4.不存在,根据id查询数据库 即shopJson == null,说明redis中的缓存过期
        Shop shop = getById(id);
        // 5.数据库不存在,返回错误
        if (shop == null) {
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 7.返回
        return shop;
    }
}

4. 缓存雪崩

5.缓存击穿

  • 互斥锁:当请求查询缓存且缓存未命中时,线程获取互斥锁,只有获取锁的线程去查询数据库并将数据写入缓存,其他线程需等待,待缓存有数据后再读取。
  • 逻辑过期:给缓存数据设置逻辑过期时间,当请求发现缓存数据逻辑过期时,启动一个新线程去查询数据库更新缓存,当前线程继续返回旧的缓存数据。

5.1 基于互斥锁方式解决缓存击穿问题

业务层关于根据id查询店铺的代码:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        //互斥锁解决缓存击穿
        Shop shop = queryWithMutex(id);
        if (shop == null) {
            return Result.fail("店铺不存在");
        }

        // 7.返回
        return Result.ok(shop);
    }

    public Shop queryWithMutex(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的是否是空字符串""(在数据库中查询依旧不存在的id,我们存入redis的值为"")
        if (shopJson != null) {
            return null;
        }

        //redis中没有对应的缓存,则查询数据库
        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = "lock:shop:" + id;// 锁的 key 与店铺 id 绑定(lock:shop:1),不同店铺的缓存重建互不干扰,减少锁竞争。
        try{
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取失败,则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }

            // 4.4.获取锁成功,根据id查询数据库
            Shop shop = getById(id);
            //模拟重建的延时
            Thread.sleep(200);

            // 5.数据库不存在,返回错误
            if (shop == null) {
                //将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            // 7.返回
            return shop;
        } catch (InterruptedException e){
            throw new RuntimeException(e);
        } finally {
            // 8.释放锁
            unLock(lockKey);
        }

    }

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

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

实现原理:通过 Redis 的setIfAbsent命令实现分布式锁,保证同一时间只有一个线程能执行缓存重建(从数据库查询数据并写入缓存),避免大量请求同时穿透到数据库。

tryLock方法的实现依赖 Redis 的setIfAbsent命令(原子操作),setIfAbsent是 Redis 的原子命令,确保多个线程同时尝试获取锁时,只有一个线程能成功。

private boolean tryLock(String key) {
    // setIfAbsent:若key不存在则设置值,返回true;若已存在则不操作,返回false
    // 同时设置锁的过期时间(10秒),避免锁未释放导致死锁
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

5.2 基于逻辑过期方式解决缓存击穿问题

业务层具体实现:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {

        //逻辑过期解决缓存击穿
        Shop shop = queryWithLogicalExpire(id);

        // 7.返回
        return Result.ok(shop);
    }

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

    public Shop queryWithLogicalExpire(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3.不存在,直接返回null
            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())) {
            //未过期,直接返回
            return shop;
        }
        //已过期,需要缓存重建
        //6.重建缓存
        //获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);

        //判断获取锁是否成功
        if (isLock) {
            //获取锁成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                //重建缓存
                try {
                    this.saveShopToRedis(id, 3600L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        //返回过期的商铺信息
        return shop;
    }

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

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

    private void saveShopToRedis(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.写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }
}

RedisData:

@Data
public class RedisData {
    private LocalDateTime expireTime;// 逻辑过期时间
    private Object data;// 实际数据(Shop对象)
}

核心思路:不依赖 Redis 的原生过期时间,而是在缓存数据中嵌入一个 "逻辑过期时间",当检测到数据过期时,先返回旧数据,同时异步重建缓存,避免大量请求阻塞或穿透到数据库。

原理:

  1. 缓存不删除:热点数据的缓存始终存在(Redis 不自动删除),避免了 "缓存突然消失导致大量请求穿透" 的问题。
  2. 过期仍可用:数据过期后,先返回旧数据保证服务可用性,用户无感知。
  3. 异步重建:只有一个线程(通过互斥锁控制)在后台异步重建缓存,避免数据库压力。
  4. 最终一致性:重建完成后,新数据会覆盖旧缓存,后续请求会获取到最新数据,保证数据最终一致。

逻辑过期适合容忍短期数据不一致的热点数据(如热门商品、高频访问的店铺信息)。其优势是响应速度快(不阻塞请求),缺点是需要额外存储过期时间,且数据存在短暂不一致窗口。

6.封装Redis缓存工具类

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)));
        // 写入redis
        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;
        // 1.从redis查询缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        //判断命中的是否是空字符串 ""
        if (json != null) {
            return null;
        }

        // 4.不存在,根据id查询数据库 即shopJson == null,说明redis中的缓存过期
        R r = dbFallback.apply(id);

        // 5.数据库不存在,返回错误
        if (r == null) {
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        // 7.返回
        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;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.不存在,直接返回null
            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())) {
            //未过期,直接返回
            return r;
        }
        //已过期,需要缓存重建
        //6.重建缓存
        //获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);

        //判断获取锁是否成功
        if (isLock) {
            //获取锁成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                //重建缓存
                try {
                    //查询数据库
                    R rr = dbFallback.apply(id);
                    //写入redis
                    this.setWithLogicalExpire(key, rr, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        //返回过期的商铺信息
        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);
    }
}

3. 优惠卷秒杀

1. 全局ID生成器

RedisIdWorker:
 

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号位数
     */
    private static final long COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - 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;
    }
}

2. 超卖问题

乐观锁:

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

1. 版本号法

2. CAS法

业务层抢优惠卷逻辑:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    ISeckillVoucherService seckillVoucherService;
    @Resource
    RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠卷
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀尚未开始");
        }

        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已结束");
        }

        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足");
        }

        // 5.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .eq("stock", voucher.getStock()).gt("stock", 0)//乐观锁
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }

        // 6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回订单id

        return Result.ok(orderId);
    }
}

3. 一人一单

3.1 单机情况

基于悲观锁实现:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    ISeckillVoucherService seckillVoucherService;
    @Resource
    RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠卷
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀尚未开始");
        }

        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已结束");
        }

        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足");
        }

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 5.一人一单
        Long userId = UserHolder.getUser().getId();

            // 查询订单
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            // 判断是否存在
            if (count > 0) {
                //用户已经购买过
                return Result.fail("用户已经购买过");
            }

            // 6.扣减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock=stock-1")
                    .eq("voucher_id", voucherId).gt("stock", 0)
                    .update();
            if (!success) {
                return Result.fail("库存不足");
            }

            // 7.创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            long orderId = redisIdWorker.nextId("order");
            voucherOrder.setId(orderId);
            voucherOrder.setUserId(userId);
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);

            // 8.返回订单id

            return Result.ok(orderId);
        }
}

核心功能写在createVoucherOrder方法中,这个方法涉及两个表的修改,所以要加上事务。

详细分析:

1. synchronized 问题

如果这样加锁:

public synchronized Result createVoucherOrder(Long voucherId) {
     ......
}

这个锁的范围是整个方法,锁的对象是this(VoucherOrderServiceImpl的对象),意味着任何一个用户来了都要加这个锁,即所有用户共有一把锁,方法为串行执行,性能大大降低。

因为业务需要一人一单,所以应该同一个用户一个锁,不同用户不同锁。这里改为

synchronized (userId.toString().intern()) {}

toString方法内部new了一个字符串,所以每调用这个方法都会创建一个新的 String 对象。这样的话即使userId一样,不同线程对同一个用户ID执行 userId.toString() 会产生不同的 String 实例,又因为 synchronized 块的锁定是基于对象的,锁作用于不同的对象就无法实现业务功能。使用 intern() 可以确保相同内容的字符串指向常量池中的同一个对象,该方法在常量池中找与本身值相等的字符串的地址或引用并返回,这样就保证用户id一样时锁就一样。

问题又来了: synchronized应该放哪儿呢?

假如这样写:

@Transactional
public Result createVoucherOrder(Long voucherId) {
       synchronized (userId.toString().intern()) {
            ......
        }
}

这样写的话,执行逻辑是先放锁再提交事务。事务被spring管理,spring在函数执行完以后才会提交事务,而如果有其他线程在锁释放后和事务提交之前来查询订单的话,那么刚刚新增的订单可能还没写入数据库,就会出现并发安全问题。

因此锁应该把整个createVoucherOrder方法锁起来,即在事务提交后再释放锁。因此在seckillVoucher方法里用锁将createVoucherOrder括起来。如:

public Result seckillVoucher(Long voucherId) {
      ......
      synchronized (userId.toString().intern()){
            return this.createVoucherOrder(voucherId);
        }
}

2.事务问题:

在seckillVoucher方法上没有加事务,调用this对象的createVoucherOrder方法,this拿到的是当前的VoucherOrderServiceImpl对象而不是VoucherOrderServiceImpl的代理对象,不会触发事务管理。事务生效是因为Spring对当前的VoucherOrderServiceImpl对象做了动态代理,拿到了VoucherOrderServiceImpl的代理对象来做事务处理。

而当前的this是目标对象而非代理对象,没有事务功能,这样的话事务就失效了,所以在这里要拿到代理对象。所以又改为这样写:

synchronized (userId.toString().intern()) {
            //获取当前对象的代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

这样写还要引入依赖:

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

在启动类上添加注解@EnableAspectJAutoProxy(exposeProxy = true)来暴露代理对象:

@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

@EnableAspectJAutoProxy(exposeProxy = true) 的作用:启用 Spring 的 AspectJ 自动代理功能,exposeProxy = true 参数表示将当前代理对象暴露到 ThreadLocal 中,这样可以在目标对象内部访问到其代理对象。

AopContext.currentProxy() 的作用:从 ThreadLocal 中获取当前线程绑定的代理对象,只有当 exposeProxy = true 时才能成功获取到代理对象,如果没有启用 exposeProxy,调用此方法会抛出 IllegalStateException 异常。

3.2 并发安全问题

发现在集群模式下锁失效了。

正常串行执行流程如图:

集群模式下,不同的tomcat,有不同的jvm,jvm里的锁监视器也就不同了。同一服务器同一userId依然只有一个线程可以拿到锁,但是在不同服务器但是有同一userId的多个线程可以同时获得锁。

如何解决这个问题呢?由此引入分布式锁。

4. redis分布式锁

1. 工作原理

让多个JVM进程都能看到同一个锁监视器,实现互斥,只有一个线程可以拿到锁。

2. 具体实现:

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁的过期时间
     * @return true代表获取锁成功
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unLock();
}
public class SimpleRedisLock implements ILock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        long threadId = Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

VoucherOrderServiceImpl的seckillVoucher方法内部调用createVoucherOrder的逻辑改为:

        Long userId = UserHolder.getUser().getId();
        //创建锁对象
        SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);

        // 获取锁
        boolean isLock = redisLock.tryLock(1200);

        //判断获取锁是否成功
        if (!isLock) {
            //获取锁失败
            return Result.fail("不允许重复下单");
        }
        try {
            //获取当前对象的代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
        finally {
            //释放锁
            redisLock.unLock();
        }

3. 实现缺陷
3.1 误删问题

当线程1拿到锁的时候,如果业务阻塞,使得业务完成之前锁已经过期自动删除了,然后线程2拿到锁并执行业务,在执行期间线程1业务完成了,又把线程2持有的锁删除了;这时线程3又获取到了锁,然后线程2业务执行完成了,又把线程3持有的锁删除了......

解决方法:

获取锁时,我们存入了当前线程的标识 threadId , 我们每次在删除锁时,要判断当前锁的threadId与我们存入的是否一致,不一致就不做操作,一致才删除锁。

问题又出现了:在JVM内部每创建一个线程,线程id就会递增1。在集群模式下,有多个jvm,每个jvm内部都会维护这样一个递增的数字,很有可能出现线程冲突

应对方法:在threadId前拼接一个UUID生成的前缀,确保不同jvm的线程id不冲突。

public class SimpleRedisLock implements ILock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, 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);
        }
    }
}
3.2 释放锁问题

线程1在释放锁的步骤中,先获取锁标识并确定锁是当前线程持有后,再执行stringRedisTemplate.delete方法释放锁;如果在这时发生了阻塞(比如jvm的垃圾回收阻塞),如果阻塞时间过长,导致锁过期了,线程2就可以获取锁,线程2获取锁之后执行业务;此时阻塞又结束了,线程1去释放锁,然后线程3又获取锁开始执行业务........就发生了线程安全问题。

由此可以看出:我们必须保证获取锁标识判断是否一致和释放是一个原子操作

3.3 基于Lua脚本实现分布式锁的释放逻辑

RedisTemplate调用Lua脚本的API如下:

实现:

在resource目录下新建一个unLock.lua文件,在该文件中写lua脚本,要下载插件 EmmyLua。

Lua脚本:

-- 比较线程标识与锁中的标识是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
    -- 释放锁
    return redis.call('del', KEYS[1])
end
return 0

修改SimpleRedisLock中unLock方法:

    //释放锁的脚本
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
    
    @Override
    public void unLock() {
        //调用Lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId()
        );
    }

4. 基于Redisson的分布式锁优化

1.基于setnx实现的分布式锁存在的问题
1.1 不可重入:同一个线程无法多次获取同一把锁

例如:有一个方法A去调用方法B,方法A要先获取一把锁,然后去调B,B方法又要获取同一把锁,如果锁是不可重入的,B方法就会等待锁的释放,然而A方法又因为未执行完不会释放锁,就形成死锁。

1.2 不可重试:获取锁只尝试一次就返回false,没有重试机制

在业务中,我们常常希望第一次获取锁失败时可以等待一会,再次尝试获取锁,而不是直接返回失败。

1.3 超时释放

锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放。如果锁过期时间设置的比较短,可以业务还没执行完就释放了;如果锁过期时间设置的比较长,如果出现故障,会导致线程阻塞等待锁释放。

1.4 主从一致性

如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现

由此引入Redisson。

2.Redisson

2.1 入门

引入依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

配置Redisson客户端:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        //配置
        Config config = new Config();
        //添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://localhost:6379").setPassword("123456");
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}

使用Redisson的分布式锁:

@Resource
private RedissonClient redissonClient;

@Test
void testRedisson() throws InterruptedException {
    // 获取锁(可重入),指定锁的名称
    RLock lock = redissonClient.getLock("anyLock");
    // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
    // 判断释放获取成功
    if (isLock) {
        try {
            System.out.println("执行业务");
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}

2.2 Redisson的可重入锁原理

执行流程:

1. 之前的锁存储方式为key-value,现在使用哈希结构,field是持有锁的线程id,value是重入次数。

2. 方法1获取锁时,先判断锁是否存在,不存在就创建锁,并将重入次数设为1;再调用方法2,方法2尝试获取锁时,先判断锁是否存在,若存在又判断持有锁的线程是不是当前线程,若是,拿到锁并将重入次数加一,并重新设置锁的过期时间,为接下来的业务留出充足时间。

3. 方法2执行完成后,在释放锁这一步,先判断锁是不是当前线程持有,若是,将重入次数减一,再判断重入次数释放为0,是则释放锁;再次重置锁的有效期。

4. 方法1业务执行完成后,释放锁时,将重入次数减一,如果重入次数为0,直接释放锁。

我们使用Lua脚本确保以上操作具有原子性

获取锁的Lua脚本:

local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if(redis.call('exists', key) == 0) then
    -- 不存在,获取锁
    redis.call('hset', key, threadId, '1');
    -- 设置有效期
    redis.call('expire', key, releaseTime);
    return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
    -- 不存在,获取锁,重入次数+1
    redis.call('hincrby', key, threadId, '1');
    -- 设置有效期
    redis.call('expire', key, releaseTime);
    return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败

释放锁的Lua脚本:

local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
    return nil; -- 如果已经不是自己, 则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
    -- 大于0说明不能释放锁,重置有效期然后返回
    redis.call('EXPIRE', key, releaseTime);
    return nil;
else  -- 等于0说明可以释放锁,直接删除
    redis.call('DEL', key);
    return nil;
end;

2.3 Redisson分布式锁一致性问题(multiLock)

Java应用在获取锁时,要在主节点执行写操作,写入成功后,在主节点和从节点同步数据时主节点宕机了,这时从节点中一个变为主节点,但是此时的主节点中没有锁信息,其他线程就可以来获取锁,造成线程安全问题。

解决方法:

获取锁时要向多个redis节点中写入数据,都保存锁的标识才算获取成功。获取锁时如果其中一台redis挂了,主节点的写入没有同步到从节点,此时从节点变为主节点,没有锁标识;当其他线程想来获取锁,依然不会有线程安全问题:在这个新的主节点可以获取锁,但在其他主节点中已有锁信息,不能写入,这个线程获取锁就失败了。

3. 秒杀优化
3.1 优化思路

这是当前业务逻辑的执行流程,发现减库存和创建订单步骤都要执行数据库写操作,而这里为了线程安全还加了分布式锁,所以性能大大降低了;

我们将判断秒杀库存和校验一人一单的操作放在redis中执行,如果用户有下单资格,保存信息到阻塞队列,直接返回订单id,并开启一个线程读取信息执行减库存和创建订单等耗时的数据库写操作。

在redis中的实现:

需要将优惠将库存信息和订单信息存储到redis中:用string集合存储优惠卷库存信息,用set集合存储购买过优惠卷的用户id(具有唯一性)

3.2 改进秒杀业务,提高并发性能

① 新增秒杀优惠券的同时,将优惠券信息保存到 Redis 中

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到Redis中
    stringRedisTemplate.opsForValue().set("seckill:stock:" + voucher.getId(), voucher.getStock().toString());
}

② 基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.用户已经下单,返回2
    return 2
end
-- 3.4.扣减库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.记录用户已经下单 SADD orderKey userId
redis.call('sadd', orderKey, userId)
return 0

③ 如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列

④ 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    // 秒杀优惠券服务,用于查询秒杀优惠券信息
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    // Redis ID生成器,用于生成全局唯一的订单ID
    @Resource
    private RedisIdWorker redisIdWorker;

    // Redis字符串操作模板,用于执行Lua脚本和缓存操作
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // Redisson客户端,用于实现分布式锁
    @Resource
    private RedissonClient redissonClient;

    // 秒杀Lua脚本,用于原子性地检查库存和下单资格
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

    // 订单任务队列,用于缓冲订单请求
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

    // 秒杀订单处理线程池,单线程执行保证订单处理顺序
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    // 当前服务的代理对象,用于处理事务
    private IVoucherOrderService proxy;

    // 静态初始化块,用于加载秒杀Lua脚本
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        // 设置Lua脚本位置
        SECKILL_SCRIPT.setLocation(new ClassPathResource("secKill.lua"));
        // 设置脚本返回值类型
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    /**
     * 初始化方法,在Bean创建完成后自动执行
     * 启动订单处理线程,开始监听订单队列
     */
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderTask());
    }

    /**
     * 订单处理任务类
     * 负责从订单队列中取出订单并处理
     */
    private class VoucherOrderTask implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    // 从订单队列中取出一个订单任务,如果队列为空则阻塞等待
                    VoucherOrder voucherOrder = orderTasks.take();
                    // 处理订单任务
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    // 记录订单处理异常日志
                    log.error("处理订单异常", e);
                }
            }
        }
    }

    /**
     * 处理订单任务
     * 主要功能:
     * 1. 获取用户ID
     * 2. 使用Redisson分布式锁防止重复下单
     * 3. 调用代理对象创建订单(保证事务生效)
     *
     * @param voucherOrder 待处理的订单对象
     */
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        // 获取用户ID,用于构建分布式锁的key
        Long userId = voucherOrder.getUserId();

        // 创建分布式锁,每个用户一个独立的锁
        RLock lock = redissonClient.getLock("lock:order:" + userId);

        // 尝试获取锁,立即返回结果不等待
        boolean isLock = lock.tryLock();

        // 判断获取锁是否成功
        if (!isLock) {
            // 获取锁失败,说明该用户正在下单,不允许重复下单
            log.error("不允许重复下单");
            return; // 添加return避免继续执行
        }

        try {
            // 获取当前对象的代理对象(事务)
            // 通过AopContext.currentProxy()获取代理对象,确保@Transactional注解生效
            proxy = (IVoucherOrderService) AopContext.currentProxy();
            // 调用代理对象的创建订单方法,保证事务生效
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            // 释放分布式锁,防止死锁
            lock.unlock();
        }
    }

    /**
     * 秒杀优惠券
     * 主要流程:
     * 1. 执行Lua脚本进行库存检查和下单资格验证
     * 2. 如果有购买资格,则生成订单并加入队列
     * 3. 异步处理订单创建
     *
     * Lua脚本返回值含义:
     * 0 - 有购买资格
     * 1 - 库存不足
     * 2 - 不能重复下单
     *
     * @param voucherId 优惠券ID
     * @return 订单结果,包含订单ID或错误信息
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 获取当前登录用户ID
        Long userId = UserHolder.getUser().getId();

        // 1.执行Lua脚本进行库存检查和下单资格验证
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );

        // 将返回结果转换为int类型便于比较
        int r = result.intValue();

        // 2.判断结果是否为0(0表示有购买资格)
        if (r != 0) {
            // 不为0,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }

        // 为0,有购买资格,把下单信息保存到阻塞队列
        // 生成全局唯一的订单ID
        long orderId = redisIdWorker.nextId("order");

        // 7.创建订单对象
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(orderId);           // 设置订单ID
        voucherOrder.setUserId(userId);        // 设置用户ID
        voucherOrder.setVoucherId(voucherId);  // 设置优惠券ID

        // 放入阻塞队列,等待异步处理
        orderTasks.add(voucherOrder);

        // 获取当前对象的代理对象(事务)
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        // 3.返回订单id
        return Result.ok(orderId);
    }

    /**
     * 创建优惠券订单
     * @param voucherOrder 订单对象
     */
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        // TODO 保存订单信息到数据库,具体实现省略
    }
}

4. Redis消息队列

基于jvm的阻塞队列实现异步秒杀存在问题:
1. 内存限制:高并发情况下,大量订单需要创建时,有可能超出jvm阻塞队列的上限。

2. 数据安全:jvm内存没有持久化记忆,每当服务出现重启或宕机时,阻塞队列中的所有订单都会丢失;还有当从阻塞队列中拿一个订单任务还尚未处理,若此时出现异常,那这个订单也不会再被处理

4.1 消息队列

独立于jvm以外存储,数据安全。

4.2 基于List结构模拟消息队列

基于List的消息队列有哪些优缺点?

优点:  利用Redis存储,不受限于JVM内存上限 ;基于Redis的持久化机制,数据安全性有保证 ; 可以满足消息有序性

缺点: 无法避免消息丢失 ; 只支持单消费者

4.3 基于PubSub的消息队列

基于PubSub的消息队列有哪些优缺点?

优点: - 采用发布订阅模型,支持多生产、多消费

缺点: 不支持数据持久化 ;无法避免消息丢失 ; 消息堆积有上限,超出时数据丢失

4.4 基于Stream的消息队列

单消费模式:

特点: - 消息可回溯 - 一个消息可以被多个消费者读取 - 可以阻塞读取 - 有消息漏读的风险。

消费者组:

消费者监听消息的基本思路:

while(true){
    // 尝试监听队列,使用阻塞模式,最长等待 2000 毫秒
    Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >");
    if(msg == null){ // null说明没有消息,继续下一次
        continue;
    }
    try {
        // 处理消息,完成后一定要ACK
        handleMessage(msg);
    } catch(Exception e){
        while(true){
            Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 STREAMS s1 0");
            if(msg == null){ // null说明没有异常消息,所有消息都已确认,结束循环
                break;
            }
            try {
                // 说明有异常消息,再次处理
                handleMessage(msg);
            } catch(Exception e){
                // 再次出现异常,记录日志,继续循环
                continue;
            }
        }
    }
}
```

STREAM类型消息队列的XREADGROUP命令特点:

 消息可回溯 ; 可以多消费者争抢消息,加快消费速度 ;可以阻塞读取 ; 没有消息漏读的风险 ; 有消息确认机制,保证消息至少被消费一次。

5. 基于Redis的Stream实现异步秒杀下单

① 创建一个Stream类型的消息队列,名为stream.orders

XGROUP CREATE stream.orders g1 0 MKSTREAM

② 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.用户已经下单,返回2
    return 2
end
-- 3.4.扣减库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.记录用户已经下单 SADD orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

③ 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    // 秒杀优惠券服务,用于查询秒杀优惠券信息
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    // Redis ID生成器,用于生成全局唯一的订单ID
    @Resource
    private RedisIdWorker redisIdWorker;

    // Redis字符串操作模板,用于执行Lua脚本和缓存操作
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // Redisson客户端,用于实现分布式锁
    @Resource
    private RedissonClient redissonClient;

    // 秒杀Lua脚本,用于原子性地检查库存和下单资格
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

//    // 订单任务队列,用于缓冲订单请求
//    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

    // 秒杀订单处理线程池,单线程执行保证订单处理顺序
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    // 静态初始化块,用于加载秒杀Lua脚本
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        // 设置Lua脚本位置
        SECKILL_SCRIPT.setLocation(new ClassPathResource("secKill.lua"));
        // 设置脚本返回值类型
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    /**
     * 初始化方法,在Bean创建完成后自动执行
     * 启动订单处理线程,开始监听订单队列
     */
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderTask());
    }

    /**
     * 订单处理任务类
     * 负责从订单队列中取出订单并处理
     */
    private class VoucherOrderTask implements Runnable {

        String queueName = "stream.orders";
        @Override
        public void run() {
            while (true) {
                try {
                    // 1.获取消息队列中的订单消息
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    //2.判断消息获取是否成功
                    if (list == null || list.isEmpty()) {
                        //如果获取失败,说明没有消息,继续循环
                        continue;
                    }

                    // 3.解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    // 4.如果获取成功,可以下单
                    handleVoucherOrder(voucherOrder);
                    // 5.ACK确认
                    stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

                } catch (Exception e) {
                    // 记录订单处理异常日志
                    log.error("处理订单异常", e);
                    handlePendingList();
                }
            }
        }

        private void handlePendingList() {
            while (true) {
                try {
                    // 1.获取pending-list中的订单消息
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    //2.判断消息获取是否成功
                    if (list == null || list.isEmpty()) {
                        //如果获取失败,说明pending-list没有异常消息,结束循环
                        break;
                    }

                    // 3.解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    // 4.如果获取成功,可以下单
                    handleVoucherOrder(voucherOrder);
                    // 5.ACK确认
                    stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

                } catch (Exception e) {
                    // 记录订单处理异常日志
                    log.error("处理订单异常", e);
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException ex) {
                        ex.printStackTrace();
                    }
                }
            }
        }
    }
    

    /**
     * 处理订单任务
     * 主要功能:
     * 1. 获取用户ID
     * 2. 使用Redisson分布式锁防止重复下单
     * 3. 调用代理对象创建订单(保证事务生效)
     *
     * @param voucherOrder 待处理的订单对象
     */
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        // 获取用户ID,用于构建分布式锁的key
        Long userId = voucherOrder.getUserId();

        // 创建分布式锁,每个用户一个独立的锁
        RLock lock = redissonClient.getLock("lock:order:" + userId);

        // 尝试获取锁,立即返回结果不等待
        boolean isLock = lock.tryLock();

        // 判断获取锁是否成功
        if (!isLock) {
            // 获取锁失败,说明该用户正在下单,不允许重复下单
            log.error("不允许重复下单");
            return;
        }

        try {
            // 获取当前对象的代理对象(事务)
            // 通过AopContext.currentProxy()获取代理对象,确保@Transactional注解生效
            proxy = (IVoucherOrderService) AopContext.currentProxy();
            // 调用代理对象的创建订单方法,保证事务生效
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            // 释放分布式锁,防止死锁
            lock.unlock();
        }
    }

    // 当前服务的代理对象,用于处理事务
    private IVoucherOrderService proxy;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 获取当前登录用户ID
        Long userId = UserHolder.getUser().getId();
        // 生成全局唯一的订单ID
        long orderId = redisIdWorker.nextId("order");

        // 1.执行Lua脚本进行库存检查和下单资格验证
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(), String.valueOf(orderId)

        );

        // 将返回结果转换为int类型便于比较
        int r = result.intValue();

        // 2.判断结果是否为0(0表示有购买资格)
        if (r != 0) {
            // 不为0,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }

        // 获取当前对象的代理对象(事务)
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        // 3.返回订单id
        return Result.ok(orderId);
    }



    /**
     * 创建优惠券订单
     * @param voucherOrder 订单对象
     */
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        // TODO 保存订单信息到数据库,具体实现省略
    }
}

4. 达人探店

1. 点赞功能

原本的实现:

    @PutMapping("/like/{id}")
    public Result likeBlog(@PathVariable("id") Long id) {
        // 修改点赞数量
        blogService.update()
                .setSql("liked = liked + 1").eq("id", id).update();
        return Result.ok();
    }

这种实现方式用户可以无限制的为某个blog点赞。

我们使用Redis的set类型存储blog的id和给它点赞的用户id信息,将blog的Id作为key,将用户id存入set集合中,判断用户是否点过赞时从redis中查询与blog对应的set集合,减轻数据库的压力。

@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);
            this.idBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 1.查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }
        // 2.查询blog有关的用户消息
        queryBlogUser(blog);
        // 3.查询blog的是否被点赞
        idBlogLiked(blog);
        return Result.ok(blog);
    }

    private void idBlogLiked(Blog blog) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();

        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + blog.getId();
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        blog.setIsLike(BooleanUtil.isTrue(isMember));
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }

    @Override
    public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();

        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + id;
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        if (BooleanUtil.isFalse(isMember)){
            // 3.如果未点赞,则点赞
            // 3.1.数据库点赞数+1
            // 3.2.保存用户到Redis的set集合
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            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.3.把用户从Redis的set集合移除
            if (isSuccess){
                stringRedisTemplate.opsForSet().remove(key, userId.toString());
            }

        }

        return Result.ok();
    }
}

2. 点赞排行榜

点赞时我们使用set集合存储点赞过的用户id,但是set无法排序,我们不知道用户点赞的先后顺序。所以这里我们改为使用SortedSet数据结构实现,存入id时同时存入时间戳作为排序分数;根据时间戳排序可以获得最早点赞的用户id,查询用户信息并在前端展示。

@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);
            this.idBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 1.查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }
        // 2.查询blog有关的用户消息
        queryBlogUser(blog);
        // 3.查询blog的是否被点赞
        idBlogLiked(blog);
        return Result.ok(blog);
    }

    private void idBlogLiked(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null){
            // 用户未登录,无需查询是否点赞
            return;
        }
        Long userId = user.getId();

        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        blog.setIsLike(score != null);
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }

    @Override
    public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();

        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + id;
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null){
            // 3.如果未点赞,则点赞
            // 3.1.数据库点赞数+1
            // 3.2.保存用户到Redis的set集合
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            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.3.把用户从Redis的set集合移除
            if (isSuccess){
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
            }

        }

        return Result.ok();
    }

    @Override
    public Result queryBlogLikes(Long id) {
        // 1.查询top5的点赞用户
        String key = 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);//确保数据库按照ids中的顺序查询,直接传递ids,sql的in语句不一定会按照ids中的id顺序查数据

        // 3.根据用户id查询用户信息
        List<UserDTO> userDTOS = userService.query()
                .in("id", ids)
                .last("ORDER BY FIELD(id," + idStr + ")").list()
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        // 4.返回
        return Result.ok(userDTOS);
    }
}

5. 好友关注

1. 关注和取关

业务层方法实现:

@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 获取当前用户
        Long userId = UserHolder.getUser().getId();
        // 1.判断是关注还是取关
        if (isFollow){
            // 2.关注,插入数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            save(follow);
        }else {
            // 3.取关,删除数据
            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);
    }
}

2. 共同关注

我们使用redis中的set集合存储用户关注的其他用户的id,对于两个不同的set集合,可以使用 SINTER 命令查询这两个集合的交集。这样我们就能实现查询两个用户的共同关注功能。

修改关注和取关业务逻辑:

@Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 获取当前用户
        Long userId = UserHolder.getUser().getId();
        String key = "follow:" + userId;
        // 1.判断是关注还是取关
        if (isFollow){
            // 2.关注,插入数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            boolean isSuccess = save(follow);
            if (isSuccess){
                // 把关注的用户的id放入redis的set集合
                stringRedisTemplate.opsForSet().add(key, followUserId.toString());
            }
        }else {
            // 3.取关,删除数据
            boolean isSuccess = remove(new QueryWrapper<Follow>()
                    .eq("user_id", userId).eq("follow_user_id", followUserId));
            if (isSuccess){
                // 把取关的用户的id从redis的set集合中移除
                stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
            }
        }
        return Result.ok();
    }

实现共同关注业务方法:

    @Override
    public Result followCommons(Long id) {
        // 1.获取当前用户
        Long userId = UserHolder.getUser().getId();
        String key = "follow:" + userId;
        // 2.求交集
        String key2 = "follow:" + id;
        Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
        if (intersect == null || intersect.isEmpty()){
            // 无交集
            return Result.ok(Collections.emptyList());
        }
        // 3.解析id集合
        List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
        // 4.查询用户
        List<UserDTO> users = userService.listByIds(ids)
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        return Result.ok(users);
    }

3. 关注推送

Feed流的模式:

1. 拉模式

2. 推模式

3. 推拉结合模式

对于粉丝比较少的博主,直接使用推模式。

对于粉丝量很大的博主:活跃粉丝使用推模式,不活跃粉丝使用拉模式。

基于推模式实现关注推送功能:

1. 推送到粉丝收件箱

传统分页:

滚动分页(游标分页):

redis的list队列只能实现传统分页,sortedset数据结构可以实现滚动查询。

业务层具体实现:

@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private IFollowService followService;

    @Override
    public Result saveBlog(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 2.保存探店博文
        boolean isSuccess = save(blog);
        // 3.查询笔记作者的所有粉丝
        if (!isSuccess){
            return Result.fail("发布笔记失败!");
        }
        List<Follow> follows = followService.query()
                .eq("follow_user_id", user.getId()).list();
        // 4.推送笔记id给所有粉丝
        for (Follow follow : follows) {
            // 获取粉丝id
            Long userId = follow.getUserId();
            // 推送
            String key = "feed:" + userId;
            stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
        }

        // 5.返回id
        return Result.ok(blog.getId());
    }
}
2.滚动分页查询收件箱

前端获取blog列表时只用传递两个参数即可:

lastId:上一次查询的最小时间戳。(第一次查询为当前时间戳)

offset:偏移量:上一页blog列表中时间戳与最小时间戳相等的数量(第一次查询时为0)。

业务层的具体实现:

@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private IFollowService followService;

    private void idBlogLiked(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null){
            // 用户未登录,无需查询是否点赞
            return;
        }
        Long userId = user.getId();

        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        blog.setIsLike(score != null);
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }

    @Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // 1.获取当前用户
        Long userId = UserHolder.getUser().getId();
        String key = FEED_KEY + userId;
        // 2.查询收件箱
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 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;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples){
            // 3.1 获取id
            ids.add(Long.valueOf(tuple.getValue()));
            // 3.2 获取分数:时间戳
            long time = tuple.getScore().longValue();
            if (time == minTime){//降序排列,集合中越靠后的时间戳越小
                os++;
            }else {
                minTime = time;
                os = 1;
            }
        }
        // 4.根据id获取blog
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

        for (Blog blog : blogs){
            // 4.2 查询blog有关的用户消息
            queryBlogUser(blog);
            // 4.3 查询blog的是否被点赞
            idBlogLiked(blog);
        }

        // 5.封装并返回
        ScrollResult scrollResult = new ScrollResult();
        scrollResult.setList(blogs);
        scrollResult.setOffset(os);
        scrollResult.setMinTime(minTime);
        return Result.ok(scrollResult);
    }
}

6. 附近商铺查询(基于redis实现地理坐标搜索)

1. GEO数据结构

练习:

1. 添加数据

2. 计算距离

3. 搜索

2. 附近商铺搜索

2.1 导入商铺信息
@SpringBootTest
class HmDianPingApplicationTests {

    @Resource
    private ShopServiceImpl shopService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void loadShopData(){
        //1. 查询店铺信息
        List<Shop> list = shopService.list();
        //2. 把店铺按照typeId分组,typeId一致的放到一个集合
        Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
        //3. 分批完成写入redis
        for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
            //3.1. 获取类型id
            Long typeId = entry.getKey();
            String key = "shop:geo:" + typeId;

            //3.2. 获取同类型的店铺列表
            List<Shop> value = entry.getValue();
            List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
            //3.3. 缓存到redis GEOADD key, longitude, latitude, member
            for (Shop shop : value){
                //stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());//交互次数太多
                locations.add(new RedisGeoCommands.GeoLocation<>(
                        shop.getId().toString(),
                        new Point(shop.getX(), shop.getY())
                ));
            }
            stringRedisTemplate.opsForGeo().add(key, locations);
        }
    }
}
2.2 实现附近商铺功能

三. 高级篇

1. 分布式缓存

1.1 单点Redis的问题

数据丢失:Redis是内存存储,服务重启可能会丢失数据。

并发能力问题:单节点Redis并发能力虽然不错,但也无法满足如618这样的高并发场景。

故障恢复问题:如果Redis宕机,则服务不可用,需要一种自动的故障恢复手段。

存储能力问题:Redis基于内存,单节点能存储的数据量难以满足海量数据需求。

1.2 Reids持久化

1)RDB持久化

redis会在停机时执行一次RDB,但是如果redis突然宕机没来得及做持久化,数据就全丢了。

1. 初始阶段:主进程的内存结构

主进程拥有自己的 “页表”(用于映射虚拟内存到物理内存),物理内存中存储着实际数据(比如图中的 “数据 A、数据 B”),此时主进程可以正常读写这些数据。

2. 步骤 1:fork 创建子进程

当执行bgsave时,主进程会通过fork系统调用创建子进程:

  • 子进程会复制主进程的页表(但不复制物理内存数据),因此子进程的页表和主进程共享同一部分物理内存(图中 “物理内存” 里的read-only区域)。
  • 此时主进程、子进程的页表都指向同一块物理内存(数据 A、数据 B),实现内存共享,避免了 fork 时的大量内存拷贝。
3. 步骤 2:子进程执行持久化

子进程通过自己的页表,访问共享的物理内存数据(数据 A、数据 B),将这些数据写入磁盘的 RDB 文件,完成持久化。

4. 步骤 3:主进程的 “写时复制” 逻辑

在子进程持久化过程中,主进程可能会执行读 / 写操作,此时触发copy-on-write

  • 读操作:主进程直接访问共享的物理内存(数据 A、数据 B),不需要额外操作。
  • 写操作:如果主进程要修改某数据(比如图中的 “数据 B”),会先拷贝一份数据 B 的副本到新的物理内存区域,然后主进程只修改这个 “数据 B 副本”;而子进程的页表仍然指向原来的 “数据 B”(保证子进程能读取到 fork 时刻的原始数据,不被主进程的修改干扰)。
核心作用

通过copy-on-write,既实现了子进程安全读取 fork 时刻的内存快照(保证 RDB 文件的一致性),又避免了 fork 时的全量内存拷贝(节省资源、提升效率)。

2)AOF持久化

3)RDB和AOF的对比

1.3 Redis主从集群

1)搭建主从架构

2) 数据同步原理
1. 全量同步

简述全量同步的流程:

  • slave 节点请求增量同步
  • master 节点判断 replid,发现不一致,拒绝增量同步
  • master 将完整内存数据生成 RDB,发送 RDB 到 slave
  • slave 清空本地数据,加载 master 的 RDB
  • master 将 RDB 期间的命令记录在 repl_backlog,并持续将 log 中的命令发送给 slave

2. 增量同步

简述全量同步和增量同步区别?

  • 全量同步:master 将完整内存数据生成 RDB,发送 RDB 到 slave。后续命令则记录在 repl_baklog,逐个发送给 slave。
  • 增量同步:slave 提交自己的 offset 到 master,master 获取 repl_baklog 中从 offset 之后的命令给 slave

什么时候执行全量同步?

  • slave 节点第一次连接 master 节点时
  • slave 节点断开时间太久,repl_baklog 中的 offset 已经被覆盖时

什么时候执行增量同步?

  • slave 节点断开又恢复,并且在 repl_baklog 中能找到 offset 时

1.4 Redis哨兵机制

slave 节点宕机恢复后可以找 master 节点同步数据,那 master 节点宕机怎么办?

从slave中挑选一个作为新的master,原本的master重启后成为slave。

这个监测和重启的动作由Redis的哨兵来做。

1)哨兵的作用和原理

选举新的master:

一旦发现 master 故障,sentinel 需要在 salve 中选择一个作为新的 master,选择依据是这样的:

  • 首先会判断 slave 节点与 master 节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该 slave 节点
  • 然后判断 slave 节点的 slave-priority 值,越小优先级越高,如果是 0 则永不参与选举
  • 如果 slave-prority 一样,则判断 slave 节点的 offset 值,越大说明数据越新,优先级越高
  • 最后是判断 slave 节点的运行 id 大小,越小优先级越高。
如何实现故障转移:

2)RedisTemplate的哨兵模式

在 Sentinel 集群监管下的 Redis 主从集群,其节点会因为自动故障转移而发生变化,Redis 的客户端必须感知这种变化,及时更新连接信息。Spring 的 RedisTemplate 底层利用 lettuce 实现了节点的感知和自动切换。

配置RedisTemplate使其整合redis集群和sentinel集群

1. 在 pom 文件中引入 redis 的 starter 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 然后在配置文件 application.yml 中指定 sentinel 相关信息:

spring:
  redis:
    sentinel:
      master: mymaster # 指定master名称
      nodes: # 指定redis-sentinel集群信息
        - 192.168.150.101:27001
        - 192.168.150.101:27002
        - 192.168.150.101:27003

3. 配置主从读写分离

通过 Spring 的@Bean定义 Lettuce 客户端的自定义配置,指定读取策略为REPLICA_PREFERRED(优先从从节点读取):

@Bean
public LettuceClientConfigurationBuilderCustomizer configurationBuilderCustomizer(){
    return configBuilder -> configBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

ReadFrom是 Lettuce 提供的读取策略枚举,包含 4 种选项:

  • MASTER:从主节点读取
  • MASTER_PREFERRED:优先从 master 节点读取,master 不可用才读取 replica
  • REPLICA:从 slave(replica)节点读取
  • REPLICA_PREFERRED:优先从 slave(replica)节点读取,所有的 slave 都不可用才读取 master

1.5 Redis的分片集群

1)搭建分片式集群

2)散列插槽

Redis如何判断某个key应该在哪个实例?

  • 将 16384 个插槽分配到不同的实例
  • 根据 key 的有效部分计算哈希值,对 16384 取余
  • 余数作为插槽,寻找插槽所在实例即可

如何将同一类数据固定的保存在同一个 Redis 实例?

  • 这一类数据使用相同的有效部分,例如 key 都以 {typeId} 为前缀

3)集群伸缩

4)故障转移

5)RedisTemplate访问分片集群

2. 多级缓存

2.1 什么是多级缓存?

传统缓存的问题:

多级缓存方案:

2.2 jvm进程缓存

1)初识Caffeine

2)Caffeine示例
@Test
void testBasicOps() {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 存数据
    cache.put("gf", "迪丽热巴");

    // 取数据,不存在则返回null
    String gf = cache.getIfPresent("gf");
    System.out.println("gf = " + gf);

    // 取数据,不存在则去数据库查询
    String defaultGF = cache.get("defaultGF", key -> {
        // 这里可以去数据库根据 key查询value
        return "柳岩";
    });
    System.out.println("defaultGF = " + defaultGF);
}

3)实现进程缓存

实现商品的查询的本地进程缓存:

利用Caffeine实现下列需求:

  • 给根据 id 查询商品的业务添加缓存,缓存未命中时查询数据库
  • 给根据 id 查询商品库存的业务添加缓存,缓存未命中时查询数据库
  • 缓存初始大小为 100
  • 缓存上限为 10000

配置Caffeine:

@Configuration
public class CaffeineConfig {

    @Bean
    public Cache<Long, Item> itemCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }

    @Bean
    public Cache<Long, ItemStock> stockCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
}

实现接口:

@Autowired
private Cache<Long,Item> itemCache;
@Autowired
private Cache<Long,ItemStock> stockCache;

@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id) {
    return itemCache.get(id, key -> itemService.query()
            .ne( column: "status", val: 3).eq( column: "id", key)
            .one()
    );
}

@GetMapping("/stock/{id}")
public ItemStock findStockById(@PathVariable("id") Long id) {
    return stockCache.get(id, key -> stockService.getById(key));
}

2.3 Lua语法

1)初识Lua

2)变量和循环

变量:

循环:

3)条件控制、函数

函数:

条件控制:

2.4 Redis缓存预热

编写Redis的初始化类:

@Component
public class RedisHandler implements InitializingBean {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private IItemService itemService;

    @Autowired
    private IItemStockService stockService;

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 初始化缓存
        // 1.查询商品信息
        List<Item> itemList = itemService.list();
        // 2.放入缓存
        for (Item item : itemList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(item);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
        }
        // 3.查询商品库存信息
        List<ItemStock> stockList = stockService.list();
// 4.放入缓存
        for (ItemStock stock : stockList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(stock);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
        }
    }
}    

注意:实现了 InitializingBean 接口就一定要实现 afterPropertiesSet() 方法,这个方法会在bean创建完、Autowired注入完以后执行,相当于在项目启动后执行。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值