蘭儿 發表於 2025-9-24 16:17:00

Redis数据结构的最佳实践

<p>本文已收录在Github,<strong>关注我,紧跟本系列专栏文章,咱们下篇再续!</strong></p>
<ul>
<li>🚀 魔都架构师 | 全网30W技术追随者</li>
<li>🔧 大厂分布式系统/数据中台实战专家</li>
<li>🏆 主导交易系统百万级流量调优 &amp; 车联网平台架构</li>
<li>🧠 AIGC应用开发先行者 | 区块链落地实践者</li>
<li>🌍 以技术驱动创新,我们的征途是改变世界!</li>
<li>👉 实战干货:编程严选网</li>
</ul>
<h2 id="0-概述">0 概述</h2>
<h3 id="数据结构和内部编码">数据结构和内部编码</h3>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161728927-1227425728.png" class="lazyload"></p>
<h3 id="无传统关系型数据库的-table-模型">无传统关系型数据库的 Table 模型</h3>
<p>schema所对应的db仅以编号区分。同一 db 内,key 作为顶层模型,它的值是扁平化的。即 db 就是key的命名空间。</p>
<p>key的定义通常以 <code>:</code> 分隔,如:<code>Article:Count:1</code></p>
<p>常用的Redis数据类型有:string、list、set、map、sorted-set</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161729667-1189379065.png" class="lazyload"></p>
<h3 id="redisobject通用结构">redisObject通用结构</h3>
<p>Redis中的所有value 都是以object 的形式存在的,其通用结构:</p>
<pre><code class="language-c">typedef struct redisObject {
        // type数据类型:指 string、list 等类型
    unsigned type:4;

    // 编码方式:这些结构化类型具体实现方式,同一类型可有多种实现。如string可用int实现,也可用char[];list可用ziplist或链表实现
    unsigned encoding:4;

    /**
   * 对象最后一次被访问的时间
   * 本对象的空转时长,用于有限内存下长时间不访问的对象清理
   * 记录LRU信息,LRU_BITS=24 bits
   * LRU time(LRU时钟值) (relative to global lru_clock,相对于全局LRU时钟) or
   * LFU data (least significant 8 bits frequency
   * and most significant 16 bits access time).
   */
    unsigned lru:LRU_BITS;
               
        // 对象引用计数,用于GC
    int refcount;

    // 指向实际值的指针 数据指针:指向以 encoding 方式实现这个对象实际实现者的地址。如:string 对象对应的SDS地址(string的数据结构/简单动态字符串)
    void *ptr;
} robj;
</code></pre>
<h3 id="单线程">单线程</h3>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161730137-303587619.png" class="lazyload"></p>
<h3 id="单线程为何这么快">单线程为何这么快?</h3>
<ul>
<li>纯内存</li>
<li>非阻塞I/O</li>
<li>避免线程切换和竞态消耗</li>
</ul>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161730582-1185977963.png" class="lazyload"></p>
<ul>
<li>一次只运行一条命令</li>
<li>拒绝长(慢)命令:keys, flushall, flushdb, slow lua script, mutil/exec, operate big value(collection)</li>
<li>其实不是单线程<br>
fysnc file descriptor<br>
close file descriptor</li>
</ul>
<h2 id="1-string">1 string</h2>
<p>Redis中的 string 可表示很多语义</p>
<ul>
<li>字节串(bits)</li>
<li>整数</li>
<li>浮点数</li>
</ul>
<p>redis会按具体场景完成自动转换,并按需选取底层的实现方式。如整数可由32-bit/64-bit、有符号/无符号承载,以适应不同场景对值域的要求。</p>
<p>字符串键值结构,也能是JSON串或XML结构</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161731083-394557078.png" class="lazyload"></p>
<h3 id="内存结构">内存结构</h3>
<p>在Redis内部,string的内部以 int、SDS(简单动态字符串 simple dynamic string)作为存储结构</p>
<ul>
<li>int 用来存放整型</li>
<li>SDS 用来存放字节/字符和浮点型SDS结构</li>
</ul>
<h3 id="sds">SDS</h3>
<pre><code class="language-c">typedef struct sdshdr {
    // buf中已经占用的字符长度
    unsigned int len;
    // buf中剩余可用的字符长度
    unsigned int free;
    // 数据空间
    char buf[];
}
</code></pre>
<p>结构图:</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161731545-1737769400.png" class="lazyload"></p>
<p>存储内容为“Redis”,Redis用类似C语言存储方法,用'\0'结尾(仅是定界符)。<br>
SDS的free 空间大小为0,当free &gt; 0时,buf中的free 区域的引入提升了SDS对字符串的处理性能,可减少处理过程中的内存申请和释放次数。</p>
<h3 id="buf的扩缩容">buf的扩缩容</h3>
<p>当对SDS 进行操作时,若超出容量。SDS会对其进行扩容,触发条件如下:</p>
<ul>
<li>字节串初始化时,buf的大小 = len + 1,即加上定界符'\0'刚好用完所有空间</li>
<li>当对串的操作后小于1M时,扩容后的buf 大小 = 业务串预期长度 * 2 + 1,也就是扩大2倍。</li>
<li>对于大小 &gt; 1M的长串,buf总是留出 1M的 free空间,即2倍扩容,但是free最大为 1M。</li>
</ul>
<h3 id="字节串与字符串">字节串与字符串</h3>
<p>SDS中存储的内容可以是ASCII 字符串,也可以是字节串。由于SDS通过len 字段来确定业务串的长度,因此业务串可以存储非文本内容。对于字符串的场景,buf 作为业务串结尾的'\0' 又可以复用C的已有字符串函数。</p>
<h3 id="sds编码的优化">SDS编码的优化</h3>
<p>value在内存中有2个部分:redisObject和ptr指向的字节串部分。创建时,通常要分别为2个部分申请内存,但是对于小字节串,可一次性申请。</p>
<p>incr userid:pageview(单线程:无竞争)。缓存视频的基本信息(数据源在MySQL)</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161732034-1468676815.png" class="lazyload"></p>
<pre><code class="language-java">public VideoInfo get(Long id) {
        String redisKey = redisPrefix + id;
        VideoInfo videoInfo e redis.get(redisKey);
        if (videoInfo == null) {
                videoInfo = mysql.get(id);
                if (videoInfo != null) {
                        // 序列化
                        redis.set(redisKey serialize(videoInfo)):
                }
        }
}                       
</code></pre>
<h3 id="实现分布式id生成器">实现分布式id生成器</h3>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161732512-1102146741.png" class="lazyload"></p>
<h4 id="incr-id-原子操作">incr id (原子操作)</h4>
<h4 id="set-setnx-setxx">set setnx setxx</h4>
<ul>
<li>set key value    #不管key是否存在,都设置 o(1)</li>
<li>setnx key value   #key不存在,才设置 o(1)</li>
<li>set key value xx   #key存在,才设置 o(1)</li>
</ul>
<h4 id="mget-mset">mget mset</h4>
<p>mget key1 key2 key3   #批量获取key,原子操作o(n)<br>
mset key1 valuel key2 value2 key3 value3#批量设置key-valueo(n)</p>
<h5 id="n次get">n次get</h5>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161732992-116748289.png" class="lazyload"></p>
<p>n次get = n次网络时间 + n次命令时间</p>
<h5 id="1次mget">1次mget</h5>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161733462-1974477895.png" class="lazyload"></p>
<p>1次mget = 1次网络时间 + n次命令时间</p>
<h4 id="getsetappendstrlen">getset、append、strlen</h4>
<p>getset key newvalue   #set key newvalue 并返回旧的valueO(1)<br>
append key value #将value追加到旧的valueO(1)<br>
strlen key #返回字符串的长度(注意中文)O(1)</p>
<h4 id="incrbyfloat-getrange-setrange">incrbyfloat getrange setrange</h4>
<pre><code class="language-bash">#增加key对应的值3.5o(1)
incrbyfloat key 3.5

