万丈高楼平地起 ——Redis 基础数据结构
Redis 有 5 种基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。
一、string (字符串)
字符串内部结构Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的ArrayList
,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
键值对
jedis.set(STR,STR);
Assert.assertTrue(jedis.exists(STR));
Assert.assertEquals(STR,jedis.get(STR));
jedis.del(STR);
批量键值对
可以批量对多个字符串进行读写,节省网络耗时开销
jedis.mset(STR+1,STR+1,STR+2,STR+2);
String[] mKey = new String[]{STR + 1, STR + 2};
List<String> mStr = jedis.mget(mKey);
Assert.assertEquals(2,mStr.size());
Assert.assertEquals(CollectionUtil.newArrayList(mKey),mStr);
jedis.del(mKey);
过期和 set 命令扩展
可以对 key 设置过期时间,到点自动删除,这个功能常用来控制缓存的失效时间。
jedis.set(STR,STR);
jedis.expire(STR,2); // 2s 后过期
Thread.sleep(2000);
Assert.assertFalse(jedis.exists(STR));
jedis.setex(STR,2,STR); // 2s 后过期
Thread.sleep(2000);
Assert.assertFalse(jedis.exists(STR));
Long setnx = jedis.setnx(STR, STR);// 如果 key 不存在就执行 set 创建
Assert.assertEquals(1,setnx.longValue());
setnx = jedis.setnx(STR,STR+1); // 因为 key 已经存在,所以 set 创建不成功
Assert.assertEquals(0,setnx.longValue());
jedis.del(STR);
计数
如果 value 值是一个整数,还可以对它进行自增操作。自增是有范围的,它的范围是signed long 的最大最小值,超过了这个值,Redis 会报错
jedis.set(STR,"0");
jedis.incr(STR);
Assert.assertEquals("1",jedis.get(STR));
jedis.incrBy(STR,5);
Assert.assertEquals("6",jedis.get(STR));
jedis.set(STR,Long.MAX_VALUE + StrUtil.EMPTY);
try {
jedis.incr(STR);
}catch (Exception e){
Assert.assertEquals(JedisDataException.class,e.getClass());
}
jedis.del(STR);
字符串是由多个字节组成,每个字节又是由 8 个 bit 组成,如此便可以将一个字符串看成很多 bit 的组合,这便是 bitmap「位图」数据结构。
二、list (列表)
Redis 的列表相当于 Java 语言里面的 LinkedList
,注意它是链表而不是数组。这意味着list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为O(n),这点让人非常意外。
当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。
Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。
右边进左边出:队列
jedis.rpush(BOOKS,"python","java","golang");
Assert.assertEquals(3,jedis.llen(BOOKS).longValue());
Assert.assertEquals("python",jedis.lpop(BOOKS));
Assert.assertEquals("java",jedis.lpop(BOOKS));
Assert.assertEquals("golang",jedis.lpop(BOOKS));
Assert.assertEquals(null,jedis.lpop(BOOKS));
右边进右边出:栈
Assert.assertEquals(3,jedis.rpush(BOOKS,"python","java","golang").longValue());
Assert.assertEquals("golang",jedis.rpop(BOOKS));
Assert.assertEquals("java",jedis.rpop(BOOKS));
Assert.assertEquals("python",jedis.rpop(BOOKS));
Assert.assertEquals(null,jedis.rpop(BOOKS));
慢操作
lindex
相当于 Java 链表的 get(int index)
方法,它需要对链表进行遍历,性能随着参数index 增大而变差。 ltrim
跟的两个参数 start_index 和 end_index 定义了一个区间,在这个区间内的值要保留,区间之外统统砍掉。我们可以通过 ltrim 来实现一个定长的链表,这一点非常有用。index 可以为负数,index=-1 表示倒数第一个元素,同样 index=-2 表示倒数第二个元素
Assert.assertEquals(3,jedis.rpush(BOOKS, "python", "java", "golang").longValue());
Assert.assertEquals("java",jedis.lindex(BOOKS,1)); // O(n) 慎用
Assert.assertEquals(CollectionUtil.newArrayList("python", "java", "golang"),
jedis.lrange(BOOKS,0,-1)); // O(n) 慎用
jedis.ltrim(BOOKS,1,-1);
Assert.assertEquals(CollectionUtil.newArrayList( "java", "golang"),
jedis.lrange(BOOKS,0,-1)); // O(n) 慎用
jedis.ltrim(BOOKS,1,0); // 是清空了整个列表,因为区间范围长度为负
Assert.assertEquals(0,jedis.llen(BOOKS).longValue());
三、hash (字典)
Redis 的字典相当于 Java 语言里面的 HashMap
,它是无序字典。内部实现结构上同Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。
渐进式rehash渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个hash 结构,然后在后续的定时任务中以及 hash 的子指令中,循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。
hash 结构也可以用来存储用户信息,不同于字符串一次性需要全部序列化整个对象,hash 可以对用户结构中的每个字段单独存储。这样当我们需要获取用户信息时可以进行部分获取。而以整个字符串的形式去保存用户信息的话就只能一次性全部读取,这样就会比较浪费网络流量。
hash 也有缺点,hash 结构的存储消耗要高于单个字符串,到底该使用 hash 还是字符串,需要根据实际情况再三权衡。
jedis.hset(BOOKS,"java","think in java");
jedis.hset(BOOKS,"golang","concurrency in go");
jedis.hset(BOOKS,"python","python cookbook");
Assert.assertEquals(3,jedis.hlen(BOOKS).longValue());
Map<String, String> books = jedis.hgetAll(BOOKS);
Assert.assertEquals(3,books.size());
Assert.assertEquals(jedis.hget(BOOKS,"java"),books.get("java"));
jedis.hset(BOOKS,"golang","learning go programming" );
Assert.assertEquals("learning go programming",jedis.hget(BOOKS,"golang"));
jedis.hdel(BOOKS,"java","golang","python");
jedis.hmset( BOOKS,books); // 批量 set
Assert.assertEquals("concurrency in go",jedis.hget(BOOKS,"golang"));
jedis.hdel(BOOKS,ArrayUtil.toArray(books.keySet(),String.class));
同字符串一样,hash 结构中的单个子 key 也可以进行计数,它对应的指令是 hincrby,和 incr 使用基本一样。
jedis.hincrBy(USER_LAOQIAN, "age", 1 );
jedis.hincrBy(USER_LAOQIAN, "age", 1 ); // 老钱又老了一岁
Assert.assertEquals("2",jedis.hget(USER_LAOQIAN,"age"));
jedis.hdel(USER_LAOQIAN,"age");
三、set (集合)
Redis 的集合相当于 Java 语言里面的 HashSet
,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。 set 结构可以用来存储活动中奖的用户 ID,因为有去重功能,可以保证同一个用户不会中奖两次。
Assert.assertEquals(1,jedis.sadd(BOOKS,"python").longValue());
Assert.assertEquals(0,jedis.sadd(BOOKS,"python").longValue()); // 重复
Assert.assertEquals(2,jedis.sadd(BOOKS,"java","golang").longValue());
Set<String> smembers = jedis.smembers(BOOKS);
Console.log(JSONUtil.toJsonPrettyStr(smembers)); // 注意顺序,和插入的并不一致,因为 set 是无序的
Assert.assertTrue(jedis.sismember(BOOKS,"java")); // 查询某个 value 是否存在,相当于 contains(o)
Assert.assertFalse(jedis.sismember(BOOKS,"ruby"));
Assert.assertEquals(3,jedis.scard(BOOKS).longValue()); // 获取长度相当于 count()
Console.log(jedis.spop(BOOKS)); // 弹出一个
Assert.assertEquals(2,jedis.scard(BOOKS).longValue());
jedis.srem(BOOKS,ArrayUtil.toArray(smembers,String.class));
Assert.assertEquals(0,jedis.scard(BOOKS).longValue());
四、zset (有序列表)
zset 可能是 Redis 提供的最为特色的数据结构,它类似于 Java 的 SortedSet
和 HashMap
的结合体,一方面它是一个 set,保证了内部value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。它的内部实现用的是一种叫着「跳跃列表」的数据结构。zset 中最后一个 value 被移除后,数据结构自动删除,内存被回收。
zset 可以用来存粉丝列表,value 值是粉丝的用户 ID,score 是关注时间。我们可以对粉丝列表按关注时间进行排序。
zset 还可以用来存储学生的成绩,value 值是学生的 ID,score 是他的考试成绩。我们可以对成绩按分数进行排序就可以得到他的名次。
String book1 = "think in java";
String book2 = "java concurrency";
String book3 = "java cookbook";
jedis.zadd(BOOKS,9.0,book1);
jedis.zadd(BOOKS,8.9,book2);
jedis.zadd(BOOKS,8.6,book3);
Assert.assertEquals(CollectionUtil.newArrayList(book3,book2,book1),
CollectionUtil.newArrayList(jedis.zrange(BOOKS,0,-1))); // 按 score 排序列出,参数区间为排名范围
Assert.assertEquals(CollectionUtil.newArrayList(book1,book2,book3),
CollectionUtil.newArrayList(jedis.zrevrange(BOOKS,0,-1))); // 按 score 逆序列出,参数区间为排名范围
Assert.assertEquals(3,jedis.zcard(BOOKS).longValue()); // 相当于 count()
Assert.assertEquals("8.9",jedis.zscore(BOOKS, "java concurrency").toString()); // 获取指定 value 的 score
Assert.assertEquals(1,jedis.zrank(BOOKS,"java concurrency").longValue() ); // 排名
Assert.assertEquals(0,jedis.zrank(BOOKS,"java cookbook").longValue() ); // 排名,从0开始
Assert.assertEquals(CollectionUtil.newArrayList(book3,book2),
CollectionUtil.newArrayList(jedis.zrangeByScore(BOOKS, 0, 8.91))); // 根据分值区间遍历 zset
// 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。
Assert.assertEquals(CollectionUtil.newArrayList(new Tuple(book3,8.6),new Tuple(book2,8.9)),
CollectionUtil.newArrayList(jedis.zrangeByScoreWithScores(BOOKS, "-inf","8.91")));
jedis.zrem(BOOKS,book1,book2,book3);
Assert.assertEquals(0,jedis.zcard(BOOKS).longValue());
本文基于《Redis深度历险:核心原理和应用实践》一文的JAVA实践。更多文章请参考:高性能缓存中间件Redis应用实战(JAVA)