NoSQL那些事--Redis
Redis是个流行的内存数据库(in-momery)。接口好用,性能也很强,还支持多种数据结构,加上各种高可用性集群方案,实在是太太太好用了。
但是就是因为太好用了,好用到让很多人都晕了脑子:
- 用Redis性能就大大提高了
- 用Redis可以保证原子性
- 用Redis可以实现事务
- 用Redis可以当队列
- ……
这就好像一个股民,在手机上操作买卖几笔股票,赚了一些,然后感叹道"股市就是为我发财而存在的啊"!!他的下场可想而知。
Redis的种种优势源自于他的设计——简单直接的单线程内存操作。但这些优势是有前提的。
Redis的性能高,吗?
Redis的性能非常高。有些评测说用Redis可以达到几十万QPS(比如这里http://skipperkongen.dk/2013/08/27/how-many-requests-per-second-can-i-get-out-of-redis/)。大家可能在网文上记住了这个NB的数字,却很少关心这个数值怎么来的。这就像是你买手机评测光看跑分一样不靠谱。
Redis要达到高性能需要做到:
- Value尽可能的小。一般的测评都会用比较小的value,比如一个整数或者不长的字符串。但是如果用Redis做缓存,那么缓存的大小的可能偏离这个数字。比如一个页面几十KB;再比如,一个5年的市场价格序列数据可能高达几MB。这么大的数据量在带宽的限制下直接的效果就是QPS骤降,跌到几百都毫不意外。毕竟Redis不是神仙,不能改变物理定律。此外,因为Redis是单线程的,过大尺寸的数据访问会block所有其他的操作。
- 使用Pipeline或Lua Script。Redis一般被用做网络服务。所有的请求都是跨网络进行的。所以TCP Round Trip的长短对Redis的性能表现很重要。尽量减少Round Trip可以有效的提高吞吐。所以,通常的优化方法是使用Pipeline,使得客户端可以一次性把一组Redis命令发给Redis Server;或者预先在Redis Server中定义Lua Script,使用时直接调用。但无论是Pipeline还是Lua Script,都会受到业务需求的制约——不是所有业务都适合用Pipeline/Lua Script的。
- 使用快一些的网络。很多Redis的测评为了彰显其NB,都是在本地同时跑客户端和服务器的。也就是说,它们要么使用了loopback网络(localhost),要么使用了Unix Socket。这根本就不能反映一般分布式的网络场景下的情况。同时,一些Redis的HA/Sharding方案会选择用Twemproxy这样的代理来实现。代理的加入会让性能进一步的打折扣。
- 不开启RDB或者AOF。RDB和AOF是Redis的持久化方案。开启他们会对Redis的性能表现有损耗。比如RDB在开始执行时,会fork一个新的用于写入rdb文件的进程。这个fork的过程和内存空间的复制会让Redis卡顿一下;AOF每次sync数据到磁盘,也会block一小会。如果为了确保数据严格持久化,开启了AOF的
appendfsync=everysec
设置,使得每个写入指令都要立刻sync到磁盘,就会打破Redis快的前提——内存数据操作。简单来说,开启任何一种持久化方案都会影响Redis的性能表现。
所以,如果想真实评价Redis的性能,一定要把你的场景设计好,然后用Redis自带Redis-benchmark(见https://redis.io/topics/benchmarks),设定value的尺寸、要测试的Redis命令、和Pipeline的开启情况,再把Redis Server按照生产环境的样子配置好。然后跑一下压测,看看Redis的实际表现到底是怎样的。
Redis可以保证原子性,吗?
我们先定义一下什么是原子性:
- 一般编程语言这么定义:原子性是指一组操作在执行过程中,不受其他并发操作的干扰。这样进行的数据操作的值不会被相互覆盖。
- 数据库事务中ACID的A这么定义:原子性是指一组操作,要不完成,要不没做,不存在改了一半的状态。没完成的操作可以回滚。
很显然,Redis并不支持回滚,所以第二条肯定没戏。
那么第一条呢?
Redis是单线程执行的。在完成一个操作之前,不会有其他的操作被执行。这的确是真的。但是,在业务开发中,需要的不是一个简单操作的原子性,而需要实现一个临界区的原子性。
业务中对数据的操作往往都不是简单的一个set,一个incr就可以搞定的。一个复杂的业务逻辑,往往需要多个带有逻辑判断的写入指令。业务中要保证的是这一组指令是原子的。比如下面的逻辑,希望一个value只能越设置越大。
(async function setBiggerV(v) {
let currentV = parseInt(await redis.get('key'));
if (currentV < v) {
await redis.set('key', v);
}
})();
这其实是有bug的,考虑到如下执行序列(假设v一开始是5):
client A:尝试将v设置为7 | client B:尝试将v设置为8 |
---|---|
读取key,得到5 | |
读取key,得到5 | |
设置key,为8 | |
设置key,为7 |
最终,Redis中v的值被设置为7,这就违反了这段逻辑的设计。如果这个机制被应用于协调一个分布式系统,那么整个系统就会因此挂掉。set这个命令是不是原子并不能让这段业务代码变成原子的。我们需要的是让get和set这个整体原子。
在Redis中,可以用Redis事务或者Lua Script来实现原子性。Redis事务和Lua Script都可以保证一组指令执行不受其他指令的打扰。比如上面的例子,用Lua Script实现,就可以正确运行。
但如果业务逻辑涉及到其他存储,Redis事务和Lua Script就帮不上忙。比如,在Redis中放一个库存的数字。用户下单时,要在Redis中扣减库存,并且在另外一个数据库中INSERT一条交易记录。这段逻辑是没法做到原子的——除非你自行实现了某种分布式事务的机制。而分布式事务的实现复杂度往往会超过Redis带来的好处。
用Redis可以实现事务,吗?
我们一般场景下说的事务的意思往往指的是数据库系统中的”ACID事务“。(见https://www.jianshu.com/p/cb97f76a92fd)。ACID事务是计算机科学中一个非常重要的抽象。它极大地简化了编写业务代码的难度。没有ACID事务,开发人员需要花大量精力处理由于并发和系统意外崩溃带来的数据一致性问题。
Redis也有一个“事务”的概念。原文(见https://redis.io/topics/transactions)。大致含义是:Redis将MULTI
指令和EXEC
指令之间的多个指令视作一个事务;一旦Redis看到了EXEC
就开始执行这一组指令,并保证执行过程中不被打断——除非Redis本身或者所在机器crash掉。如果发生了,就可能出现只有部分指令被执行的情况。
所以,Redis事务与ACID事务是完全不同的!
Redis的事务只支持Isolation,不支持ACD。
有人说,AOF的appendfsync=everysec
是可以持久化的。但这种持久化只在单机情况下有效。多机情况下,Redis是没有一个机制能够将数据修改同步sync到其他节点的,即便是Redis Cluster的WAIT指令也不行。
在这种限制下,在Redis中实现业务逻辑差不多就只有两种可能:
- 不在意ACID事务——数据丢了没事,改错了也没大关系
- 基于Redis的接口实现自己的ACID,或者ACID的某种子集
缓存属于第一个场景。数据丢了没事,从数据库里重新加载就行了。
但如果是第二种场景,你要自己搞一个ACID。不是不可能,但要反复确认这样做的必要性。你是否具有专业的存储开发技能,你能投入多少精力在ACID上,你的公司能给你多少资源做开发测试,这些都需要仔细考虑。
用Redis可以当队列,吗?
Redis实现了一个List的数据结构。借助它,可以实现出队,入队的功能。实际上很多人早就熟练使用Redis做队列。比如Sidekiq就是使用Redis作为异步job队列的存储。然而,这样靠谱吗?
靠谱不靠谱,得看你怎么定义“队列”的要求:
- 队列可不可能丢东西?比如,如果队列短时间挂掉。此时,producer是必须停止服务,还是继续服务但不再插入队列(这样就会丢东西),或者说producer有某种机制可以在本地先暂时堆积一下,直到队列恢复工作?
- 队列的consumer是否需要一个“commit”的语义,表示处理完了一个事件?还是说,只要从队列里取出来就可以了,万一没处理也没所谓?
- 是否有事件重放的需要?比如上线了一个版本的consumer然后发现有bug,处理错了3个小时的数据。修复后,希望能重新处理一遍之前出错的数据,那么这个队列能不能做事件的”重放“?
- 如果consumer处理失败怎么处理?是直接丢弃,还是重新插入到队列中?
- 队列是不是需要有最大的长度限制?如果到了最大长度,说明Consumer跟不上Producer的速度;此时,需要卡住Producer吗?
- ……
Redis的List基本上对于所有这些问题都是完全不管的。也就是说,它不能给你任何的保证。更严重的是,就算你能接受一定程度的数据丢失,但是Redis无法告诉你他丢了多少东西,并且找不回来(MySQL还能翻翻binlog)!到最后,到底丢了多少,造成多少损失,是无法监控,是无法衡量的。
在业务上,“保证”一个事情能够发生相当重要。试想一下,你的界面允许用户下一笔订单,用户已经看到了“成功下单”的界面,结果之后却发现什么订单也没有。用户是不是有一句MMP不知道当讲不当讲。
也许,你会说,"我的场景不需要这么严格的一致性,数据丢了没所谓,也不需要事件重放,数据处理错了就错了"。这个Redis的确可以办到,而且可以做得很好。但我建议你和你的产品经理聊一下,看看需求是不是真的这样。也许他会有不同的意见 ; - )
一般来讲,一个技术公司需要两大类“队列”。一种是业务事件队列。这种队列绝对不能丢东西,而且可能需要exactly once语义,需要高可用。为了保证可用性,多节点的部署是必须的。而引入了多节点,就必须解决复制的问题和分布式一致的问题,主从切换的问题,分片的问题等。这种队列的典型代表是Rabbit MQ和Kafka。
另外一种队列是收集服务前后端业务事件的队列(比如登陆、注册、下单成功、下单失败……)。通过队列,这些事件会被收集到数据分析中心,支持错误分析、客服、数据分析等功能。这种队列可以容忍一些数据丢失,也能容忍数据延迟性比较大,但要求吞吐巨大。这种队列的典型代表是Fluentd和Logstash。
也许你一开始在用Redis的List做队列,但是如果这个业务是认真的,你的系统一定会逐渐演进到这二者之一。
Redis 4.2计划引入Disque作为新的队列实现。也许能够扭转这个情况。但4.2离发布还要很久,并且成熟到可以在生产使用,也至少要到4.4版本——大概在2019年甚至更晚。所以目前观望一下就好,不必特别在意。
更新一下:Redis 5.0beta引入了Stream Date Type。实现了类似于Kafka的append only数据结构和API。不过很可能要到5.2才能在生产中使用(2019年年底)。见https://redis.io/topics/streams-intro
Redis适合用来做什么?
在我看来,Redis适合以下场景:
- 共享Cache ,不怕丢数据,丢了可以从DB中reload;
- 共享Session ,不怕丢数据,丢了可以重新登录;
- batch job的中间结果。不怕丢数据,丢了重新跑job就可以了;
- 一些简单数据的存储,低频改动,但是会被频繁读取。比如首页推荐的产品列表。但此时必须增加HA的防护,sentinel、cluster或者自定义的机制都可以;
- 一些更加复杂存储的building block,比如分布式锁,此时需要多节点来实现一个简单的quorum
其他场景,往往有更好的、更成熟的方案。
特别注意,不要用Redis存储任何需要“认真对待”的数据,请用支持ACID事务的数据库。
Redis是非常优秀的工具,但非是银弹。只有认真的了解业务对“保证”的要求,认真的了解所用工具的工作原理,才能做出正确的设计决策。