Redis实现数据库读写分离

Redis是一种NoSQL的文档数据库,通过key-value的结构存储在内存中,Redis读的速度是110000次/s,写的速度是81000次/s,性能很高,使用范围也很广。

下面用一个实例实现redis的读写分离,步骤如下:

第一步:下载redis

官网下载地址: https://redis.io/download

未分类

下载最新的稳定版,解压拷贝到 ~/redis 中

未分类

编译代码:

$ make
$ test

第二步:配置redis

编辑redis.conf文件

bind 127.0.0.1
port 6379

拷贝redis.conf文件,改名为slave.conf,打开编辑

bind 127.0.0.1
port 6380
slaveof 127.0.0.1 6379  

第三步:运行服务

开启【主服务】

$ src/redis-server

开启【从服务】

$ src/redis-server slave.conf

运行【主服务】的客户端

$ src/redis-cli

运行【从服务】的客户端

$ src/redis-cli -h 127.0.0.1 -p 6380

查看主从服务的关系

$ src/redis-cli info replication  

未分类

第四步:测试服务器

下面实例演示:在主服务器中存储一些数据,然后在从服务器中查询出来

未分类

未分类

可以看出,从服务器成功的获取到了主服务器的备份数据。

假如我们在从服务器中保存数据,看结果如何?

未分类

提示错误:

(error) READONLY You can't write against a read only slave.

说明从服务器只能读数据,而不能写入数据。

数据在【从服务器】里【读】,在【主服务器】里【写】。

这样就实现了redis数据库的读写分离功能。

Redis性能优化tips

读完了Redis实战,感觉收获还是蛮多的。像往常那样,读完就想将书束之高阁。这几天总感觉差点什么,于是又翻了一下这本书,打算记录书上和自己知道的关于Redis优化的小知识点。

数据持久化

  • 选择恰当的持久化方式。Redis提供RDB和AOF两种持久化方式。用户需要根据实际场景对两种持久化方式进行考量和选择。 RDB会在一定时间间隔内一次性将内存中的所有数据刷到磁盘上,非增量。它的一个主要缺点是如果Redis宕机了,那么可能会造成部分数据丢失,丢失的数据两和RDB持久化的时间间隔有关。此外,如果数据集非常大,Redis在创建子进程时可能会消耗相对较长的时间,这期间客户端请求都会被阻塞。这种情况下我们可以关闭自动保存,通过手动发送SAVE或者BGSAVE命令来控制停顿出现的时间。由于SAVE命令不需要创建子进程,从而避免了与子进程的资源竞争,因此它比BGSAVE速度更快。手动生成快照可以选择在线用户很少的情况下执行,比如使用脚本在凌晨三点执行SAVE命令。 从上述内容可以看出,RDB适用于即使丢失部分数据也不会造成问题的场景。同时我们需要注意快照是否生成得过于频繁或者稀少。 AOF持久化会将被执行的命令追加到AOF文件末尾。在redis.conf中该功能默认是关闭的,设置appendonly yes以开启该功能。这种方式会对磁盘进行大量写入,因此Redis处理命令的速度会受到硬盘性能的限制。并且AOF文件通常比RDB文件更大,数据恢复速度比RDB慢,性能消耗也比RDB高。由于它记录的是实际执行的命令,所以也易读。为了兼顾写入性能和数据安全,可以在配置文件设置appendfsysnc everysec。并不推荐appendfsync no选项,因为这种方式是由操作系统决定何时对AOF文件进行写入。在缓冲区被待写入硬盘的数据填满时,可能造成Redis的写入操作被阻塞,严重影响性能。

  • 重写AOF文件。如果用户开启了AOF功能,Redis运行时间越长,`AOF文件也会越来越大。用户可以发送BGREWRITEAOF重写AOF文件,它会移除AOF文件中的冗余命令以此来减小AOF文件的体积。由于AOF文件重写会用到子进程,因此也存在BGSAVE`命令持久化快照时因为创建子进程而导致的性能问题和内存占用问题。除了使用命令重写AOF文件,也可以在配置文件中配置,以让Redis自动执行重写命令。
    #当aof文件体积大于64mb且比上次重写之后的体积增大了至少一倍

auto-aof-rewrite-percentage 100 
auto-aof-rewrite-min-size 64mb

内存优化

  • 设置maxmemory。设置Redis使用的最大物理内存,即Redis在占用maxmemory大小的内存之后就开始拒绝后续的写入请求,该参数可以确保Redis因为使用了大量内存严重影响速度或者发生OOM。此外,可以使用info命令查看Redis占用的内存及其它信息。

  • 让键名保持简短。键的长度越长,Redis需要存储的数据也就越多

  • 使用短结构。这节主要谈谈Redis的list、hash、set、zset这四种数据结构的存储优化。 在Redis3.2之前,如果列表、散列或者有序集合的长度或者体积较小,Redis会选择一种名为ziplist的数据结构来存储它们。该结构是列表、散列和有序集合三种不同类型的对象的一种非结构化表示,与Redis在通常情况下使用双向链表来表示列表、使用散列表示散列、使用散列加跳跃表表示有序集合相比,它更加紧凑,避免了存储额外的指针和元数据(比如字符串值的剩余可用空间和结束符”″)。但是压缩列表需要在存储的时候进行序列化,读取的时候进行反序列化。以散列为例,在redis.conf中,可以进行如下设置

hash-max-ziplist-entries 512 hash-max-ziplist-value 64

