listpack

listpack 源码分析(紧凑列表,取代 ziplist)

listpack 是一块连续内存里顺序摆放多个元素的序列化结构,没有结构体、没有指针,Redis 7 起全面取代 ziplist,作为 Hash/ZSet/List/Stream 在「元素少且短」时的底层编码。
它对 ziplist 的核心修正只有一处:每个 entry 尾部记「自己的长度」(backlen)而非「前驱的长度」(prevlen),一条 entry 变长绝不牵连邻居,从根上消除了 ziplist 最坏 O(N^2) 的连锁更新。

整体布局:<tot-bytes:4B> <num-elements:2B> <entry>...<entry> <0xFF>,头部固定 6 字节,尾部 1 字节 EOF。
单个 entry 布局:<encoding> <data> <backlen:1~5B>,其中 backlen 记录 encoding+data 的总字节数。

布局与编码常量

代码块C · 39 行收起展开
// 基于本地 Redis 仓 (unstable), src/listpack.c
#define LP_HDR_SIZE 6       /* 32 bit total len + 16 bit number of elements. */ // 头部比 ziplist 的 11 字节还小:没有 tail 偏移量,找尾靠 EOF 前的 backlen 反推
#define LP_HDR_NUMELE_UNKNOWN UINT16_MAX    // 元素数字段只有 2 字节,计数涨到 65535 即变 UNKNOWN,lpLength 退化为全扫
#define LP_MAX_INT_ENCODING_LEN 9
#define LP_MAX_BACKLEN_SIZE 5
// ...
#define LP_ENCODING_7BIT_UINT 0             // 首字节 0xxxxxxx:0~127 小整数,数值直接就是 encoding 字节,data 长度为 0
// ...
#define LP_ENCODING_6BIT_STR 0x80           // 10xxxxxx:长度 <64 的短串,长度塞进首字节低 6 位,只花 1 字节头
// ...
#define LP_ENCODING_13BIT_INT 0xC0          // 110xxxxx + 1B:13 位整数
// ...
#define LP_ENCODING_12BIT_STR 0xE0          // 1110xxxx + 1B:长度 <4096 的串
// ...
#define LP_ENCODING_16BIT_INT 0xF1          // 0xF1~0xF4:16/24/32/64 位整数,按数值大小逐级升格
#define LP_ENCODING_24BIT_INT 0xF2
#define LP_ENCODING_32BIT_INT 0xF3
#define LP_ENCODING_64BIT_INT 0xF4
// ...
#define LP_ENCODING_32BIT_STR 0xF0          // 0xF0 + 4B 长度:大字符串兜底
// ...
#define LP_EOF 0xFF                         // 结束哨兵,正向遍历撞到 0xFF 即结束

#define lpGetTotalBytes(p)           (((uint32_t)(p)[0]<<0) | \
                                      ((uint32_t)(p)[1]<<8) | \
                                      ((uint32_t)(p)[2]<<16) | \
                                      ((uint32_t)(p)[3]<<24))   // 手工按小端拼字节:不依赖机器字节序,这块内存可以原样进 RDB/复制流
#define lpGetNumElements(p)          (((uint32_t)(p)[4]<<0) | \
                                      ((uint32_t)(p)[5]<<8))
// ...

unsigned char *lpNew(size_t capacity) {
    unsigned char *lp = lp_malloc(capacity > LP_HDR_SIZE+1 ? capacity : LP_HDR_SIZE+1);
    if (lp == NULL) return NULL;
    lpSetTotalBytes(lp,LP_HDR_SIZE+1);
    lpSetNumElements(lp,0);
    lp[LP_HDR_SIZE] = LP_EOF;
    return lp;      // 空 listpack 一共 7 字节;对比 Java 一个空 HashMap 光对象头+table 引用就不止这个数
}

元素编码与 backlen

写入前先「试算」:元素能不能编成整数、编码后多少字节,全部算清了才动内存。

