Redis使用手册

149 阅读14分钟

简介

为什么需要redis

mysql记录数据需要io到磁盘+同时有些数据并不需要持久化下来,比如时效数据,这种可以通过放在客户端但是如果有些对于服务端敏感的数据则不能依赖于客户端,需要放在服务端。那么redis就十分必要了,例如一些实时性较高的数据比如热榜点击排名等。

redis 基础

mp.weixin.qq.com/s/ur91v2Iny…

Redis 提供了丰富的数据类型,常见的有五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)

随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)

Redis数据类型

string

常见操作

key value 的存取

## 设置 查询 删除 判断存在否 长度
set name "laios"
get name 
del name 

exists name
strlen name

## 批量操作
mset k1 v1 k2 v2 k3 v3
mget k1 k2 k3

*内部实现

String的底层数据结构 = int + SDS( 简单动态字符串 )

其他操作

  • 数字操作
set number 1
get number

incr number 
decr number

incrby number 10
decrby number 10
  • 过期操作
## 设置value同时指定过期时间
set key value ex 60
setex key 60 value

## 单独设置过期时间
expire name 60
ttl name
  • 不存在则新增
setnx key value

应用场景

  • 缓存key -value = value可是json字符串。
  • 常规计数 = 一段时间内的计数操作(不需要持久化的数据)
  • 分布式锁 = setnx - 如果锁存在则失败如果key不存在则成功

list

就是一个队列 , 长度在 2^32 -1 = 40亿长度。

*内部实现

List的底层数据结构 = 双向列表 / 压缩列表。同时redis 3.2 版本后 通过 quicklist来实现了list。

常见操作

lpush l1 1 
lrange l1 0 -1


rpush l1 2 
lpush l1 0

lpop
rpop

## 带有阻塞操作的pop元素
## blpop l1 timeout
blpop l1 10
brpop l1 10

应用场景

  1. 消息队列 -不推荐- 好像有stream的封装

解决三个问题 = 消息有序 | 消息不重复消费 | 消息不丢失

消息有序 = list 是队列进入有序即可。但是不会主动通知消费者所以只能阻塞读取性能不高。(不像普通的消息队列有发布订阅能基于事件驱动)。

消息不重复消费 = 每个消息分配全局唯一id , 然后处理时先判断是否已经处理过这个id了。

消息不丢失 = 持久化

Hash

常见操作

双key value 的存取 。表示一个对象 - 第一个key是对象的key , 第二个key是对象字段的key。

hset k1 f1 v1 f2 v2
hget k1 f1
hget k1 f2 
hdel k1 f1 f2


## 批量操作
hmset hashkey1 f1 v1 f2 v2 f3 v4
hmget hashkey1 f1 f2 f3

# 对象的key和value的遍历
hgetall k1

*内部实现

Hash底层的数据结构 = 压缩列表 / 哈希表。

应用场景

  1. 缓存对象
  2. 购物车。 key= 用户id:商品id 。value=商品数量。
添加商品 = hset cart:{用户id}:{商品id} 1
添加数量 = hincrby cart:{用户id} {商品id} 10
商品总数 = hlen cart:{用户id} 
删除商品 = hdel cart:{用户id} {商品id} 
获取购物车所有的商品 = hgetall cart:{用户id}

Set

set = 无序但是不会重复的键值集合。

常见操作

sadd setKey v1 v2 v3
srem setKey v1 v2 v3

## 获取元素
smembers setKey

# 获取集合key中的元素个数
scard setKey						
# 判断 值 是否存在集合 setKey中
sismember setKey 1
## 从集合 setKey中随机选出 n 个元素
srandmember setKey n
## 从集合 setKey中随机选出 count 个元素 且从集合中删除
spop setKey count

## 计算部分
### 交集
sinter sk1 sk2

### 并集
sunion sk1 sk2 
### 并集并存储
sunionstore targetSet sk1 sk2

### 差集
sdiff sk1 sk2
sdiffstore target2 sk1 sk2

*内部实现

