扫二维码与项目经理沟通
我们在微信上24小时期待你的声音
解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流
Redis 因具有丰富的数据结构和超高的性能以及简单的协议,使其能够很好的作为数据库的上游缓存层。但在大规模的 Redis 使用过程中,会受限于多个方面:单机内存有限、带宽压力、单点问题、不能动态扩容等。
我们提供的服务有:成都网站建设、成都网站制作、微信公众号开发、网站优化、网站认证、芦溪ssl等。为超过千家企事业单位解决了网站和推广的问题。提供周到的售前咨询和贴心的售后服务,是有科学管理、有技术的芦溪网站制作公司
基于以上, Redis 集群方案显得尤为重要。通常有 3 个途径:官方 Redis Cluster ;通过 Proxy 分片;客户端分片 (Smart Client) 。以上三种方案各有利弊。
Redis Cluster( 官方 ) :虽然正式版发布已经有一年多的时间,但还缺乏最佳实践;对协议进行了较大修改,导致主流客户端也并非都已支持,部分支持的客户端也没有经过大规模生产环境的验证;无中心化设计使整个系统高度耦合,导致很难对业务进行无痛的升级。
Proxy :现在很多主流的 Redis 集群都会使用 Proxy 方式,例如早已开源的 Codis 。这种方案有很多优点,因为支持原声 redis 协议,所以客户端不需要升级,对业务比较友好。并且升级相对平滑,可以起多个 Proxy 后,逐个进行升级。但是缺点是,因为会多一次跳转,平均会有 30% 左右的性能开销。而且因为原生客户端是无法一次绑定多个 Proxy ,连接的 Proxy 如果挂了还是需要人工参与。除非类似 Smart Client 一样封装原有客户端,支持重连到其他 Proxy ,但这也就带来了客户端分片方式的一些缺点。并且虽然 Proxy 可以使用多个,并且可以动态增加 proxy 增加性能,但是所有客户端都是共用所有 proxy ,那么一些异常的服务有可能影响到其他服务。为每个服务独立搭建 proxy ,也会给部署带来额外的工作。
而我们选择了第三种方案,客户端分片 (Smart Client) 。客户端分片相比 Proxy 拥有更好的性能,及更低的延迟。当然也有缺点,就是升级需要重启客户端,而且我们需要维护多个语言的版本,但我们更爱高性能。
下面我们来介绍一下我们的Redis集群:
概貌:
如图0所示,
我们的 Redis 集群一共由四个角色组成:
Zookeeper :保存所有 redis 集群的实例地址, redis 实例按照约定在特定路径写入自身地址,客户端根据这个约定查找 redis 实例地址,进行读写。
Redis 实例:我们修改了 redis 源码,当 redis 启动或主从切换时,按照约定自动把地址写到 zookeeper 特定路径上。
Sentinel : redis 自带的主从切换工具,我们通过 sentinel 实现集群高可用。
客户端( Smart Client ):客户端通过约定查找 redis 实例在 ZooKeeper 中写入的地址。并且根据集群的 group 数,进行一致性哈希计算,确定 key 唯一落入的 group ,随后对这个 group 的主库进行操作。客户端会在Z ooKeeper 设置监视,当某个 group 的主库发生变化时,Z ooKeeper 会主动通知客户端,客户端会更新对应 group 的最新主库。
我们的Redis 集群是以业务为单位进行划分的,不同业务使用不同集群(即业务和集群是一对一关系)。一个 Redis 集群会由多个 group 组成 ( 一个 group 由一个主从对 redis 实例组成 ) 。即 group 越多,可以部署在更多的机器上,可利用的内存、带宽也会更多。在图0中,这个业务使用的 redis 集群由 2 个 group 组成,每个 group 由一对主从实例组成。
Failover
如图1所示,
当 redis 启动时,会 把自己的 IP:Port 写入到 ZooKeeper 中。其中的 主实例模式启动时会在 /redis/ 业务名 / 组名 永久节点写入自己的 IP:Port (如果节点不存在则创建)。由 主模式 变成 从模式 时,会创建 /redis/ 业务名 / 组名 /slaves/ip:port 临时节 点,并写入自己的 IP:Port (如果相同节点已经存在,则先删除,再创建)。而从实例 模式 启动时会创建 /redis/ 业务名 / 组名 /slaves/ip:port 临时节点,并写入自己的 ip:port (如果相同节点已经存在,则先删除,再创建)。由 从模式 变成 主模式 时,先删除 /redis/ 业务名 / 组名 /slaves/ip:port 临时节点,并在 /redis/ 业务名 / 组名 永久节点写入自己的 IP:Port 。
ZooKeeper 会一直保存当前有效的 主从实例 IP:Port 信息。至于主从自动切换过程,使用 redis 自带的 sentinel 实现,现设置为超过 30s 主 server 无响应,则由 sentinel 进行主从实例的切换,切换后就会触发以主、从实例通过以上提到的一系列动作,从而完成最终的切换。
而客户端侧通过给定业务名下的所有 groupName 进行一致性哈希计算,确定 key 落入哪个组。 客户端启动时,会从 ZooKeeper 获取指定业务名下所有 group 的 主从 IP:Port ,并在 ZooKeeper 中设置监视(监视的作用是当 ZooKeeper 的节点发生变化时,会主动通知客户端)。若客户端从 Zookeeper 收到节点变化通知,会重新获取最新的 主从 I:Port ,并重新设置监视( ZooKeeper 监视是一次性的)。通过此方法,客户端可以实时获知当前可访问最新的 主从 IP:Port 信息。
因为我们的所有 redis 实例信息都按照约定保存在 ZooKeeper 上,所以不需要针对每个实例部署监控,我们编写了一个可以自动通过 ZooKeeper 获取所有 redis 实例信息,并且监控 cpu 、 qps 、内存、主从延迟、主从切换、连接数等的工具。
发展:
现在 redis 集群在某些业务内存需求超过预期很多后,无法通过动态扩容进行扩展。所以我们正在做动态扩容的支持。原先的客户端我们是通过一致性哈希进行 key 的
路由策略,但这种方式在动态扩容时会略显复杂,所以我们决定采用实现起来相对简单的预分片方式。一致性哈希的好处是可以无限扩容,而预分片则不是。预分片
时我们会在初始化阶段指定一个集群的所有分片数量,这个数量一旦指定就不能再做改变,这个预分片数量就是后续可以扩容到最大的 redis 实例数。假设预分片 128 个 slot ,每个实例 10G 也可以达到 TB 级别的集群,对于未来数据增长很大的集群我们可以预分片 1024 ,基本可以满足所有大容量内存需求了。
原先我们的 redis 集群有四种角色, Smart Client, redis , sentinel , ZooKeeper 。为了支持动态扩容,我们增加了一个角色, redis_cluster_manager (以下简称 manager ),用于管理 redis 集群。主要工作是初始化集群(即预分片),增加实例后负责修改Z ooKeeper 状态,待客户端做好准备后迁移数据到新增实例上。为了尽量减少数据迁移期间对现性能带来的影响,我们每次只会迁移一个分片的数据,待迁移完成,再进行下一个分片的迁移。
如图2所示
相比原先的方案,多了 slots 、M anager Lock 、 clients 、M igrating Clients 节点。
Slots: 所有分片会把自身信息写入到 slots 节点下面。 Manager 在初始化集群时,根据设置的分片数,以及集群下的 group 数,进行预分片操作,把所有分片均匀分配给已有 group 。分片的信息由一个 json 串组成,记录有分片的状态 (stats) ,当前拥有此分片的 group(src) ,需要迁移到的 group(dst) 。分片的状态一共有三种: online 、 pre_migrate 、 migrating 。
Online 指这个分片处于正常状态,这时 dst 是空值,客户端根据 src 的 group 进行读写。
Pre_migrate 是指这个分片被 manager 标记为需要迁移,此时 dst 仍然为空, manager 在等所有 client 都已经准备就绪,因为 ZooKeeper 回掉所有客户端有时间差,所以如果某些 client 没有准备就绪的时候 manager 进行了数据迁移,那么就会有数据丢失。
Migrating 是 manager 确认了所有客户端都已经做好迁移准备后,在 dst 写入此分片需要迁移的目标 group 。待迁移完成,会在 src 写入目标 group_name , dst 设为空, stats 设为 online 。
Manager Lock: 因为我们是每次只允许迁移一个 slot ,所以不允许超过一个 manager 操作一个集群。所以 manager 在操作集群前,会在M anager Lock 下注册临时节点,代表这个集群已经有 manager 在操作了,这样其他 manager 想要操作这个集群时就会自动退出。
Clients 和M igrating Clients 是为了让 manager 知道客户端是否已经准备就绪的节点。客户端通过 uid 代表自己,格式是 客户端语言 _ 主机名 _pid 。当集群没有进行迁移,即所有分片都是 online 的时候,客户端会在 clients 下创建 uid 的临时节点。
当某个 slot 从 online 变成 pre_migrate 后,客户端会删除 clients 下的 uid 临时节点,然后在M igrating Clients 创建 uid 临时节点。注意,因为需要保证数据不丢失,从 pre_migrate 到 migrating 期间,这个 slot 是被锁定的,即所有对这个 slot 的读写都会被阻塞。所以 mananger 会最多等待 10s ,确认所有客户端都已经切换到准备就绪状态,如果发现某个客户端一直未准备就绪,那么 mananger 会放弃此次迁移,把 slot 状态由 pre_migrate 改为 online 。如果客户端发现 slot 状态由 pre_migrate 变成 online 了,那么会删除 migrating_clients 下的 uid 节点,在 clients 下重新创建 uid 节点。还需要注意的一点是,有可能一个客户刚启动,并且正在往 clients 下创建 uid 节点,但是因为网络延迟还没创建完成,导致 manager 未确认到这个 client 是否准备就绪,所以 mananger 把 slot 改为 pre_migrate 后会等待 1s 再确认所有客户端是否准备就绪。
如果 Manager 看到 clients 下已经没有客户端的话(都已经准备就绪),会把 slot 状态改为 migrating 。 Slot 变成 migrating 后,锁定也随之解除, manager 会遍历 src group 的数据,把对应 slot 的数据迁移到 dst group 里。客户端在 migrating 期间如果有读写 migrating slot 的 key ,那么客户端会先把这个 key 从 src group 迁移到 dst group ,然后再做读写操作。即这期间客户端性能会有所下降。这也是为什么每次只迁移一个 slot 的原因。这样即使只有 128 个分片的集群,在迁移期间受到性能影响的 key 也只有 1/128 ,是可以接受的。
Manager 发现已经把 slot 已经迁移完毕了,会在 src 写入目标 group_name , dst 设为空, stats 设为 online 。客户端也删除 migrating_clients 下的 uid ,在 clients 下创建 uid 节点。
redis开创了一种新的数据存储思路,使用redis,我们不用在面对功能单调的数据库时,把精力放在如何把大象放进冰箱这样的问题上,而是利用redis灵活多变的数据结构和数据操作,为不同的大象构建不同的冰箱。
redis常用数据类型
redis最为常用的数据类型主要有以下五种:
string
hash
list
set
sorted set
在具体描述这几种数据类型之前,我们先通过一张图了解下redis内部内存管理中是如何描述这些不同数据类型的:
首先redis内部使用一个redisobject对象来表示所有的key和value,redisobject最主要的信息如上图所示:type代表一
个value对象具体是何种数据类型,encoding是不同数据类型在redis内部的存储方式,比如:type=string代表value存储的是
一个普通字符串,那么对应的encoding可以是raw或者是int,如果是int则代表实际redis内部是按数值型类存储和表示这个字符串的,当然
前提是这个字符串本身可以用数值表示,比如:"123"
"456"这样的字符串。
这里需要特殊说明一下vm字段,只有打开了redis的虚拟内存功能,此字段才会真正的分配内存,该功能默认是关闭状态的,该功能会在后面具体描述。通过
上图我们可以发现redis使用redisobject来表示所有的key/value数据是比较浪费内存的,当然这些内存管理成本的付出主要也是为了给
redis不同数据类型提供一个统一的管理接口,实际作者也提供了多种方法帮助我们尽量节省内存使用,我们随后会具体讨论。
下面我们先来逐一的分析下这五种数据类型的使用和内部实现方式:
string
常用命令:
set,get,decr,incr,mget 等。
应用场景:
string是最常用的一种数据类型,普通的key/value存储都可以归为此类,这里就不所做解释了。
实现方式:
string在redis内部存储默认就是一个字符串,被redisobject所引用,当遇到incr,decr等操作时会转成数值型进行计算,此时redisobject的encoding字段为int。
hash
常用命令:
hget,hset,hgetall 等。
应用场景:
我们简单举个实例来描述下hash的应用场景,比如我们要存储一个用户信息对象数据,包含以下信息:
用户id为查找的key,存储的value用户对象包含姓名,年龄,生日等信息,如果用普通的key/value结构来存储,主要有以下2种存储方式:
第一种方式将用户id作为查找key,把其他信息封装成一个对象以序列化的方式存储,这种方式的缺点是,增加了序列化/反序列化的开销,并且在需要修改其中一项信息时,需要把整个对象取回,并且修改操作需要对并发进行保护,引入cas等复杂问题。
第二种方法是这个用户信息对象有多少成员就存成多少个key-value对儿,用用户id+对应属性的名称作为唯一标识来取得对应属性的值,虽然省去了序列化开销和并发问题,但是用户id为重复存储,如果存在大量这样的数据,内存浪费还是非常可观的。
那么redis提供的hash很好的解决了这个问题,redis的hash实际是内部存储的value为一个hashmap,并提供了直接存取这个map成员的接口,如下图:
也就是说,key仍然是用户id,
value是一个map,这个map的key是成员的属性名,value是属性值,这样对数据的修改和存取都可以直接通过其内部map的key(redis里称内部map的key为field),
也就是通过 key(用户id) + field(属性标签)
就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。很好的解决了问题。
这里同时需要注意,redis提供了接口(hgetall)可以直接取到全部的属性数据,但是如果内部map的成员很多,那么涉及到遍历整个内部map的
操作,由于redis单线程模型的缘故,这个遍历操作可能会比较耗时,而另其它客户端的请求完全不响应,这点需要格外注意。
实现方式:
上面已经说到redis
hash对应value内部实际就是一个hashmap,实际这里会有2种不同实现,这个hash的成员比较少时redis为了节省内存会采用类似一维数组的方式来紧凑存储,而不会采用真正的hashmap结构,对应的value
redisobject的encoding为zipmap,当成员数量增大时会自动转成真正的hashmap,此时encoding为ht。
list
常用命令:
lpush,rpush,lpop,rpop,lrange等。
应用场景:
redis
list的应用场景非常多,也是redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表等都可以用redis的list结构来实现,比较好理解,这里不再重复。
实现方式:
redis
list的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销,redis内部的很多实现,包括发送缓冲队列等也都是用的这个数据结构。
set
常用命令:
sadd,spop,smembers,sunion 等。
应用场景:
redis
set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。
实现方式:
set 的内部实现是一个
value永远为null的hashmap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。
sorted set
常用命令:
zadd,zrange,zrem,zcard等
使用场景:
redis sorted set的使用场景与set类似,区别是set不是自动有序的,而sorted
set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted
set数据结构,比如twitter 的public
需求:
开发一个风控系统,系统包括, 规则引擎和计算引擎, 主要的内容如下:
1. 规则的增删改和实时生效, 规则的分类执行
2. 按照一定的纬度计算累计值,比如按照 IP, 用户 id, 账户 等纬度。
3. 需要支持滑动窗口,滚动窗口,长度窗口等
遇到的问题主要有以下几点:
1. redis 做流计算太过勉强,一是根据业务上的需求,需要统计的key 至少有几亿个,最多也有几十亿个,另外redis 中需要存储少量的交易的信息。估算下来量也是非常可观
2. redis 中 hot key 特别明显,比如按照商户的纬度去统计,如果不对商户的key 进行拆封,像盒马那种流量的商户,对redis 的压力是非常大的。
我们采用的是redis 的cluster 模式,这样的话redis hot key 对redis 影响会更大。对其进行拆分是非常必要的,比如 按照小时拆分。
3. 流式计算中,一个是乱序导致累加的计算不准确(有负值),另外一个是消息延迟. 当时我们尝试使用flink 中的水印的概念去解决问题,发现并不适合。这个坑也是我们实践过后才发现的。
最痛苦的经历是乱序和延迟消息的解决,现在是采取纠正的方式解决。
规则引擎
规则引擎我们选用了drools,简单的探索了drools core, drools DRL, drools CEP 等,但是回头看看,针对drools的使用缺点还是很多, 而且很明显,暂时还没有替换的打算.
1. 使用 drools CEP 如何做分布式? 我们发现drools CEP中的几种窗口都是内存计算的,应用到分布式中就没有很好的办法,几乎做不到,除非drools 也去集成redis等这种分布式缓存。
2. 使用drools 觉得很笨重,因为依赖比较多,二是我们只用到了 drools 中的 if else 等判断,许多其它的功能基本就用不到,因为 1 中解决不了分布式的问题。所以从这点来说drools 已经废了,根本不用在创建kiesession 这种 重量级的东西。
3. drools中支持的运算符不是特别充分,比如像 log 运算,sum, max, avg 这种的运算等都是不支持的. DRL 语言对业务人员来说不是非常的友好。
4. 另外drools 中的 连续,非连续的规则,没有看出来如何配置,至少flink cep 是有这样API的。
综上所描述,不得不吐槽下 drools真是无语,也许了解的很简单,还有别的方式,另外drools workbench 也是很无语,很复杂,估计drools 厂商想通过这种方式挣钱。
总体感觉,如果有别的选择,最好不要选用drools,分布式的问题没有解决,就等于废了,因为各种分布式窗口都需要我们自己去实现。怎么办呢?
规则引擎最后还是采用了drools,根据具体的业务含义创建不同的kiesession, drools 起到了if else 判断的作用,至于滚动窗口,长度窗口和滑动窗口都通过redis来做计算。遇到头疼的问题,是
1. 根据不同的统计纬度,大概计算了下,需要几十亿个key,在redis 中做计算
2. 滑动窗口暂时靠 redis的zsort 的数据结构,性能不是非常好
3. 热点key 的问题,特别对于大商户的热点key 的问题,需要做拆分,拆分起来是比较复杂的
4. 消息延迟和消息乱序问题。
所以计算引擎的需求一般是
1. 计算很快,大几百个规则,能够很快的计算出准确的结果来
2. 计算准确率,当面对乱序和延迟消息的时候,如何计算的更加准确
3. 计算的量的问题,正如前面提到的,几十亿个key,另外还需要存储一些信息,计算的中间状态等,如何在redis 中丢失,就会造成计算不准确。
基于以上的问题,关键是如何做的更好,优化的更好,说实话,我没有找到答案,可以做的就是不断的优化redis 计算(暂时不能上大数据,比如flink, spark 等),减少redis 的操作带来的网络开销。
其实最后还要提一下,如果能采用内存计算,不用分布式计算,会不会速度更快点,比如根据业务来做分片,这样在各个实例统计的中间值就不用汇总,那么每个实例只需要内存计算就好,不需要访问redis而带来的网络开销。但是这样做也会带来架构层面的调整,比如 如何做 fault tolerance, 如何做 状态持久化, 等一系列的问题。
从使用redis结果来看,效果也不是那么差,不考虑非常热点key 的情况下,最高tps 也达到6000多(2 台机器,16core,32G 内存), 一般公司的业务其实是可以满足的,对于非常热点的key,后续的优化是继续拆分.
一个好的风控系统是非常难的,做以笔记,以希望不断成长
我们在微信上24小时期待你的声音
解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流