如何对上亿条数据做redis容量评估

2023-10-31 13:30

本文主要是介绍如何对上亿条数据做redis容量评估,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

一、背景

年终了,需要做个用户年度报告,类似支付宝那种年度账单,告诉你今年多少笔订单,花了多少钱等等。
从数据侧知悉,这次需要处理并记录的有约7亿用户,聚合逻辑比较复杂就不说了,总之最后需要把统计结果都写到redis,每个用户一条记录,hash存储,key是用户id,feild是各个指标,那么问题来了,需要申请多大容量的资源呢?

二、redis常用数据结构

做容量评估之前,有必要对redis常用数据结构有大概了解。

推荐阅读《Redis深度历险:核心原理和应用实践》

1、SDS

redis没有直接使用c语言传统的字符串(以空字符为结尾的字符数组),而是自己创建了一种名为SDS(简单动态字符串)的抽象类型,用作redis默认的字符串。

SDS的定义如下(sds.h/sdshdr):

struct sdshdr {int len;         // 记录buf数组中已使用字节的数量int free;        // 记录buf数组中未使用字节的数量char buf[];      // 字节数组,用于保存实际字符串
}

在这里插入图片描述
如图所示,SDS实例中存储了字符串“Redis”, sdshdr中对应的free长度为5,len长度为5, SDS占用的总字节数为sizeof(int) * 2 + 5 + 5 + 1 = 19。

2、链表

链表在redis中的应用非常广泛,列表键的底层实现之一就是链表。每个链表节点使用一个listNode结构来表示,具体定义如下(adlist.h/listNode):

typedef struct listNode {struct listNode *prev;              // 前置节点struct listNode *next;              // 后置节点void *value;                        // 节点的值
} listNode;

redis另外还使用了list结构来管理链表,以方便操作,具体定义如下(adlist.h/list):

typedef struct list {listNode *head;                             // 表头节点listNode *tail;                             // 表尾结点void *(*dup)(void *ptr);                    // 节点值复制函数void (*free)(void *ptr);                    // 节点值释放函数int (*match)(void *ptr, void *key);         // 节点值对比函数unsigned int len;                           // 链表所包含的节点数量
} list;

listNode结构占用的总字节数为24,list结构占用的总字节数为48。

3、跳跃表

redis采用跳跃表(skiplist)作为有序集合键的底层实现之一,跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表由redis.h/zskiplistNoderedis.h/zskiplist两个结构定义,zskiplistNode结构具体定义如下:

typedef struct zskiplistNode {robj *obj;                                 // 成员对象double score;                              // 成员对象分值struct zskiplistNode *backward;            // 后退指针struct zskiplistLevel                      // 节点层{struct zskiplistNode *forward;         // 前进指针unsigned int span;                     // 跨度} level[];
} zskiplistNode;

跳跃表可以理解为多层的有序双向链表,zskiplistNode结构用于表示跳跃表节点,obj属性和score属性分别表示具体的值对象和对应的排序分值,backward属性和forward属性分别表示后退和前进指针,和普通链表不同,前进指针可以直接指向后续第n个节点,两个节点之间的距离用span属性表示。

