基于C的 - Redis哨兵模式客户端实现
最近工作上需要用到内存数据库 redis,架构设计使用redis的哨兵模式,也就是集群模式。
因为是用C开发,但是redis所提供的hiredis头文件中并未提供有关集群模式或者哨兵模式调用的方式,前辈说可以参考一下java库中的jedis的实现,然后有了这篇博客。
一、哨兵模式简述
哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。
其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个Redis实例。
它的主要功能有以下几点
1、通过发送命令,让Redis服务器返回监控其运行状态,包括主服务器和从服务器。
2、如果发现某个Redis节点运行出现状况,能够通知另外一个进程(例如它的客户端);
3、当哨兵监测到Master宕机,能够从Master的多个Slave中(至少存活一台Slave)选举一个来作为新的Master,其它的Slave节点会将它指定的Master的地址改为被提升为Master的Slave的新地址。
在使用过程中如果只使用一个哨兵进程对Redis服务器进行监控,可能会出现问题,为此,我们可以使用多个哨兵进行监控。各个哨兵之间还会进行监控,这样就形成了多哨兵模式。
二、java客户端实现方式:
因为对java并不是很熟悉,研究了很久jedis的包只是知道java通过配置三个哨兵端口,以及一个链接池实现了主从,但是一直没弄明白jedis是如何进行主从路由的。
后面在一篇描述redis 哨兵模式详解的博客里面看到了对java-redis客户端的原理介绍,详细可以看下面链接,6、7两点。参考链接 : https://www.cnblogs.com/myitnews/p/13732901.html
总结来说,jedis客户端是通过遍历所有的哨兵端口,找到任意一个可以连接上的哨兵,发送请求 get-master-addr-by-name 请求,确认Master节点,然后会向这个 Master节点发送role或info replication 命令,来确定其是否是Master节点,同时获取slave节点的信息,然后存到了java的链接池中。后续使用的时候,就会通过链接池来进行操作了,若master挂掉了,会重复上面操作,重新查询新的Master节点。
三、偷懒了的C语言实现方式:
一开始了解到java客户端的实现方式后,我暂时陷入了一个比较困扰的境地。
用过C链接redis的读者可能知道,redis提供的C语言动态库libhiredis,其中并没有提供直接链接集群模式、哨兵模式的链接池链接方法。libhiredis中提供的方式全部链接方式是与redis直连。所以支持redis的集群模式,至少会需要手动实现一个链接池。
就目前的需求而言,我需要满足哨兵模式的支持,实现程序与redis哨兵模式的交互。
时间有限,从简单的方式先实现需求,我至少需要实现以下几点:
1、redis连接池(暂时不考虑哨兵查询,直接与redis-server建立连接)
2、连接池能够实现主从鉴别(根据主从读写分离进行判断)
3、需要支持高并发场景(建立长连接,避免重复重连影响效率)
建立单独连接节点及连接池,使用的是静态的变量保存的连接池。
//连接节点及相关信息
typedef struct connNode{ char ip[200]; int port; redisContext * conn; }conn_node;
//redis节点池,因为需求使用的是3台redis,一主二从,设置当前最大为八台机器 typedef struct redisNode{ int size; /*总机器数*/ int master; /*主机序号*/ int slaver; /*从机序号* conn_node nodeInfo[8];/*连接节点列表*/ }redisconn;
static redisconn connList; /*静态连接池,保持长连接*/
初始化连接池
/******************************************** * 建立redis链接 * 支持单机版与哨兵模式版本 通过linux环境变量配置 .bash_profile * 单机版本,同一台机器进行读写 * 哨兵模式,一主多从,主机写高可用,从机器只可读 * 通过插入测试值方式对哨兵模式下主机进行甄别,并记录主机 *********************************************/
int redis_init() { char addr[1024]; char *p,*pAddr; int i,len; memset(addr, 0, sizeof(addr)); if (getenv("REDIS_ADDR") == NULL) { PTLOG_FILE("redis节环境变量未配置;【REDIS_ADDR】"); return -1; } snprintf(addr, sizeof(addr), "%s,", getenv("REDIS_ADDR")); /*初始化前需要将redis中的连接对象释放掉,否则不会关闭句柄,也可能内存泄漏*/ for(i=0;i<8;i++){ if(connList.nodeInfo[i].conn != NULL){ redisFree(connList.nodeInfo[i].conn); } } memset(&connList,0,sizeof(connList)); //哨兵模式,读写分离,初始化主从机器均为 -1 connList.master = -1; connList.slaver = -1; connList.size=0; p = strchr(addr, ','); //环境变量,配置逗号分隔,表示多个 if(p != NULL){//多台redis,哨兵模式 p=addr; len = strlen(addr); for(i = 0 ; i< len ; i++){ if(addr[i]==','){ pAddr = addr + i; memset(connList.nodeInfo[connList.size].ip,0,200); snprintf(connList.nodeInfo[connList.size].ip,(pAddr - p + 1),"%s",p); p = addr + i + 1; //查询端口信息 pAddr = strrchr(connList.nodeInfo[connList.size].ip, ':'); if (pAddr == NULL || (connList.nodeInfo[connList.size].port = atoi(pAddr+1)) <= 0) { //端口有误,跳过当前配置,不进行计数 PTLOG_FILE("环境变量 REDIS_ADDR 配置有误:[%s]",connList.nodeInfo[connList.size].ip); continue ; } *pAddr = '\0'; //建立当前连接,存入连接列表中 connList.nodeInfo[connList.size].conn = redisConnect(connList.nodeInfo[connList.size].ip, connList.nodeInfo[connList.size].port); //建立连接失败,不进行计数,否则后续会有问题 if (connList.nodeInfo[connList.size].conn == NULL) { *pAddr = ':'; PTLOG_FILE("[%s],%s",connList.nodeInfo[connList.size].ip,strerror(errno)); continue ; } else if (connList.nodeInfo[connList.size].conn->err) { *pAddr = ':'; PTLOG_FILE("redis连接失败:[%s] error %d:%s",connList.nodeInfo[connList.size].ip, connList.nodeInfo[connList.size].conn->err, connList.nodeInfo[connList.size].conn->errstr); if(connList.nodeInfo[connList.size].conn != NULL){ redisFree(connList.nodeInfo[connList.size].conn); } continue ; } /* 建立长连接 KeepAlive*/ redisEnableKeepAlive(connList.nodeInfo[connList.size].conn); //连接列表数量++ connList.size++; } } //初始化主从机器,公共连接默认为主连接 conn = connAsMaster(); connAsSlaver(); }else{ //单机器模式,主从均为同一台机器 connList.master = -1; connList.slaver = -1; connList.size=0; conn = connAsSingle( 0 ); return 1; } //默认连接master连接 conn = connList.nodeInfo[connList.master].conn; return connList.size; }
将所有连接建立后,需要校验哪一台是主机,哪一台是从机,目前使用的方法是指定一台为专门写的主机,指定一台从机为专门读的从机。
目前实现方法,根据环境变量配置查找,查找主机从前往后查,查找从机从后往前查,当主机经过多次挂机重启之后,有可能会出现最后一台为主机的情况,该情况会使得读写在同一台机器上。(可优化)
/********************************************** * redis哨兵模式读写分离,master机器写高可用,slaver不能进行写操作,需要选择主机进行写入值,如果主机参数不为-1,则说明已经经过初始化,并且已经确定主机,直接返回主机连接 **********************************************/
redisContext* connAsMaster(){ redisReply *reply; int i; if(connList.master == -1){ for( i = 0 ; i < connList.size; i++ ){ reply = redisCommand(connList.nodeInfo[i].conn, "set %s %s", "redis_master_key", "1");//测试插入值 if (reply == NULL || reply->type == REDIS_REPLY_ERROR || connList.nodeInfo[i].conn->err) { if (reply != NULL) { freeReplyObject(reply); }else { /*redis连接断开情况,返回值为NULL,重连再次执行一次*/ if(connList.nodeInfo[i].conn!=NULL){ redisFree(connList.nodeInfo[i].conn); } connList.nodeInfo[i].conn = redisConnect(connList.nodeInfo[i].ip,connList.nodeInfo[i].port); reply = redisCommand(connList.nodeInfo[i].conn, "set %s %s", "redis_master_key", "1");//测试插入值 if (!(reply == NULL && reply->type == REDIS_REPLY_ERROR && connList.nodeInfo[i].conn->err)){ freeReplyObject(reply); } } continue; } connList.master = i; break; } } if(connList.master == -1){//无可用连接 PTLOG_FILE("FAIL:[无可用连接]"); return NULL; } return connList.nodeInfo[connList.master].conn; }
/********************************************** * 连接从机器,通过查询主机写入的值,查询成功则选定为从机,如果从机参数不为-1,则说明已经经过初始化,并确定从机,直接返回从机连接 **********************************************/
redisContext* connAsSlaver(){ int i; redisReply *reply; if(connList.slaver == -1){ for( i = connList.size - 1 ; i >= 0 ; i-- ){ reply = redisCommand(connList.nodeInfo[i].conn, "get redis_master_key "); if (reply == NULL || reply->type == REDIS_REPLY_ERROR || connList.nodeInfo[i].conn->err) { if (reply != NULL) { freeReplyObject(reply); }else { /*redis连接断开情况,返回值为NULL,重连再次执行一次*/ if(connList.nodeInfo[i].conn!=NULL){ redisFree(connList.nodeInfo[i].conn); } connList.nodeInfo[i].conn = redisConnect(connList.nodeInfo[i].ip,connList.nodeInfo[i].port); reply = redisCommand(connList.nodeInfo[i].conn, "get redis_master_key ");//测试插入值 if (!(reply == NULL && reply->type == REDIS_REPLY_ERROR && connList.nodeInfo[i].conn->err)){ freeReplyObject(reply); } } continue; } connList.slaver = i; break; } } if(connList.slaver == -1){//无可用连接 PTLOG_FILE("FAIL:[无可用连接]"); return NULL; } return connList.nodeInfo[connList.slaver].conn; }
四、复盘反思
当初赶进度两个礼拜要完成开发测试,包括熟悉jedis的实现方式,时间实在赶就没有去深入思考怎么实现更合适。
当然上面成功实现了redis集群模式的支持,但是还是有很多可以进行改进的方式。
简单举个例子:上述实现没有考虑redis的密码模式(虽然是需求上没有提及,没实现也没问题。)说白了,就是没考虑到!是 bug! ORZ
有个小插曲:测试在测代码的时候,问了我一个问题,他说他之前测试的jar包使用了redis的依赖,在配置文件中需要配置redis的节点并不是redis-server的端口,而是sentinel端口,而我是通过直接连接redis实现的,有什么区别。
其实这就是我这个实现与jedis客户端的区别了。
按照jedis客户端的实现,连接确实是配置sentinel,然后需要通过sentinel查询master机器。
127.0.0.1:26379> SENTINEL get-master-addr-by-name mymaster
1) "127.0.0.1"
2) "6379"
mymaster是在进行集群配置的时候,写在sentinel.conf中的主机名称。通过这个主机名称可以查出主机的ip和port
然后建立指向主机的连接,通过命令 role 或者 info replication查看当前机器是否为Master,并查看其从节点。从而来建立从节点的连接。
再进一步,对于高并发查询的场景,可以将从节点进行一个负载均衡,避免大量查询在一个从节点上。(官方数据表示Redis读的速度是110000次/秒,写的速度是81000次/秒。跑~~~
127.0.0.1:6380> role 1) "master" 2) (integer) 73735184 3) 1) 1) "127.0.0.1" 2) "6379" 3) "73735184" 2) 1) "127.0.0.1" 2) "6381" 3) "73735184" 127.0.0.1:6380> INFO replication # Replication role:master connected_slaves:2 slave0:ip=127.0.0.1,port=6379,state=online,offset=73735982,lag=1 slave1:ip=127.0.0.1,port=6381,state=online,offset=73735982,lag=1 master_repl_offset:73736115 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:72687540 repl_backlog_histlen:1048576
在最开始开发的时候,看到不存在哨兵连接的接口,我甚至认为C语言不支持哨兵侦测,但是经过熟悉了解后,我还是naive了~
有时间码一个,实现一下(挖坑~
总结:其实很多情况并不是无法实现,而是缺乏思考。
代码千万条,思考第一条,开发不规范,bug码里藏。
要沉淀每一次的思考,下次代码能写得更好。