redis java 客户端

374 阅读20分钟

redis 客户端介绍

image.png

jedis 的使用

快速入门

引入依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.3.0</version>
</dependency>

连接步骤--

  1. 引入依赖
  2. 建立连接
  3. 释放资源
private Jedis jedis;

@BeforeEach
void before() {
    /*建立连接*/
    jedis = new Jedis("192.168.122.1", 6379);
    /*输入密码*/
    jedis.auth("1234");
    /*选择库*/
    jedis.select(0);
}

@Test
void contextLoads() {
    /*设置值,返回ok表示成功*/
    String names = jedis.set("names", "123");
    /*得到值*/
    String names1 = jedis.get("names");
    System.out.println("names = " + names);
    System.out.println("names1 = " + names1);
}

建立连接

// 参数1 表示连接地址,参数2 是端口号
private Jedis new Jedis("192.168.122.1", 6379);
// 输入密码
jedis.auth("1234");
// 选择库第 0个库
jedis.select(0);

Hset的使用

//添加值
jedis.hset("user:1","name","lack");
jedis.hset("user:1", "age","18");
// 打印出来
Map<String, String> stringStringMap = jedis.hgetAll("user:1");
System.out.println(stringStringMap);

jedis 连接池

  • 首先创建公共静态类,类里配置信息,并返回jedis对象
import java.time.Duration;

public class JedisConnectionFactory {
    // 创建对象
    private static final JedisPool JEDIS_POOL;

    static {
        // 配置连接池
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        // 最大连接数
        poolConfig.setMaxTotal(2);
        // 最大空闲连接
        poolConfig.setMaxIdle(2);
        // 最小空闲连接
        poolConfig.setMinIdle(0);
        // 设置最长等待时间,ms
//        poolConfig.setMaxWaitMillis(200); 弃用
        poolConfig.setMaxWait(Duration.ofSeconds(5));
        // 创建连接池对象, 参数1:连接池,参数2:端口号,参数3,超时时间,连接密码
        JEDIS_POOL = new JedisPool(poolConfig, "192.168.122.1", 6379, 1000, "1234");
    }

    public static Jedis getJedis() {
        // 获取jedis对象
        return JEDIS_POOL.getResource();
    }
}

SpringDataRedis

SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:spring.io/projects/sp…

提供了对不同Redis客户端的整合(Lettuce和Jedis)

提供了RedisTemplate统一API来操作Redis

支持Redis的发布订阅模型

支持Redis哨兵和Redis集群

支持基于Lettuce的响应式编程

支持基于JDK、JSON、字符串、Spring对象的数据序列化及反序列化

支持基于Redis的JDKCollection实现

快速入门

引入依赖

redis 依赖

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

连接池依赖

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

SpringDataRedis 默认使用的是 lettuce 客户端
 需要改变要先导入 jedis 依赖

注意:必须关掉 lettuce-core包

spring:
  redis:
    host: 192.168.122.1
    port: 6379
    password: 1234
    jedis:
      pool:
        # 最大连接
        max-active: 8
        # 最大空闲连接
        max-idle: 8
        # 最小空闲
        min-idle: 0
        # 连接等待时间
        max-wait: 100ms
    # 选择库
    database: 0

测试类

    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    void contextLoads() {
        // 存储一个值 string
        redisTemplate.opsForValue().set("name","胡歌");

        Object name = redisTemplate.opsForValue().get("name");
        System.out.println("name = " + name);
     }

RedisSerializer 序列化

上面快速入门写完,发现在数据库里存入的数据并没有被修改。

序列化方式,会把java对象转换成字节数组,存入到 redis。 序列化默认是jdk的 ObjectOutputStream

  • ObjectOutputStream:就是把java对象转成字节

那么大家思考一下,这种方式有什么问题吗?

缺点可读性差、内存占用较大

所以我们必须要改变序列化方式

序列化方式
StringRedisSerializerkey 都是字符串的时候用它
GenericJackson2JsonRedisSerializer转 json 字符串的工具,值用这个

修改Serializer(方式一)

首先创建类,配置好 Serializer

@Configuration
public class redisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        // 创建 RedisTemplate 对象
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 设置连接工程
        template.setConnectionFactory(connectionFactory);
        // 创建JSON序列化工具
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        // 设置key的序列化,分别是Hash 和 string
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置value的序列化,分别是Hash 和 string
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);
        return template;
    }
}

这里会报错因为没有引用 jackson-databind 依赖啊 使用了这个 GenericJackson2JsonRedisSerializer

发现这个时候,可以存进去了,存到 redis里是这样的

@Test
void testSaveUser() {
    // 写入数据
    redisTemplate.opsForValue().set("user:100", new User("胡歌", 22));
    // 获取数据
    User o = (User) redisTemplate.opsForValue().get("user:100");
    System.out.println("o = " + o);
}

正是因为有@class行才可以反序列成对应的类型 image.png

修改Serializer(方式二)

方式一是有缺点的,因为数据库会存入java对象的类型,会占用大量的内存空间。

