文章目录
助记提要
- 内存优化技术 3种;
- 压缩列表的结构和配置;
- 整数集合的限制和配置;
- 分片结构的原理;
- 列表和有序集合分片的问题;
- 实现散列分片;
- 实现集合分片;
- 利用分片集合统计唯一访客数;
- 字符串打包存储的条件和原理;
9章 降低内存占用
降低内存占用,可以减少创建快照和加载快照所用的时间、缩短服务器进行同步所需的时间、让Redis在同样的硬件条件下存储更多数据。
内存优化技术:短结构、结构分片、打包存储二进制位和字节
9.1 短结构
通常情况下,列表的底层是双链表,散列和集合的底层是散列表,有序集合的底层是散列表加跳跃表。
Redis为列表、集合、散列和有序集合提供了一组配置选项,可以让Redis内长度较短的结构,以更节约空间的方式存储。
压缩列表
当列表、散列、有序集合的长度较短时,可以使用压缩列表来紧凑存储这些结构。
压缩列表底层是序列化的结构。每次读取压缩列表时都需要解码,写入时也需要局部地重编码,且可能移动内存里面的数据。
- 压缩列表的结构
Redis双向链表简图
链表中的每个节点都包含3个指针,指向前一节点、指向后一个节点、指向字符串值。
每个节点的字符串值也包含3部分数据,字符串长度、字符串中剩余可用字节数和字符串本身。
Redis压缩列表简图
压缩列表的每个节点包含3部分数据,前一个节点的长度、当前节点的长度和字符串本身。不存储额外的指针和元数据,且存储长度的部分用尽量少的字节,大大降低了双向链表的额外开销。
压缩列表的节点被分配在连续的空间上,所以插入时可能需要移动所有元素。
- 配置压缩列表
shell
list-max-ziplist-entries 512
list-max-ziplist-value 64
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 512
zset-max-ziplist-value 64
ehtries选项表示列表、散列和有序集合在被编码为压缩列表的情况下,允许包含的最大元素数量;
value选项表示压缩列表每个节点的最大体积。
这两个限制条件有任意一个被突破,Redis就会把响应的数据从压缩列表编码转为其他的结构,内存占用也会增加。
- 判断一个结构是否被存为压缩列表
debug_onject命令可以查看对象的相关信息,其中encoding表示对象的编码状态。
shell
conn.debug_object('test')
debug_object命令在服务器的调试模式才能使用,客户端可能使用不了。
整数集合
如果集合包含的所有成员都可以被解释为十进制整数,并且这些整数都在当前系统的有符号整数的范围内,同时集合的成员足够少的话,Redis就会以有序整数数组的方式存储集合。
- 配置使用整数集合编码允许的最大元素数量
shell
set-max-intset-entries 512
低于配置指定的大小时,Redis会使用整数集合来存储集合,降低内存消耗,同时可以提升标准集合操作的执行速度。
压缩列表过长或整数集合过大的问题
紧凑存储结构的体积越来越大时,操作这些结构的速度会变得越来越慢。
一般把压缩列表的长度限制在500-2000个元素内,并把每个元素的体积限制在128字节以下,压缩列表的性能就还可接受。
9.2 分片结构
分片是基于简单的规则把数据分为更小的部分,然后根据数据所属的部分来决定将数据发送到哪个位置上。
分片操作和短结构可以同时使用。
列表和有序集合
不使用Lua脚本的情况下对列表进行分片非常困难。
有序集合的基本操作ZRANGE、ZRANGEBYSCORE、ZRANK、ZCOUNT、ZREMRANGE、ZREMRANGEBYSCORE等命令的分片版本需要对所有分片进行操作才能计算出结果。因此分片有序集合作用不大。
当有序集合体积较大,但是只会对排名前N位和后N位的元素进行操作时,可以用散列分片的方法对有序集合进行分片,维持额外的最高分值有序集合和最低分值有序集合,添加新元素时使用ZREMRANGEBYRANK命令确保数量不会超限制。
搜索索引的体积很大时,分片有序集合能够减少执行单个命令的延迟,但是查找分值最大或最小的元素会变慢。
分片散列
分片散列时,可以利用散列里存储的键作为依据。
python
def shard_key(base, key, total_elements, shard_size):
# 组件的名字前缀、请求读取的键、预计的元素总数、请求的分片大小
# 整数值会直接计算分片id
if isinstance(key, (int)) or key.isdigit():
shard_id = int(str(key), 10) // shard_size
else:
# 非整数键,先计算所需的分片数量
shards = 2 * total_elements // shard_size
# 计算哈希值,取模得到分片id
shard_id = binascii.crc32(key) % shards
# 基础键和分片id组合为分片键
return "%s:%s" % (base, shard_id)
数值型的键会被假设是连续且密集地出现的,然后按其本身的值来指派分片id。
CRC32算法会简单直接地返回一个整数,比MD5或SHA1哈希算法更快。
total_elements和shard_size参数用于控制分片的总数,不要轻易更改。实在要修改的话,应该使用重新分片的程序把数据从旧的分片迁移到新分片。
分片式的HSET和HGET函数:
python
def shard_hset(conn, base, key, value, total_elements, shard_size):
shard = shard_key(base, key, total_elements, shard_size)
return conn.hset(shard, key, value)
def shard_hget(conn, base, key, total_elements, shard_size):
shard = shard_key(base, key, total_elements, shard_size)
return conn.hget(shard, key)
之前转换IP地址和城市用到的城市信息存到散列约占用44MB。对其使用分片,然后把hash-max-ziplist-entries设为1024,hash-max-ziplist-value设为256,只需要12MB就能存储这些信息。
如果有很多相关联的短字符串或数字是单独存在字符串键里面的,将它们放到散列中有时能降低内存占用。
分片集合
示例:实现唯一访客计数器。
计算唯一访客数的一种方式就是维持一个即时更新的唯一访客计数器,并使用集合做唯一判定。但是大量访客存到一个集合里,集合的体积会很大,因此需要对其分片。
每个访客都有唯一标识符,类似会话cookie中的UUID。这些标识符可以直接用来分片,但是为了使用到整数集合编码,仅选取标识符的前15个16进制数字有合作被分片的键。
选择前15个16进制数字作为键的原因:全部128位的UUID占用空间较大。前15位转为10进制后只占8字节内存。
15位就足够使用的原因:15位16进制相当于56个二进制位,冲突的概率很小。同一天的2.5亿访客前15位相同的概率只有1%。
记录每天唯一访客数的函数
python
SHARD_SIZE = 512
def count_visit(conn, session_id):
# 生成唯一访客计数器的键
today = date.today()
key = 'unique:%s' % today.isoformat()
# 计算预计的唯一访客人数
expected = get_expected(conn, key, today)
# 计算出访客的id
id = int(session_id.replace('-', '')[:15], 16)
# 如果这个id在唯一访客计数器不存在,就对唯一访客计数器加1
if shard_sadd(conn, key, id, expected, SHARD_SIZE)
conn.incr(key)
分片式SADD函数:
python
def shard_sadd(conn, base, member, total_elements, shard_size):
shard = shard_key(base, 'X'+str(member), total_elements, shard_size)
return conn.sadd(shard, member)
这里加上字符前缀是为了避免被当做数值来计算,因为UUID转为数值并不是密集连续的。
由于Web页面的访客数会随着时间变化,每天维持同样的分片数量无法适应访客人数增多的情况。可以基于前一天的唯一访客数计算出明天的唯一访客数。
python
# 初始值设得需要比预计的访客人数高一些
DAILY_EXCEPTED = 1000000
# 本地存储一份计算出的预计访客数副本
EXCEPTED = {}
def get_expected(conn, key, today):
# 如果程序已经计算过就使用已经计算出的数字
if key in EXCEPTED:
return EXCEPTED[key]
# 如果其他客户端有在服务器存当日的预计访客数,也直接使用
exkey = key + ':excepted'
expected = conn.get(exkey)
if not excepted:
# 取昨天的唯一访客人数,取不到就使用默认值100万
yestoday = (today - timedelta(days=1)).isoformat()
expected = conn.get('unique:%s' % yesterday)
expected = int(expected or DAILY_EXCEPTED)
# 计算今天的唯一访客数
expected = 2**int(math.ceil(math.log(expected*1.5, 2)))
# 将预计访客人数写到Redis,其他程序也可以用
if not conn.setnx(exkey, expected):
# 已经有客户端存了这个值,选择用已有的
expected = conn.get(exkey)
# 存到本地副本
EXCEPTED[key] = int(excepted)
return EXCEPTED[key]
程序假设明天的访客人数会比今天多50%,然后向上舍入至下一个底数为2的幂。
9.3 打包存储二进制位和字节
对于大量的短字符串或数值型数据,如果使用的键是不连续的,那把它们装到分片散列可以有效地降低内存。
如果它们的键是连续的ID,则有更节省内存的存储方法。
示例:存储用户的位置信息。用户id是大量连续的,位置信息包含国家和州。
基本思路:
- 即使用户的量再大,他们的位置信息去重后也是很有限的。如果有固定的位置信息列表,就可以使用实际位置在这个表里的索引来标记用户的位置。这样每个用户的位置信息都是一样的格式和长度。
- 由于用户id的连续性,可以直接按先后顺序直接把位置信息连着存储,用户id的排序位置,也是位置信息在全部用户的位置信息字符串中排序的位置。
确定存储格式
构建包含所需的基本位置信息的列表
python
# ISO3国家编码组成的字符串,切割为列表
COUNTRIES = """ABW AFG AIA ... ZAF ZMB ZWE""".split()
# 加拿大和美国各个州的缩写
STATES = {
"CAN": """AB BC MB NB ... SK YT""".split(),
"USA": """AA AE AK ... WI WV WY""".split(),
}
将给定的国家信息和州信息转换为编码
python
def get_code(country, state):
# 找到指定国家在国家表中的偏移量
cindex = bisect.bisect_left(COUNTRIES, country)
# 未找到指定国家地区,索引设为 -1
if cindex > len(COUNTRIES) or COUNTRIES[cindex] != country:
cindex = -1
# Redis中的未初始化数据在返回时会转换为空值,所以把未找到国家、地区的返回值改为0,
# 第一个国家的索引改为1
cindex += 1
sindex = -1
if state and country in STATES:
states = STATES[country]
sindex = bisect.bisect_left(states, state)
if sindex > len(states) or states[sindex] != state:
sindex = -1
sindex += 1
# 将整数值转为ASCII字符,拼接后返回
return chr(cindex) + chr(sindex)
bisect.bisect_left,会已排序的序列中找某个元素的位置。
chr,将一个0-1114111范围内的整数值转换为unicode码。
存储数据
-
需要分片
Redis的字符串键最大存储的大小是512MB。每个用户2字节,一个字符串键最多只能存储2.6亿的用户。
Redis对现有字符串进行设置值的时候,如果改动的部分超过了现有字符串的末尾,Redis就需要分配更多内存,这会耗费很多时间。
-
字符串分片大小
散列和集合分片时,每个分片足够小的话,可以用到压缩列表或整数集合,进一步节省内存空间。
但是字符串分小的话没有这个效果。并且Redis的SETRANGE、GETRANGE、SETBIT、GETBIT命令可以高效地写入和读取字符串指定位置的数据,因此不需要将分片的大小控制得很小。
字符串分片需要在控制内存碎片的同时,尽可能地减少分片的数量。
将位置数据存到分片后的字符串键中
python
USERS_PER_SHARD = 2**20
def set_location(conn, user_id, country, state):
# 用户所在位置的编码
code = get_code(country, state)
# 计算分片ID以及在分片中的位置
shard_id, position = divmod(user_id, USERS_PER_SHARD)
# 用户数据偏移量,每个用户占2字节
offset = position * 2
pipe = conn.pipeline(False)
# 将用户的位置信息存储到位置
pipe.setrange('location:%s' % shard_id, offset, code)
# 更新目前已知的最大用户id的有序集合
tkey = str(uuid.uuid4())
pipe.zadd(tkey, 'max', user_id)
pipe.zunionstore('location:max', ['tkey', 'location:max'], aggregate='max')
pipe.delete(tkey)
pipe.execute()
divmod计算两个数的商和余数。divmod(x, y) 返回一个元组 (x//y, x%y)。
每次更新最大用户id的记录,是因为进行聚合运算时,需要根据最大用户id决定何时停止计算。
聚合计算
位置信息进行聚合计算有两种情况:
- 对所有用户的位置信息做聚合计算
- 对一部分用户的位置信息做聚合计算
对所有用户的位置信息进行聚合计算:
python
def aggregate_location(conn):
countries = defaultdict(int)
states = defaultdict(lambda: defaultdict(int))
max_id = int(conn.zscore('location:max', 'max'))
max_block = max_id // USERS_PER_SHARD
for shard_id in range(max_block + 1):
for block in readblocks(conn, 'location:%s' % shard_id)
for offset in range(0, len(block)-1, 2):
code = block[offset: offset+2]
update_aggregates(countries, states, [code])
return countries, states
readblocks是第6章实现的从给定键中读取块的函数,可以一次通信就取出数千个用户的位置信息。
位置编码转换为国家或地区信息:
python
def update_aggregates(countries, states, codes):
for code in codes:
if len(code) != 2:
continue
# 计算出国家和州在总表中的偏移量
country = ord(code[0]) - 1
state = ord(code[1]) - 1
if country < 0 or country >= len(COUNTRIES):
continue
# 获取国家编码
country = COUNTRIES[country]
# 该国家的用户计数加1
countries[country] += 1
if country not in STATES:
continue
if state < 0 or state >= STATES[country]:
continue
# 取州编码,该州的用户计数加1
state = STATES[country][state]
states[country][state] += 1
针对一部分用户的位置信息做聚合计算
python
def aggregate_location_list(conn, user_ids):
pipe = conn.pipeline(False)
countries = defaultdict(int)
states = defaultdict(lambda: defaultdict(int))
for i, user_id in enumerate(user_ids):
shard_id, position = divmod(user_id, USERS_PER_SHARD)
offset = position * 2
pipe.substr('location:%s' % shard_id, offset, offset+1)
if (i+1) % 1000 == 0:
update_aggregates(countries, states, pipe.execute())
update_aggregates(countries, states, pipe.execute())
return countries, states
有需要的话,还可以使用GETBIT、SETBIT存储单个二进制位,或对一组二进制位进行设置。