Redis String 操作及常见应用场景

711 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

本文主要讲述Redis String的用法及其应用场景。下面要讲的应用场景属于实战,用的是lettuce。

Redis String命令

基础操作:

Redis-cli -h 127.0.0.1 -p 6379

auth [username]password

String命令

读写命令

  • SET和GET

SETNX和SETXX可以用到锁上面

set key value
get key
TTL name 查看过期时间
SET name kouzhaoxuejie NX

参数:

EX 参数是给这个 Key 设置一个过期时间,单位是秒。

PX 参数的话,和 EX 参数类似,唯一的区别就是时间单位变成了毫秒

NX 是在目标 Key 不存在时候,才写入这个 Key;要是 Key 已存在的话,这个 Key 就写不进去。

XX 参数的功能正好是和 NX 反着的,XX 参数表示的是 Key 不存在的时候,写不进去。

EXATPXAT 是指定 Key 在某个时间点过期,后面跟的是一个明确的时间戳,意思是到了这个点 Key 就没了。

  • MSET和MGET批量操作,用法和set、get一样

  • 字符串批量命令是 MSETNX,它是 SETNX 的批量版本,就是多个 Key 都不存在的时候,可以一把写入多个 Key

    递增操作

    SET age 25
    INCR age
    ->26
    INCRBY age 100
    ->126
    
  • INCR 命令DECR 命令 对这个 age 进行 加一减一操作。

  • INCRBYDECRBY 两个命令,这两条命令后面可以指定加多少、减多少。

操作部分字符串

set name kouzhao
append name xuejie
get name
->"kouzhaoxuejie"
setrange name 0 kz
get name
-> "kzuzhaoxuejie"
  • APPEND,它是往一个 Key 里面追加一个字符串,和 Java 字符串的 append() 方法一个意思。
  • SETRANGE 命令,功能是指定一个下标,然后用传进去的字符串,替换这个下标后的内容

复合操作

复合操作其实就是上面几个命令的组合,常用的有三个命令:GETDEL、GETEX 和 GETSET。

get name
->"kouzhaoxuejie"
getset name zhangsan
->"kouzhaoxuejie"
get name
->"zhangsan"

GETSET 命令的意思是,返回当前这个 name 的值,也就是返回 kouzhaoxuejie,然后同时用 zhangsan 来覆盖 name 的值,这个时候再查 name 这个 Key,拿到的就是 zhangsan 了。

getex name ex 10
->"zhangsan"
TTL name
->8
TTL name
->7

GETEX 命令的意思是,获取 Key 的值,同时给 Key 设置一个过期时间。来看下图的例子,我们这里获取 name 的同时,把 name 这个 Key 的过期时间设置成 10 秒。我们用 TTL 命令可以看到 name 剩余的过期时间不断在减少,最后返回一个 -2,就表示 name 过期了。

set name kouzhaoxuejie
getdel name
->"kouzhaoxuejie"
get name
(nil)

DEL 命令是删除一个 Key,GETDEL 命令 就是先获取一个 Key 的值,同时删除这个 Key 的值。如下图所示,这里我们用 GETDEL 命令读取 name 值的时候,就把它一并删除了,再用 GET 命令查,就查询不到了。

使用场景

这部分主要介绍在什么场景下可以考虑使用 Redis 字符串,同时结合 Lettuce 客户端,看看如何在 Java 里面执行 Redis 字符串命令

三个场景: 用 Redis 的 String 结构缓存一个 Java 对象、实现分布式的效果以及实现限流的效果。

image.png

项目环境搭建(lettuce)

先创建一个 Maven 项目 lettuce-demo,然后 pom 文件里面加上 lettuce 的依赖。

<!--lettuce依赖-->
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.1.8.RELEASE</version>
</dependency>
<!--json依赖-->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.13.3</version>
</dependency>
<!--juint依赖-->
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
</dependency>

缓存对象

这部分主要就是要掌握Lettuce的基本使用。

对象序列化与反序列化。

这边就简单的创建一个商品实体类

public class Product {
    private String name;
    private double price;
    private String desc;
    // 省略getter/setter方法
    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder("Product{");
        sb.append("name='").append(name).append(''');
        sb.append(", price=").append(price);
        sb.append(", desc='").append(desc).append(''');
        sb.append('}');
        return sb.toString();
    }
}