#获取字符串指定下标所有的值o(1)
getrange key start end

#设置指定下标所有对应的值o(1)
setrange key index value
</code></pre>
<h3 id="字符串总结">字符串总结</h3>
<table>
<thead>
<tr>
<th>命令</th>
<th>含义</th>
<th>复杂度</th>
</tr>
</thead>
<tbody>
<tr>
<td>set key value</td>
<td>设置key-value</td>
<td>O(1)</td>
</tr>
<tr>
<td>get key</td>
<td>获取key-value</td>
<td>O(1)</td>
</tr>
<tr>
<td>del key</td>
<td>删除key-value</td>
<td>O(1)</td>
</tr>
<tr>
<td>setnx setxx</td>
<td>根据key是否存在设置key-value</td>
<td>O(1)</td>
</tr>
<tr>
<td>Incr decr</td>
<td>计数</td>
<td>O(1)</td>
</tr>
<tr>
<td>mget mset</td>
<td>批量操作key-value</td>
<td>O(n)</td>
</tr>
</tbody>
</table>
<h3 id="string类型的value基本操作">String类型的value基本操作</h3>
<p>针对数字类型的操作:</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>inc</td>
<td>将指定key内容+1</td>
</tr>
<tr>
<td>decr</td>
<td>将指定key内容-1</td>
</tr>
<tr>
<td>incrby</td>
<td>将指定key的内容增加指定值</td>
</tr>
<tr>
<td>decrby</td>
<td>将指定key的内容减少指定值</td>
</tr>
<tr>
<td>incrbyfloat</td>
<td>将指定key的内容减少指定的浮点值</td>
</tr>
</tbody>
</table>
<p>针对字符串类型的操作:</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>append</td>
<td>将指定字符串添加到key的value后</td>
</tr>
<tr>
<td>getrange</td>
<td>对字符串value做范围截取</td>
</tr>
<tr>
<td>setrange</td>
<td>对字符串的指定start和end 做覆盖操作</td>
</tr>
<tr>
<td>strlen</td>
<td>获取字符串value的长度</td>
</tr>
<tr>
<td>getbit</td>
<td>对字符串value,获取指定偏移量上的bit(位)</td>
</tr>
<tr>
<td>setbit</td>
<td>将字符串value视为bit,并设置给定起始位置设置值</td>
</tr>
<tr>
<td>bitcount</td>
<td>将字符串value视为bit,并统计1的数量</td>
</tr>
<tr>
<td>bitop</td>
<td>对多个key的value做位运算,如:XOR、OR、AND、NOT</td>
</tr>
</tbody>
</table>
<p>string类型value还有一些CAS原子操作,如:get、set、set value nx(如果不存在就设置)、set value xx(若存在就设置)。</p>
<p>String类型是二进制安全的,即在Redis中String类型可包含各种数据,比如一张JPEG图片或者是一个序列化的Ruby对象。一个String类型的值最大长度512M。</p>
<h3 id="场景">场景</h3>
<ul>
<li>原子计数器,使用INCR家族命令:, , </li>
<li>使用命令给一个String追加内容</li>
<li>做一个随机访问的向量(Vector),使用和 </li>
<li>使用 和方法,在一个很小的空间中编码大量的数据或创建一个基于Redis的Bloom Filter 算法</li>
</ul>
<h2 id="2-list">2 List</h2>
<p>可从头部(左侧)加入元素,也可以从尾部(右侧)加入元素。有序列表。</p>
<p>可通过lrange命令,即从某元素开始读取多少元素,可基于list实现分页查询,这就是基于redis实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西,性能高,就一页一页走。</p>
<p>搞个简单的消息队列,从list头推进去,从list尾拉出来。</p>
<p>List类型中存储一系列String值,这些String按照插入顺序排序。</p>
<h3 id="21-数据结构">2.1 数据结构</h3>
<p>List 类型的value对象,由 linkedlist 或 ziplist 实现,满足以下任一条件,会从 ziplist 升级为 linkedlist:</p>
<ul>
<li>元素个数 &gt; list-max-ziplist-entries(默认 512)</li>
<li>任一元素长度 &gt; list-max-ziplist-value 字节(默认 64 字节)</li>
</ul>
<h4 id="211-linkedlist实现">2.1.1 linkedlist实现</h4>
<p>双向链表:</p>
<pre><code class="language-c">typedef struct list {
// 头结点
listNode *head;
// 尾节点
listNode *tail;
// 节点值复制函数
void *(*dup)(void * ptr);
// 节点值释放函数
void *(*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
// 链表长度
unsigned long len;
} list;

// Node节点结构
typedef struct listNode {
        struct listNode *prev;
        struct listNode *next;
        void *value;
} listNode;
</code></pre>
<p>linkedlist结构图:</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161733961-741819413.png" class="lazyload"></p>
<h4 id="212-ziplist实现">2.1.2 ziplist实现</h4>
<p>存储在连续内存</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161734413-886527923.png" class="lazyload"></p>
<ul>
<li>zlbytes,ziplist 的总长度</li>
<li>zltail,指向最末元素</li>
<li>zllen,元素的个数</li>
<li>entry,元素内容</li>
<li>zlend,恒为0xFF,作为ziplist的定界符</li>
</ul>
<p>linkedlist和ziplist的rpush、rpop、llen的时间复杂度都是O(1):</p>
<ul>
<li>ziplist的lpush、lpop都会牵扯到所有数据的移动,时间复杂度为O(N)<br>
由于List的元素少,体积小,这种情况还是可控的。</li>
</ul>
<p>ziplist的Entry结构:</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161734836-727997846.png" class="lazyload"></p>
<p>记录前一个相邻的Entry的长度,便于双向遍历,类似linkedlist的prev指针。</p>
<p>ziplist是连续存储,指针由偏移量来承载。Redis中实现了2种方式:</p>
<ul>
<li>当前邻Entry的长度小于254时,prevlen 只用 1 字节存储长度</li>
<li>否则,prevlen 用 5 字节存储:第 1 个字节固定为 254(十六进制 FE),表示后面跟着一个 4 字节的长度。<br>
后面 4 个字节用小端序存储前一个 entry 的实际长度。</li>
</ul>
<blockquote>
<p>当前一个Entry长度变化时,可能导致后续的所有空间移动,虽然这种情况发生可能性较小。</p>
</blockquote>
<p>Entry内容本身是自描述的,意味着第二部分(Entry内容)包含了几个信息:Entry内容类型、长度和内容本身。而内容本身包含:类型长度部分和内容本身部分。类型和长度同样采用变长编码:</p>
<ul>
<li>
<p>00xxxxxx :string类型;长度小于64,0~63可由6位bit 表示,即xxxxxx表示长度</p>
</li>
<li>
<p>01xxxxxx|yyyyyyyy : string类型;长度范围是,可由14位 bit 表示,即xxxxxxyyyyyyyy这14位表示长度。</p>
</li>
<li>
<p>10xxxxxx|yy..y(32个y) : string类型,长度大于16383.</p>
</li>
<li>
<p>1111xxxx :integer类型,integer本身内容存储在xxxx 中,只能是1~13之间取值。也就是说内容类型已经包含了内容本身。</p>
</li>
<li>
<p>11xxxxxx :其余情况,Redis用1个字节的类型长度表示了integer的其他几种情况,如:int_32、int_24等。<br>
由此可见,ziplist 的元素结构采用的是可变长的压缩方法,针对于较小的整数/字符串的压缩效果较好</p>
</li>
<li>
<p>LPUSH:头部加入一个新元素</p>
</li>
<li>
<p>RPUSH:尾部加入一个新元素</p>
</li>
</ul>
<p>在一个空的K执行这些操作时,会创建一个新list。当一个操作清空了一个list时,该list对应的key会被删除。若使用一个不存在的K,就会使用一个空list。</p>
<pre><code class="language-bash">LPUSH mylist a&amp;nbsp;&amp;nbsp; # 现在list是 "a"
LPUSH mylist b&amp;nbsp;&amp;nbsp; # 现在list是"b","a"
RPUSH mylist c&amp;nbsp;&amp;nbsp; # 现在list是 "b","a","c" (注意这次使用的是 RPUSH)
</code></pre>
<p>list的最大长度是<code>2^32 – 1</code>个元素(4294967295,一个list中可以有多达40多亿个元素)。</p>
<p>从时间复杂度的角度来看,Redis list类型的最大特性是:即使是在list的头端或者尾端做百万次的插入和删除操作,也能保持稳定的很少的时间消耗。在list的两端访问元素是非常快的,但是如果要访问一个很大的list中的中间部分的元素就会比较慢了,时间复杂度是O(N)</p>
<h3 id="22-适用场景">2.2 适用场景</h3>
<p>粉丝列表</p>
<pre><code class="language-bash">key = 某大v

value =
</code></pre>
<ul>
<li>
<p>文章的评论列表</p>
</li>
<li>
<p>社交中作为时间表的建模,LPUSH在用户时间线中加入新元素,再用LRANGE获得最近加入的元素</p>
</li>
<li>
<p>可将LPUSH、LTRIM结合用来实现定长列表,列表中只保存最近N个元素</p>
</li>
<li>
<p>做MQ,依赖BLPOP这种阻塞命令</p>
</li>
</ul>
<h2 id="3-set">3 Set</h2>
<p>类似List,但无序且元素不重复。</p>
<p>向集合中添加多次相同的元素,集合中只存在一个该元素。实际应用中,意味着在添加一个元素前不需要先检查元素是否存在。</p>
<p>支持多个服务器端命令来从现有集合开始计算集合,所以执行集合的交集,并集,差集都很快。</p>
<p>set的最大长度是<code>2^32 – 1</code>个元素(一个set中可多达40多亿个元素)。</p>
<h3 id="31-数据结构">3.1 数据结构</h3>
<p>Set在Redis中以intset 或 hashtable存储:</p>
<ul>
<li>对于Set,HashTable的value永远为NULL</li>
<li>当Set中只包含整型数据时,采用intset作为实现</li>
</ul>
<h4 id="intset">intset</h4>
<p>核心元素是一个字节数组,从小到大有序的存放元素</p>
<pre><code class="language-c">typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;
</code></pre>
<p>结构图:</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161735264-931419026.png" class="lazyload"></p>
<p>因为元素有序排列,所以SET的获取操作采用二分查找,复杂度为O(log(N))。</p>
<p>进行插入操作时:</p>
<ul>
<li>首先通过二分查找到要插入位置</li>
<li>再对元素进行扩容</li>
<li>然后将插入位置之后的所有元素向后移动一个位置</li>
<li>最后插入元素</li>
</ul>
<p>时间复杂度为O(N)。为使二分查找的速度足够快,存储在content 中的元素是定长的。</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161735699-1097249906.png" class="lazyload"></p>
<p>插入2018时,所有元素向后移动,且不会发生覆盖。当Set中存放的整型元素集中在小整数范围[-128, 127]内时,可大大节省内存空间。</p>
<p>IntSet支持升级,不支持降级。</p>
<h3 id="32-api">3.2 API</h3>
<p>基本操作</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>命令</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>sadd</td>
<td><code>sadd key member </code></td>
<td>将一个或多个member放入集合</td>
</tr>
<tr>
<td>srem</td>
<td><code>srem key member </code></td>
<td>移除一个或多个member</td>
</tr>
<tr>
<td>sismember</td>
<td><code>sismember key member</code></td>
<td>检查member是否在集合中</td>
</tr>
<tr>
<td>scard</td>
<td><code>scard key</code></td>
<td>返回集合中元素的个数</td>
</tr>
<tr>
<td>smembers</td>
<td><code>smembers key</code></td>
<td>返回集合中所有的元素</td>
</tr>
<tr>
<td>srandmember</td>
<td><code>srandmember key </code></td>
<td>随机返回1个(默认)或多个成员</td>
</tr>
</tbody>
</table>
<p>复合操作</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>命令</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>sinter</td>
<td><code>sinter key </code></td>
<td>返回多个集合的交集</td>
</tr>
<tr>
<td>sunion</td>
<td><code>sunion key </code></td>
<td>返回多个集合的并集</td>
</tr>
<tr>
<td>sdiff</td>
<td><code>sdiff key </code></td>
<td>返回第一个集合与后面所有集合的差集</td>
</tr>
</tbody>
</table>
<p>复合存储</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>命令</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>sinterstore</td>
<td><code>sinterstore destination key </code></td>
<td>与sinter类似,不过将结果集存储放到destination集合中</td>
</tr>
<tr>
<td>sunionstore</td>
<td><code>sunionstore destination key </code></td>
<td>与sunion类似,将结果集存储放到destination中</td>
</tr>
<tr>
<td>sdiffstore</td>
<td><code>sdiffstore destination key </code></td>
<td>与sdiff类似,将结果集存储放到destination中</td>
</tr>
</tbody>
</table>
<h3 id="33-适用场景">3.3 适用场景</h3>
<p>无序集合,自动去重,数据太多时不推荐用。对数据快速全局去重,若系统集群部署,就需基于redis进行全局set去重。</p>
<p>可基于set玩交集、并集、差集操作,如交集:</p>
<ul>
<li>把两个人的粉丝列表整一个交集,看看俩人的共同好友</li>
<li>把两个大v的粉丝都放在两个set中,对两个set做交集</li>
</ul>
<p>全局这种计算开销也大。</p>
<ul>
<li>
<p>记录唯一的事物:如想知访问某博客的IP地址,不要重复的IP,这种情况只需要在每次处理一个请求时简单的使用SADD命令就可以了,可确保不会插入重复IP</p>
</li>
<li>
<p>表示关系:可用Redis创建一个标签系统,每个标签使用一个Set表示。然后可用SADD把具有特定标签的所有对象的所有ID放在表示这个标签的Set中。如想知道同时拥有三个不同标签的对象,那么使用SINTER</p>
</li>
<li>
<p>可使用或 命令从集合中随机的提取元素。</p>
</li>
</ul>
<h2 id="4-hash">4 Hash</h2>
<p>一般可将结构化的数据,如一个对象(前提是这个对象未嵌套其他对象)缓存在Redis。每次读写缓存时,直接操作hash里的某字段。</p>
<pre><code class="language-json">key=150

value={
"id": 150,
"name": "JavaEdge",
"age": 18
}
</code></pre>
<p>hash类的数据结构,主要存放一些对象,缓存一些简单对象,后续操作时,可直接仅修改该对象中的某个字段值。</p>
<pre><code class="language-c">value={
"id": 150,
"name": "JavaEdge",
"age": 25
}
</code></pre>
<p>因为Redis本身是一个KV存储结构,Hash结构可理解为subkey - subvalue<br>
这里面的subkey - subvalue只能是</p>
<ul>
<li>整型</li>
<li>浮点型</li>
<li>字符串</li>
</ul>
<p>因为Map的 value 可表示整型和浮点型,因此Map也可用<code> hincrby</code> 对某个field的value值做自增操作。</p>
<h3 id="51-内存数据结构">5.1 内存数据结构</h3>
<p>hash有HashTable 和 ziplist 两种实现。对于数据量较小的hash,使用ziplist 实现。</p>
<h4 id="511-hashtable实现">5.1.1 HashTable实现</h4>
<p>HashTable在Redis中分为3层,自底向上:</p>
<ul>
<li>dictEntry:管理一个field - value 对,保留同一桶中相邻元素的指针,以此维护Hash 桶中的内部链</li>
<li>dictht:维护Hash表的所有桶链</li>
<li>dict:当dictht需要扩容/缩容时,用户管理dictht的迁移</li>
</ul>
<p>dict是Hash表存储的顶层结构</p>
<pre><code class="language-c">// 哈希表(字典)数据结构,Redis 的所有键值对都会存储在这里。其中包含两个哈希表。
typedef struct dict {
    // 哈希表的类型,包括哈希函数,比较函数,键值的内存释放函数
    dictType *type;
    // 存储一些额外的数据
    void *privdata;
    // 两个哈希表
    dictht ht;
    // 哈希表重置下标,指定的是哈希数组的数组下标
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 绑定到哈希表的迭代器个数
    int iterators; /* number of iterators currently running */
} dict;
</code></pre>
<p>Hash表的核心结构是dictht,它的table 字段维护着 Hash 桶,桶(bucket)是一个数组,数组的元素指向桶中的第一个元素(dictEntry)。</p>
<pre><code class="language-c">typedef struct dictht {
    //槽位数组
    dictEntry **table;
    //槽位数组长度
    unsigned long size;
    //用于计算索引的掩码
    unsigned long sizemask;
    //真正存储的键值对数量
    unsigned long used;
} dictht;
</code></pre>
<p>结构图:</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161736179-1672590005.png" class="lazyload"></p>
<p>Hash表使用【链地址法】解决Hash冲突。当一个 bucket 中的 Entry 很多时,Hash表的插入性能会下降,此时就需要增加bucket的个数来减少Hash冲突。</p>
<h5 id="hash表扩容">Hash表扩容</h5>
<p>和大多数Hash表实现一样,Redis引入负载因子,判定是否需增加bucket个数:</p>
<pre><code class="language-bash">负载因子 = Hash表中已有元素 / bucket数量
</code></pre>
<p>扩容后,bucket 数量是原先2倍。目前有2 个阀值:</p>
<ul>
<li>
<p><1:一定不扩容</p>
</li>
<li>
<p>>5:一定扩容</p>
</li>
<li>
<p>1 ~ 5 之间:Redis 若未进行<code>bgsave/bdrewrite</code> 操作时则会扩容</p>
</li>
<li>
<p>当key - value 对减少时,低于0.1时会进行缩容。缩容之后,bucket的个数是原先的0.5倍</p>
</li>
</ul>
<h4 id="512-ziplist-实现">5.1.2 ziplist 实现</h4>
<p>和List#ziplist实现类似,都是通过Entry 存放元素。不同的是,Map#ziplist的Entry个数总是2的整数倍:</p>
<ul>
<li>第奇数个Entry存放key</li>
<li>下个相邻Entry存放value</li>
</ul>
<p>ziplist承载时,Map的大多数操作不再是O(1),而是由Hash表遍历,变成链表遍历,复杂度变为O(N)。<br>
由于Map相对较小时采用ziplist,采用Hash表时计算hash值的开销较大,因此综合起来ziplist性能相对好一些。</p>
<p>哈希键值结构</p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161736716-1058236204.png" class="lazyload"></p>
<p><img alt="" loading="lazy" src="https://img2024.cnblogs.com/other/1097393/202509/1097393-20250924161737212-523668607.png" class="lazyload"></p>
<h4 id="特点">特点</h4>
<ul>
<li>Map的map</li>
<li>Small redis</li>
<li>field不能相同,value可相同</li>
</ul>
<pre><code class="language-bash">hget key field O(1)
# 获取 hash key 对应的 field 的 value

hset key field value O(1)
#设置 hash key 对应的 field 的 value

hdel key field O(1)
# 删除 hash key 对应的 field 的 value
</code></pre>
<h3 id="52--实操">5.2实操</h3>
<pre><code class="language-bash">127.0.0.1:6379&gt; hset user:1:info age 23
(integer) 1
127.0.0.1:6379&gt; hget user:1:info age
"23"
127.0.0.1:6379&gt; hset user:1:info name JavaEdge
(integer) 1
127.0.0.1:6379&gt; hgetall user:1:info
1) "age"
2) "23"
3) "name"
4) "JavaEdge"
127.0.0.1:6379&gt; hdel user:1:info age
(integer) 1
127.0.0.1:6379&gt; hgetall user:1:info
1) "name"
2) "JavaEdge"
</code></pre>
<pre><code class="language-bash">hexists key field O(1)
# 判断hash key是否有field
hlen key O(1)
# 获取hash key field的数量
</code></pre>
<pre><code class="language-bash">127.0.0.1:6379&gt; hgetall user:1:info
1) "name"
2) "JavaEdge"
127.0.0.1:6379&gt; HEXISTS user:1:info name
(integer) 1
127.0.0.1:6379&gt; HLEN user:1:info
(integer) 1
</code></pre>
<pre><code class="language-bash">hmget key field1 field2... fieldN O(N)
# 批量获取 hash key 的一批 field 对应的值
hmset key field1 value1 field2 value2...fieldN valueN O(N)
# 批量设置 hash key的一批field value
</code></pre>
<pre><code class="language-java">redis-cli
127.0.0.1:6379&gt; hmset user:2:info age 30 name kaka page 50
OK