代码块C · 71 行收起展开
// 基于本地 Redis 仓 (unstable), src/listpack.c
static inline int lpEncodeGetType(unsigned char *ele, uint32_t size, unsigned char *intenc, uint64_t *enclen) {
    int64_t v;
    if (lpStringToInt64((const char*)ele, size, &v)) {  // 严格解析:前导零/空格都不算数字,保证整数<->字符串能无损往返
        lpEncodeIntegerGetType(v, intenc, enclen);
        return LP_ENCODING_INT;                         // 内容是 "123" 的字符串会被自动降成整数编码存
    } else {
        if (size < 64) *enclen = 1+size;
        else if (size < 4096) *enclen = 2+size;
        else *enclen = 5+(uint64_t)size;
        return LP_ENCODING_STRING;
    }
}

static inline void lpEncodeIntegerGetType(int64_t v, unsigned char *intenc, uint64_t *enclen) {
    if (v >= 0 && v <= 127) {
        /* Single byte 0-127 integer. */
        if (intenc != NULL) intenc[0] = v;
        if (enclen != NULL) *enclen = 1;                // 整条 entry 数据部分就 1 字节
    } else if (v >= -4096 && v <= 4095) {
        /* 13 bit integer. */
        if (v < 0) v = ((int64_t)1<<13)+v;              // 负数按 2^13 折叠成无符号存,解码端超过一半再减回去
        if (intenc != NULL) {
            intenc[0] = (v>>8)|LP_ENCODING_13BIT_INT;
            intenc[1] = v&0xff;
        }
        if (enclen != NULL) *enclen = 2;
    } else if (v >= -32768 && v <= 32767) {
        // ... 16/24/32 位分支同一套路,按数值范围逐级升格
    } else {
        /* 64 bit integer. */
        uint64_t uv = v;
        if (intenc != NULL) {
            intenc[0] = LP_ENCODING_64BIT_INT;
            intenc[1] = uv&0xff;
            // ... 小端逐字节写满 8 字节
        }
        if (enclen != NULL) *enclen = 9;
    }
}

static inline unsigned long lpEncodeBacklen(unsigned char *buf, uint64_t l) {
    if (l <= 127) {
        if (buf) buf[0] = l;                // 最高位 0 表示「到头了」,绝大多数小 entry 一字节搞定
        return 1;
    } else if (l < 16383) {                 // 真源就是 <而非 <=:16383 本可用 2 字节却走 3 字节,无害 off-by-one,因为 lpEncodeBacklenBytes 用同一套条件,两端自洽
        if (buf) {
            buf[0] = l>>7;                  // 高位字节在左、不带标志位
            buf[1] = (l&127)|128;           // 低位字节在右、置 128 位表示「左边还有」——这套标志位方向是为从右往左读设计的
        }
        return 2;
    } else if (l < 2097151) {
        // ... 3/4/5 字节分支同理,每字节 7 位有效载荷
    }
}

static inline uint64_t lpDecodeBacklen(unsigned char *p) {
    uint64_t val;

    /* Fast path: single byte (most common for small entries <= 127 bytes) */
    if (likely(!(p[0] & 128))) {            // p 指向 entry 的最后一个字节,从右往左解析
        return p[0] & 127;
    }

    /* Two bytes */
    val = (uint64_t)(p[0] & 127);
    if (!(p[-1] & 128)) {
        return val | ((uint64_t)(p[-1] & 127) << 7);
    }
    // ... 三/四/五字节继续向左取;超过 5 字节说明数据已损坏,返回 UINT64_MAX
}

双向遍历

正向靠 encoding 自描述长度,反向靠 backlen,两个方向都不需要额外索引。

代码块C · 45 行收起展开
// 基于本地 Redis 仓 (unstable), src/listpack.c
static inline uint32_t lpCurrentEncodedSizeUnsafe(unsigned char *p) {
    if (LP_ENCODING_IS_7BIT_UINT(p[0])) return 1;   // 看首字节就能算出整条 entry(不含 backlen)的长度
    if (LP_ENCODING_IS_6BIT_STR(p[0])) return 1+LP_ENCODING_6BIT_STR_LEN(p);
    // ...
    if (p[0] == LP_EOF) return 1;
    return 0;
}