entries选项说明允许被编码为ziplist的最大元素数量,value表示压缩列表每个节点的最大体积是多少个字节。如果任意一个条件不满足,则压缩列表会退化成相应的常规结构。这样做的原因是,当压缩列表的体积越来越大时,操作这些数据结构的速度也会越来越慢,特别是当需要扫描整个列表的时候,因为Redis需要解码很多单独的节点。那么上述值各取多少合适呢?合理的做法是将压缩列表长度限制在500~2000个元素之内,并且每个元素体积在128字节之内。Redis实战推荐的做法是将压缩列表长度限制在1024个元素之内,并且每个元素体积不超过64字节。这类参数可能还得由应用的实际场景来定。此外,我们可以使用DEBUG OBJECT命令来查看某个存储的数据使用了何种数据结构及其它一些重要信息。 在Redis3.2及以后,列表的内部实现变成了quicklist而非ziplist或者传统的双端链表。官方定义是A doubly linked list of ziplists,即由ziplist组成的双向链表。quicklist这样设计的原因大概是一个空间和时间的折中:(1)双向链表便于在表的两端进行push和pop操作,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。(2)ziplist由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的realloc。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能。那么到底一个quicklist节点包含多长的ziplist合适呢?我们从存储效率来分析:(1)每个quicklist节点上的ziplist越短,则内存碎片越多。内存碎片多了,有可能在内存中产生很多无法被利用的小碎片,从而降低存储效率。这种情况的极端是每个quicklist节点上的ziplist只包含一个数据项,这就退化成一个普通的双向链表了。(2)每个quicklist节点上的ziplist越长,则为ziplist分配大块连续内存空间的难度就越大。有可能出现内存里有很多小块的空闲空间(它们加起来很多),但却找不到一块足够大的空闲空间分配给ziplist的情况。这同样会降低存储效率。这种情况的极端是整个quicklist只有一个节点,所有的数据项都分配在这仅有的一个节点的ziplist里面。这其实退化成一个ziplist了。 redis.conf提供了以下参数来设置quicklist的相关属性

list-max-ziplist-size -2
list-compress-depth 0

size参数可取正值和负值,取正的时候表示按照数据项个数来限定每个quicklist节点上的ziplist长度。取负的时候表示按照数据项大小来限制每个quicklist上的ziplist长度。计算方式是2^(abs(n)+1),比如这里-2表示每个quicklist节点上的ziplist大小不能超过2^(2+1)即8kb。 当列表很长的时候,最容易被访问的很可能是两端的数据,中间的数据被访问的频率比较低(访问起来性能也很低)。如果应用场景符合这个特点,那么list还提供了一个选项,能够把中间的数据节点进行压缩,从而进一步节省内存空间。Redis的配置参数list-compress-depth就是用来完成这个设置的。它表示两端不被压缩的元素个数。这里节点个数指quicklist双向链表的节点个数,如果一个quicklist节点上的ziplist被压缩,就是整体被压缩。如果值为0,则表示两端数据都不被压缩,为n,则表示两端各n个数据不被压缩。 关于ziplist和quicklist细节可以阅读参考链接中的相关文章。
集合(set)也有自己的紧凑表示形式。如果集合元素全是整数,而这些整数处于平台的有符号范围之内,并且它们的数量又在一定范围内,那么Redis会以有序整数数组的方式存储集合,这种方式被成为整数集合(intset)。redis.conf中可以通过

set-max-intset-entries 512

设置该范围。当存储数据个数大于512的时候或者存储了其它类型的数据时,它会退化为hashtable。在数据量较大的时候,与ziplist由于编码解码数据(如果有对数据移动的操作也会有影响)主要造成性能瓶颈的原因不同,主要影响intset性能的原因是它在执行插入或者删除操作的时候都需要对数据进行移动。因此,需要根据实际情况设置intset最大的元素个数。

  • 对数据进行分片。比如当单个散列比较大的时候,可以按一定规则(key+id%shard_num)对数据进行分片,然后ziplist便更不容易退化为hashtable,且不会出现编码解码引起的性能问题。

扩展读写能力

  • 扩展读性能。在redis.conf中添加slaveof host port即可将其配置为另一台Redis服务器的从服务器。注意,在从服务器连接主服务器的时候,从服务器之前的数据会被清空。可以用这种方式建立从服务器树,扩展其读能力。但这种方式并未做故障转移,高可用Redis部署方案可以参考Redis Sentinel,Redis Cluster和Codis。

  • 扩展写性能。(1)使用集群分片技术,比如Redis Cluster;(2)单机上运行多个Redis实例。由于Redis是单线程设计,在涉及到cpu bound的操作的时候,可能速度会大大降低。如果服务器的cpu、io资源充足,可以在同一台机器上运行多个Redis服务器。

应用程序优化

应用程序优化部分主要是客户端和Redis交互的一些建议。主要思想是尽可能减少操作Redis往返的通信次数。

  • 使用流水线操作。Redis支持流水线(pipeline)操作,其中包括了事务流水线和非事务流水线。Redis提供了WATCH命令与事务搭配使用,实现CAS乐观锁的机制。WATCH的机制是:在事务EXEC命令执行时,Redis会检查被WATCH的key,只有被WATCH的key从WATCH起始时至今没有发生过变更,EXEC才会被执行。如果WATCH的key在WATCH命令到EXEC命令之间发生过变化,则EXEC命令会返回失败。使用事务的一个好处是被MULTI和EXEC包裹的命令在执行时不会被其它客户端打断。但是事务会消耗资源,随着负载不断增加,由WATCH、MULTI、EXEC组成的事务(CAS)可能会进行大量重试,严重影响程序性能。 如果用户需要向Redis发送多个命令,且一个命令的执行结果不会影响另一个命令的输入,那么我们可以使用非事务流水线来代替事务性流水线。非事务流水线主要作用是将待执行的命令一次性全部发送给Redis,减少来回通信的次数,以此来提升性能。

  • 使用mset、lpush、zadd等批量操作数据。它的原理同非事务性流行线操作。

  • 使用lua脚本。Lua脚本跟单个Redis命令及MULTI/EXEC组成的事务一样,都是原子操作。Redis采用单线程设计,每次只能执行一个命令,每个单独的命令都是原子的。Lua脚本有两个好处:(1)减少多个操作通信往返带来的开销(2)无需担心由于事务竞争导致的性能开销。

  • 尽可能使用时间复杂度为O(1)的操作,避免使用复杂度为O(N)的操作。避免使用这些O(N)命令主要有几个办法:(1)不要把List当做列表使用,仅当做队列来使用;(2)通过机制严格控制Hash、Set、Sorted Set的大小;(3)可能的话,将排序、并集、交集等操作放在客户端执行;(4)绝对禁止使用KEYS命令;(5)避免一次性遍历集合类型的所有成员,而应使用SCAN类的命令进行分批的,游标式的遍历

