在Redis中选择合适的数据结构存储Java类型
# 前言
本文针对Java数据类型在Redis中怎么存储比较合适进行一系列的思考与验证。
# 测试的类型
Java中比较常见的数据类型:
- Object
- Number
- List
- Map
- Set
# 1. Object
首先是普通java对象,单说Object可能比较抽象,毕竟是顶级父类。
暂定这里的Object指包含基本数据类型或包装类的javaBean例如:
@Data
public class User {
private Long id;
private String name;
private Integer age;
private Boolean sex;
private String phone;
private String email;
private String address;
private String description;
}
2
3
4
5
6
7
8
9
10
11
在Redis中存储这样普通的java对象,常见的有三种做法:
- jdk序列化 →
String(不推荐) - json序列化 →
String - 转map存储 →
Hash
依次进行测试及验证。
# 1.1 JDK序列化
测试代码
@GetMapping("/demo1")
public void demo1() {
User user = new User();
user.setId(1L);
user.setName("zhangsan");
user.setAge(18);
user.setSex(true);
user.setPhone("123456789");
user.setEmail("123345@163.com");
user.setAddress("beijing");
user.setDescription("this is a description");
redisService.set("user", user);
}
2
3
4
5
6
7
8
9
10
11
12
13
使用Redis指令查看内存占用以及编码
# 内存占用
[db3] > MEMORY USAGE user
(integer) 507
# 编码
[db3] > object encoding user
"raw"
2
3
4
5
6
可以看到占用了507B内存以及编码是raw。
# 1.2 JSON序列化
测试代码
@GetMapping("/demo1")
public void demo1() {
User user = new User();
user.setId(1L);
user.setName("zhangsan");
user.setAge(18);
user.setSex(true);
user.setPhone("123456789");
user.setEmail("123345@163.com");
user.setAddress("beijing");
user.setDescription("this is a description");
redisService.set("userJson", user);
}
2
3
4
5
6
7
8
9
10
11
12
13
使用Redis指令查看内存占用以及编码
# 内存占用
[db3] > MEMORY USAGE userJson
(integer) 255
# 编码
[db3] > object encoding userJson
"raw"
2
3
4
5
6
可以看到占用了255B内存以及编码是raw,相比jdk序列化内存占用低了近一倍。
为什么呢?我们直接来看看存储的内容。
jdk序列化:
{
"fields": [
{
"address": "beijing"
},
{
"age": {
"fields": {},
"annotations": [],
"className": "java.lang.Integer",
"serialVersionUid": 1360826667806852920
}
},
{
"description": "this is a description"
},
{
"email": "123345@163.com"
},
{
"id": {
"fields": {},
"annotations": [],
"className": "java.lang.Long",
"serialVersionUid": 4290774380558885855
}
},
{
"name": "zhangsan"
},
{
"phone": "123456789"
},
{
"sex": {
"fields": {},
"annotations": [],
"className": "java.lang.Boolean",
"serialVersionUid": 14780939874695183086
}
}
],
"annotations": [],
"className": "cn.kk.controller.User",
"serialVersionUid": 7306047194980300375
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
json序列化:
{
"@class": "cn.kk.controller.User",
"address": "beijing",
"age": 18,
"description": "this is a description",
"email": "123345@163.com",
"id": [
"java.lang.Long",
1
],
"name": "zhangsan",
"phone": "123456789",
"sex": true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
可以看到jdk的序列化需要携带很多类加载的元信息,例如序列化版本、注解、类信息等;而json就比较简单,kv结构,直白明了不会携带过多冗余的信息,这个还是redisson的json序列号器附加处理过了的,更纯净的会连类信息都省略掉。
为什么我会强调尽可能的减少冗余字段的存储,或许冗余的字段在进行反序列化的时候有提速的作用,但在Redis存储层面可能会涉及底层数据结构的变动,进而影响存储和查询性能。
原因是String类型根据字符串长度的不同,使用的编码也不一样,当然如果存在复杂对象,JSON序列化后在Redis的编码一致,那么肯定优先于JSON的序列化和反序列化性能,这里补充一下String结构的相关编码知识帮助理解:
# 1.2.1 String相关编码知识
当字符串大于44个字符时会使用raw编码,也就是String类型的基本编码,基于简单动态字符串(SDS)实现,存储上限为512mb。存储结构如下:

但当字符串小于等于44个字符时会转为EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。结构如下:

而当存储的字符串是整数值且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS结构了:

我们来做一个验证就很直观了:
- 字符长度大于44字节时
[db3] > set str45 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' #45 字符
"OK"
[db3] > object encoding str45 # 查看编码
"raw"
[db3] > memory usage str45 # 查看内存占用
(integer) 96
2
3
4
5
6
7
8
- 字符长度小于等于44字节时
[db3] > set str44 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' #44 字符
"OK"
[db3] > object encoding str44 # 查看编码
"embstr"
[db3] > memory usage str43 # 查看内存占用
(integer) 93
2
3
4
5
6
7
8
- 字符串为整数
[db3] > set number 9223372036854775807
"OK"
[db3] > object encoding number # 查看编码
"int"
[db3] > memory usage number # 查看内存占用
(integer) 48
# 与embstr编码做一个对比
[db3] > set number 9223372036854775808 # +1 超出long最大值
"OK"
[db3] > object encoding number
"embstr"
[db3] > memory usage number
(integer) 69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
( ̄︶ ̄)什么叫细节成就质变!但话又说回来,实际在json序列化中很难保证字符长度在44字节以内,毕竟复杂对象字段显然会更多,如果夹杂了List啊Map之类的更是如此,但JDK序列化和JSON序列化相比结果已经很明显了,关于44字符长度和长整型最大值的知识点就当是扩展内容吧。
# 1.3 字段拆分为Map存储
这里再提一个可能不是那么常见的存储方式,就是按字段拆分为KV作为Map存储于Redis的hash结构中。
例如:
public class User {
private Long id;
private String name;
private Integer age;
}
2
3
4
5
转为
[
{key: "id", value: "1"},
{key: "name", value: "zhangsan"},
{key: "age", value: "18"},
]
2
3
4
5
优点:
- 字段多,值短的情况下有较好的存储空间压缩表现
- 支持个别字段更新,需要重分配的内存少,效率高
缺点:
- 编码复杂,需要手动编写存入和取出、个别字段更新的逻辑
- 根据对象的复杂度,在匹配更新字段或转换耗时可能会更长,极端情况性能或许并没那么理想
- 存在大字段的情况下,存储空间压缩效果会有减弱
# 1.3.1 字段多,值短对象测试
首先来对比一下【字段多,值短】的情况下两者内存压缩上的表现:
因为JDK序列化实在是拉胯所以这里就直接pass了
测试代码
@GetMapping("/demo1")
public void demo1() throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
User user = new User();
user.setId(1L);
user.setName("zhangsan");
user.setAge(18);
user.setSex(true);
user.setPhone("123456789");
user.setEmail("123345@163.com");
user.setAddress("beijing");
user.setDescription("this is a description");
user.setTags(Collections.emptyList());
redisService.set("userJson", user); // json序列化
// redisService.matchTypeAndSet("userMap", user, null); // 转map存hash
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
存储效果
# json序列化
[db3] > get userJson
"{\"@class\":\"cn.kk.controller.User\",\"address\":\"beijing\",\"age\":18,\"description\":\"this is a description\",\"email\":\"123345@163.com\",\"id\":[\"java.lang.Long\",1],\"name\":\"zhangsan\",\"phone\":\"123456789\",\"sex\":true,\"tags\":[\"java.util.Collections$EmptyList\",[]]}"
[db3] > memory usage userJson
(integer) 301
# hash存储
[db3] > hgetall userMap
1) ""address""
2) "\"beijing\""
3) ""phone""
4) "\"123456789\""
5) ""sex""
6) "true"
7) ""name""
8) "\"zhangsan\""
9) ""description""
10) "\"this is a description\""
11) ""id""
12) "[\"java.lang.Long\",1]"
13) ""age""
14) "18"
15) ""email""
16) "\"123345@163.com\""
17) ""tags""
18) "[\"java.util.Collections$EmptyList\",[]]"
[db3] > memory usage userMap
(integer) 290
[db3] > object encoding userMap
"ziplist"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
可以看到hash结构在zipList编码的前提下是存在存储优势的,这也归结于zipList编码结构的压缩特性。
# 1.3.2 存在大字段测试
再来对比一下【存在大字段】的情况下两者内存压缩上的表现:
测试代码
@GetMapping("/demo1")
public void demo1() throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
User user = new User();
user.setId(1L);
user.setName("zhangsan");
user.setAge(18);
user.setSex(true);
user.setPhone("123456789");
user.setEmail("123345@163.com");
user.setAddress("beijing");
user.setDescription("this is a description long long long long long long long long long long long long long long long long long long long long long long long long long long long long long");
List<String> tags = List.of("tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9", "tag10");
user.setTags(tags);
redisService.set("userJson", user); // json序列化
// redisService.matchTypeAndSet("userMap", user, null); // 转map存hash
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
存储效果
[db3] > get userJson
"{\"@class\":\"cn.kk.controller.User\",\"address\":\"beijing\",\"age\":18,\"description\":\"this is a description long long long long long long long long long long long long long long long long long long long long long long long long long long long long long\",\"email\":\"123345@163.com\",\"id\":[\"java.lang.Long\",1],\"name\":\"zhangsan\",\"phone\":\"123456789\",\"sex\":true,\"tags\":[\"java.util.ImmutableCollections$ListN\",[\"tag1\",\"tag2\",\"tag3\",\"tag4\",\"tag5\",\"tag6\",\"tag7\",\"tag8\",\"tag9\",\"tag10\"]]}"
[db3] > memory usage userJson
(integer) 523
[db3] > hgetall userMap
1) ""name""
2) "\"zhangsan\""
3) ""sex""
4) "true"
5) ""email""
6) "\"123345@163.com\""
7) ""id""
8) "[\"java.lang.Long\",1]"
9) ""description""
10) "\"this is a description long long long long long long long long long long long long long long long long long long long long long long long long long long long long long\""
11) ""phone""
12) "\"123456789\""
13) ""address""
14) "\"beijing\""
15) ""age""
16) "18"
17) ""tags""
18) "[\"tag1\",\"tag2\",\"tag3\",\"tag4\",\"tag5\",\"tag6\",\"tag7\",\"tag8\",\"tag9\",\"tag10\"]"
[db3] > memory usage userMap
(integer) 984
[db3] > object encoding userMap
"hashtable"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
可以看到hash存储一下子内存占用由290B飙升至984B,而JSON序列化只提升了222B,原因就是hash结构编码的变化,由ziplist编码变为了hashtable编码,这里再补充一下hash结构的编码知识:
# 1.3.2 Hash相关编码知识
Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value。
ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。
| 属性 | 类型 | 长度 | 用途 |
|---|---|---|---|
| zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数 |
| zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
| zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
| entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
| zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:
- previous_entry_length:前一节点的长度,占1个或5个字节。
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
- encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
- contents:负责保存节点的数据,可以是字符串或整数
ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412。
基于这个结构,ZipList可以在不影响寻址效率的前提下,不像intSet,动态的保存不同大小的节点数据,从而达到节省内存的效果。
当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
- ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
- ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)