static inline unsigned char *lpSkip(unsigned char *p) {
    unsigned long entrylen = lpCurrentEncodedSizeUnsafe(p);
    entrylen += lpEncodeBacklenBytes(entrylen);     // backlen 自身占几字节不用存:由 entrylen 反推,编解码两端算出来必然一致
    p += entrylen;
    return p;
}

unsigned char *lpNext(unsigned char *lp, unsigned char *p) {
    assert(p);
    p = lpSkip(p);
    if (p[0] == LP_EOF) return NULL;
    lpAssertValidEntry(lp, lpBytes(lp), p);         // 每步都校验不越界:listpack 可能来自 RDB/主从流,不能信任内容
    return p;
}

unsigned char *lpPrev(unsigned char *lp, unsigned char *p) {
    assert(p);
    if (p-lp == LP_HDR_SIZE) return NULL;           // 已是第一个元素
    p--; /* Seek the first backlen byte of the last element. */     // 退一步正好落在前一条 entry 的 backlen 末字节上
    uint64_t prevlen = lpDecodeBacklen(p);
    prevlen += lpEncodeBacklenBytes(prevlen);
    p -= prevlen-1; /* Seek the first byte of the previous entry. */
    lpAssertValidEntry(lp, lpBytes(lp), p);
    return p;
}

unsigned char *lpLast(unsigned char *lp) {
    unsigned char *p = lp+lpGetTotalBytes(lp)-1; /* Seek EOF element. */    // 头部没存 tail 偏移,跳到 EOF 再 lpPrev 一步到位
    return lpPrev(lp,p); /* Will return NULL if EOF is the only element. */
}

unsigned long lpLength(unsigned char *lp) {
    uint32_t numele = lpGetNumElements(lp);
    if (numele != LP_HDR_NUMELE_UNKNOWN) return numele;     // 计数器没溢出就是 O(1)
    // ... 超过 65535 时全扫计数;扫完若数量回落到范围内,顺手把缓存写回头部
}

增删改统一入口 lpInsert

插入、替换、删除三种操作共用这一个函数:删除被建模为「用零长元素替换」,LP_AFTER 被归一成「在下一条之前 LP_BEFORE」,主体只处理两种情况。