Redis提供了Slow Log功能,可以自动记录耗时较长的命令,redis.conf中的配置如下

#执行时间慢于10000毫秒的命令计入Slow Log
slowlog-log-slower-than 10000  
#最大纪录多少条Slow Log
slowlog-max-len 128

使用SLOWLOG GET n命令,可以输出最近n条慢查询日志。使用SLOWLOG RESET命令,可以重置Slow Log。

Redis命令行遍历所有key方法

最常见的指令是:

keys 前缀*

后面的参数跟通配符来列出所有符合的key。

由于KEYS命令一次性返回所有匹配的key,所以,当redis中的key非常多时,对于内存的消耗和redis服务器都是一个隐患,
对于Redis 2.8以上版本给我们提供了一个更好的遍历key的命令 SCAN 该命令的基本格式:

SCAN cursor [MATCH pattern] [COUNT count]  

SCAN 每次执行都只会返回少量元素,所以可以用于生产环境,而不会出现像 KEYS 或者 SMEMBERS 命令带来的可能会阻塞服务器的问题。

SCAN命令是一个基于游标的迭代器。这意味着命令每次被调用都需要使用上一次这个调用返回的游标作为该次调用的游标参数,以此来延续之前的迭代过程

当SCAN命令的游标参数(即cursor)被设置为 0 时, 服务器将开始一次新的迭代, 而当服务器向用户返回值为 0 的游标时, 表示迭代已结束。

简单的迭代演示:

redis 127.0.0.1:6379> scan 0
1) "17"
2)  1) "key:12"
    2) "key:8"
    3) "key:4"
    4) "key:14"
    5) "key:16"
    6) "key:17"
    7) "key:15"
    8) "key:10"
    9) "key:3"
   10) "key:7"
   11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
   2) "key:18"
   3) "key:0"
   4) "key:2"
   5) "key:19"
   6) "key:13"
   7) "key:6"
   8) "key:9"
   9) "key:11"

在上面这个例子中, 第一次迭代使用 0 作为游标, 表示开始一次新的迭代。第二次迭代使用的是第一次迭代时返回的游标 17 ,作为新的迭代参数 。

显而易见,SCAN命令的返回值 是一个包含两个元素的数组, 第一个数组元素是用于进行下一次迭代的新游标, 而第二个数组元素则又是一个数组, 这个数组中包含了所有被迭代的元素。

注意:返回的游标不一定是递增的,可能后一次返回的游标比前一次的小。

在第二次调用 SCAN 命令时, 命令返回了游标 0 , 这表示迭代已经结束, 整个数据集已经被完整遍历过了。

full iteration :以 0 作为游标开始一次新的迭代, 一直调用 SCAN 命令, 直到命令返回游标 0 , 我们称这个过程为一次完整遍历。

SCAN增量式迭代命令并不保证每次执行都返回某个给定数量的元素,甚至可能会返回零个元素, 但只要命令返回的游标不是 0 , 应用程序就不应该将迭代视作结束。

不过命令返回的元素数量总是符合一定规则的, 对于一个大数据集来说, 增量式迭代命令每次最多可能会返回数十个元素;而对于一个足够小的数据集来说,可能会一次迭代返回所有的key。

Django中Mysql Redis连接池

MySQL 连接

对Django服务进行压测,DB报错数据库连接数过多,如果设置MySQL的最大连接数为1000,很快连接数就会达到上限,调整到2000,也很快连接数达到上限。

xuetangx DB最大连接数2048

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 2048   |
+-----------------+-------+
1 row in set (0.00 sec)

通过Django文档可以发现Django其实提供了一个连接池的另一种时间方式

Django的默认数据库连接

Django程序接受到请求之后,在第一次访问数据库的时候会创建一个数据库连接,直到请求结束,关闭数据库连接。(Django opens a connection to the database when it first makes a database query. It keeps this connection open and reuses it in subsequent requests.)下次请求也是如此。因此,这种情况下,随着访问的并发数越来越高,就会产生大量的数据库连接。也就是我们在压测时出现的情况。

使用CONN_MAX_AGE减少数据库请求(连接池)

每次请求都会创建新的数据库连接,这对于高并发的应用来说是不能接受的。因此在Django1.6时,提供了持久的数据库连接,通过DATABASE配置CONN_MAX_AGE来控制每个连接的最大存活时间。

The default value is 0, preserving the historical behavior of closing the database connection at the end of each request. To enable persistent connections, set CONN_MAX_AGE to a positive number of seconds. For unlimited persistent connections, set it to None.

这个参数的原理就是在每次创建完数据库连接之后,把连接放到一个Theard.local的实例中。在request请求开始结束的时候,打算关闭连接时会判断是否超过CONN_MAX_AGE设置这个有效期。超过则关闭。每次进行数据库请求的时候其实只是判断local中有没有已存在的连接,有则复用。