我们并不会使用JSON序列化来处理value,所以会手动完成对象的序列化。key和value都使用string进行存储

使用 StringRedisTemplate,它默认key和value都是string类型 不用写配置类了。程序员自己知道自己转什么类型即可

@Test
void testSaveUser() throws JsonProcessingException {
    // 写入数据
    User user = new User("胡歌", 22);
    // 转JSON
    String json = MAPPER.writeValueAsString(user);

    redisTemplate.opsForValue().set("user:200", json);
    // 获取数据
    String jsonUser = redisTemplate.opsForValue().get("user:200");

    // 手动反序列化
    User user1 = MAPPER.readValue(jsonUser, User.class);
    System.out.println("user1 = " + user1);
}

Hash存值取值

@Test
void testHash(){
    // 存值
    redisTemplate.opsForHash().put("user:400","name","胡歌");
    redisTemplate.opsForHash().put("user:400","age","18");
    // 取值
    Map<Object, Object> map = redisTemplate.opsForHash().entries("user:400");
    System.out.println("map = " + map);
}

redis 实战演练

短信登录的实现

基于 Session 的实现

ThreadLocal通常用于解决线程安全问题,特别是在多线程共享数据的情况下。通过将共享数据存储在ThreadLocal中,每个线程都有自己独立的副本,从而避免了多线程之间的竞争和同步问题。(使用 Map 存储数据)

image.png

使用拦截器,在请求之前先进行拦截处理

直接从 userHolder 中的 Threadlocal 中获取当前线程的用户对象

session共享问题:多台Tomcat并不共享session存储空间
当请求切换到不同tomcat服务时导致数据丢失的问题。
session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value结构

注入对象

@Autowired
private StringRedisTemplate stringRedisTemplate;

存储字符串

  • 参数1:key
  • 参数2:value
  • 参数3:时间
  • 参数4:时间单位
// 设置
stringRedisTemplate.opsForValue().set(phone, numbers, 2, TimeUnit.MINUTES);

获取字符串

  • 参数:key
// 获取
String code = stringRedisTemplate.opsForValue().get("code");

存储 hash

putAll: 根据map对象,将 key 和 value 全部存储为hash

  • 参数1:key
  • 参数2:map集合
Map<String, Object> map = BeanUtil.beanToMap(userDTO);
stringRedisTemplate.opsForHash().putAll(token, map);

给 key 设置有效期

  • 参数1:key
  • 参数2:时间
  • 参数3:时间单位
// 将 token key 设置 30 分钟有效期
stringRedisTemplate.expire(token, 30, TimeUnit.MINUTES);

细节:应该在用户每次访问请求的时候,刷新用户的key有效期。

根据 key 获取 hash返回 map

如果为空,自动返回一个空 map,并不是 null

  • 参数:key
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(token);

删除 key

  • 参数 key
stringRedisTemplate.delete(key);

锁,不存在则设置 key(setIfAbsent)

当 key 在 redis 中不存在时,则设置 key,否则 false

  • 参数1:key
  • 参数2:值
  • 参数3:时间
  • 参数4:时间单位
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);

经常用于分布式锁

商户缓存

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

缓存成本;数据一致性成本,代码维护成本,运维成本

image.png

业务场景:

低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存

高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

image.png

    1. 开发者编码
    1. 使用服务
    1. 最终一致,操作缓存 image.png

我们操作数据库和操作缓存,需要注意的三个问题?

  1. 删除缓存还是更新缓存?
    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  2. 如何保证缓存与数据库的操作的同时成功或失败?
    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
  3. 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存(线程问题出现性低)

容错性大,删除缓存和更新数据库速度慢,而查询和写入缓存速度快

image.png

容错性低,三个条件:缓存失效,并发执行,在查询缓存微秒中更新数据库。

image.png

缓存穿透

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

缓存空对象

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗,可能造成短期的不一致

布隆过滤

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

也可以把 ID 增加复杂度,格式验证。进行主动拦截,还有热点参数限流

image.png

缓存雪崩

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

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

缓存击穿

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

缓存击穿:多个请求,进行查询为命中,去数据库查询进行重建业务。

image.png

image.png

给 key 设置一个路基过期时间,如果时间已经过期,开启新线程获取锁,并去重建,其他线程去获取不到锁,不进行重建,使用旧数据。

image.png

image.png

互斥锁

流程 image.png

private Shop mutualExclusion(Long id) {
    // 从 redis 查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String cacheShop = stringRedisTemplate.opsForValue().get(key);
    // 判断是否存在
    if (StrUtil.isNotBlank(cacheShop)) {
        // 不为空,转对象返回
        return JSONUtil.toBean(cacheShop, Shop.class);
    }
    if (Objects.equals(cacheShop, "")) {
        return null;
    }

    // 不存在从数据库查询0
    // 4 未命中,从这里开始写互斥锁
    String lockKey = "lock:shop:" + id;
    Shop shop = null;
    // 表示抛异常也需要释放锁
    try {
        // 获取锁
        boolean tryLock = tryLock(lockKey);
        if (!tryLock) {
            // 失败
            Thread.sleep(50);
            // 重试
            return mutualExclusion(id);
        }

        shop = shopService.getById(id);
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
            return null;
        }
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 释放锁
        unLock(lockKey);
    }
    return shop;
}

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