Set类型底层的数据结构 = 哈西表 / 整数集合。

应用场景

  1. 点赞集合
  2. 共同关注集合
  3. 中奖活动 -- 不能重复中奖。
  • 如果是允许重复抽奖的话 , srandmember setKey 1
  • 如果是不允许重复抽象的话 , spop setKey 1

ZSet

ZSet类型 = Set类型多加个排序属性 score。

*内部实现

ZSet = 压缩列表 / 跳表。redis7中 , 压缩列表被 listpack来替换了。

常用操作

## 添加 / 删除
zadd key 1 obj1 2 obj2 3 obj3 
zrem key obj3 

## 返回有序集合key的obj1的分数
zscore key obj1
## 返回key里面的元素个数
zcard key

## 给集合中某个元素添加分数
zincrby key 10 obj1

## 返回集合元素
zrange key 0 -1
zrevrange key 0 -1

## 返回指定分数区间的
zrangebyscore key 0 100

应用场景

  1. 排行榜 ZADD user:xiaolin:ranking 200 arcticle:1 。key=排行榜拼的key , value就是文章的信息id , 分数就是排序用的。

BitMap

位图 = 一串连续的二进制数组 (0/1) 可以通过偏移量来定位元素。适用于二值统计场景。

常用操作

## 设置值 + 获取值 key = bm
setbit bm 100 1
setbit bm 100 0
getbit bm 100

## 1的个数 key = bm
bitcount bm 0 500
  • 操作部分

应用场景

  1. 签到统计
  2. 判断用户登陆态
  3. 连续签到用户总数

HyperLogLog

hyperloglog用于统计基数。统计基数 = 统计一个集合中不重复的元素个数。十分地节省了存储。12KB能取计算近 2^64个值。

常见操作

pfadd key v1 v2 v3

pdcount key

## 将多个HyperLogLog 合并为一个 HyperLogLog
pdfmerge targetKey sourceKey1 sourceKey2

应用场景

  1. 百万级别网页UV计数
PFADD page1:uv user1 user2 user3 user4 user5

pfcount page1:uv

GEO

GEO存储地理位置信息 , 并对存储的信息进行操作。LBS , Location-Based Service。

常见操作

# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]

# 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
GEOPOS key member [member ...]

# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [m|km|ft|mi]

# 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

应用场景

  1. 滴滴叫车
## 添加车辆位置信息
GEOADD cars:locations 116.034579 39.030452 33

## 对于车的位置集合 -- 5km附近
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

Stream

直接参考这里就行。

mp.weixin.qq.com/s/ur91v2Iny…

使用手册

www.iocoder.cn/Spring-Boot…

缓存经典问题

什么数据适合放在缓存中

  1. 读多写少
  2. 即时信不高的数据。

缓存三大问题

团面试拷打:Redis 缓存穿透、缓存击穿、缓存雪崩区别和解决方案

缓存穿透 = 不存在的key一直发请求 , 一直请求数据库。灰产、爬虫拼接条件然后不存在。null值可能会将空间比较多。 缓存结果为null的key。 == SpringCache 默认缓存null。

缓存击穿 = 热点数据 , 失效 , db请求过多。 不设置过期时间 / 自己定时更新不断续 / 穿透后加锁查询(单体jvm级别锁 , 多实例全局分布式锁) -设置过期时间然后value里面设置一个有一个逻辑过期时间。 分布式锁 = 获取成功则查询数据库并且设置缓存。获取失败则阻塞等待或者直接返回一个默认值。

缓存雪崩 = 过期时间相同 , 同一时刻大量缓存失效 , db请求过多。 时间base+随机时间数 +失效后加锁查询(单体jvm级别锁 , 多实例全局分布式锁)。

数据一致性

【IT老齐062】缓存一致性如何保障?先写库还是先写缓存?聊聊Cache Aside Pattern与延迟双删_哔哩哔哩_bilibili

关系型数据库保证事务保证事务绝对的正确性。 一般都是保证最终一致性,而不是强一致性,如果是强一致性则要加锁那么则会阻塞那么redis作为缓存快的意义就少了。

