Thanks to visit codestin.com
Credit goes to github.com

Skip to content

cxiaopao/hmdp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 

Repository files navigation

点评项目

使用 Redis 解决了在集群模式下的 Session 共享问题,使用拦截器实现了用户的登录校验和权限刷新

为什么用 Redis 替代 Session?

Session 共享问题:多台 Tomct 并不共享 session 存储空间,当请求切换到不同 tomcat 服务时导致数据丢失的问题。 解决方法:Session 持久化到 Redis,所有服务器实例都从 Redis 中读取和写入 Session 数据,保证一致性。

还有其他解决方法吗?

基于 Cookie 的 Token 机制,不再使用服务器端保存 Session,而是通过客户端保存 Token(如 JWT)。Token 包含用户的认证信息(如用户 ID、权限等),并通过签名验证其完整性和真实性。每次请求,客户端将 Token 放在 Cookie 或 HTTP 头中发送到服务

怎么使用拦截器实现这些功能?

系统中设置了两层拦截器

第一层拦截器是做全局处理,例如获取 Token,查询 Redis 中的用户信息,刷新 Token 有效期等通用操作。

第二层拦截器专注于验证用户登录的逻辑,如果路径需要登录,但用户未登录,则直接拦截请求。

优点

职责分离:这种分层设计让每个拦截器的职责更加单一,代码更加清晰、易于维护

提升性能:如果直接在第一层拦截器处理登录验证,可能会对每个请求都进行不必要的检查。而第二层拦截器仅在 “需要登录的路径” 中生效,可以避免不必要的性能开销。

灵活性:这种机制方便扩展,不需要修改第一层的全局逻辑。

复用 ThreadLocal 数据:第一层拦截器已经将用户信息保存到 ThreadLocal 中,第二层拦截器可以直接使用这些数据,而不需要重复查询 Redis 或其他数据源。

基于 Cache Aside 模式解决数据库与缓存的一致性问题

删除缓存还是更新缓存?

删除缓存最好

  • 更新缓存:每次更新数据库都更新缓存,无效的写操作更多
  • 删除缓存:更新数据库时缓存失效,查询时在更新缓存

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

  • 单体系统,将缓存和数据库操作放到一个事务
  • 分布式系统,利用TCC等分布式事务方案

先操作缓存还是先操作数据库

  • 先删除缓存,在操作数据库

  • 先操作数据库,再删除缓存,下图这种情况发生的概率比较小,更新操作的时长是比较大的。

缓存更新策略的最佳实践方案:

  1. 低一致性需求:使用Redis自带的内存淘汰机制
  2. 高一致性需求:主动更新,并以超时剔除作为兜底方案
  • 读操作:
    • 缓存命中则直接返回
    • 缓存未命中则查询数据库,并写入缓存,设定超时时间、写操作:
  • 写操作
    • 先写数据库,然后再删除缓存
    • 要确保数据库与缓存操作的原子性

使用 Redis 对高频访问的信息进行缓存,降低了数据库查询的压力,解决了缓存穿透、雪崩、击穿问题

什么是缓存穿透,怎么解决?

大量并发去访问一个数据库不存在的数据,由于缓存中没有该数据导致大量并发查询数据库,这个现象叫缓存穿透。 缓存穿透可以造成数据库瞬间压力过大,连接数等资源用完,最终数据库拒绝连接不可用。

解决方法

1、对请求增加校验机制:

eg: 字段 id 是长整型,如果发来的不是长整型则直接返回。

2、使用布隆过滤器(本项目应用)

为了避免缓存穿透,我们需要缓存预热,将要查询的课程或商品信息的 id 提前存入布隆过滤器,添加数据时将信息的 id 也存入过滤器,当去查询一个数据时先在布隆过滤器中找一下如果没有到到就说明不存在,此时直接返回。

3、缓存空值或特殊值(本项目应用)

请求通过了第一步的校验,查询数据库得到的数据不存在,此时我们仍然去缓存数据,缓存一个空值或一个特殊值的数据。但是要注意:如果缓存了空值或特殊值要设置一个短暂的过期时间。

什么是缓存雪崩,怎么解决?

缓存雪崩是缓存中大量 key 失效后当高并发到来时导致大量请求到数据库,瞬间耗尽数据库资源,导致数据库无法使用。造成缓存雪崩问题的原因是是大量 key 拥有了相同的过期时间,比如对课程信息设置缓存过期时间为 10 分钟,在大量请求同时查询大量的课程信息时,此时就会有大量的课程存在相同的过期时间,一旦失效将同时失效,造成雪崩问题。

解决方法

1、对同一类型信息的 key 设置不同的过期时间

通常对一类信息的 key 设置的过期时间是相同的,这里可以在原有固定时间的基础上加上一个随机时间使它们的过期时间都不相同。

2、利用Redis集群提高服务的可用性

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

4、给业务添加多级缓存