基于上述原因,Django中对于CONN_MAX_AGE的使用是有些限制的,使用不当,会适得其反。因为保存的连接是基于线程局部变量的,因此如果你部署方式采用多线程,必须要注意保证你的最大线程数不会多余数据库能支持的最大连接数(一个线程一个连接)。另外,如果使用开发模式运行程序(直接runserver的方式),建议不要设置CONN_MAX_AGE,因为这种情况下,每次请求都会创建一个Thread。同时如果你设置了CONN_MAX_AGE,将会导致你创建大量的不可复用的持久的连接。

CONN_MAX_AGE设置

CONN_MAX_AGE的时间怎么设置主要取决于数据库对空闲连接的管理,比如你的MySQL设置了空闲1分钟就关闭连接,那你的CONN_MAX_AGE就不能大于一分钟,不过DBA已经习惯了程序中的线程池的概念,会在数据库中设置一个较大的值。

Redis 连接

在做一个直播活动时,所有评论数据保存Redis,收到Redis服务报错:max number of clients reached

redis 127.0.0.1:6379> CONFIG GET maxclients
1) "maxclients"
2) "200"

发现设置的这个最大连接数太小了,因为每次request都会创建redis连接,几百个人同时使用就达到最大上线了。

Django-redis可以设置Redis连接池,并设置最大连接数,这样就能保证连接的复用和连接数的控制。

the default connection pool is simple. You can only customize the maximum number of connections in the pool, by setting CONNECTION_POOL_KWARGS in the CACHES setting。

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        ...
        "OPTIONS": {
            "CONNECTION_POOL_KWARGS": {"max_connections": 100}
        }
    }
}

END

redis 突然大量逐出导致读写请求block

现象

redis作为缓存场景使用,内存耗尽时,突然出现大量的逐出,在这个逐出的过程中阻塞正常的读写请求,导致 redis 短时间不可用;

背景

redis 中的LRU是如何实现的?

  • 当mem_used内存已经超过maxmemory的设定,对于所有的读写请求,都会触发redis.c/freeMemoryIfNeeded(void)函数以清理超出的内存。

  • 这个清理过程是阻塞的,直到清理出足够的内存空间。

  • 这里的LRU或TTL策略并不是针对redis的所有key,而是以配置文件中的maxmemory-samples个key作为样本池进行抽样清理。
    maxmemory-samples在redis-3.0.0中的默认配置为5,如果增加,会提高LRU或TTL的精准度,redis作者测试的结果是当这个配置为10时已经非常接近全量LRU的精准度.

原因

逐出qps突增非常大的原因:一次需要逐出释放太多的空间会导致阻塞;具体的原因是 mem_tofree 的计算逻辑有问题;
mem_tofree 统计的是:实际已分配的内存总量 – AOF 缓冲区相关的内存;
如果这时候有rehash,会临时分配一个桶来做rehash,这部分内存未排除,所以在rehash阶段,算出来的mem_tofree 就会很大,造成一个时刻需要逐出大量的key,逐出的loop是阻塞的,这个阶段会block redis的请求;

逐出qps的计算:

freeMemoryIfNeeded(...)
    // 计算出 Redis 目前占用的内存总数,但有两个方面的内存不会计算在内:
    // 1)从服务器的输出缓冲区的内存
    // 2)AOF 缓冲区的内存
    // 3)AOF 重写缓冲区中的内存
    mem_used = zmalloc_used_memory();
    if (slaves) {
        listIter li;
        listNode *ln;

        listRewind(server.slaves,&li);
        while((ln = listNext(&li))) {
            redisClient *slave = listNodeValue(ln);
            unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);
            if (obuf_bytes > mem_used)
                mem_used = 0;
            else
                mem_used -= obuf_bytes;
        }
    }
    if (server.aof_state != REDIS_AOF_OFF) {
        mem_used -= sdslen(server.aof_buf);
        mem_used -= aofRewriteBufferSize();
    }
    // 计算需要释放多少字节的内存
    mem_tofree = mem_used - server.maxmemory;
    propagateExpire(db,keyobj);
    // 计算删除键所释放的内存数量
    delta = (long long) zmalloc_used_memory();
    dbDelete(db,keyobj);
    delta -= (long long) zmalloc_used_memory();
    mem_freed += delta;
    // 对淘汰键的计数器增一
    server.stat_evictedkeys++;

解决方案

github上 @Rosanta 给出的解决方案:释放内存的循环逻辑中最多执行一定次数,达到阈值了就不再逐出,到下个请求来时再释放一点空间;这个方案的好处是不会 block 整个进程,正常的业务读写请求无影响;潜在问题是可能单次写入的数据比释放的空间还大,导致总的内存是一直上升,而不是下降;

@antirez 给的方案:同样是迭代删除,但会加个标志,保证在迭代删除的逻辑下内存是逐渐下降的,而如果是上升的,还是会block住正常的请求(要控制主总的内存大小);
详见:
https://github.com/antirez/redis/pull/4583

ref

关于 redis 4.0的逐出算法优化
http://antirez.com/news/109

缓存重构 – 减少Redis Key的数量

未分类

前不久重构系统的时候,发现redis的key已经超过5000万个了,已经没法用keys做遍历了,即使用迭代器*scan做遍历,开销也大到无法接受了。对业务我是相当熟悉的,我很确定我们不需要这么多的key,于是着手开始清理。

首先我跑了个脚本,统计出最常见的key的前缀,发现有两类最多,都超过1000万,分别是

  • carbrand_udid_,缓存的是每个用户的绑定了车牌的车型

  • 1100_341_,缓存的是某个URL对应的图片是否允许被展示

