Redis String底层结构与SDS
Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。
一、为什么不用C字符串
#
1.1 C字符串的缺陷
C语言使用\0结尾的字符数组表示字符串:
缺陷一:获取长度O(n)
缺陷二:缓冲区溢出
char str[5] = "hello"; strcat(str, " world");
|
缺陷三:频繁内存分配
char *str = malloc(6); strcat(str, "!");
|
缺陷四:二进制不安全
#
1.2 SDS的设计目标
Redis需要一种更安全、更高效的字符串结构:
- O(1)获取长度
- 避免缓冲区溢出
- 减少内存分配次数
- 支持二进制数据
二、SDS结构
#
2.1 SDS定义
Redis 3.2之后,SDS根据字符串长度使用不同头部结构:
struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; char buf[]; };
struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; uint8_t alloc; unsigned char flags; char buf[]; };
struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; unsigned char flags; char buf[]; };
struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; uint32_t alloc; unsigned char flags; char buf[]; };
struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; uint64_t alloc; unsigned char flags; char buf[]; };
|
#
2.2 SDS内存布局
sdshdr8 示例(存储"Redis"): ┌─────────┬─────────┬─────────┬──────┬─────┐ │ len=5 │ alloc=5 │ flags=1 │ R-e-d-i-s │ \0 │ └─────────┴─────────┴─────────┴──────┴─────┘ 1字节 1字节 1字节 5字节 1字节
总内存:1 + 1 + 1 + 5 + 1 = 9字节
|
注意:buf末尾仍然以\0结尾,这样可以复用部分C字符串函数。
#
2.3 SDS操作API
sds sdsnew(const char *init); sds sdsnewlen(const void *init, size_t initlen); sdsempty(void);
void sdsfree(sds s);
size_t sdslen(const sds s);
size_t sdsavail(const sds s);
sds sdsMakeRoomFor(sds s, size_t addlen);
sds sdscat(sds s, const char *t); sds sdscatsds(sds s, const sds t);
void sdsclear(sds s);
|
三、SDS核心设计
#
3.1 空间预分配
SDS修改字符串时,不仅分配需要的空间,还会预分配额外空间:
sds sdsMakeRoomFor(sds s, size_t addlen) { struct sdshdr *sh = (void*)(s - sizeof(struct sdshdr)); size_t free = sdsavail(s); if (free >= addlen) return s; size_t len = sdslen(s); size_t newlen = len + addlen; if (newlen < SDS_MAX_PREALLOC) { newlen *= 2; } else { newlen += SDS_MAX_PREALLOC; } }
|
策略:
- 修改后长度 < 1MB:分配2倍所需空间
- 修改后长度 >= 1MB:额外分配1MB
优势:
- 减少内存分配次数(从N次降到logN次)
- 均摊时间复杂度接近O(1)
#
3.2 惰性释放
SDS缩短字符串时,不立即释放内存:
void sdsclear(sds s) { struct sdshdr *sh = (void*)(s - sizeof(struct sdshdr)); sh->len = 0; sh->buf[0] = '\0'; }
|
优势:
- 后续追加操作可能不需要重新分配内存
- 避免频繁的内存分配和释放
真正释放:
sds = sdscatsds(sds, "");
|
#
3.3 二进制安全
SDS可以存储任意二进制数据:
sds binary = sdsnewlen("\x00\x01\x02\x03", 4);
sdslen(binary);
|
因为SDS通过len字段记录长度,而不是依赖\0。
四、RedisObject编码
#
4.1 Redis对象系统
Redis所有数据都封装在redisObject中:
typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; int refcount; void *ptr; } robj;
|
#
4.2 String的编码方式
| 编码 |
说明 |
转换条件 |
| OBJ_ENCODING_RAW |
SDS |
字符串长度 > 44字节 |
| OBJ_ENCODING_EMBSTR |
嵌入式SDS |
字符串长度 <= 44字节 |
| OBJ_ENCODING_INT |
整数 |
可以表示为long的字符串 |
#
4.3 EMBSTR编码
EMBSTR内存布局(redisObject + sdshdr + 数据 连续内存): ┌────────────────┬────────────────┬─────────────┐ │ redisObject │ sdshdr8 │ buf + \0 │ │ (16字节) │ (3字节) │ │ └────────────────┴────────────────┴─────────────┘ 连续分配,只需一次malloc
|
优势:
- 只需一次内存分配
- 释放时也只需一次free
- 更好的缓存局部性
为什么是44字节?
64字节(jemalloc最小分配单位) - 16字节(redisObject) - 3字节(sdshdr8) - 1字节(\0) = 44字节
|
#
4.4 INT编码
robj *createStringObjectFromLongLong(long long value) { robj *o; if (value >= 0 && value < OBJ_SHARED_INTEGERS) { o = shared.integers[value]; } else { o = createObject(OBJ_STRING, NULL); o->encoding = OBJ_ENCODING_INT; o->ptr = (void*)((long)value); } return o; }
|
优势:
#
4.5 编码转换
SET num "12345" → INT编码 APPEND num "abc" → 转换为RAW编码(因为不再是纯数字)
SET str "short" → EMBSTR编码(<=44字节) APPEND str "..." → 如果超过44字节,转换为RAW编码
|
五、String命令的实现
#
5.1 SET命令
void setCommand(client *c) { robj *expire = NULL; int unit = UNIT_SECONDS; setKey(c->db, c->argv[1], c->argv[2]); if (expire) setExpire(c, c->db, c->argv[1], milliseconds); addReply(c, ok ? shared.ok : shared.err); }
|
#
5.2 INCR命令
void incrCommand(client *c) { robj *o = lookupKeyWrite(c->db, c->argv[1]); if (o && o->encoding == OBJ_ENCODING_RAW) { } if (o && o->encoding == OBJ_ENCODING_INT) { long value = (long)o->ptr; value++; o->ptr = (void*)value; } }
|
#
5.3 APPEND命令
void appendCommand(client *c) { robj *o = lookupKeyWrite(c->db, c->argv[1]); if (o == NULL) { c->argv[2] = tryObjectEncoding(c->argv[2]); dbAdd(c->db, c->argv[1], c->argv[2]); } else { if (o->encoding == OBJ_ENCODING_INT) { o = createStringObjectFromLongLong((long)o->ptr); } sds s = o->ptr; s = sdscatsds(s, c->argv[2]->ptr); o->ptr = s; } }
|
六、SDS vs C字符串对比
| 特性 |
C字符串 |
SDS |
| 获取长度 |
O(n) |
O(1) |
| 安全性 |
不安全(可能溢出) |
安全(自动扩容) |
| 内存分配 |
每次修改可能分配 |
预分配减少次数 |
| 二进制安全 |
否(遇到\0结束) |
是(按len读取) |
| 内存使用 |
仅数据+\0 |
头部+数据+\0 |
| 适用函数 |
C字符串函数 |
部分兼容 |
七、实践建议
#
7.1 控制String大小
#
7.2 利用INT编码
redis.set("counter:10001", "0"); redis.incr("counter:10001");
|
#
7.3 避免频繁APPEND
for (String item : items) { redis.append("log", item); }
StringBuilder sb = new StringBuilder(); for (String item : items) { sb.append(item); } redis.set("log", sb.toString());
|
八、总结
SDS是Redis最核心的数据结构之一:
| 设计特点 |
解决的问题 |
| len字段 |
O(1)获取长度 |
| alloc字段 |
防止缓冲区溢出 |
| 空间预分配 |
减少内存分配次数 |
| 惰性释放 |
优化缩短操作性能 |
| 二进制安全 |
支持任意二进制数据 |
| 多种头部 |
节省小字符串内存 |
理解SDS的设计思想:
- 用空间换时间(len字段)
- 预分配减少系统调用
- 惰性策略减少不必要的操作
- 针对不同场景优化编码
这些设计思想不仅适用于Redis,也是高性能系统设计的通用原则。
核心要点
String:简单的键值对,适合缓存、计数器
Hash:存储对象属性,适合用户信息、配置
List:有序列表,适合消息队列、最新列表
Set:无序去重,适合共同好友、抽奖
ZSet:有序集合,适合排行榜、积分系统
总结
选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。