Redis拾遗

最近在策划一个类twitter的应用,看到一些相关的架构介绍,原来twitter的大部分核心功能是基于Redis的。很多年前从Memcache转而使用Redis,一直到今天,做了多少项目已经记不清了,对Redis的理解始终仅限于一个内存“键-值”缓存而已。

最近几天拜读了Josiah L. Carlson的《Redis in Action》,对Redis有了更深入的理解。不由感叹它真是应了”重剑无锋,大巧不工“。几种简单的数据结构,不同的组合与运用,竟然可以优雅的解决各种项目中常见的需求,甚至独立的支持很多应用的存储与业务逻辑。

这是接下来要写的一系列Redis文章的第一篇,介绍基本数据结构、持久化与事务,以及关于Redis性能的测试。

性能

对于一个内存“键-值”存储,性能一直是最被关注的点。从硬盘与内存的物理速度的差距,以及Hash Table与BTree在算法复杂度的上的差别,Redis与其他的硬盘存储形式,比如关系数据库相比,性能应该有很大的提高。在书中作者提到了一些数据:

大多数关系数据库在每台数据库服务器上每秒也只能插入、更新或者删除200~2000个数据库行。Redis每秒可以写20000条记录,性能提升了10~100倍。

含有大量数据的页面的延迟一般在20~50毫秒。查询本地redis的延迟通常低于1毫秒,而查询同一个数据中心的redis的延迟通常低于5毫秒。

对于只使用单个客户端的redis-benchmark来说,根据被调用命令的复杂度,一个不使用流水线的python客户端的性能大概只有50%~60%。

在我的开发机器:2015 MacBookPro, I5 2.7Ghz, 8GB DDR3, 128GB SSD上,做了一个简单的测试:

MySQL

版本: 5.7.18_1

命令:

1
2
3
sysbench /usr/local/Cellar/sysbench/1.0.7/share/sysbench/oltp_read_write.lua --mysql-user=root --mysql-db=benchmark --tables=10 --table-size=1000000 --events=100000000 --report-interval=10 --threads=4 --time=300 prepare

sysbench /usr/local/Cellar/sysbench/1.0.7/share/sysbench/oltp_read_write.lua --mysql-user=root --mysql-db=benchmark --tables=10 --table-size=1000000 --events=100000000 --report-interval=10 --threads=4 --time=300 run

结果:

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
SQL statistics:
queries performed:
read: 2731386
write: 780396
other: 390198
total: 3901980
transactions: 195099 (650.31 per sec.)
queries: 3901980 (13006.27 per sec.)
ignored errors: 0 (0.00 per sec.)
reconnects: 0 (0.00 per sec.)

General statistics:
total time: 300.0057s
total number of events: 195099

Latency (ms):
min: 2.82
avg: 6.15
max: 237.65
95th percentile: 11.87
sum: 1199383.81

Threads fairness:
events (avg/stddev): 48774.7500/70.55
execution time (avg/stddev): 299.8460/0.00

TPS为650.31,QPS达到了13006.27,这与MySQL官网提供的数据基本一致。

Redis

版本: 3.2.9

命令:

1
redis-benchmark -q -n 100000

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PING_INLINE: 76335.88 requests per second
PING_BULK: 77399.38 requests per second
SET: 77459.34 requests per second
GET: 76745.97 requests per second
INCR: 76569.68 requests per second
LPUSH: 78369.91 requests per second
RPUSH: 77760.50 requests per second
LPOP: 77700.08 requests per second
RPOP: 77821.02 requests per second
SADD: 77279.75 requests per second
SPOP: 76745.97 requests per second
LPUSH (needed to benchmark LRANGE): 78554.59 requests per second
LRANGE_100 (first 100 elements): 22143.49 requests per second
LRANGE_300 (first 300 elements): 9728.57 requests per second
LRANGE_500 (first 450 elements): 6888.95 requests per second
LRANGE_600 (first 600 elements): 5212.13 requests per second
MSET (10 keys): 56689.34 requests per second

在没有使用pipeline的情况下,SET为77459.34,GET为76745.97,这个结果也与Redis官网的数据比较接近。

结论

书中的数据应该是从日常应用运行的经验得出的,测试工具得出的结果要比书中的快一些。与关系数据库相比,“性能提升了10~100倍”的结论还是基本正确的。在使用SSD的情况下,硬盘与内存的物理差距已经缩小了很多,在简单应用场景下(不涉及到事务),Redis大概有接近一个数量级的优势。

数据类型

Redis官方文档的数据类型部分开篇有如下一句话:

Redis is not a plain key-value store, it is actually a data structures server, supporting different kinds of values.

“data structures server”说的很到位。Redis把几种很常用的数据结构,用高效的方式做了实现,向外提供一组API来进行操作。

Redis基本的“键-值”存储,使用的是separate chaining hash table。算法复杂度是O(1+n/k),其中n是数据总数,k是hash桶的数量。Redis在数据量(n)增长时会保证桶的数量(k)也相应增长,因此n/k会一直是一个较小的值。所以,算法复杂度接近于O(1)。它的键值是Binary-safe的,可以是任意内容,普通字符串或者二进制文件,但是最大长度不能超过512 MB。太长的键不但会占用大量的内存,查找的效率也比较低。对于长键,使用SHA等算法做一下hash更加高效。

List

Redis的List使用双链表(Doubly Linked Lists)实现,没有长度限制。在链表头尾写入都是O(1),查询是O(n)。在List中的条目数量较少时,Redis会使用压缩双链表(ZipList),这种结构更省内存,但是对其中一部分进行读取或更新,可能会需要对整个压缩列表进行解码,效率较低,在小数据量时,两者的性能差别不大。可以通过修改配置文件中的*-max-ziplist-*选项来进行调整。