这些key都没有过期时间,也没有清理机制。而业务本身是要求carbrand_udid_永不过期的,1100_341_的时效则比较短,通常在半个月以内,最长也不会超过三个月。

针对第一种情况,原先一个carbrand_udid_$userId对应一个车型列表,通常这个列表长度不超过10,毕竟拥有10个以上车牌且在我们平台上绑定过的用户是极其稀有的。而且每次对这个列表都是整体存取的,不需要单独访问其中一个车型。这种情况其实很好改造,我们放弃每个用户一个key的做法,改用hashtable,table.carbrand,用$userId做hashtable的field,然后把JSON序列化后的车型ID作为value,Redis的hashtable可以存储40亿个键值对,我们用户数才3亿,绑定了牌照的用户不过几千万,在可以预见的未来,不存在超过40亿的可能性。第一种情况的改造,把数千万个key塞到了hashtable内,变成了一个key加这个key内部的数千万个field。

本质上这种改造,是把二维的数据压缩成一维的,data[][]变成data[],消失的那个维度是通过JSON编码成字符串完成的。

针对第二种情况,原先是1100_341_$URL,值是0或1,用来存储一个广告图片是否能展示,0是不能展示,1是可以展示。直接用上面那种方法也能大大减少key的数量,但是我需要做的更好,增加一个过期的功能。由于redis只能对key设置过期时间,不能对二维数据结构内部元素做过期设置,只能想别的办法了。能不能好好利用有效期最长也不会超过三个月这个特性呢?当然可以,而且很简单。我们把日期编码进key里面就行了,比如这样

  • image.audit.white.2018.Q1

  • image.audit.black.2018.Q2

  • image.audit.white.2018.Q1

  • image.audit.black.2018.Q2

每个季度一个白名单set和黑名单set,把URL存储在set里面,再通过判断集合中是否存在某个元素决定URL是否能访问。我们每次最多需要查4个名单,可以确定URL在哪个集合,利用redis的pipeline特性,可以在一个报文里批量查询,一次查完。4条O(1)复杂度的命令执行起来是很快的。还能不能继续优化一些呢?考虑到大部分URL都很长,在空间利用上有些浪费,所以我选择用sha1把URL做个签名,只存储这个签名就行了。第二种改造,成功把数千万个key,减少到每个季度2个,然后定期删除上上季度的key,就能完成清理工作了。

Redis有丰富的数据结构,需要因地制宜好好使用。如若不然,还不如用memcached算了。

基于Redis的任务调度设计方案

一个网关服务器就跟快餐店一样,总是希望客人来得快、去得也快,这样在相同时间内才可以服务更多的客人。如果快餐店的服务员在一个顾客点餐、等餐和结账时都全程跟陪的话,那么这个服务员大部分时间都是在空闲的等待。应该有专门的服务员负责点餐,专门的服务员负责送餐,专门的服务员负责结账,这样才能提高效率。同样道理,网关服务器中也需要分工明确。举个例子:

假设有一个申请发送重置密码邮件的网关接口,须知道发送一封邮件可能会花费上好几秒钟,如果网关服务器直接在线上给用户发送重置密码邮件,高并发的情况下就很容易造成网络拥挤。但实际上,网关服务器并非一定要等待邮件发送成功后才能响应用户,完全可以先告知用户邮件会发送的,而后再在线下把邮件发送出去(就像快餐店里点餐的服务员跟顾客说先去找位置坐,饭菜做好后会有人给他送过去)。

那么是谁来把邮件发送出去呢?

任务队列

为了网关接口能够尽快响应用户请求,无需即时知道结果的耗时操作可以交由任务队列机制来处理。
任务队列机制中包含两种角色,一个是任务生产者,一个是任务消费者,而任务队列是两者之间的纽带:

  • 生产者往队列里放入任务;

  • 消费者从队列里取出任务。

任务队列的整体运行流程是:任务生产者把当前操作的关键信息(后续可以根据这些信息还原出当前操作)抽象出来,比如发送重置密码的邮件,我们只需要当前用户邮箱和用户名就可以了;任务生产者把任务放进队列,实际就是把任务的关键信息存储起来,这里会用到MySQL、Redis之类数据存储工具,常用的是Redis;而任务消费者就不断地从数据库中取出任务信息,逐一执行。

任务生产者的工作是任务分发,一般由线上的网关服务程序执行;任务消费者的工作是任务调度,一般由线下的程序执行,这样即使任务耗时再多,也不阻塞网关服务。

这里主要讨论的是任务调度(任务消费者)的程序设计。

简单直接

假设我们用Redis列表List存储任务信息,列表键名是queues:default,任务发布就是往列表queues:default后追加数据:

<?php
// PHP伪代码
    Redis::rpush('queues:default', serialize($task));

那么任务调度可以这样简单直接的实现:

<?php
// PHP伪代码
Class Worker {

    public function schedule() {
        while(1) {
            $seri = Redis::lpop('queues:default');
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                continue;
            }
            sleep(1);
        }
    }

    public function handle($task) {
        // do something time-consuming
    }
}

$worker = new Worker;
$worker->schedule();

意外保险

上面代码是直接从queues:default列表中移出第一个任务(lpop),因为handle($task)函数是一个耗时的操作,过程中若是遇到什么意外导致了整个程序退出,这个任务可能还没执行完成,可是任务信息已经完全丢失了。保险起见,对schedule()函数进行以下修改:

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            sleep(1);
        }
    }
...

即在任务完成后才将任务信息从列表中移除。

延时执行

queues:default列表中的任务都是需要即时执行的,但是有些任务是需要间隔一段时间后或者在某个时间点上执行,那么可以引入一个有序集合,命名为queues:default:delayed,来存放这些任务。任务发布时需要指明执行的时间点$time:

<?php
// PHP伪代码
    Redis::zadd('queues:default:delayed', $time, serialize($task));

任务调度时,如果queues:default列表已经空了,就从queues:default:delayed集合中取出到达执行时间的任务放入queues:default列表中:

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            sleep(1);
        }
    }
...

任务超时

预估任务正常执行所需的最大时间值,若是任务执行超过了这个时间,可能是过程中遇到一些意外,如果任由它继续卡着,那么后面的任务就会无法被执行了。

首先我们给任务设定一个时限属性timeout,然后在执行任务前先给进程本身设置一个闹钟信号,timeout后收到信号说明任务执行超时,需要退出当前进程(用supervisor守护进程时,进程自身退出,supervisor会自动再拉起)。

注意:pcntl_alarm($timeout)会覆盖之前闹钟信号,而pcntl_alarm(0)会取消闹钟信号;任务超时后,当前任务放入queues:default:delayed集合中延时执行,以免再次阻塞队列。

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->timeoutHanle($task);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            pcntl_alarm(0);
            sleep(1);
        }
    }

    public function timeoutHanle($task) {
        $timeout = $task->timeout;
        pcntl_signal(SIGALRM, function () {
            $seri = Redis::lpop('queues:default');
            Redis::zadd('queues:default:delayed', time() + ($timeout > 0 ? $timeout * 2 : 10), $seri_arr);
            posix_kill(getmypid(), SIGKILL);
        });
        pcntl_alarm($timeout > 0 ? $timeout : 0);
    }
...

并发执行

上面代码,直观上没什么问题,但是在多进程并发执行的时候,有些任务可能会被重复执行,是因为没能及时将当前执行的任务从queues:default列表中移出,其他进程也可以读取到。为了避免重复执行的问题,我们需要引入一个有序集合SortedSet存放正在执行的任务,命名为queues:default:reserved。
首先任务是从queues:default列表中直接移出,然后开始执行任务前先把任务放进queues:default:reserved集合中,任务完成了再从queues:default:reserved集合中移出。
假设一个任务执行时间不可能超过6060秒(可以按需调整),在queues:default列表为空的时候,queues:default:reserved集合中有任务已经存放超过了6060秒,那么有可能是某些进程在执行任务是意外退出了,所以把这些任务放到queues:default:delayed集合中稍后执行。

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lpop('queues:default', 0);
            if($seri) {
                Redis::zadd('queues:default:reserved', time()+10, $seri);
                $task = unserialize($seri);                
                $this->timeoutHanle($task);
                $this->handle($task);
                Redis::zrem('queues:default:reserved', $seri);
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:reserved', 0, time()-60*60);
            if($seri_arr) {
                foreach($seri_arr as $seri) {
                    Redis::zadd('queues:default:delayed', time()+10, $seri);
                }
            }

            sleep(1);
        }
    }

    public function timeoutHanle($task) {
        $timeout = $task->timeout;
        pcntl_signal(SIGALRM, function () {
            posix_kill(getmypid(), SIGKILL);
        });
        pcntl_alarm($timeout > 0 ? $timeout : 0);
    }
...

其他

失败重试

以上代码没有检验任务是否执行成功,应该有任务失败的处理机制:比如给任务设定一个最多重试次数属性retry_times,任务每执行一次retry_times,任务执行失败时,若是retry_times等于0,则将任务放入queues:default:failed列表中不在执行;否则放入放到queues:default:delayed集合中稍后执行。

休眠时间

以上代码是进程忙时连续执行,闲时休眠一秒,可以按需调整优化。

事件监听

若是需要在任务执行成功或失败时进行某些操作,可以给任务设定成功操作方法afterSucceeded()或失败操作方法afterFailed(),在相应的时候回调。

最后

以上讲述了一个任务调度程序的逐步演变,设计方案很大程度上参考了Laravel Queue。
用工具,知其然,知其所以然。

在kubernetes集群中创建redis主从多实例

继续使用上次实验环境 https://www.58jb.com/html/180.html ,因为环境中已经配置好flannel网络了,接下要就是慢慢在此环境中创建一些实例。因为只是搭建简单的环境是比较容易上手,随便网上一搜可能就出来了。但是要自己去从头一步一步跑起项目,还是基于真实的项目来跑的话还是需要自己多研究,当然目前还是止于使用阶段,要深入还得多看资料。

未分类

本次要记录的是自己从创建镜像到实现redis主从示例,为什么要自己制作镜像呢?其实就是为了自己可以学多点,毕竟网上的都人别人做好的,你自己没做过根本不知道制作过程中会遇到什么。为什么有些镜像大,有些镜像比较小。这都是在哪里做的优化?
本次直接使用alpine基础镜像,因为它小,加上个redis也就11M左右的镜像,如果使用centos可能就大点了,这里先不做存储的方便,只是简单的实现redis-master和redis-slave之间的自动发现。
为了自己好区分我直接制作两个镜像,一个为redis-master专门用来跑master服务的,一个为redis-slave专门跑slave实例的。其实可以只用一个,只需分两个配置文件或者是两个不同的启动方法,这里不做这个介绍。

redis-slave镜像制作

需要二个文件:Dockerfile、run.sh

Dockerfile内容如下:

FROM alpine:3.4  
RUN apk add --no-cache redis sed bash  
COPY run.sh /run.sh  
CMD [ "/run.sh" ]  
ENTRYPOINT [ "bash" ]  

其实默认可以不使用redis.conf文件的,如果需要就在上面的Dockerfile文件也加入,这里目前也处于测试,先不加配置。

run.sh启动脚本,也就是一行文件。

[root@k8s-node1 slave]# cat run.sh 
#/bin/sh 
redis-server --slaveof ${REDIS_MASTER_SERVICE_HOST} ${REDIS_MASTER_SERVICE_PORT} 