// 释放锁
private void unLock(String key) {
    stringRedisTemplate.delete(key);
}

逻辑过期

流程 image.png

首先考虑逻辑过期,怎么添加到数据中

在不对源代码进行修改的情况下添加。使用以下方案

@Data
public class RedisData {
    private LocalDateTime expireTime; // 逻辑过期
    private Object data; // 对象
}
@Override
public Result queryById(Long id) {
    Shop shop = mutualExclusion(id);
    if (shop == null) {
        return Result.fail("店铺不存在!");
    }
    return Result.ok(shop);
}

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

private Shop mutualExclusion(Long id) {
    // 从 redis 查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String cacheShop = stringRedisTemplate.opsForValue().get(key);
    // 判断是否存在
    if (StrUtil.isBlank(cacheShop)) {
        // 为空 返回null
        return null;
    }
    // 不为空
    RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 判断缓存是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 没有过期直接返回
        return shop;
    }
    // 过期进行缓存重建
    // 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 判断是否获取成功
    if (isLock) {
        // 成功开启线程进行重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                saveShopToRedis(id, 20L);
            } finally {
                // 释放锁
                unLock(lockKey);
            }
        });
    }
    // 并返回过期的商铺信息, 获取失败表示已经进行重建, 返回过期商铺信息
    return shop;
}

public void saveShopToRedis(Long id, Long expireSecond) {
    // 查询店铺数据
    Shop shop = getById(id);
    // 封装逻辑过期
    RedisData redisData = new RedisData();
    // 模拟延迟
    redisData.setData(shop);
    // 当前时间的基础上加 expireSecond 秒
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond));
    // 写入 redis(永久有效,没有过期时间)
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

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

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

封装缓存工具

使用


@Autowired
private CacheClient cacheClient;

@Override
public Result queryById(Long id) {
    Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, LOCK_SHOP_KEY, id,
            Shop.class, this::getById, 20L, TimeUnit.SECONDS);
    if (shop == null) {
        return Result.fail("店铺不存在!");
    }
    return Result.ok(shop);
}

工具类

@Component
@Slf4j
public class CacheClient {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

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

    /**
     * 设置 key
     */
    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(LocalDateTime.now().plusSeconds(unit.toSeconds(time)), value);
        // 写入
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 缓存穿透
     */
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> rClass, 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, rClass);
        }
        if (Objects.equals(json, "")) {
            return null;
        }
        // 不存在查询数据库
        R r = dbFallback.apply(id);
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
            return null;
        }
        // 存入最新数据
        set(key, r, time, unit);
        return r;
    }

    public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, String lockPrefix, ID id, Class<R> rClass, Function<ID, R> dbFallback,
            Long time, TimeUnit unit) {
        // 从 redis 查询商铺缓存
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        // 判断是否存在
        if (StrUtil.isBlank(json)) {
            // 为空 返回null
            return null;
        }
        // 不为空
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), rClass);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 判断缓存是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 没有过期直接返回
            return r;
        }
        // 过期进行缓存重建
        // 获取互斥锁
        String lockKey = lockPrefix + id;
        boolean isLock = tryLock(lockKey);
        // 判断是否获取成功
        if (isLock) {
            // 成功开启线程进行重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R r2 = dbFallback.apply(id);
                    setWithLogicalExpire(key, r2, time, unit);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 并返回过期的商铺信息, 获取失败表示已经进行重建, 返回过期商铺信息
        return r;
    }

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

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

}

优惠券秒杀

全局唯一ID

唯一性,高可用,高性能,递增性,安全性 ID的组成部分:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

基于 redis 生成

/**
 * 基于 Redis 的 id 生成器
 */
@Component
public class RedisIdWorker {

    // 开始时间戳
    private static final long BEGIN_TIMESTAMP = 1691924580;
    // 序列号位数
    private static final int COUNT_BITS = 32;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * @param keyPrefix 前缀,区分不同的 key
     */
    public long nextId(String keyPrefix) {
        // 1. 生成时间戳
        // 当前时间
        LocalDateTime now = LocalDateTime.now();
        // 转为秒
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        // 得到时间差
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        // 2. 生成序列号
        // 获取天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 指定自增,redis 的key 最大 2的3次方,所以我们按照时间分 key
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 3. 拼接返回
        // 使用 位运算,序列号占用32位,我们需要将时间戳向左移动32位
        return timestamp << COUNT_BITS | count;
    }

    public static void main(String[] args) {
        // 创建一个表示日期和时间的 LocalDateTime 对象,设置为:2023年8月13日11时3分0秒
        LocalDateTime of = LocalDateTime.of(2023, 8, 13, 11, 3, 0);
        // 将 LocalDateTime 对象转换为以 UTC 时区为基准的秒数
        long second = of.toEpochSecond(ZoneOffset.UTC);
        System.out.println(second);
    }
}