对于缓存的更新操作不要使用update。两者可能会导致持有旧数据的线程因为网络原因覆盖了最新的缓存。所以最好使用删除缓存,别的线程查询数据库来并设置最新的缓存。

Cache Aside Pattern = 先写库再删缓存。 + 延迟双删。

总结 所以就是通过缓存双删来解决。对于消费失败以及重试机制就有mq的消费者处理来决定,如果消费失败则重试重试到一定次数就发邮件。如果是结合SpringCache的话需要对于查询操作可以直接使用注解,但是对于删除和更新操作需要自己实现并添加延迟删除的逻辑。

gulimall缓存场景

  1. 普通的缓存对象数据 一个是对于分类树的缓存 = 更新然后删除缓存 = 查询用@Cacheable 然后更新用@CacheEvict

SpringBoot+SpringCache整合Redis

www.iocoder.cn/Spring-Boot… github.com/YunaiV/Spri…

常用注解

@Cacheable

  • 1)首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。
  • 2)然后,执行方法,获得方法结果。
  • 3)之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
  • 4)最后,返回方法结果。

@CachePut

  • 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法
  • 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
  • 3)最后,返回方法结果

@CacheEvict

  • allEntries 属性,是否删除缓存名( cacheNames )下,所有 key 对应的缓存。默认为 false ,只删除指定 key 的缓存。
  • beforeInvocation 属性,是否在方法执行删除缓存。默认为 false ,在方法执行删除缓存。

Bugfix: 编写的时候遇到写问题。貌似 注解在接口层会出现点问题,在serviveIMpl没什么问题。

原因在于不支持非public的方法而且如果是类内部方法调用则不能生效。

zhuanlan.zhihu.com/p/452611889

常见场景

这里貌似不能直接使用mapper继承的方法会报null key的问题。需要这样包一层。

自动过期

仅限于SpringCache使用到的缓存。具体的也可以通过redistempalte来其他方法来直接设置带过期时间的缓存。

  • 通过配置全局的过期时间来实现自动过期然后自动更新缓存。

zhuanlan.zhihu.com/p/138295935

  • 也可以通过 @CachePut方法+前端定时器查询/后端定时任务来自动更新缓存。
  • 配置过期时间 通过增强注解的解析逻辑获取其中的ttl事件 = 这个也是yudao项目的类似的封装不过他封装到key那里了
  • 写个新的注解再包一层。自定义缓存管理器。实现bean初始化增强的类扫描带有自定义注解的对象并设置上过期时间 - CacheExpireTimeInit。
  • 自动刷新 - 好像在查询db的逻辑service那里加个锁就行了 / 这里是比较麻烦感觉

mp.weixin.qq.com/s/zzJH-enXl…


SpringBoot 整合 Redis

一般是使用 封装好的 redistempalte 以及 RedisUtils

www.iocoder.cn/Spring-Boot…

基本使用

不同数据类型的封装的 opration 简单来说就是当做集合类使用就行了就是 get和put的关系.

  • Lua 脚本执行器,提供 Redis scripting API 操作。
  • Redis 常见数据结构操作类。

序列化

RedisSerializer

主要分成四类:

  • JDK 序列化方式
  • String 序列化方式
  • JSON 序列化方式
  • XML 序列化方式

redis-starter的配置

装配了 RedisCacheConfiguration 和 RedisCacheManager

@AutoConfiguration
@EnableConfigurationProperties({CacheProperties.class, YudaoCacheProperties.class})
@EnableCaching
public class YudaoCacheAutoConfiguration {