代码块C · 102 行收起展开
// 基于本地 Redis 仓 (unstable), src/listpack.c
unsigned char *lpInsert(unsigned char *lp, unsigned char *elestr, unsigned char *eleint,
                        uint32_t size, unsigned char *p, int where, unsigned char **newp)
{
    unsigned char intenc[LP_MAX_INT_ENCODING_LEN];
    unsigned char backlen[LP_MAX_BACKLEN_SIZE];

    uint64_t enclen; /* The length of the encoded element. */
    int delete = (elestr == NULL && eleint == NULL);    // 两个元素指针都不传 = 删除

    if (delete) where = LP_REPLACE;                     // 删除 = 用零长元素替换,操作归一

    if (where == LP_AFTER) {
        p = lpSkip(p);                                  // AFTER = 在下一条(可能是 EOF)之前插入,再归一
        where = LP_BEFORE;
        ASSERT_INTEGRITY(lp, p);
    }

    /* Store the offset of the element 'p', so that we can obtain its
     * address again after a reallocation. */
    unsigned long poff = p-lp;                          // realloc 可能把整块内存搬家,裸指针会悬空,必须换算成偏移量

    int enctype;
    if (elestr) {
        enctype = lpEncodeGetType(elestr,size,intenc,&enclen);  // 试算阶段:只求类型和长度,暂不写入
        if (enctype == LP_ENCODING_INT) eleint = intenc;
    } else if (eleint) {
        enctype = LP_ENCODING_INT;
        enclen = size; /* 'size' is the length of the encoded integer element. */
    } else {
        enctype = -1;
        enclen = 0;
    }

    unsigned long backlen_size = (!delete) ? lpEncodeBacklen(backlen,enclen) : 0;
    uint64_t old_listpack_bytes = lpGetTotalBytes(lp);
    uint32_t replaced_len  = 0;
    if (where == LP_REPLACE) {
        replaced_len = lpCurrentEncodedSizeUnsafe(p);   // 被替换(或被删)的旧 entry 总长
        replaced_len += lpEncodeBacklenBytes(replaced_len);
        ASSERT_INTEGRITY_LEN(lp, p, replaced_len);
    }

    uint64_t new_listpack_bytes = old_listpack_bytes + enclen + backlen_size
                                  - replaced_len;
    if (new_listpack_bytes > UINT32_MAX) return NULL;   // 头部 tot-bytes 只有 4 字节;实际上调用方还有 lpSafeToAdd 的 1GB 安全线

    unsigned char *dst = lp + poff; /* May be updated after reallocation. */

    /* Realloc before: we need more room. */
    if (new_listpack_bytes > old_listpack_bytes &&
        new_listpack_bytes > lp_malloc_size(lp)) {      // 问分配器要真实容量,jemalloc 的富余空间够用就省一次 realloc
        if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
        dst = lp + poff;
    }

    if (where == LP_BEFORE) {
        memmove(dst+enclen+backlen_size,dst,old_listpack_bytes-poff);   // 插入点之后整体右移腾位,O(N) 成本就在这一行
    } else { /* LP_REPLACE. */
        memmove(dst+enclen+backlen_size,
                dst+replaced_len,
                old_listpack_bytes-poff-replaced_len);  // 按新旧长度差挪动;删除时 enclen=0,等于把后半段拉回来盖掉旧 entry
    }

    /* Realloc after: we need to free space. */
    if (new_listpack_bytes < old_listpack_bytes) {      // 缩容必须放在 memmove 之后,先缩会把还没挪走的尾部数据截掉
        if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
        dst = lp + poff;
    }

    /* Store the entry. */
    if (newp) {
        *newp = dst;
        if (delete && dst[0] == LP_EOF) *newp = NULL;
    }
    if (!delete) {
        if (enctype == LP_ENCODING_INT) {
            memcpy(dst,eleint,enclen);
        } else if (elestr) {
            lpEncodeString(dst,elestr,size);
        } else {
            redis_unreachable();
        }
        dst += enclen;
        memcpy(dst,backlen,backlen_size);   // 只写这一条自己的 backlen,前后邻居完全不动——ziplist 的连锁更新在这里被终结
        dst += backlen_size;
    }

    /* Update header. */
    if (where != LP_REPLACE || delete) {
        uint32_t num_elements = lpGetNumElements(lp);
        if (num_elements != LP_HDR_NUMELE_UNKNOWN) {    // 计数器已溢出成 UNKNOWN 就不再维护,反正查长度要全扫
            if (!delete)
                lpSetNumElements(lp,num_elements+1);
            else
                lpSetNumElements(lp,num_elements-1);
        }
    }
    lpSetTotalBytes(lp,new_listpack_bytes);
    // ... (#if 0 包裹的调试代码略:强制每次修改都换新地址,用来揪出忘记更新 lp 指针的调用方)
    return lp;
}

原理串讲

以小 Hash 上一次 HSET 的落点为例。t_hash 层确认还在 listpack 编码后,经 lpAppend 追加 field 和 value:lpAppendlpGetTotalBytes(lp)-1 直接定位到 EOF 字节,然后调 lpInsert(lp, ele, NULL, size, eofptr, LP_BEFORE, NULL)——「追加」就是「插在 EOF 之前」,不需要任何特殊分支。
进入 lpInsert 后第一步是 lpEncodeGetType:先用 lpStringToInt64 严格解析,“1024” 这样的字符串会被编成 13 位整数只占 2 字节,而不是按字符串编码的 1+4=5 字节;解析失败才按 6/12/32 位长度前缀的字符串编码。
这一步只算不写,拿到 enclen 后再用 lpEncodeBacklen 算出 backlen 占几字节,于是新 entry 的总尺寸在动内存之前就完全确定了。
为什么坚持「先试算后落笔」?因为 listpack 是一整块连续内存,扩容(realloc)和腾位(memmove)都是重操作,只有预先知道精确尺寸,才能保证每次插入至多一次 realloc、恰好一次 memmove,而不是边写边发现不够再挪第二次。