测试

@Test
public void ssw2() throws InterruptedException {
    // 500 个线程
    CountDownLatch countDownLatch = new CountDownLatch(500);
    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            long order = redisIdWorker.nextId("order");
            System.out.println("id = " + order);
        }
        countDownLatch.countDown(); // 记录一个
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 500; i++) {
        executorService.submit(task);
    }
    countDownLatch.await(); // 等待
    long end = System.currentTimeMillis();
    System.out.println("time:" + (end - begin));
}

测试500个线程,需要 1756

全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增

Redis自增ID策略:

  • 每天一个key,方便统计订单量
  • ID构造是 时间戳 + 计数器

超卖问题

我们使用下面的方式去扣减库存,100个库存,两百人线程去执行,执行结束发现库存还有76个

boolean flag = seckillVoucherService.updateById(seckillVoucher);

这种方式更好一点 image.png

解决方案:乐观锁。在更新时去判断版本是否和传入的版本一致,不一致则不更新,这就是乐观锁。

还有一种解决方案,判断库存是否和刚刚的库存一样,原理一样的,这叫CAS法

实现一个用户只能领取一个券一次

@Override
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 抢券未开始!
        // 抢券已结束!
        // 券抢完啦!
        // 一人一单
        /*
         * 对谁加锁(对每个用户)
         * 动态代理失效,事务的失效原则
         */
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            /*
             * 事务问题,用this.是不会产生事务的,需要使用Spring代理
             * Spring AOP提供的一个静态方法,用于获取当前代理对象。
             * 开启注解支持
             * 在启动类上加:@EnableAspectJAutoProxy(exposeProxy = true)
             *  <dependency>
                <groupId>org.aspectj</groupId>
                    <artifactId>aspectjweaver</artifactId>
                </dependency>
             */
            IVoucherOrderService voucherService = (IVoucherOrderService) AopContext.currentProxy();
            return voucherService.getResult(voucherId, seckillVoucher, userId);
        }
    }

    @Transactional
    public Result getResult(Long voucherId, SeckillVoucher seckillVoucher, Long userId) {
        /*
         *  intern:从常量池中检查当前变量是否存在,如果过在使用常量池变量,
         * 才能保证 synchronized 加锁的正确性。
         * 但是锁没有锁住事务,会导致事务没提交,就又被读取。
         */
        LambdaQueryWrapper<VoucherOrder> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(VoucherOrder::getUserId, userId).eq(VoucherOrder::getVoucherId, voucherId);
        if (count(queryWrapper) > 0) {
            return Result.fail("当前用户已经领取过了...");
        }
        // 可以继续抢,扣减库存
        seckillVoucher.setStock(seckillVoucher.getStock() - 1);
        boolean flag = seckillVoucherService.updateById(seckillVoucher);
        if (!flag) return Result.fail("券抢完啦!");
        long order = redisIdWorker.nextId("order");
        // 新增订单
        VoucherOrder voucherOrder = new VoucherOrder(order, userId, voucherId);
        boolean save = save(voucherOrder);
        return Result.ok(order);
    }

不可以在集群模式下使用(会有问题)
集群模式下,Synchronize 会出现问题

因为 userId.toString().intern() 是在一个 JVM 里面获取常量,判断是否可以放行,无法判断另一个JVM下的常量池,因此我们知道 一个JVM有一个锁监视器,接下来使用 分布式锁解决此问题

分布式锁

模拟一个集群,使用 nginx 负载均衡配置


worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/json;

    sendfile        on;

    keepalive_timeout  65;

    server {
        listen       8080;
        server_name  localhost;
        # 指定前端项目所在的位置
        location / {
            root   html/hmdp;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # 拦截 api
        location /api {
            default_type  application/json;
            #internal;
            keepalive_timeout   30s;
            keepalive_requests  1000;
            #支持keep-alive
            proxy_http_version 1.1;
            rewrite /api(/.*) $1 break;
            proxy_pass_request_headers on;
            #more_clear_input_headers Accept-Encoding;
            proxy_next_upstream error timeout;
            # proxy_pass http://127.0.0.1:8081;
            # 负载均衡到下面 backend 节点
            proxy_pass http://backend;
        }
    }

    upstream backend {
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
    }
}

我们应该让多个JVM使用同一个锁监视器

MySQLRedisZookeeper
互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

使用 redis 的setnx 只能设置一个,为了防止服务宕机,并设置过期时间。

原子性问题:设置过期时间和设置锁必须同一时间。

锁接口创建和实现

public interface ILock {

    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有时间,过期自动释放
     * @return true 获取成功,false 获取失败,不会重复获取,造成资源浪费(非阻塞模式)
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
    
}

实现

public class SimpleRedisLock implements ILock {

    private final StringRedisTemplate stringRedisTemplate;