    /**
     * RedisCacheConfiguration Bean
     * <p>
     * 参考 org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 的 createConfiguration 方法
     */
    @Bean
    @Primary
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 设置使用 : 单冒号,而不是双 :: 冒号,避免 Redis Desktop Manager 多余空格
        // 详细可见 https://blog.csdn.net/chuixue24/article/details/103928965 博客
        // 再次修复单冒号,而不是双 :: 冒号问题,Issues 详情:https://gitee.com/zhijiantianya/yudao-cloud/issues/I86VY2
        config = config.computePrefixWith(cacheName -> {
            String keyPrefix = cacheProperties.getRedis().getKeyPrefix();
            if (StringUtils.hasText(keyPrefix)) {
                keyPrefix = keyPrefix.lastIndexOf(StrUtil.COLON) == -1 ? keyPrefix + StrUtil.COLON : keyPrefix;
                return keyPrefix + cacheName + StrUtil.COLON;
            }
            return cacheName + StrUtil.COLON;
        });
        // 设置使用 JSON 序列化方式
        config = config.serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(buildRedisSerializer()));

        // 设置 CacheProperties.Redis 的属性
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }

    @Bean
    public RedisCacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate,
                                               RedisCacheConfiguration redisCacheConfiguration,
                                               YudaoCacheProperties yudaoCacheProperties) {
        // 创建 RedisCacheWriter 对象
        RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
        RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
                BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
        // 创建 TenantRedisCacheManager 对象
        return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);
    }

}

装配 RedisTemplate

@AutoConfiguration
public class YudaoRedisAutoConfiguration {

    /**
     * 创建 RedisTemplate Bean,使用 JSON 序列化方式
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        // 创建 RedisTemplate 对象
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 设置 RedisConnection 工厂。😈 它就是实现多种 Java Redis 客户端接入的秘密工厂。感兴趣的胖友,可以自己去撸下。
        template.setConnectionFactory(factory);
        // 使用 String 序列化方式,序列化 KEY 。
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        // 使用 JSON 序列化方式(库是 Jackson ),序列化 VALUE 。
        template.setValueSerializer(buildRedisSerializer());
        template.setHashValueSerializer(buildRedisSerializer());
        return template;
    }

    public static RedisSerializer<?> buildRedisSerializer() {
        RedisSerializer<Object> json = RedisSerializer.json();
        // 解决 LocalDateTime 的序列化
        ObjectMapper objectMapper = (ObjectMapper) ReflectUtil.getFieldValue(json, "mapper");
        objectMapper.registerModules(new JavaTimeModule());
        return json;
    }

}

对于过期时间的支持


/**
 * 支持自定义过期时间的 {@link RedisCacheManager} 实现类
 *
 * 在 {@link Cacheable#cacheNames()} 格式为 "key#ttl" 时,# 后面的 ttl 为过期时间。
 * 单位为最后一个字母(支持的单位有:d 天,h 小时,m 分钟,s 秒),默认单位为 s 秒
 *
 * @author 芋道源码
 */
public class TimeoutRedisCacheManager extends RedisCacheManager {

    private static final String SPLIT = "#";

    public TimeoutRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
        super(cacheWriter, defaultCacheConfiguration);
    }

    @Override
    protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
        if (StrUtil.isEmpty(name)) {
            return super.createRedisCache(name, cacheConfig);
        }
        // 如果使用 # 分隔,大小不为 2,则说明不使用自定义过期时间
        String[] names = StrUtil.splitToArray(name, SPLIT);
        if (names.length != 2) {
            return super.createRedisCache(name, cacheConfig);
        }

        // 核心:通过修改 cacheConfig 的过期时间,实现自定义过期时间
        if (cacheConfig != null) {
            // 移除 # 后面的 : 以及后面的内容,避免影响解析
            names[1] = StrUtil.subBefore(names[1], StrUtil.COLON, false);
            // 解析时间
            Duration duration = parseDuration(names[1]);
            cacheConfig = cacheConfig.entryTtl(duration);
        }
        return super.createRedisCache(name, cacheConfig);
    }

    /**
     * 解析过期时间 Duration
     *
     * @param ttlStr 过期时间字符串
     * @return 过期时间 Duration
     */
    private Duration parseDuration(String ttlStr) {
        String timeUnit = StrUtil.subSuf(ttlStr, -1);
        switch (timeUnit) {
            case "d":
                return Duration.ofDays(removeDurationSuffix(ttlStr));
            case "h":
                return Duration.ofHours(removeDurationSuffix(ttlStr));
            case "m":
                return Duration.ofMinutes(removeDurationSuffix(ttlStr));
            case "s":
                return Duration.ofSeconds(removeDurationSuffix(ttlStr));
            default:
                return Duration.ofSeconds(Long.parseLong(ttlStr));
        }
    }

    /**
     * 移除多余的后缀,返回具体的时间
     *
     * @param ttlStr 过期时间字符串
     * @return 时间
     */
    private Long removeDurationSuffix(String ttlStr) {
        return NumberUtil.parseLong(StrUtil.sub(ttlStr, 0, ttlStr.length() - 1));
    }

}