接下来是连续内存最容易踩的坑:lp_realloc 可能把整块内存搬到新地址,所以插入点指针 p 在 realloc 前先换算成偏移量 poff 保存,realloc 后用 lp + poff 重建。
扩容与缩容的时机也刻意不对称——变大时「先 realloc 再 memmove」(不然没地方挪),变小时「先 memmove 再 realloc」(不然尾部数据先被截掉)。
腾好位后依次写入 encoding+data(整数走 memcpy 预编码好的 intenc,字符串走 lpEncodeString)和这一条自己的 backlen,最后更新头部计数与总字节数。

为什么 backlen 记「自己的长度」就能消除连锁更新?对比 ziplist:它的每个 entry 头部存前驱长度 prevlen,前驱从小于 254 字节涨到不小于 254 字节时,本 entry 的 prevlen 字段要从 1 字节扩成 5 字节,本 entry 因此变长,又可能触发下一个 entry 扩 prevlen…一次插入最坏引发整条链的重写。
listpack 里每条 entry 只描述自己,任何一条的膨胀收缩都被 lpInsert 的一次 memmove 吸收(整体平移不改变各 entry 内容),编码层面互不耦合。

那把长度放在 entry 尾部,反向遍历怎么知道 backlen 本身占几个字节?看 lpPrev:p-- 落在前一条 entry 的最后一个字节上,lpDecodeBacklen 从这个字节开始向左逐字节取 7 位载荷,靠每字节的最高位判断「左边还有没有」——lpEncodeBacklen 写入时故意让最左(最高位)字节不带 128 标志、其余字节都带,这个方向和常规 varint 相反,正是为了让解析器能从右往左走。
拿到 entry 数据长度 prevlen 后,再用 lpEncodeBacklenBytes(prevlen) 反推 backlen 自身的字节数——因为长度到字节数的映射是确定函数,编码端与解码端算出的结果必然一致,这个「自身长度」就无需存储。
正向遍历则完全不碰 backlen:lpSkip 读首字节 encoding 即知 entry 数据多长,同样反推出 backlen 字节数后跨过去。
一份数据、零冗余索引,支撑了双向 O(1) 步进。

设计取舍

  • 连续内存 + 无指针:内存省到极致且缓存友好,代价是定位第 N 个元素 O(N)、增删要 memmove。
    所以只在小集合用,由 hash-max-listpack-entries(默认 128)、hash-max-listpack-value(默认 64 字节)这类阈值把关,超限即转 hashtable/skiplist,且这个转换是单向的。
  • num-elements 只给 2 字节看似抠门,实则自洽:listpack 本来就只服务小集合,65535 的上限在正常配置下永远碰不到;真溢出就置 LP_HDR_NUMELE_UNKNOWN 退化为全扫。
  • 数字型字符串自动整数化(lpEncodeGetType 里的 lpStringToInt64)是纯存储优化,读取时 lpGet 可用 intbuf 还原成字符串,上层无感知。
  • List 数据大了也没有完全放弃 listpack:quicklist 把多个 listpack 用双向链表串起来,单节点内紧凑、节点间用指针,是「省内存」和「改动局部化」的折中。
  • ziplist 头部有 tail 偏移字段(11 字节头),listpack 砍掉它(6 字节头),lpLast 改为「跳到 EOF、lpPrev 一步回退」——反正 backlen 机制让反向步进本来就是 O(1),专门存 tail 偏移是冗余。

延伸阅读