    // 锁的名称不能写死,不同的业务不同的锁
    private final String name;
    private static final String KEY_PREFIX = "lock:";

    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取锁
        // value 加线程表示,表示那个线程获取了锁
        long id = Thread.currentThread().getId();
        Boolean aBoolean = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, String.valueOf(id), timeoutSec, TimeUnit.SECONDS);
        // 自动装箱,避免空的问题,使用true 进行比较
        return Boolean.TRUE.equals(aBoolean);
    }

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

业务实现

@Override
public Result seckillVoucher(Long voucherId) {
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) return Result.fail("抢券未开始!");
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) return Result.fail("抢券已结束!");
    if (seckillVoucher.getStock() < 1) return Result.fail("券抢完啦!");
    // 一人一单
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
    // 获取锁, 抢购时间大约 60秒,所以设置 60 秒,跟业务有关系。这里设置20分钟
    boolean tryLock = simpleRedisLock.tryLock(1200);
    // 因为非阻塞,所以判断返回值
    if (!tryLock) {
        // 获取锁失败
        return Result.fail("一人只允许下一单!");
    }
    try {
        // 获取锁成功!
        IVoucherOrderService voucherService = (IVoucherOrderService) AopContext.currentProxy();
        return voucherService.getResult(voucherId, seckillVoucher, userId);
    } finally {
        // 释放锁
        simpleRedisLock.unlock();
    }
}

误删问题

当程序出现意外情况

  1. 线程一获取锁,在执行过程中,业务阻塞了,超时锁释放了
  2. 线程二获取锁,执行业务
  3. 线程一恢复过来,将线程二的锁释放了
  4. 线程三,获取锁....

这就导致了误删锁问题。

image.png

解决方式:就是删除锁判断一下锁是不是自己的

image.png

代码实现

public class SimpleRedisLock implements ILock {

    private final StringRedisTemplate stringRedisTemplate;
    private final String name;

    private static final String KEY_PREFIX = "lock:";
    /*
     * UUID标识 保证多个 JVM 不会重复生成
     */
    private static final String ID_PREFIX = UUID.fastUUID().toString(true) + "-";

    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        /*
         * 将标识设置成 UUID形式存入,保证
         */
        String threadID = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean aBoolean = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadID, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(aBoolean);
    }

    @Override
    public void unlock() {
        /*
         * 需要将存入的锁标识和 和 当前线程标识进行判定,一样则删除
         */
        String currentKey = ID_PREFIX + Thread.currentThread().getId();
        String lockKey = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if (currentKey.equals(lockKey)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

原子性问题导致的误删除(lua 脚本)

在 JVM 里有个机制,叫做 垃圾回收,可能会在业务执行完毕后,即将释放锁之前发生程序阻塞,导致锁过期。就是一种 fullGC。

因为判断锁和释放锁是两个操作,可能触发原子性问题。

我们使用 lua 脚本,将多个 redis 命令放到一块实现原子性。

那么 lua 是如何调用 redis 的呢

redis.call('命令名称', 'key', '其他参数...')

redis.call("SET", key, value)

-- 使用 local 声明变量
local key = "your_key"

使用脚本

在 redis 控制台创建脚本, Eval 执行一个脚本,后面的 0 表示没有变量参数

Eval "return redis.call('set', 'name', 'Jack')" 0

变量参数传递

在 lua 语言中,keys 数组中存入的是 key,值存入到 ARGV 里面。并且变量的下标是从 1 开始的,没有 0

下方代码中,其中 name 是key,heihei 是值

EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name heihei

编写lua脚本

-- 锁的 key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]

-- 获取锁中的线程标识
local id = redis.call('get', key)

-- 比较线程标识与锁的标识是否一致
if(id == threadId)
    then
        -- 释放锁
        return redis.call('del', key)
    end
    -- 不一致
    return 0 

在 Java 中使用脚本,这里使用静态方式,在类加载的时候就加载,提升性能导入脚本。DefaultRedisScript 脚本的类

// 定义一个 Redis Lua 脚本,用于解锁操作
private static final DefaultRedisScript<Long> UN_LOCK_SCRIPT;

static {
    UN_LOCK_SCRIPT = new DefaultRedisScript<>();
    UN_LOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 设置 Lua 脚本的位置,通常是在 resources 目录下
    UN_LOCK_SCRIPT.setResultType(Long.class); // 设置脚本的返回类型为 Long
}

@Override
public void unlock() {
    // 调用 lua 脚本
    stringRedisTemplate.execute(
            UN_LOCK_SCRIPT, // 使用之前定义的 Lua 脚本
            Collections.singletonList(KEY_PREFIX + name), // 设置键值参数,这里是锁的键
            ID_PREFIX + Thread.currentThread().getId() // 设置参数,这里是线程 ID,用于区分不同的锁
    );
}

总结

  • 利用 set nx ex 获取锁,并设置过期时间,保存线程标识
  • 释放锁先判断线程标识是不否与自己一致,一致则删除锁

特性

  • 利用 set nx 满足互斥
  • 利用 set ex 保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用 redis 集群保证高可用和高并发特性。

还可以优化,还可以进一步提升

基于 Redis 分布式锁的优化

不可重入,同一个线程无法多次获取同一把锁

同一个线程 A方法获取锁后,B方法无法获取锁,B 方法会等待锁释放,因为A方法没有执行完,这就导致了死锁。

不可重试

没有重试机制,一旦发生错误就直接返回 false,应该一旦发现获取锁失败,就应该等一等,重新尝试获取

超时释放

之前的超时释放锁,虽然可以避免死锁,但如果业务执行耗时较长,导致超时释放,存在安全隐患。

主从一致性问题

读写分离模式,读操作访问从节点,写操作访问主节点,主节点需要将数据同步到从节点。

假设一个 Set 操作,假设尚未同步到从节点上,主节点宕机了,此时,会选择一个从作为主,从节点因为没有完成同步,没有锁标识,这个时候其他线程可以拿到锁,也就等于多个线程拿到锁。所以可能极端情况下出现问题。

Redisson 入门

依赖

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

配置 Redisson

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        // 配置---org.redisson.config
        Config config = new Config();
        // 集群配置
//        config.useClusterServers().addNodeAddress()
        config.useSingleServer().setAddress("redis://127.0.0.1:6379")
                .setPassword(null);
        return Redisson.create(config);
    }
}