使用式例

Redis执行lua脚本

保证一组命令能够串行化的执行

resources/compareAndSet.lua

if redis.call('GET', KEYS[1]) ~= ARGV[1] then
    return 0
end
redis.call('SET', KEYS[1], ARGV[2])
return 1

执行脚本

@RunWith(SpringRunner.class)
@SpringBootTest
public class ScriptTest {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void test01() throws IOException {
        // <1.1> 读取 /resources/lua/compareAndSet.lua 脚本 。注意,需要引入下 commons-io 依赖。
        String  scriptContents = IOUtils.toString(getClass().getResourceAsStream("/lua/compareAndSet.lua"), "UTF-8");
        // <1.2> 创建 RedisScript 对象
        RedisScript<Long> script = new DefaultRedisScript<>(scriptContents, Long.class);
        // <2> 执行 LUA 脚本
        Long result = stringRedisTemplate.execute(script, Collections.singletonList("yunai:1"), "shuai02", "shuai");
        System.out.println(result);
    }
}

Redisson

www.iocoder.cn/Spring-Boot…

github.com/yudaocode/S…

redisson 依赖

    <!-- 实现对 Redisson 的自动化配置 --> <!-- X -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.11.3</version>
    </dependency>

applicaiton.yaml对redisson的配置

spring:
  # 对应 RedisProperties 类
  redis:
    host: 127.0.0.1
    port: 6379
#    password: # Redis 服务器密码,默认为空。生产中,一定要设置 Redis 密码!
    database: 0 # Redis 数据库号,默认为 0 。
    timeout: 0 # Redis 连接超时时间,单位:毫秒。
    # 对应 RedissonProperties 类
    redisson:
      config: classpath:redisson.yml # 具体的每个配置项,见 org.redisson.config.Config 类

Redisson 分布式锁

官方文档的分布式锁的案例

github.com/redisson/re…

获取分布式锁的逻辑就是中间两行代码。尝试获取锁然后阻塞一段时间 , timeout后则失败 , 走失败分支即可。

@RunWith(SpringRunner.class)
@SpringBootTest
public class LockTest {

    private static final String LOCK_KEY = "anylock";

    @Autowired // <1>
    private RedissonClient redissonClient;

    @Test
    public void test() throws InterruptedException {
        // <2.1> 启动一个线程 A ,去占有锁
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 加锁以后 10 秒钟自动解锁
                // 无需调用 unlock 方法手动解锁
                final RLock lock = redissonClient.getLock(LOCK_KEY);
                lock.lock(10, TimeUnit.SECONDS);
            }
        }).start();
        // <2.2> 简单 sleep 1 秒,保证线程 A 成功持有锁
        Thread.sleep(1000L);

        // <3> 尝试加锁,最多等待 100 秒,上锁以后 10 秒自动解锁
        System.out.println(String.format("准备开始获得锁时间:%s", new SimpleDateFormat("yyyy-MM-DD HH:mm:ss").format(new Date())));
        final RLock lock = redissonClient.getLock(LOCK_KEY);
        boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
        if (res) {
            System.out.println(String.format("实际获得锁时间:%s", new SimpleDateFormat("yyyy-MM-DD HH:mm:ss").format(new Date())));
        } else {
            System.out.println("加锁失败");
        }
    }

}