测试单元,这部分是重点。

  • password@host:port/0

    RedisClient redisClient = RedisClient.create("redis://admin1234@127.0.0.1:6379/0");
    
  • 对象序列化

    ObjectMapper这个是为了将Product对象序列化,然后存入redis。如何序列化呢,就是使用writeValueAsString这个方法,即objectMapper.writeValueAsString(product);

  • 创建redis客户端实例并创建连接

    Lettuce用RedisClient的create方法来创建redis客户端,返回的是RedisClient对象。

    使用connect方法连接客户端,返回的是StatefulRedisConnection对象。

    连接完之后通过 async() 方法创建一个异步的 Command 对象,即RedisAsyncCommands<String, String> asyncCommands= connection.async();返回的是RedisAsyncCommands对象。当然redis还提供了一个同步的方法,如RedisCommands<String, String> syncCommands = connection.sync();

  • java执行Redis命令

    以字符串的set命令举例,asyncCommands.set("product", json),方法返回的是Future 对象。可以用 get() 方法设置一个等待的超时时间,等待 SET 命令执行完成。我们这里设置 1 秒,TimeUnit 用来指定时间的单位。如asyncCommands.set("product", json).get(1, TimeUnit.SECONDS);设置一个合理的超时时间,可以防止 Redis 长时间不返回把上游服务拖垮了。

    当然,在用同步的对象执行命令的时候,就不用再调 get() 方法,设置超时时间了,它会一直阻塞等待命令执行结束。

    当业务与 Redis 的交互结束之后,我们就把连接和 RedisClient 都关闭掉,用来释放比较珍贵的网络资源。

    connection.close();redisClient.shutdown();

public class RedisTest {
    @Test
    public void testCacheProduct() throws Exception {
        ObjectMapper objectMapper = new ObjectMapper();
        Product product = new Product(); // 创建Product对象
        product.setName("杯子");
        product.setPrice(100d);
        product.setDesc("这是一个杯子");
        String json = objectMapper.writeValueAsString(product);
      
        RedisClient redisClient = RedisClient.create("redis://127.0.0.1:6379/0");
        StatefulRedisConnection<String, String> connection = redisClient.connect();
        RedisAsyncCommands<String, String> asyncCommands = connection.async();
        asyncCommands.set("product", json).get(1, TimeUnit.SECONDS);  
        // RedisCommands<String, String> syncCommands = connection.sync();
        // syncCommands.set("product", json);
        connection.close();
        redisClient.shutdown();
    }
}

上述代码执行结果:

127.0.0.1:6379> GET product
"{"name":"\xe6\x9d\xaf\xe5\xad\x90","price":100.0,"desc":"\xe8\xbf\x99\xe6\x98\xaf\xe4\xb8\x80\xe4\xb8\xaa\xe6\x9d\xaf\xe5\xad\x90"}"

我们再加一个 GET 命令的单测,同时把单测类的结构调整一下。我们把建连和创建 Command 对象的逻辑放到 before() 方法里面,把释放连接的逻辑放到 after() 方法里面,然后加上 @Before 和 @After 注解,这样的话,每次跑单测的时候,就会先执行 before() 进行建连,再执行单测逻辑,最后执行 after() 关闭连接。

  • 上述代码redis连接相关的都先拿出来,需要RedisClient对象(也就是redis客户端实例)、StatefulRedisConnection对象(由redis客户端创建连接所得)、RedisAsyncCommands(异步的命令执行对象)。

  • 将redis客户端的创建与连接等配置相关的放到Before注解下的函数中,程序运行前会先执行该步。

  • 连接释放相关的放入After注解的函数中执行。

  • 像上述程序需要写的测试代码就可以改成,先对product对象做序列化,然后直接用RedisAsyncCommands对象执行redis命令即可。

  • 对于get命令,我们需要先get拿到product这个键对应的东西,然后再反序列化拿到实际结果。

    代码中第二个get与前面set里面的是一样的,都是设置等待超时时间。

    String json = asyncCommands.get("product").get(1, TimeUnit.SECONDS);
    Product product = objectMapper.readValue(json, new TypeReference<Product>() {
            });
    