使用

@Autowired
private RedissonClient redissonClient;

// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁,API 默认时间是,不重试不等待,超时释放,默认是30秒
boolean tryLock = lock.tryLock();
// 因为非阻塞,所以判断返回值
if (!tryLock) {
    // 获取锁失败
    return Result.fail("一人只允许下一单!");
}
try {
    // 获取锁成功!
    IVoucherOrderService voucherService = (IVoucherOrderService) AopContext.currentProxy();
    return voucherService.getResult(voucherId, seckillVoucher, userId);
} finally {
    // 释放锁
    lock.unlock();
}

可重入锁原理

我们之前的锁,是采用 key value 的结构,在一个线程中,由于 Redis 的 nx 原理,设置过一次就不可以再设置了,所以是不可重入的。

可重入原理

采用的是 Redis 中的 Hash 结构,在获取锁的时候,加了一个判断,判断当前锁是不是当前线程,如果是,就让数量 + 1,这样就可以重复获取锁了,在释放锁的时候也一样,让锁 -1,如果是 0 就可以删除锁了。

走到 0 也就表示业务走到了最后,才可以释放锁。

image.png

内部采用的也是 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; -- 代码走到这里,说明获取锁的不是自己,获取锁失败

释放锁脚本

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;

异步秒杀思路

看下图,我们查询优惠券,查询订单,都需要查询数据库,以及判断操作,这就导致频繁查询,在数据量大的情况下,浪费时间。

image.png

我们将信息提前存入 Redis 里,从Redis中查询,提高速度。预先指定。

image.png

将抢购信息存入 redis,当抢购开始会直接判断,那大家想,都已经反馈给用户了。那存入数据库,是不是就不是那么着急存入了,就可以通过异步的方式将数据,存入数据库。

image.png

那我们改进一下,提高并发性能

需求:

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

第二步的lua脚本,参考如下(传递时需要将类型转换成 String)


-- 参数列表
local voucherId = ARGV[1]

-- 用户ID
local userId = ARGV[2]

-- 数据 key
-- 库存key .. 是拼接符
local stockKey = 'seckill:stock:' .. voucherId
-- 订单 key
local orderKey = 'seckill:order:' .. voucherId

-- 脚本业务

--3.1  判断库存是否充足 tonumber 转成数字
local stoke = tonumber(redis.call('get', stockKey))
if(stoke == nil or stoke <= 0) then
    -- 库存不足
    return 1
end
-- 判断用户是否已经下单
local user = tonumber(redis.call('sismember', orderKey, userId))
if(user == nil or user == 1) then
-- 存在,说明重复
    return 2
end
-- 扣库存
redis.call('incrby', stockKey, -1)
-- 下单
redis.call('sadd', orderKey, userId)

return 0

阻塞队列

阻塞队列,当一个线程从阻塞队列中获取元素时,如果没有元素,该线程会被阻塞,直到队列中有元素,才会被唤醒。

创建一个 1024 * 1024 的阻塞队列,数组列表

private final BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024);
// 将信息放入 阻塞队列
orderTask.add(voucherOrder);

实战异步下单

// 用于存放秒杀订单的阻塞队列,设置容量为 1024 * 1024
private final BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024);

// 单线程的线程池,用于处理秒杀订单
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

// 在构造函数执行后,使用 @PostConstruct 标记的方法
// 用于初始化秒杀订单处理逻辑
@PostConstruct
public void init() {
    // 提交一个任务到单线程的线程池
    SECKILL_ORDER_EXECUTOR.submit(() -> {
        // 无限循环,从阻塞队列中取出订单并处理
        while (true) {
            try {
                // 从阻塞队列中取出订单,如果队列为空则阻塞等待
                VoucherOrder take = orderTask.take();
                // 处理秒杀订单
                voucherOrderService.getResult(take);
            } catch (InterruptedException e) {
                // 处理中断异常,可以根据实际情况进行处理
                Thread.currentThread().interrupt();
            }
        }
    });
}