打包成镜像传到内网仓库:

docker build -t redis-slave . 
docker tag redis-slave reg.docker.tb/harbor/redis-slave 
docker push reg.docker.tb/harbor/redis-slave 

redis-master镜像制作

跟上面的配置差不多一样,只是启动时的命令有点不而已。
Dockerfile内容如下:

FROM alpine:3.4  
RUN apk add --no-cache redis sed bash  
COPY run.sh /run.sh  
CMD [ "/run.sh" ]  
ENTRYPOINT [ "bash" ]  

其实默认可以不使用redis.conf文件的,如果需要就在上面的Dockerfile文件也加入,这里目前也处于测试,先不加配置。

run.sh启动脚本,也就是一行文件。

[root@k8s-node1 master]# cat run.sh 
#/bin/sh 
redis-server

打包成镜像传到内网仓库:

docker build -t redis-master . 
docker tag redis-master reg.docker.tb/harbor/redis-master 
docker push reg.docker.tb/harbor/redis-master 

创建kube的配置文件yaml

创建一个master-service.yaml 来统一Master的入口,同时会自动关联到labels为redis-master的所有Pod,这里要注意Service要优于pod启动,不然无法通过环境变量把配置信息写入到pod中。

apiVersion: v1 
kind: Service 
metadata: 
  name: redis-master 
  labels: 
    name: redis-master 
spec: 
  ports: 
  - port: 6379 
    targetPort: 6379 
  selector: 
    name: redis-master 

redis-master.yaml 以rc的方式来创建一个master容器,它会保证容器的副本数量。

apiVersion: v1 
kind: ReplicationController 
metadata: 
  name: redis-master 
  labels: 
    name: redis-master 
spec: 
  replicas: 1 
  selector: 
    name: redis-master 
  template: 
    metadata: 
      labels: 
        name: redis-master 
    spec: 
      containers: 
      - name: master 
        image: reg.docker.tb/harbor/redis-master 
        ports: 
        - containerPort: 6379 

redis-slave-service.yaml跟上面的Service一样,都是管理redis-slave所有容器的,此服务创建后会把所有的slave实例的容器分配一个集群IP.

apiVersion: v1 
kind: Service 
metadata: 
  name: redis-slave 
  labels: 
    name: redis-slave 
spec: 
  ports: 
  - port: 6379 
  selector: 
    name: redis-slave 

redis-slave.yaml 同样是以RC的方式来创建redis-slave实例,这里的数量为两个。需要注意的是Image这里指定是刚才打包的镜像名。

apiVersion: v1 
kind: ReplicationController 
metadata: 
  name: redis-slave 
  labels: 
    name: redis-slave 
spec: 
  replicas: 2 
  selector: 
    name: redis-slave 
  template: 
    metadata: 
      labels: 
        name: redis-slave 
    spec: 
      containers: 
      - name: worker 
        image: reg.docker.tb/harbor/redis-slave 
        env: 
        - name: GET_HOSTS_FROM 
          value: env 
#value: dns
        ports: 
        - containerPort: 6379 

注意:要实现master和slave服务自动发现,需要配置它们之间的对应关系。Kubernetes有两种方法就是环境变量ENV和DNS记录解析,因为我的实验环境没有使用这个DNS来解析,所以只能使用ENV环境变量。

上面的redis-slave.yaml 就是使用了env环境变量。

接下来创建实例吧:

master: 
kubectl create -f redis-service.yaml 
kubectl create -f redis-master.yaml 

slave: 
kubectl create -f redis-slave-service.yaml 
kubectl create -f redis-slave.yaml 

查看启动的效果:

[root@k8s-master redis-pod]# kubectl get all -o wide 
NAME                    READY     STATUS    RESTARTS   AGE       IP           NODE 
po/redis-master-qpzlh   1/1       Running   0          1h        172.21.2.2   k8s-node1 
po/redis-slave-2rwk5    1/1       Running   0          1h        172.21.2.3   k8s-node1 
po/redis-slave-6tf2f    1/1       Running   0          1h        172.21.8.3   k8s-node2 

NAME              DESIRED   CURRENT   READY     AGE       CONTAINERS   IMAGES                              SELECTOR 
rc/redis-master   1         1         1         1h        master       reg.docker.tb/harbor/redis-master   name=redis-master 
rc/redis-slave    2         2         2         1h        worker       reg.docker.tb/harbor/redis-slave    name=redis-slave 

NAME               TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE       SELECTOR 
svc/kubernetes     ClusterIP   10.254.0.1       <none>        443/TCP    3d        <none> 
svc/redis-master   ClusterIP   10.254.152.116   <none>        6379/TCP   1h        name=redis-master 
svc/redis-slave    ClusterIP   10.254.188.60    <none>        6379/TCP   1h        name=redis-slave 

简单的测试master和slave是否可以同步数据:
连接master容器,可以看到有两个Replication中有两个connected_slaves就是从实例,后面跟着的就是IP地址。

[root@k8s-master redis-pod]# kubectl exec -it redis-master-qpzlh -- /bin/bash 
bash-4.3# redis-cli                                                                                                                                          
127.0.0.1:6379> info 
# Server 
redis_version:3.2.11 
redis_git_sha1:535782f7 
redis_git_dirty:0 
redis_build_id:80ce8a1f388ac530 
redis_mode:standalone 
os:Linux 4.4.113-1.el7.elrepo.x86_64 x86_64 
arch_bits:64 
multiplexing_api:epoll 
gcc_version:5.3.0 
process_id:5 
run_id:6629f021c2e5bd972fff05c84566cc92c7e59be0 
tcp_port:6379 
uptime_in_seconds:7105 
uptime_in_days:0 

