redis二三事

最近redis集群的研究比较多,把心得稍微梳理一下

redis客户端

redis的c客户端可以用hiredis,完全开源

API主要是以下四个

1
2
3
4
5
6
7
8
redisContext* redisConnect(const char *ip, int port);
//连接
void* redisCommand(redisContext *c, const char *format, ...);
//命令操作
void freeReplyObject(void *reply);
//释放操作结果
void redisFree(redisContext *c);
//释放操作句柄

当需要在命令中传递一个二进制安全的字符串时,可以使用%b,一个字符串指针,和size_t类型的字符串长度。

1
reply = redisCommand(context, "SET foo %b", value, valuelen);

reply是如下的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* This is the reply object returned by redisCommand() */
typedef struct redisReply {
int type; /* REDIS_REPLY_* */
long long integer; /* The integer when type is REDIS_REPLY_INTEGER */
int len; /* Length of string */
char *str; /* Used for both REDIS_REPLY_ERROR and REDIS_REPLY_STRING */
size_t elements; /* number of elements, for REDIS_REPLY_ARRAY */
struct redisReply **element; /* elements vector for REDIS_REPLY_ARRAY */
} redisReply;

//通过获取type可以得到返回的结果情况
#define REDIS_REPLY_STRING 1
//命令返回一个字符串对象。字符串可以使用reply->str访问,字符串长度使用reply->len访问
#define REDIS_REPLY_ARRAY 2
//命令返回一个数组对象。数组元素个数保存在reply->elements,数组中的每个元素都是一个redisReply对象,可以通过reply->element[…index…]访问。Redis支持嵌套数组。
#define REDIS_REPLY_INTEGER 3
//命令返回一个整数。整数值可以使用reply->integer域访问,类型为long long。
#define REDIS_REPLY_NIL 4
//命令返回一个nil对象,表示访问数据不存在
#define REDIS_REPLY_STATUS 5
//命令返回一个status。访问同string
#define REDIS_REPLY_ERROR 6
//命令返回一个error。error字符串访问同string。

如何用好redis

slowlog

redis的slowlog是参考redis集群瓶颈优化的一个点

当业务出现性能瓶颈的时候,可以分析slowlog里占用时间较长较多的command。

在我的经验中HGETALL,DEL对于存储较多value的key,hash,会阻塞较长时间,这方面业务需要极力去规避,因为一个命令可能造成多个客户端timeout,或者是连接池timeout,在使用连接池的场合中,read timeout或者connect timeout都是灾难性的,会极大的拖垮业务系统的TPS

BGREWRITEAOF

这个机制主要是为了减少AOF对业务的影响,在执行这个命令的时候,redis服务器会创建一个aof重写缓冲区,在重写的过程中,所有写命令会写入这个缓冲区

当数据集非常大的时候,遍历内存来重写AOF会占用很多时间,在这段时间如果有高并发写,就会占用很多很多内存,所以一定要注意大数据集是否有足够内存来做操作,或者BGREWRITEAOF命令执行时是否有很高的写并发

value的优化

实例共享

最近遇到有个业务的TPS无论如何都上不去的情况,根据测试负载压在redis的网络上,由于公司的资源限制不能加机器了,所以只好砍掉取用的数据量

这个业务的歌词文件属于存储较大的value,把这部分砍掉以后业务的TPS很快就上去了 (后期需要歌词的时候对这部分value进行压缩再存储)

分析

redis内部的value按照三种编码存储int,raw,embstr

当存储的value为一个整数,按照int对象来存储

当存储的value不为整数,且小于32字节,按照embstr来存储

当存储的value不为整数,且大于32字节,按照raw来存储

int存储整数,并且对于0-1000的常用数字做了共享对象,存储或者读写改内容会直接连接到该对象

embstr是为了短字串存储优化而实现的编码,只能进行读,当修改以后会转换为raw编码

raw顾名思义,未经加工的字串,所以当value较大时,读写这样的数据会造成网络瓶颈

结论

1.如果对字串需要修改,那么直接存大于32字节的,预留空间,这样减少了转换所需时间

2.redis的字符串是二进制安全的,因此可能会误导人什么样的东西都往里塞,

其实长value是不会压缩的,因此短value的读写效率比长value高得多,

对长value的存储,如果是纯文本,例如歌词,完全可以进行压缩,现在的速度快压缩率高的文本压缩算法非常成熟,加大业务服务器的cpu负载减少IO负载,对于网络IO密集型应用来说显然是划得来的

内存利用率的优化

参考了antirez blog(鼎鼎大名的redis作者)

他提出一种叫做trick RedisHL的方法,可以显著的提高redis的内存利用率。

稍微翻译一下这篇文章:

假如要存储一个key "foo",做以下三件事情:

1。计算"foo"的十六进制SHA1校验和:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33

2。使用头四个字节0bee作为存储的(真实)key

3。把"foo"当作0bee的field存储

所以"SET foo bar"相当于"HSET 0bee foo bar"

具体ruby代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
require 'rubygems'
require 'redis'
require 'digest/sha1'

class RedisHL
def initialize(r)
@redis = r
end

def get_hash_name(key)
Digest::SHA1.hexdigest(key.to_s)[0..3]
end

def exists(key)
@redis.hexists(get_hash_name(key))
end

def [](key)
@redis.hget(get_hash_name(key),key)
end

def []=(key,value)
@redis.hset(get_hash_name(key),key,value)
end

def incrby(key,incr)
@redis.hincrby(get_hash_name(key),key,incr)
end
end

r = Redis.new()
rr = RedisHL.new(r)

rr[:foo] = "bar"
puts(rr[:foo])

1000W的短K使用常规方式如果需要存储空间1.7G的话,使用这种方式只需要300M内存!

文章末还附带了一张测试数据的图片

测试数据图

分析

我来分析一下如此amazing的结果是怎样的原理

redis对于hash类型有两种实现方式,一种是ziplist,一种是hashtable。

如果直接使用get/set对总hash进行数据操作,那么在数据利用率(used/size)达到一定数量时,会开始rehash,即为二倍扩容

由于hashtable的预留空间机制,所以数据利用率其实不是很高

ziplist是一种非常高效的数据存储格式,他的指针用的很少,且没有预留空间。

在一个hash类型被创建时,他会以ziplist格式存储,达到一定规模后转为hashtable。

规模的限制可以在redis.conf中以下字段修改

hash-max-zipmap-entries 512

hash-max-zipmap-value 512

当大量的数据以ziplist存储时,内存利用率是非常高的

但是值得注意的是,ziplist的数据插入删除等等是较为消耗CPU资源的,所以对于大量的短key以及读大于写的场合来说,这个trick RedisHL的方式是很不错的

结论

1.对于大量的短key以及读大于写的场合来说,这个trick RedisHL的方式是很不错的

2.对于大量的短key以及读大于写的场合来说,直接用get/set操作,不如将一些key均衡的打包为hash类型进行操作(注意hash类型里的field不能过多,不然就掉进前文中HGETALL的坑了),当这些hash类型以ziplist的格式存储时,内存利用率是惊人的。

twemproxy下redis使用的注意点

twemproxy是由twitter开发的一个Redis 和 Memcached 代理。

鉴于目前redis cluster是unstable版本,机制的实现还有待商榷。(不是特别看好使用gossip算法的广播机制,对网络环境要求很高,cassandra就是这种机制)

在大规模的redis集群使用中,twemproxy为代表的proxy方式成为了主流,在这之中twemproxy脱颖而出,甚至在redis作者的blog上被点名分析了,据他说proxy的性能损耗在20%左右。

Antirez weblog - Twemproxy, a Redis proxy from Twitter

twemproxy的机制和源码分析会在下一篇文章中放出。这一篇只是讨论一下twemproxy下redis使用的注意点。

避免使用MGET

这个是被redis作者在前文中提到的blog中点名批评的,原话是:

So I expected to see almost the same numbers with an MGET as I see when I run the MGET against a single instance, but I get only 50% of the operations per second.

在twemproxy上操作MGET的性能居然只有操作单机的50%!

我大致分析了twemproxy的源码,当MGET涉及的key分布在多个redis实例上,由于网络波动,每台实例需要提取的key数目不一样等原因,proxy不是一次性处理完整合结果反馈客户端。

而是每接受到一次以后,等待整合事件被触发,最后一次性整合然后发送结果给客户端。

消耗时间 = 消耗最多的那台redis实例 + 每次时间轮询间隔*分配的redis实例个数

这样慢也就很正常了,所以

避免在twemproxy环境下使用MGET!!!

避免使用超大field的复杂类型对象

复杂类型对象指的是hash,set之类的非string对象

这样的对象都是key field value格式的。

由于twemproxy对对象做hash,只是针对key,而不会也无法对field做hash(如果对field做hash,很多redis原生操作无法支持,而且也会造成上问中由于访问多台redis实例才能返回结果而造成的MGET坑)

所以一整个对象会存储在同一个redis实例上,如果一个对象过大,会严重影响redis的分布性能,造成存储和性能上严重的分布不均衡。

使用OBJECT指令可以查看对象的详细信息,如果核实是超大field,那么:

超大field需要进行切割,控制在每个50m左右来保证redis集群的分布均衡

未完待续