订单业务逻辑

@Transactional
public void getResult(VoucherOrder voucherOrder) {
    RLock lock = redissonClient.getLock("lock:order:" + voucherOrder.getUserId());
    boolean tryLock = lock.tryLock();
    if (!tryLock) {
        log.error("当前用户已经领取过了...");
        return;
    }
    Long count = lambdaQuery().eq(VoucherOrder::getUserId, voucherOrder.getUserId())
            .eq(VoucherOrder::getVoucherId, voucherOrder.getVoucherId())
            .count();
    if (count > 0) {
        log.error("当前用户已经领取过了...");
        return;
    }

    // 可以继续抢,扣减库存
    boolean flag = seckillVoucherService.lambdaUpdate().setSql("stock = stock - 1")
            .eq(SeckillVoucher::getVoucherId, voucherOrder.getVoucherId())
            .gt(SeckillVoucher::getStock, 0)
            .update();

    if (!flag) {
        log.error("券抢完啦!");
        return
    }    
    // 新增订单
    save(voucherOrder);
}

秒杀业务的优化思路是什么?

  • 先利用Redis完成库存余量、一人一单判断,完成抢单业务
  • 再将下单业务放入阻塞队列,利用独立线程异步下单

基于阻塞队列的异步秒杀存在哪些问题?使用的是 JDK 的阻塞队列,它使用的是JVM内存。

  • 内存限制问题
  • 数据安全问题
  • 内存满了,就会溢出。
  • 如果 Redis 的宕机了,就会导致数据安全问题。

消息队列(解决内存问题和数据安全问题)

消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列,将消息持久化,防止宕机数据消失,会将消息给消费者确认,保证消息一次。
  • 消费者:从消息队列获取消息并处理消息

image.png

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

基于List结构模拟消息队列

消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。

不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。

等待数据

# 阻塞等待 l1 的队列的数据,最长时间等待 20s
brpop l1 20

添加数据

# 往 l1 队列中添加两个数据
lpush l1 e2 32

优缺点

优点:

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

缺点:

  • 无法避免消息丢失
  • 只支持单消费者(因为拿完就删掉了,无法被多个消费者获取)

基于PubSub的消息队列

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel [channel] :订阅一个或多个频道
  • PUBLISH channel msg :向一个频道发送消息
  • PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道

下图中,生产者发送了 order.queue msg1,那么消费者接收 order.queue 和 order.*都会收到订阅。

image.png

订阅--阻塞订阅

# 精确订阅 order.q1
subscribe order.q1
# 模式匹配订阅 order.*
psubscribe order.*

发送信息

# 向 order.q1 发送 hello
publish order.q1 hello

优缺点

如果没有订阅接收,数据发送会丢失,并且如果消费者缓存区满了,发送数据也会丢失。

优点:

  • 采用发布订阅模型,支持多生产、多消费 缺点:
  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

可靠性较高不建议使用。

基于Stream的消息队列

Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列。

发送信息

image.png

# 添加信息--- 添加了一个 users 的键值对信息,*表示id由redis创建,
xadd users * name jack
# 查看 users 消息队列的长度
xlen users

读消息

image.png

数据可以重复的读取,读完不会删除,但是使用 $,只会读取最新的消息,不会读取已经被消费的了。

# 读取信息,count 表示读多少,streams 表示读取那个消息队列,0表示读取第一个,也可以写 $ 表示一直读取最新的
xread count 1 streams users 0

阻塞读取

block 0 一直阻塞,0表示时间,不限制

# 阻塞方式
xread count 1 block 0 streams users $

但是会出现消息漏读的情况

比如:正在阻塞时,发来一条信息,可以正常读取到,但是,没有阻塞等待消息时,发来的信息,就会遗失。

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

  • 消息可回溯
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

基于Stream的消息队列-消费者组

消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:

消息分流

队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度

消息标识

消费者组会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费

消息确认

消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。

创建消费者组

命令

XGROUP CREATE  key groupName ID [MKSTREAM]
  • key:队列名称
  • groupName:消费者组名称
  • ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
  • MKSTREAM:队列不存在时自动创建队列
# 删除指定的消费者组
XGROUP DESTORY key groupName
# 给指定的消费者组添加消费者
XGROUP CREATE CONSUMER key groupname consumername
# 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername

创建消费者组

# 在 users 队列中,创建消费者组,组名称 g1,从起始开始消费
xgroup create users g1 0

从消费者组读取信息

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • group:消费组名称
  • consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
  • count:本次查询的最大数量
  • BLOCK milliseconds:当没有消息时最长等待时间
  • NOACK:无需手动ACK,获取到消息后自动确认
  • STREAMS key:指定队列名称
  • ID:获取消息的起始ID:
  • ">":从下一个未消费的消息开始 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始