...... 
# Clients 
connected_clients:2 
client_longest_output_list:0 
client_biggest_input_buf:0 
blocked_clients:0 
...... 

# Replication 
role:master 
connected_slaves:2 
slave0:ip=172.21.2.3,port=6379,state=online,offset=5276,lag=1 
slave1:ip=172.21.8.0,port=6379,state=online,offset=5276,lag=1 
master_repl_offset:5276 
repl_backlog_active:1 
repl_backlog_size:1048576 
repl_backlog_first_byte_offset:2 
repl_backlog_histlen:5275 

连接一个slave容器查看信息,可以看到Replication中role:slave为从实例,master的IP和端口等信息。

[root@k8s-master ~]# kubectl exec -it redis-slave-2rwk5 -- /bin/bash 
bash-4.3# redis-cli                                                                                                                                          
127.0.0.1:6379> info 
# Server 
redis_version:3.2.11 
redis_git_sha1:535782f7 
redis_git_dirty:0 
redis_build_id:80ce8a1f388ac530 
redis_mode:standalone 
os:Linux 4.4.113-1.el7.elrepo.x86_64 x86_64 
arch_bits:64 
multiplexing_api:epoll 
gcc_version:5.3.0 
process_id:5 
run_id:d48c42ea0a83babda663837ed5a6f9ef5a3ff9bf 
tcp_port:6379 
uptime_in_seconds:3957 
uptime_in_days:0 

...... 
# Replication 
role:slave 
master_host:10.254.152.116 
master_port:6379 
master_link_status:up 
master_last_io_seconds_ago:7 
master_sync_in_progress:0 
slave_repl_offset:5542 
slave_priority:100 
slave_read_only:1 
connected_slaves:0 
master_repl_offset:0 
repl_backlog_active:0 
repl_backlog_size:1048576 
repl_backlog_first_byte_offset:0 
repl_backlog_histlen:0 

此时在Master上创建一个key, 在slave上立马就可以获取到了。

master: 
127.0.0.1:6379> set name swper 
OK 

slave: 
127.0.0.1:6379> get name 
"swper" 

slave上只读: 
127.0.0.1:6379> set test 1 
(error) READONLY You can't write against a read only slave. 

这样就实现了一主多从的效果了,集群的IP现在是外部无法访问的,kubernetes节点中可以相互连通。
其实原本是很简单的实验,我居然在传递参数的时候少了个$号导致一直获取不到。

redis主备同步配置方法

1. 配置主备

假设主机ip:10.136.16.146 port:6789
备机ip:10.136.30.144

我们有两种方式为其配置备机

方法1:修改备机配置文件

redis.conf中增加

daemonize yes
slaveof 10.136.16.146 6789
# 如果主机有密码,则修改下面一行即可
# masterauth <master-password>

在备机上启动redis

redis-server ./tmp/redis.conf

连接上备机reids,执行info replication, 可以看到下面的结果

127.0.0.1:9303> info replication
# Replication
role:slave
master_host:10.136.16.146
master_port:6789
master_link_status:up
master_last_io_seconds_ago:0
master_sync_in_progress:0

...

此时,我们的主备就已配好。数据会自动同步(包括主机之前的数据),如果之前主机中有数据,此时已可以在备机中读取。

连接主机,执行info replication,可以看到下面结果

10.136.16.146:6789> info replication
# Replication
role:master
connected_slaves:1
slave1:ip=10.136.30.144,port=9303,state=online,offset=40383758059,lag=0
master_repl_offset:40383983932

...

可以看到,主机上已连接一台备份redis,正是我们刚刚启动的那台。

方法2:动态修改备机配置

当然,有时你可能没有权限修改备机redis配置文件或重启redis-server。这时,如果你想配置主备,只需要连上备机redis,执行:

slaveof 10.136.16.146 6789

#主机有密码,还要执行这条
#config set masterauth <password>

同样可以达到配置主备的目的

2. 取消主备

有两种方法取消息主备

  1. 直接在配置文件中去掉slaveof的配置,然后重启redis-server
  2. 连入备机,执行
slaveof no one
  • 1

取消主备后,在备机上执行info replication, 会看到

127.0.0.1:9303> info replication
# Replication
role:master
connected_slaves:0
master_repl_offset:0
repl_backlog_active:0
repl_backlog_size:1048576
repl_backlog_first_byte_offset:0
repl_backlog_histlen:0

当前机器的角色已不再是slave, 而是master

3. 几个可能用到的参数

  • slave-read-only:两个值,yes/no,用于控制slave是否为只读
  • slave-serve-stale-data: 用于控制当slave和master失去连接或正在进行同步时,slave的行为。
    • yes(默认):仍然正常处理客户端请求,但数据可能是旧的
    • no:向客户端回复SYNC with master in progress

4. 建议

如果配置了主备,建议master开启数据持久化,或者至少不要让master在挂掉后可以自动重启。

可以想象这样一个场景:master未开启持久化,master挂了后被监控重启。这时,master中没有任何数据,slave由于和master同步,上面的数据也将被清空!

Redis数据的备份和还原

未分类

redis属于内存数据库,速度很快,对于有键值对数据存储需求的是非常便利的。

redis数据库的备份和还原也非常的方便。

1、redis数据库备份(导出)

首先进入redis数据库的控制台。

redis-cli

然后输入备份命令

save

未分类

查看一下导出的文件(dumo.rdb)

未分类

2、redis数据库还原(导入)

首先进入redis数据库的控制台。

redis-cli

然后输入如下命令,获取redis的安装路径

CONFIG GET dir

然后在bin的同级目录下找到var,把备份的数据文件(dump.rdb)复制到var目录下,然后重启redis服务即可。