什么是缓存击穿,怎么解决?

缓存击穿是指大量并发访问同一个热点数据,当热点数据失效后同时去请求数据库,瞬间耗尽数据库资源,导致数据库无法使用。比如某手机新品发布,当缓存失效时有大量并发到来导致同时去访问数据库。

解决方法:

1、基于互斥锁

互斥锁(时间换空间)

优点:内存占用小,一致性高,实现简单

缺点:性能较低,容易出现死锁

这里使用 Redis 中的 setnx 指令实现互斥锁,只有当值不存在时才能进行 set 操作

锁的有效期更具体业务有关,需要灵活变动,一般锁的有效期是业务处理时长 10~20 倍

线程获取锁后,还需要查询缓存(也就是所谓的双检),这样才能够真正有效保障缓存不被击穿

/**
 * 通过id获取商品信息
 *
 * @param id
 * @return
 */
@Override
public Result queryById(Long id) {
    // 1.使用布隆过滤器拦截非法请求(可能有误判,但一定不会漏判)
    if (!shopBloomFilter.contains(id)) {
        return Result.fail("店铺不存在");
    }

    // 2.从 Redis 查询缓存
    String key = CACHE_SHOP_KEY + id;
    String shopStr = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isNotBlank(shopStr)) {
        return Result.ok(BeanUtil.toBean(shopStr, Shop.class));
    }

    // 3.缓存未命中,尝试加互斥锁,防止缓存击穿
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop = null;

    try {
        boolean isLock = tryLock(lockKey);

        // 4.如果获取锁失败,自旋等待,避免大量请求涌入数据库
        if (!isLock) {
            Thread.sleep(50);
            return queryById(id); // 递归重试
        }

        // 5.加锁成功后,需再次查询 Redis,防止并发下的“缓存双写不一致”问题
        shopStr = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopStr)) {
            return Result.ok(BeanUtil.toBean(shopStr, Shop.class));
        }

        // 6. 查询数据库
        shop = getById(id);

        // 7.数据存在,写入 Redis(防止并发写入,使用 setIfAbsent)
        stringRedisTemplate.opsForValue().setIfAbsent(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    } catch (InterruptedException e) {
        // 8.恢复线程的中断状态,避免吞掉异常
        Thread.currentThread().interrupt();
        throw new RuntimeException("线程被中断", e);
    } finally {
        // 9. 释放锁
        unLock(lockKey);
    }
    return Result.ok(shop);
}

/**
 * 利用redis获取锁
 *
 * @param lockKey
 * @return
 */
private boolean tryLock(String lockKey) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES);
    return BooleanUtil.isTrue(flag);
}

/**
 * 释放锁
 *
 * @param lockKey
 */
public void unLock(String lockKey) {
    stringRedisTemplate.delete(lockKey);
}

2、基于逻辑过期方式

逻辑过期(空间换时间)

优点:性能高

缺点:内存占用较大,容易出现脏读

注意:逻辑过期一定要先进行数据预热,将我们热点数据加载到缓存中

适用场景

商品详情页、排行榜等热点数据场景。

数据更新频率低,但访问量大的场景。

// 通过线程池创建线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
 * 通过id获取商品信息
 *
 * @param id
 * @return
 */
@Override
public Result queryById(Long id) {
    // 1.使用布隆过滤器拦截非法请求(可能有误判,但一定不会漏判)
    if (!shopBloomFilter.contains(id)) {
        return Result.fail("店铺不存在");
    }

    // 2.从 Redis 查询缓存
    String key = CACHE_SHOP_KEY + id;
    String redisDataStr = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(redisDataStr)) {
        return Result.ok("店铺不存在");
    }

    // 3. 解析 RedisData
    RedisData redisData = JSONUtil.toBean(redisDataStr, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();

    // 4. 逻辑未过期,直接返回
    if (expireTime.isAfter(LocalDateTime.now())) {
        return Result.ok(shop);
    }

    // 5. 逻辑过期,尝试加锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    if (isLock) {
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                updateShopCache(id);
            } finally {
                unLock(lockKey);
            }
        });
    }

    // 6. 返回旧数据,防止阻塞
    return Result.ok(shop);
}


/**
 * 更新 Redis 缓存数据(重建逻辑过期数据)
 */