Hash

Hash与基本的“键-值”存储使用同样的hash table算法,对于单个hash的长度也没有任何的限制。

Set

Set是一个无序去重集合,也是使用hash table实现的,没有长度限制,修改和查询都是O(1),提供的一些集合操作很好用。

Sorted Set

Sorted Set是一个有序去重集合,使用hash table与skip list两种数据结构,写入复杂度为O(log(N)),读取为O(1),内存占用较大。在排序的时候,会首先比较分值,相同时比较键值。在比较键值时使用的是C语言的memcmp方法。因为Redis中的数值是有长度限制的,所以当要比较的值较大,比如为128bit的数字,可以作为前缀加在键值里边,而分值都设置为0。

Bitmap

Bitmap并没有单独的存储结构,只是在Redis String之上添加了single bit操作。

HyperLogLogs

HyperLogLogs也是使用基本的Redis String保存的,是一个专门用来去重的算法,会有不到1%的错误率,但是内存效率极高,不论多大的数据量,最多只用12K内存,读写的复杂度为O(1)。

持久化

Redis有两种将数据同步到硬盘的方式,快照(snapshotting)只追加文件(append-only file, AOF)。前者将某一时刻内存中的所有数据通过一个新创建的子进程全部写入硬盘,后者则以增量的方式记录执行的命令。两者既可以同时使用,也可以单独使用。

当Redis存储的数据只有几个GB的时候,使用快照来保存数据是没有问题的。对于真实的硬件、VMWare或KVM虚拟机,redis进程每占用一个GB的内存,创建该进程的子进程所需的时间就要增加10~20毫秒,而Xen则需要200~300毫秒。为了防止因为创建子进程而出现停顿,可以考虑关闭自动保存,转而通过手动发送BGSAVE或者SAVE来进行持久化。虽然SAVE会一直阻塞Redis直到快照生成完毕,但是因为它不需要创建子进程,所以不会像BGSAVE一样因为创建子进程而导致Redis停顿。在一台拥有68G的Xen虚拟机上,对一个占用50GB内存的Redis服务器执行BGSAVE命令的话,光是创建子进程就要15秒以上,而生成快照则需要花费15~20分钟, 但使用SAVE只需要3~5分钟。

在数据量较大,同时又不能容忍太长的持久化间隔时间的情况下,使用AOF是更好的选择。AOF有三种同步频率,always(实时同步),everysec(每秒同步),no(由操作系统决定同步时间)。如果指定了使用always频率,那么每个redis命令都会被写入硬盘,从而将发生系统崩溃时出现的数据丢失减到最少。但是速度会受到硬盘性能的限制,转盘式硬盘每秒可以处理大约200条写命令,SSD每秒大概也只能处理几万个命令。而且这种不断写入小数据的行为,会引发SSD的写入放大,影响其寿命。而使用everysec每秒做一次同步,其性能与不做持久化时相差不大。

事物与锁

Redis的事物保证了一组操作的原子性,但是没有回滚的功能,可以通过使用MULTI, EXEC, DISCARDWATCH命令或者运行Lua脚本实现事物。

如果一个事物运行的过程中Redis的进程异常终止了,并且开启了AOF,则可能出现持久化数据不完整的情况,在Redis重启以后会发现这样的错误,并会直接出错退出,可以使用redis-check-aof工具修复出错的AOF文件。

Redis不支持回滚的原因是:Redis的命令只有在出现语法错误的时候才会出错,而语法错误在开发阶段就应该可以发现,因此最终在服务器上运行的命令不应该有错误。同时,逻辑上的的错误,因为可以被成功执行,回滚也起不到任何作用。因此,回滚作用微乎其微,但是又带来一定的实现复杂性与性能损失。

Redis通过WATCH命令可以实现乐观锁的功能,也可以通过简单的代码在程序中自行实现悲观锁,在高并发的情况下,使用自己实现的细粒度的悲观锁往往可以获得更好的性能,书中的例子两者有近5倍的性能差距。下边是书中提供的悲观锁实现:

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
36
37
38
39
40
41
42
43
44
def acquire_lock_with_timeout(conn, lockname, acquire_timeout=10, lock_timeout=10):
# 128位随机标识符
identifier = str(uuid.uuid4())
lockname = 'lock:' + lockname
# 确保传给EXPIRE的都是整数
lock_timeout = int(math.ceil(lock_timeout))

end = time.time() + acquire_timeout
while time.time() < end:
# 尝试取得锁并设置过期时间
if conn.setnx(lockname, identifier):
conn.expire(lockname, lock_timeout)
return identifier
elif not conn.ttl(lockname):
# 检查过期时间,并在有需要时对其进行更新
conn.expire(lockname, lock_timeout)

time.sleep(.001)
return False

def release_lock(conn, lockname, identifier):
pipe = conn.pipeline(True)
lockname = 'lock:' + lockname

while True:
try:
pipe.watch(lockname)
# 检查进程是否仍然持有锁。
if pipe.get(lockname) == identifier:
# 释放锁
pipe.multi()
pipe.delete(lockname)
pipe.execute()
return True

pipe.unwatch()
break

# 有其他客户端修改了锁,重试
except redis.exceptions.WatchError:
pass

#进程已经失去了锁
return False

在Redis的官方文档最后提到,使用2.6版本加入的脚本功能来实现事物是更简单和高效的。在未来,如果使用传统事物的用户不多了,Redis可能会去掉对它的支持。

参考