127.0.0.1:6379&gt; hlen user:2:info
(integer) 3

127.0.0.1:6379&gt; hmget user:2:info age name
1) "30"
2) "kaka"
</code></pre>
<h4 id="记录网站每个用户个人主页的访问量">记录网站每个用户个人主页的访问量</h4>
<pre><code class="language-bash">hincrby user:1:info pageview count
</code></pre>
<h4 id="缓存视频的基本信息数据源mysql伪代码">缓存视频的基本信息(数据源MySQL)伪代码</h4>
<pre><code class="language-java">public VideoInfo get(long id) {
String redisKey = redisPrefix + id;
Map&lt;String,String&gt; hashMap = redis.hgetAll(redisKey);
VideoInfo videoInfo = transferMap ToVideo(hashMap);
if (videoInfo == null) {
videoInfo = mysql.get(id);
    if (videoInfo != nul) {
            redis.hmset(redisKey transferVideo ToMap(videoInfol);
    }
}
return videoInfo;
}
</code></pre>
<pre><code class="language-bash">hgetall key O(N) 小心使用,牢记单线程!!!
返回 hash key 对应所有的 filed 和 value

hvals key O(N)
返回 hash key 对应所有的 filed 的 value

hkeys key O(N)
返回 hash key 对应所有 filed
</code></pre>
<p>用户信息,string实现:</p>
<pre><code class="language-java">Keyuser:1
value(serializable:json,xml,protobuf)

{
"id": 1,
"name": "JavaEdge",
"age": 18,
"pageView": 500000
}

set user:1 serialize(userinfo)
</code></pre>
<h3 id="53-适用场景">5.3 适用场景</h3>
<h4 id="用户信息">用户信息</h4>
<p>方便单条更新,但信息非整体,不便管理:</p>
<h4 id="用户信息string实现-v2">用户信息(string实现-v2)</h4>
<pre><code class="language-java">
41
set user:1:age 41

key               value
user:1:name       world
user:1:age      40
user:1:pageView   500000
</code></pre>
<p>用户信息(hash)</p>
<pre><code class="language-java">key            field   value

user:1:info →name      ronaldo
               age       40
               pageView500000



key                  field      value
                      ┌─────────────┬─────────────────┐
user:1:info ────────&gt; │ name      │ ronaldo         │
                      ├─────────────┼─────────────────┤
                      │ age         │ 41            │
                      ├─────────────┼─────────────────┤
                      │ pageView    │ 500000          │
                      ├─────────────┼─────────────────┤
                      │ link      │ tv.sohu.com   │
                      └─────────────┴─────────────────┘

hset user:1:info age 41
hset user:1:info link tv.sohu.com
</code></pre>
<p>3种方案比较:</p>
<table>
<thead>
<tr>
<th>命令</th>
<th>优点</th>
<th>缺点</th>
</tr>
</thead>
<tbody>
<tr>
<td>string v1</td>
<td>编程简单<br>可能节约内存</td>
<td>1. 序列化开销<br>2. 设置属性要操作整个数据</td>
</tr>
<tr>
<td>string v2</td>
<td>直观<br>可以部分更新</td>
<td>1. 内存占用较大<br>2. key较为分散</td>
</tr>
<tr>
<td>hash</td>
<td>直观<br>节省空间<br>可以部分更新</td>
<td>1. 编程稍微复杂<br>2. ttl不好控制</td>
</tr>
</tbody>
</table>
<h4 id="hsetnxhincrbyhincrbyfloat">hsetnx、hincrby、hincrbyfloat</h4>
<pre><code class="language-bash"># 设置hash key对应field的value(如field已经存在,则失败) o(1)
hsetnx key field value

# hash key对应的field的value自增intCounter o(1)
hincrby key field intCounter

#hincrby浮点数版 o(1)
hincrbyfloat key field floatCounter
</code></pre>
<h3 id="54-小结">5.4 小结</h3>
<table>
<thead>
<tr>
<th>命令</th>
<th>复杂度</th>
</tr>
</thead>
<tbody>
<tr>
<td>hget hset hdel</td>
<td>O(1)</td>
</tr>
<tr>
<td>hexists</td>
<td>O(1)</td>
</tr>
<tr>
<td>hincrby</td>
<td>O(1)</td>
</tr>
<tr>
<td>hgetall hvals hkeys</td>
<td>O(n)</td>
</tr>
<tr>
<td>hmget hmset</td>
<td>O(n)</td>
</tr>
</tbody>
</table>
<p>Redis Hashes 保存String域和String值之间的映射,所以它们是用来表示对象的绝佳数据类型(比如一个有着用户名,密码等属性的User对象)</p>
<pre><code>| `1` | `@cli` |

| `2` | `HMSET user:1000 username antirez password P1pp0 age 34` |

| `3` | `HGETALL user:1000` |

| `4` | `HSET user:1000 password 12345` |

| `5` | `HGETALL user:1000` |
</code></pre>
<p>一个有着少量数据域(这里的少量大概100上下)的hash,其存储方式占用很小的空间,所以在一个小的Redis实例中就可以存储上百万的这种对象</p>
<p>Hash的最大长度是2^32 – 1个域值对(4294967295,一个Hash中可以有多达40多亿个域值对)。</p>
<h2 id="5-sorted-set">5 Sorted Set</h2>
<h2 id="6-bitmaps">6 Bitmaps</h2>
<p>位图类型,String类型上的一组面向bit操作的集合。由于 strings是二进制安全的blob,并且它们的最大长度是512m,所以bitmaps能最大设置 2^32个不同的bit。</p>
<h2 id="7-hyperloglogs">7 HyperLogLogs</h2>
<p>pfadd/pfcount/pfmerge。</p>
<p>redis使用标准错误小于1%的估计度量结束。</p>
<p>无需与需要统计的项相对应的内存,而是使用的内存恒定不变。最坏只需12k,就可计算接近2^64个不同元素的基数。</p>
<h2 id="8-geo">8 GEO</h2>
<p>geoadd/geohash/geopos/geodist/georadius/georadiusbymember</p>
<p>Redis 3.2推出,可将用户给定的地理位置(经、纬度)信息储存起来并操作。</p>
<blockquote>
<p>本文由博客一文多发平台 OpenWrite 发布!</p>
</blockquote><br><br>
来源:https://www.cnblogs.com/JavaEdge/p/19109445
頁: [1]
查看完整版本: Redis数据结构的最佳实践