private void updateShopCache(Long id) {
    // 1. 查询数据库
    Shop shop = getById(id);
    if (shop == null) {
        return;
    }

    // 2. 封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setExpireTime(LocalDateTime.now().plusMinutes(CACHE_SHOP_TTL)); // 逻辑过期时间
    redisData.setData(shop);

    // 3. 存入 Redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

总结:两者相比较,互斥锁更加易于实现,但是容易发生死锁,且锁导致并行变成串行,导致系统性能下降,逻辑过期实现起来相较复杂,且需要耗费额外的内存,但是通过开启子线程重建缓存,使原来的同步阻塞变成异步,提高系统的响应速度,但是容易出现脏读

缓存封装

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间

方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题

方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

@Component
public class CacheClient {

    private StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 存储redis中并设置过期时间
     *
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 逻辑过期时间存储方式
     *
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    public void setByLogicalExpire(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));
    }

    /**
     * 利用缓存空值的方式解决缓存穿透问题
     *
     * @param prefixKey
     * @param id
     * @param type
     * @param <R>
     * @param <ID>
     * @return
     */
    public <R, ID> R queryByPassTrough(String prefixKey, ID id, Long time, TimeUnit unit, Class<R> type, Function<ID, R> dbFallback) {
        String key = prefixKey + id;

        // 1.从Redis中查询
        String json = stringRedisTemplate.opsForValue().get(key);

        // 2.判断是否存在于Redis中
        if (StrUtil.isNotBlank(json)) {
            // 存在
            return JSONUtil.toBean(json, type);
        }

        // 3.判断是否为空的值("")
        if (json != null) {
            return null;
        }

        // 4.查询数据库
        R res = dbFallback.apply(id);

        // 5.判断是否存在于数据库
        if (res == null) {
            // 数据库没有,则缓存空的值到Redis中
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }

        // 6.存在数据库中,写入redis中,并返回结果
        this.set(key, res, time, unit);

        // 7.返回结果
        return res;
    }


    // 通过线程池创建线程
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 利用redis获取锁
     *
     * @param lockKey
     * @return
     */
    private boolean tryLock(String lockKey) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     *
     * @param lockKey
     */
    public void unLock(String lockKey) {
        stringRedisTemplate.delete(lockKey);
    }


    /**
     * 利用逻辑过期时间解决缓存击穿问题
     *
     * @param prefixKey
     * @param id
     * @param time
     * @param unit
     * @param type
     * @param dbFallback
     * @param <R>
     * @param <ID>
     * @return
     */
    public <R, ID> R queryByLogisticExpier(String prefixKey, String lockKeyPrefix, ID id, Long time, TimeUnit unit, Class<R> type, Function<ID, R> dbFallback) {
        String key = prefixKey + id;

        // 1.从Redis中查询
        String json = stringRedisTemplate.opsForValue().get(key);

        // 2.判断是否存在于Redis中
        if (StrUtil.isBlank(json)) {
            // 不存在
            return null;
        }

        // 3.解析 RedisData
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R res = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();

        // 4.逻辑未过期,直接返回
        if (expireTime.isAfter(LocalDateTime.now())) {
            return res;
        }

        // 5.逻辑过期,尝试加锁
        String lockKey = lockKeyPrefix + id;
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 开启独立线程更新缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    dbFallback.apply(id);
                    // 更新缓存
                    this.setByLogicalExpire(key, res, time, unit);
                } finally {
                    unLock(lockKey);
                }
            });
        }

        // 6.返回结果
        return res;
    }
}

全局唯一ID生成器

在分布式系统或高并发场景下,全局唯一 ID 生成器满足以下特性:

  • 唯一性
  • 高可用
  • 递增性
  • 安全性
  • 高性能

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

ID的组成部分:

  • 符号位:1bit,永远为0

  • 时间戳:31bit,以秒为单位,可以使用69年

  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

@Component
public class RedisWorker {

    // 设置一个时间戳
    private static final long BEGIN_TIMESTAMP = 1740676166L;

    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 生成全局唯一ID
     *
     * @param keyPrefix
     * @return
     */
    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long epochSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = epochSecond - BEGIN_TIMESTAMP;

        // 2.生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接时间戳和序列号
        return timestamp << COUNT_BITS | increment;
    }

}

优惠卷秒杀

超卖问题

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

悲观锁:添加同步锁,让线程串行执行
优点:简单粗暴
缺点:性能一般

乐观锁:不加锁,再更新时判断是否有其他线程在修改
优点:性能好
缺点:存在成功率低的问题(该项目在超卖问题中,不在需要判断数据查询时前后是否一致,直接判读库存>0;有的项目里不是库存,只能判断数据有没有变化时,还可以用分段锁,将数据分到10个表,同时十个去抢)

说一下乐观锁和悲观锁?

悲观锁:悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题 (比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

乐观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如 LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。

乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考 java.util.concurrent.atomic 包下面的原子变量类)。

本项目采用乐观锁解决

版本号法

CAS法

/**
 * 秒杀下单
 *
 * @param voucherId
 * @return
 */
@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)
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("下单失败");
    }

    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 设置订单号
    voucherOrder.setId(redisWorker.nextId("order"));
    // 设置用户ID
    voucherOrder.setUserId(UserHolder.getUser().getId());
    // 设置优惠卷ID
    voucherOrder.setVoucherId(voucherId);
    // 添加到数据库
    save(voucherOrder);

    // 7.返回订单信息
    return Result.ok(voucherOrder);
}

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published