Redis的hash之所以这样设计,是因为当ziplist变得很⼤的时候,它有如下几个缺点:
- 每次插⼊或修改引发的realloc操作会有更⼤的概率造成内存拷贝,从而降低性能。
- ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据。
- 当ziplist数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。
总之,ziplist本来就设计为各个数据项挨在⼀起组成连续的内存空间,这种结构并不擅长做修改操作。⼀旦数据发⽣改动,就会引发内存realloc,可能导致内存拷贝。
回到存储效果的差距,hash类型由zipList变为HT编码后,zipList压缩空间的优势就失去,升级为提升性能的HT编码,也就是hash表的结构,自然冗余的字段就变多了,然而我们做这类对象缓存,本来就很少会涉及单字段的更新,向来都是对整个key进行更新,而且获取缓存时也是要对hash表进行扫表,那么其快速索引的特性便失去了优势,所以用hash结构来存储并不是非常的妥当。
# 1.4 结论
- JSON序列化存储String,轻便,简单粗暴,跨平台兼容性强
- JDK序列化存储String,冗余信息过多,显然不是优解
- 转Map存储hash,个别场景下或许比JSON更优,例如对频繁访问个别属性且对查询性能有较高要求,此时建议使用hash存储。
当然还有其他的序列化方式,例如 LZ4 (opens new window) 压缩型序列化对象编码等能够压缩序列化后字符长度的,思路就是尽可能压缩序列化后的字符长度,从而保证string的编码往
embstr或int靠齐
# 2. Number
# 2.1 结论
Number使用String存储应该没有争议,理由在上一小节的String编码相关知识中已经解释了。
# 3. List
市面上常见的缓存框架如spring-cache默认的策略都是不管什么类型全都JSON序列化或者JDK序列化,不知道大家会不会好奇这类Java跟Redis存在匹配的数据类型,使用同类型存储是否更优呢?我们来一探究竟。
# 3.1 少量数据
测试代码
@GetMapping("/test")
public void test() {
List<String> list = new ArrayList<>();
for (long i = 0; i < 10; i++) {
list.add(JSON.toJSONString(new Info(i, "name" + i)));
}
redisService.set("listJson", list);
redisService.getList("list").addAll(list);
}
2
3
4
5
6
7
8
9
存储效果
> get listJson
"[\"java.util.ArrayList\",[\"{\\\"id\\\":0,\\\"name\\\":\\\"name0\\\"}\",\"{\\\"id\\\":1,\\\"name\\\":\\\"name1\\\"}\",\"{\\\"id\\\":2,\\\"name\\\":\\\"name2\\\"}\",\"{\\\"id\\\":3,\\\"name\\\":\\\"name3\\\"}\",\"{\\\"id\\\":4,\\\"name\\\":\\\"name4\\\"}\",\"{\\\"id\\\":5,\\\"name\\\":\\\"name5\\\"}\",\"{\\\"id\\\":6,\\\"name\\\":\\\"name6\\\"}\",\"{\\\"id\\\":7,\\\"name\\\":\\\"name7\\\"}\",\"{\\\"id\\\":8,\\\"name\\\":\\\"name8\\\"}\",\"{\\\"id\\\":9,\\\"name\\\":\\\"name9\\\"}\"]]"
> memory usage listJson
(integer) 401
> LRANGE list 0 -1
1) "\"{\\\"id\\\":0,\\\"name\\\":\\\"name0\\\"}\""
2) "\"{\\\"id\\\":1,\\\"name\\\":\\\"name1\\\"}\""
3) "\"{\\\"id\\\":2,\\\"name\\\":\\\"name2\\\"}\""
4) "\"{\\\"id\\\":3,\\\"name\\\":\\\"name3\\\"}\""
5) "\"{\\\"id\\\":4,\\\"name\\\":\\\"name4\\\"}\""
6) "\"{\\\"id\\\":5,\\\"name\\\":\\\"name5\\\"}\""
7) "\"{\\\"id\\\":6,\\\"name\\\":\\\"name6\\\"}\""
8) "\"{\\\"id\\\":7,\\\"name\\\":\\\"name7\\\"}\""
9) "\"{\\\"id\\\":8,\\\"name\\\":\\\"name8\\\"}\""
10) "\"{\\\"id\\\":9,\\\"name\\\":\\\"name9\\\"}\""
> memory usage list
(integer) 459
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
可以看到此时的差距并不大,但JSON序列化还是略占优。
# 3.2 大量数据
测试代码
@GetMapping("/test")
public void test() {
List<String> list = new ArrayList<>();
for (long i = 0; i < 10000; i++) {
list.add(JSON.toJSONString(new Info(i, "name" + i)));
}
redisService.set("listJson", list);
redisService.getList("list").addAll(list);
}
2
3
4
5
6
7
8
9
存储效果
# 考虑到存储内容过多就不打印了,直接看内存占用
> memory usage listJson
(integer) 377865
> memory usage list
(integer) 394550
> object encoding list
"quicklist"
2
3
4
5
6
7
8
9
10
可以看到随着数据量的增加,JSON序列化为String存储和直接存储List的差距愈发明显,原因也可以推测,这里要说到List的编码quicklist。
# 3.3 List相关编码知识
在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。
在3.2版本之后,Redis统一采用QuickList来实现List:
quicklist就是LinkedList + ZipList,优势如下:
- 是一个节点为ZipList的双端链表
- 节点采用ZipList,解决了传统链表的内存占用问题
- 控制了ZipList大小,解决连续内存空间申请效率问题
- 中间节点可以压缩,进一步节省了内存
乍一看貌似都是对内存和空间申请做了优化,为什么还是不如String呢?原因也很明显,List的优化是兼顾了正反遍历以及元素节点的增删,这此权衡下上对内存空间进一步压缩。由上图的数据结构也不难看出,每个节点包含了三个指针,前节点、后节点、节点数据存储地址的指针,这些指针一样需要占用内存空间,而且节点下的zipList,也会存储其他元信息来帮助加快数据索引。相对String的编码,就是一个SDS结构,存储的元信息很少,数组中都直接存储数据本身,所以自然空间占用就小了。
# 3.4 结论
- 整个list对象替换的情况,例如存储xxx:{id} 这样的list,更新时是对整个key进行删除来更新的,此时使用JSON序列化存储为String更优。
- 如果是作为队列或者集合使用,即存在范围查询以及范围更新的需求时,使用List存储更优,这样能减少连续的大内存块的重分配和申请,性能更佳,虽然会增加部分存储压力,但带来的性能提升是值得的。
# 4. Map & Set
Map 在Redis中存储类型的选择在上文中已经有提及,Set的理论是与List一致的,所以这里直接上结论。
# 4.1 结论
- Map应该序列化为JSON存String还是按KV存储hash结构已经在Object小节解释过了,具体使用哪个结构看场景的需求。
- Set与List的结论是一致的,因为数据本身就是已经存储在了Java的Set集合中,数据的唯一性是已经保证了的,那么直接使用Redis中的List结构存储即可,无需使用Set进行存储,其中一个原因是Set的编码有两个,一个是
HT一个是intSet,HT是默认编码,其空间占用是比List的quicklist要大的。所以如果存在存在范围查询以及范围更新的需求时,使用List存储即可。反之如果不关心查询效率和范围更新,直接使用JSON序列化存储String。
# 5. 后话 & 总结
# 5.1 对象JSON序列化存储为String
优势:
- 实现简单高效,灵活
- 跨平台跨语言兼容性强
- 内存占用小
缺点:
- 只能对整个key更新,涉及大内存块重分配和申请,效率有一定降低
- 失去原数据类型的特性
# 5.2 匹配对应的数据结构存储
优势:
- 能够使用不同存储结构的特性
- 在特殊需求的场景会表现更优的性能
缺点:
- 内存占用相对较多
- 对数据存取更新存在编码难度和工作量
- 跨平台跨语言适配性差,需要在不同语言或平台重新编写存取逻辑
# 5.3 总结
结论来讲就是按场景情况权衡利弊吧,如果就是一个java应用,而且对数据结构特性要求较高的场景巨多,那么可以考虑根据场景特化选择匹配的数据结果。
如果Redis在项目中是作为中央缓存,只考虑在缓存数据的同时尽量减少内存占用,或职能只需要查询,将JSON序列化后存储于String结构效果更佳。
如果接入Redis不单是Java应用程序,还有其他语言或平台的应用,那么JSON序列化将成为最优选择。