public class RedisTest {
    private static RedisClient redisClient;
    private static StatefulRedisConnection<String, String> connection;
    private static RedisAsyncCommands<String, String> asyncCommands;
    @Before
    public void before(){
        redisClient = RedisClient.create("redis://127.0.0.1:6379/0");
        connection = redisClient.connect();
        asyncCommands = connection.async();
        // RedisCommands<String, String> syncCommands = connection.sync();
    }
    @After
    public void after(){
        connection.close();
        redisClient.shutdown();
    }
    @Test
    public void testCacheProduct() throws Exception {
        ObjectMapper objectMapper = new ObjectMapper();
        Product product = new Product();
        product.setName("杯子");
        product.setPrice(100d);
        product.setDesc("这是一个杯子");
        String json = objectMapper.writeValueAsString(product);
        asyncCommands.set("product", json).get(1, TimeUnit.SECONDS);
    }
    @Test
    public void testGetProduct() throws Exception {
        ObjectMapper objectMapper = new ObjectMapper();
        String json = asyncCommands.get("product").get(1, TimeUnit.SECONDS);
        Product product = objectMapper.readValue(json, new TypeReference<Product>() {
        });
        System.out.println(product);
    }
}

分布式锁

SET name kouzhaoxuejie NX,设置name这个key,value是kouzhaoxuejie,若该key不存在,才能写入,若存在则无法写入。

利用 Redis 命令的原子性和 SET...NX 命令(或者是 SETNX 命令)实现锁的效果,Redis 字符串的 Key 是锁的名称,Value 存的是当前锁的拥有者

下面这个代码就是模拟三个客户抢资源的场景。

业务逻辑注意点:

  • new CountDownLatch(threadNum);创建这个对象的目的就是让三个客户对应的线程在开始时都拥堵在countDownLatch.await();处,然后三个线程一起开始执行下面的业务逻辑。业务逻辑第一步就是抢锁。
  • SetArgs.Builder.ex() 方法,对标的就是 SET 命令的 EX 参数,SetArgs.Builder.nx() 方法就是 SET 命令的 NX 参数。这里我们用 ex(5) 方法告诉 Redis 需要这个 Key 五秒自动过期,用 nx() 方法告诉 Redis 这个 Key 不存在的时候,才能创建。
  • 执行 asyncCommands.set() 方法发送 SET 命令,其中第一个参数是 SET 命令的 Key,第二个是 Value,第三个的话就是 SetArgs 这些扩展参数。
  • 如果返回值不是 “OK”,那就是当前线程加锁失败了,当前线程会休眠一会,再继续这个 while 循环去抢锁。如果返回是 “OK” 的话,那证明这个 Key 写入成功了。

代码注意点:

  • new CountDownLatch(threadNum)、SetArgs.Builder.ex(5).nx();
  • Runnable线程相关:多线程其中的一个方式就是实现Runnable接口,这里面的话是用了函数式表达式写了,然后new Thread创建线程,thread.start()启动线程。
@Test
public void testLock() throws Exception {
    int threadNum = 1;
    CountDownLatch countDownLatch = new CountDownLatch(threadNum);
    Runnable runnable = () -> {
        try {
            countDownLatch.await();
            while (true) {
                // 获取锁
                SetArgs setArgs = SetArgs.Builder.ex(5).nx();
                String succ = asyncCommands.set("update-product",
                        Thread.currentThread().getName(), setArgs).get(1, TimeUnit.SECONDS);
                // 加锁失败
                if (!"OK".equals(succ)) {
                    System.out.println(Thread.currentThread().getName() + "加锁失败,自选等待锁");
                    Thread.sleep(100);
                } else {
                    System.out.println(Thread.currentThread().getName() + "加锁成功");
                    break;
                }
            }
            // 加锁成功
            System.out.println(Thread.currentThread().getName() + "开始执行业务逻辑");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "完成业务逻辑");
            // 释放锁
            asyncCommands.del("update-product").get(1, TimeUnit.SECONDS);
            System.out.println(Thread.currentThread().getName() + "释放锁");
        } catch (Exception e) {
            e.printStackTrace();
        }
    };
​
    Thread thread1 = new Thread(runnable);
    Thread thread2 = new Thread(runnable);
    Thread thread3 = new Thread(runnable);
    thread1.start();
    thread2.start();
    thread3.start();
    countDownLatch.countDown();
​
    Thread.sleep(TimeUnit.DAYS.toMillis(1));
}

限流

这里限流的思想就是用到字符串中的incr累加,超过maxQps的部分将会被限流。

@Test
public void testLimit() throws Exception {
    String prefix = "order-service";
    long maxQps = 10;
    long nowSeconds = System.currentTimeMillis() / 1000;
    for (int i = 0; i < 15; i++) {
        Long result = asyncCommands.incr(prefix + nowSeconds).get(1, TimeUnit.SECONDS);
        if (result > maxQps) {
            System.out.println("请求被限流");
        }else{
            System.out.println("请求正常被处理");
        }
    }
}

以上就是本文要讲的所有内容了。