每个跳跃表节点的level数组大小不定,当节点新生成时,程序都会根据幂次定律(power low,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小。zskiplistNode结构占用的总字节数为(24 + 16*n),n为level数组的大小。

zskiplist结构具体定义如下:

typedef struct zskiplist {struct zskiplistNode *header, *tail;      // 表头节点和表尾结点unsigned long length;                     // 表中节点的数量int level;                                // 表中层数最大的节点的层数
} zskiplist;

zskiplist结构则用于保存跳跃表节点的相关信息,header和tail分别指向跳跃表的表头和表尾节点,length记录节点总数量,level记录跳跃表中层高最大的那个节点的层数量。zskiplist结构占用的总字节数为32。

下图展示了一个跳跃表示例:
在这里插入图片描述
位于图片最左边的是zskiplist结构,位于zskiplist结构右边的是四个zskiplistNode结构,header指向跳跃表的表头节点,表头节点和其他节点的构造是一样的,但后退指针、分值、成员对象这些属性都不会被用到,所以被省略,只显示其各个层。

4、字典

字典在redis中的应用很广泛,redis的数据库就是使用字典作为底层实现的,具体数据结构定义如下(dict.h/dict):

typedef struct dict {dictType *type;      // 字典类型void *privdata;      // 私有数据dictht ht[2];        // 哈希表数组int rehashidx;       // rehash索引,当不进行rehash时,值为-1int iterators;       // 当前该字典迭代器个数
} dict;

type属性和privdata属性是为了针对不同类型的键值对而设置的,此处了解即可。dict中还保存了一个长度为2的dictht哈希表数组,哈希表负责保存具体的键值对,一般情况下字典只使用ht[0]哈希表,只有在rehash时才使用ht[1]。dict结构占用的总节数为88。

字典所使用的哈希表dictht结构定义如下(dict.h/dictht):

typedef struct dictht {dictEntry **table;        // 哈希表节点数组unsigned long size;       // 哈希表大小unsigned long sizemask;   // 哈希表大小掩码,用于计算索引值,总是等于size-1unsigned long used;       // 该哈希表已有节点的数量
} dictht;

table属性是一个数组,数组中每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构就是一个哈希表节点,保存一个具体的键值对。size记录了哈希表总大小,used记录了哈希表已有节点的数量,sizemark值总是等于size -1,它和哈希值一起决定每个键的索引。dictht结构占用的总节数为32。

哈希节点使用dictEntry结构表示,具体定义如下(dict.h/dictEntry):

typedef struct dictEntry {void *key;void *val;struct dictEntry *next;
} dictEntry;

redis的哈希表采用链地址法来解决哈希冲突问题,多个哈希值相同的键值对通过链表连接在一起。dictEntry结构占用的总字节数为24。

字典的整体结构关系如下图3所示:
在这里插入图片描述
图3. 字典整体结构关系图

随着哈希表保存的键值对逐渐增多,哈希表中每个桶的冲突链会越来越长,为了让哈希表的负载因子维持在一个合理范围,redis会自动通过rehash的方式扩展哈希表。

rehash的过程大概就是先为ht[1]分配对应的空间,然后将ht[0]中的所有节点转移到ht[1]中,最后再释放ht[0]所占用的空间。rehash后新生成的dictEntry节点数组大小等于超过当前key个数向上求整的2的n次方,比如当前key个数为100,则新生成的节点数组大小就是128。

5、对象

前面介绍了redis的常用数据结构,但redis大多数情况下并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,每个对象都包含了一种具体数据结构。比如,当redis数据库新创建一个键值对时,就需要创建一个值对象,值对象的*ptr属性指向具体的SDS字符串。

每个对象都由一个redisObject结构表示,具体定义如下(redis.h/redisObject):

typedef struct redisObject {unsigned type: 4;        // 对象类型unsigned storage: 2;     // REDIS_VM_MEMORY or REDIS_VM_SWAPPINGunsigned encoding: 4;    // 对象所使用的编码unsigned lru: 22;        // lru time (relative to server.lruclock)int refcount;            // 对象的引用计数void *ptr;               // 指向对象的底层实现数据结构
} robj;

具体属性此处不再详细描述,只需知道redisObject结构占用的总字节数为16。

三、容量评估(7亿条)

1、理论评估

redis容量评估模型根据key类型而有所不同。

1、string

一个简单的set命令最终会产生4个消耗内存的结构,中间free掉的不考虑:

1个dictEntry结构,24字节,负责保存具体的键值对;
1个redisObject结构,16字节,用作val对象;
1个SDS结构,(key长度 + 9)字节,用作key字符串;
1个SDS结构,(val长度 + 9)字节,用作val字符串;

当key个数逐渐增多,redis还会以rehash的方式扩展哈希表节点数组,即增大哈希表的bucket个数,每个bucket元素都是个指针(dictEntry*),占8字节,bucket个数是超过key个数向上求整的2的n次方。

真实情况下,每个结构最终真正占用的内存还要考虑jemalloc的内存分配规则,综上所述,string类型的容量评估模型为:

总内存消耗 = (dictEntry大小 + redisObject大小 +key_SDS大小 + val_SDS大小)×key个数 + bucket个数 ×指针大小

测试验证
string类型容量评估测试脚本如下:

#!/bin/shold_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"for((i=1000; i<3000; i++))
do./redis-cli -h 0 set test_key_$i test_value_$i > /dev/nullsleep 0.2
donenew_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"let difference=new_memory-old_memory
echo "difference is: $difference Bytes"

测试用例中,key长度为 13,value长度为15,key个数为2000,根据上面总结的容量评估模型,容量预估值为2000 ×(32 + 16 + 32 + 32) + 2048× 8 = 240384

运行测试脚本,得到结果如下:
在这里插入图片描述
结果都是240384,说明模型预估的十分精确。

2、hash

哈希对象的底层实现数据结构可能是zipmap或者hashtable,当同时满足下面这两个条件时,哈希对象使用zipmap这种结构(此处列出的条件都是redis默认配置,可以更改):

哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
哈希对象保存的键值对的数量都小于512个;
可以看出,业务侧真实使用场景基本都不能满足这两个条件,所以哈希类型大部分都是hashtable结构,因此本篇文章只讲hashtable,对zipmap结构感兴趣的同学可以私下咨询我。

与string类型不同的是,hash类型的值对象并不是指向一个SDS结构,而是指向又一个dict结构,dict结构保存了哈希对象具体的键值对,hash类型结构关系如图4所示:
在这里插入图片描述
图4. hash类型结构关系图

一个hmset命令最终会产生以下几个消耗内存的结构:

1个dictEntry结构,24字节,负责保存当前的哈希对象;
1个SDS结构,(key长度 + 9)字节,用作key字符串;
1个redisObject结构,16字节,指向当前key下属的dict结构;
1个dict结构,88字节,负责保存哈希对象的键值对;
n个dictEntry结构,24×n字节,负责保存具体的field和value,n等于field个数;
n个redisObject结构,16×n字节,用作field对象;
n个redisObject结构,16×n字节,用作value对象;
n个SDS结构,(field长度 + 9)× n字节,用作field字符串;
n个SDS结构,(value长度 + 9)× n字节,用作value字符串;

因为hash类型内部有两个dict结构,所以最终会有产生两种rehash,一种rehash基准是field个数,另一种rehash基准是key个数,结合jemalloc内存分配规则,hash类型的容量评估模型为:

总内存消耗 = [(redisObject大小 ×2 +field_SDS大小 + val_SDS大小 + dictEntry大小)× field个数 + field_bucket个数× 指针大小 + dict大小 + redisObject大小 +key_SDS大小 + dictEntry大小 ] × key个数 + key_bucket个数×指针大小

测试验证
hash类型容量评估测试脚本如下:

#!/bin/shvalue_prefix="test_value_123456789012345678901234567890123456789012345678901234567890_"old_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"for((i=100; i<300; i++))
dofor((j=100; j<300; j++))do./redis-cli -h 0 hset test_key_$i test_field_$j $value_prefix$j > /dev/nulldonesleep 0.5
donenew_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"let difference=new_memory-old_memory
echo "difference is: $difference Bytes"

测试用例中,key长度为 12,field长度为14,value长度为75,key个数为200,field个数为200,根据上面总结的容量评估模型,容量预估值为[(16 + 16 + 32 + 96 + 32)×200 + 256×8 + 96 + 16 + 32 + 32 ]× 200 + 256× 8 = 8126848

运行测试脚本,得到结果如下:
在这里插入图片描述
结果相差40,说明模型预测比较准确。

3、zset

同哈希对象类似,有序集合对象的底层实现数据结构也分两种:ziplist或者skiplist,当同时满足下面这两个条件时,有序集合对象使用ziplist这种结构(此处列出的条件都是redis默认配置,可以更改):

有序集合对象保存的元素数量小于128个;
有序集合保存的所有元素成员的长度都小于64字节;
业务侧真实使用时基本都不能同时满足这两个条件,因此这里只讲skiplist结构的情况。skiplist类型的值对象指向一个zset结构,zset结构同时包含一个字典和一个跳跃表,占用的总字节数为16,具体定义如下(redis.h/zset):

typedef struct zset {dict *dict;zskiplist *zsl;
} zset;

跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素,dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素,这两种数据结构会通过指针来共享相同元素的成员和分值,没有浪费额外的内存。zset类型的结构关系如图5所示:
在这里插入图片描述
图5. zset类型结构关系图

一个zadd命令最终会产生以下几个消耗内存的结构:

1个dictEntry结构,24字节,负责保存当前的有序集合对象;
1个SDS结构,(key长度 + 9)字节,用作key字符串;
1个redisObject结构,16字节,指向当前key下属的zset结构;
1个zset结构,16字节,负责保存下属的dict和zskiplist结构;
1个dict结构,88字节,负责保存集合元素中成员到分值的映射;
n个dictEntry结构,24×n字节,负责保存具体的成员和分值,n等于集合成员个数;
1个zskiplist结构,32字节,负责保存跳跃表的相关信息;
1个32层的zskiplistNode结构,24+16×32=536字节,用作跳跃表头结点;
n个zskiplistNode结构,(24+16×m)×n字节,用作跳跃表节点,m等于节点层数;
n个redisObject结构,16×n字节,用作集合中的成员对象;
n个SDS结构,(value长度 + 9)×n字节,用作成员字符串;

因为每个zskiplistNode节点的层数都是根据幂次定律随机生成的,而容量评估需要确切值,因此这里采用概率中的期望值来代替单个节点的大小,结合jemalloc内存分配规则,经计算,单个zskiplistNode节点大小的期望值为53.336。

zset类型内部同样包含两个dict结构,所以最终会有产生两种rehash,一种rehash基准是成员个数,另一种rehash基准是key个数,zset类型的容量评估模型为:

总内存消耗 = [(val_SDS大小 + redisObject大小 + zskiplistNode大小 + dictEntry大小)×value个数 +value_bucket个数 ×指针大小 + 32层zskiplistNode大小 + zskiplist大小 + dict大小 + zset大小 + redisObject大小 + key_SDS大小 + dictEntry大小 ] ×key个数 +key_bucket个数 × 指针大小

测试验证
zset类型容量评估测试脚本如下:

#!/bin/shvalue_prefix="test_value_123456789012345678901234567890123456789012345678901234567890_"old_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"for((i=100; i<300; i++))
dofor((j=100; j<300; j++))do./redis-cli -h 0 zadd test_key_$i $j $value_prefix$j > /dev/nulldonesleep 0.5
donenew_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"let difference=new_memory-old_memory
echo "difference is: $difference"

测试用例中,key长度为 12,value长度为75,key个数为200,value个数为200,根据上面总结的容量评估模型,容量预估值为[(96 + 16 + 53.336 + 32)×200 + 256×8 + 640 + 32 + 96 + 16 + 16 + 32 + 32 ] ×200 + 256 × 8 = 8477888

运行测试脚本,得到结果如下:
在这里插入图片描述
结果相差672,说明模型预测比较准确。

4、list

列表对象的底层实现数据结构同样分两种:ziplist或者linkedlist,当同时满足下面这两个条件时,列表对象使用ziplist这种结构(此处列出的条件都是redis默认配置,可以更改):

列表对象保存的所有字符串元素的长度都小于64字节;
列表对象保存的元素数量小于512个;
因为实际使用情况,这里同样只讲linkedlist结构。linkedlist类型的值对象指向一个list结构,具体结构关系如图6所示:
在这里插入图片描述
图6. linkedlist类型结构关系图

一个rpush或者lpush命令最终会产生以下几个消耗内存的结构:

1个dictEntry结构,24字节,负责保存当前的列表对象;
1个SDS结构,(key长度 + 9)字节,用作key字符串;
1个redisObject结构,16字节,指向当前key下属的list结构;
1个list结构,48字节,负责管理链表节点;
n个listNode结构,24×n字节,n等于value个数;
n个redisObject结构,16×n字节,用作链表中的值对象;
n个SDS结构,(value长度 + 9)×n字节,用作值对象指向的字符串;

list类型内部只有一个dict结构,rehash基准为key个数,综上,list类型的容量评估模型为:

总内存消耗 = [(val_SDS大小 + redisObject大小 + listNode大小)× value个数 + list大小 + redisObject大小 + key_SDS大小 + dictEntry大小 ] × key个数 + key_bucket个数 × 指针大小

测试验证
list类型容量评估测试脚本如下:

#!/bin/shvalue_prefix="test_value_123456789012345678901234567890123456789012345678901234567890_"old_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"for((i=100; i<300; i++))
dofor((j=100; j<300; j++))do./redis-cli -h 0  rpush test_key_$i $value_prefix$j > /dev/nulldonesleep 0.5
donenew_memory=`./redis-cli -h 0 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"let difference=new_memory-old_memory
echo "difference is: $difference"

测试用例中,key长度为 12,value长度为75,key个数为200,value个数为200,根据上面总结的容量评估模型,容量预估值为[(96 + 16 + 32) ×200 + 48 + 16 + 32 + 32 ] × 200 + 256 ×8 = 5787648

运行测试脚本,得到结果如下:
在这里插入图片描述
结果都是5787648,说明模型预估的十分精确。

实际操作

理论评估部分帮我们理解机制,但是计算完心里也不是很有数,那么实践是检验真理的唯一标准,我们可以写入一些数据,然后借助redis rdb tools观察下某个key的内存占用情况。

rdbtools是一个redis rdb file的分析工具,可以根据rdb file生成内存报告。

1、安装工具

需要python2.4以上版本和pip。

pip install rdbtools
2、查看单个key

如果我们只需要查询单个key所使用的内存可以不必依赖rdb file, 使用redis-memory-for-key命令即可。

redis-memory-for-key 127.0.0.1 -p 6379 -a mypassword key_name
Key                             key_name
Bytes                           1048632
Type                            string

返回大小单位是:Bytes 除以1024就是多少Mb了,大家也可以通过下面这个链接自己转换想要的单位
https://www.bejson.com/convert/filesize/

3、测试数据
$data = array('bc' => 'b','a1' => '100','a2' => '紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁紫薯布丁','a3' => '9月10日,200','c4' => '100000','c5' => '9999','c6' => '财经,2000','b4' => '10000,1000000','b5' => '1000000,999999,888888,1000000','b6' => '10000000','b7' => '房产,10000',);$redis->hMSet($uid, $data);

我用php代码插入一条这样的数据,然后通过redis-memory-for-key指令查询该key的内存占用

redis-memory-for-key 'useridxxx'Key				useridxxx
Bytes				988.0
Type				hash
Encoding			hashtable
Number of Elements		11
Length of Largest Element	68

占用988Bytes,那么7亿条数据,就应该是
988 * 700000000 / 1024 / 1024/ 1024 = 644 GB

使用redis-cli info 指令查询插入10000条数据前后的used_memory变化,比这个值大,可能是统计了redis进程或其他附加产物占用的内存空间。

这篇关于如何对上亿条数据做redis容量评估的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/315684

相关文章

Redis中6种缓存更新策略详解

《Redis中6种缓存更新策略详解》Redis作为一款高性能的内存数据库,已经成为缓存层的首选解决方案,然而,使用缓存时最大的挑战在于保证缓存数据与底层数据源的一致性,本文将介绍Redis中6种缓存更... 目录引言策略一:Cache-Aside(旁路缓存)策略工作原理代码示例优缺点分析适用场景策略二:Re

Java注解之超越Javadoc的元数据利器详解

《Java注解之超越Javadoc的元数据利器详解》本文将深入探讨Java注解的定义、类型、内置注解、自定义注解、保留策略、实际应用场景及最佳实践,无论是初学者还是资深开发者,都能通过本文了解如何利用... 目录什么是注解?注解的类型内置注编程解自定义注解注解的保留策略实际用例最佳实践总结在 Java 编程

一文教你Python如何快速精准抓取网页数据

《一文教你Python如何快速精准抓取网页数据》这篇文章主要为大家详细介绍了如何利用Python实现快速精准抓取网页数据,文中的示例代码简洁易懂,具有一定的借鉴价值,有需要的小伙伴可以了解下... 目录1. 准备工作2. 基础爬虫实现3. 高级功能扩展3.1 抓取文章详情3.2 保存数据到文件4. 完整示例

使用Java将各种数据写入Excel表格的操作示例

《使用Java将各种数据写入Excel表格的操作示例》在数据处理与管理领域,Excel凭借其强大的功能和广泛的应用,成为了数据存储与展示的重要工具,在Java开发过程中,常常需要将不同类型的数据,本文... 目录前言安装免费Java库1. 写入文本、或数值到 Excel单元格2. 写入数组到 Excel表格

redis中使用lua脚本的原理与基本使用详解

《redis中使用lua脚本的原理与基本使用详解》在Redis中使用Lua脚本可以实现原子性操作、减少网络开销以及提高执行效率,下面小编就来和大家详细介绍一下在redis中使用lua脚本的原理... 目录Redis 执行 Lua 脚本的原理基本使用方法使用EVAL命令执行 Lua 脚本使用EVALSHA命令

python处理带有时区的日期和时间数据

《python处理带有时区的日期和时间数据》这篇文章主要为大家详细介绍了如何在Python中使用pytz库处理时区信息,包括获取当前UTC时间,转换为特定时区等,有需要的小伙伴可以参考一下... 目录时区基本信息python datetime使用timezonepandas处理时区数据知识延展时区基本信息

Qt实现网络数据解析的方法总结

《Qt实现网络数据解析的方法总结》在Qt中解析网络数据通常涉及接收原始字节流,并将其转换为有意义的应用层数据,这篇文章为大家介绍了详细步骤和示例,感兴趣的小伙伴可以了解下... 目录1. 网络数据接收2. 缓冲区管理(处理粘包/拆包)3. 常见数据格式解析3.1 jsON解析3.2 XML解析3.3 自定义

SpringMVC 通过ajax 前后端数据交互的实现方法

《SpringMVC通过ajax前后端数据交互的实现方法》:本文主要介绍SpringMVC通过ajax前后端数据交互的实现方法,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价... 在前端的开发过程中,经常在html页面通过AJAX进行前后端数据的交互,SpringMVC的controll

Redis 热 key 和大 key 问题小结

《Redis热key和大key问题小结》:本文主要介绍Redis热key和大key问题小结,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录一、什么是 Redis 热 key?热 key(Hot Key)定义: 热 key 常见表现:热 key 的风险:二、

C#使用StackExchange.Redis实现分布式锁的两种方式介绍

《C#使用StackExchange.Redis实现分布式锁的两种方式介绍》分布式锁在集群的架构中发挥着重要的作用,:本文主要介绍C#使用StackExchange.Redis实现分布式锁的... 目录自定义分布式锁获取锁释放锁自动续期StackExchange.Redis分布式锁获取锁释放锁自动续期分布式