使用

# group 指定组名 c1 是消费者名称,没有会自动创建 block 阻塞两秒 
streams 制定队列名 > 表示读取未消费的
xreadgroup group g1 c1 count 1 block 2000 streams users >

但是我们读入消息后,并没有确认消息。

# 处理 users 队列的 g1 消费者组下的两个信息
xack users g1 1708862116252-0 1708864566336-0

获取处于未确认状态的消息的信息

# 获取 users 中 g1 消费者组的 最早到最新的,并且限制获取十条
xpending users g1 - + 10 

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

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

实战

需求:

  • 创建一个Stream类型的消息队列,名为stream.orders
  • 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
  • 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

创建队列

# 创建消费者组,从 0 开始,mkstream 如果不存在直接创建。stream.order 是Stream 的名称
xgroup create stream.order g1 0 mkstream

修改 lua 脚本

-- 参数列表
local voucherId = ARGV[1]

-- 用户ID
local userId = ARGV[2]

-- 订单ID
local orderId -= ARGV[3]

-- 数据 key
-- 库存key .. 是拼接符
local stockKey = 'seckill:stock:' .. voucherId
-- 订单 key
local orderKey = 'seckill:order:' .. voucherId

-- 脚本业务

--3.1  判断库存是否充足 tonumber 转成数字
local stoke = tonumber(redis.call('get', stockKey))
if(stoke == nil or stoke <= 0) then
    -- 库存不足
    return 1
end
-- 判断用户是否已经下单
local user = tonumber(redis.call('sismember', orderKey, userId))
if(user == nil or user == 1) then
-- 存在,说明重复
    return 2
end
-- 扣库存
redis.call('incrby', stockKey, -1)
-- 下单
redis.call('sadd', orderKey, userId)

-- 添加信息
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

代码实现

private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

private static final ExecutorService SECKILL_ORDER_EXECURTOR = Executors.newSingleThreadExecutor();

@PostConstruct
public void init() {
    SECKILL_ORDER_EXECURTOR.submit(() -> {
        while (true) {
            try {
                // 1. 获取消息队列中的消息 xreadgroup group g1 c1 count 1 block 2000 stream streams.order >
                String quickName = "streams.order";
                List<MapRecord<String, Object, Object>> mapRecordList = stringRedisTemplate.opsForStream().read(
                        // org.springframework.data.redis.connection.stream
                        // 设置消费者组和消费者
                        Consumer.from("g1", "c1"),
                        // 每次消费1个,阻塞两秒钟
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        // 读取最新的未消费的
                        StreamOffset.create(quickName, ReadOffset.lastConsumed())
                );

                // 2. 判断消息是否获取成功
                if (mapRecordList == null || mapRecordList.isEmpty()) {
                    continue;
                }
                // 3. 解析消息
                MapRecord<String, Object, Object> entries = mapRecordList.get(0);
                Map<Object, Object> value = entries.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                // 4. 如果获取成功,可以下单
                voucherOrderService.getResult(voucherOrder);
                // 5. ACK 确认
                stringRedisTemplate.opsForStream().acknowledge(quickName, "g1", entries.getId());
            } catch (Exception e) {
                // 出现异常,就去 pendingList中查询
                log.error("处理订单异常");
                handlePendingList();
            }
        }
    });
}

private void handlePendingList() {
    while (true) {
        try {
            // 1. 获取消息队列中的消息 pending-list group g1 c1 count 1 block 2000 stream streams.order >
            String quickName = "streams.order";
            List<MapRecord<String, Object, Object>> mapRecordList = stringRedisTemplate.opsForStream().read(
                    // org.springframework.data.redis.connection.stream
                    // 设置消费者组和消费者
                    Consumer.from("g1", "c1"),
                    // 每次消费1个,阻塞两秒钟
                    StreamReadOptions.empty().count(1),
                    // 读取最新的未消费的
                    StreamOffset.create(quickName, ReadOffset.from("0"))
            );

            // 2. 判断消息是否获取成功,说明pending 中没有消息
            if (mapRecordList == null || mapRecordList.isEmpty()) {
                break;
            }
            // 3. 解析消息
            MapRecord<String, Object, Object> entries = mapRecordList.get(0);
            Map<Object, Object> value = entries.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
            // 4. 如果获取成功,可以下单
            voucherOrderService.getResult(voucherOrder);
            // 5. ACK 确认
            stringRedisTemplate.opsForStream().acknowledge(quickName, "g1", entries.getId());
        } catch (Exception e) {
            // 出现异常
            log.error("处理pend-list订单异常");
        }
    }
}

总结

ListPubSubStream
消息持久化支持不支持支持
阻塞读取支持支持支持
消息堆积处理受限于内存空间,可以利用多消费者加快处理受限于消费者缓冲区受限于队列长度,可以利用消费者组提高消费速度,减少堆积
消息确认机制不支持不支持支持
消息回溯不支持不支持支持