SpringBoot Session + Redis Sentinel

Redis HA官方提供两种方案,Cluster(分片)及Sentinel(哨兵模式),这里仅介绍Sentinel配置及使用。两者不冲突,实际可同时使用。
Sentinel优势如下:

  • 监控(Monitoring): 不断检查主从服务器是否运行正常
  • 通知(Notification) : 被监控的某个redis服务器出现问题时,可通过api通知管理员或其他应用程序
  • 自动故障迁移(Automatic Failover): 当主服务器不能正常工作时,失效主服务器的其中一台从服务器升级为新的主服务器,失效主服务器的其他从服务器从属于新主服务器;客户端尝试连接失效主服务器时,集群返回客户端新的主服务器地址,保证可用性

Redis集群搭建

Redis Sentinel方案至少需一个Master节点,两个Slave节点,三个Sentinel,本例Redis版本号为3.2.10,使用六个节点,拓扑结构如下:
sentinel_arch

配置文件

redis_config_file

redis-6379.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 端口
port 6379
# 默认为127.0.0.1。0.0.0.0不是真正意义的ip,指本机所有的ipv4地址
bind 0.0.0.0
# 守护进程方式,redis在后台运行
daemonize yes
# 日志文件名,日志记录启动过程以及故障转移过程
logfile "6379.log"
# 指定本地数据库文件名(默认为dump.rdb),redis重启时,将此文件内容执行一遍,获取数据。若开启appendonly,则优先使用appendonly.aof文件初始化数据集,不再使用此rdb文件
dbfilename "dump-6379.rdb"
# 工作目录,数据文件及日志文件存储路径
dir "/data/home/qa/redis_config/data"
# 安全策略,保护模式,开启则禁止公网访问redis, 开启的条件包括没有绑定ip以及没有设置访问密码,即不设置bind及requirepass参数
protected-mode no
# 访问主节点密码
masterauth root
# redis-server密码
requirepass root
# aof日志开启,每次写操作就会记录一条日志
appendonly yes
# 指定日志文件名(默认为appendonly.aof),redis重启时,将此文件内容执行一遍,获取数据
appendfilename appendonly.aof

注:Redis有两种持久化方式,Snapshot(rdb)和Append-only file(aof)。可使用其中一种方式,也可以两种都使用。

  • rdb方式的持久化通过快照完成,即符合一定条件时redis会自动将内存中的所有数据存储到rdb文件中。相对aof方式,rdb文件更小,数据恢复快,但是会丢失最后一次snapshot后更改的所有数据,如果容忍数据丢失,可采用这种方式。
    本例对应配置参数为
    dbfilename “dump-6379.rdb”

  • aof方式,redis默认不开启,aof方式是以追加的方式将所有写操作命令写入到磁盘文件.aof中,所以文件会越来越大,redis重启的恢复时间较长,为了缓解这种问题,redis使用了BGREWRITEAOF,用于删除重复多余的写命令,是一个占用一定系统资源的background进程(redis自行触发),因为rewrite的时候会删除旧的AOF文件,如果AOF文件比较大的话,会消耗更多的系统资源,好处就是数据一般不会丢失。
    本例对应配置参数为
    appendonly yes
    appendfilename appendonly.aof

redis-6380.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
port 6380
bind 0.0.0.0
daemonize yes
logfile "6380.log"
dbfilename "dump-6380.rdb"
dir "/data/home/qa/redis_config/data"
protected-mode no
masterauth root
requirepass root
appendonly yes
appendfilename appendonly.aof
# 指定主节点ip及端口号,若需外网访问,改为本机的外网地址
slaveof 127.0.0.1 6379

redis-6381.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
port 6381
bind 0.0.0.0
daemonize yes
logfile "6381.log"
dbfilename "dump-6381.rdb"
dir "/data/home/qa/redis_config/data"
protected-mode no
masterauth root
requirepass root
appendonly yes
appendfilename appendonly.aof
# 指定主节点ip及端口号,若需外网访问,改为本机的外网地址
slaveof 127.0.0.1 6379

sentinel-36379.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# sentinel节点端口
port 36379
# 工作目录
dir "/data/home/qa/redis_config/data"
# 日志文件
logfile "36379.log"
# 不开启保护模式
protected-mode no
# 守护进程,后台运行
daemonize yes
# 监控的主节点,mymaster为自定义的主节点别名,127.0.0.1为监听访问ip,如需外网访问则改为本机外网ip, 6379为端口号,2表示判断主节点失效至少需两个sentinel节点同意
sentinel monitor mymaster 127.0.0.1 6379 2
# 设置连接master和slave时的密码。sentinel不能分别为master和slave设置不同的密码,因此master和slave的密码应该设置相同
sentinel auth-pass mymaster root
# 每个sentinel节点均要定期执行ping命令判断Redis数据节点和其他sentinel节点是否可达,超过30000毫秒认为不可达
sentinel down-after-milliseconds mymaster 30000
# 当sentinel节点集合对主节点故障判断达成一致时,故障迁移选出新节点,原来的从节点会向新主节点发起复制操作,限制每次想新主节点发起复制操作的从节点个数为1
sentinel parallel-syncs mymaster 1
# 故障转移超时时间
sentinel failover-timeout mymaster 180000

sentinel-36380.conf和sentinel-36381.conf配置(除sentinel节点端口和日志文件名)与sentinel-36379.conf配置相同。
其他参数备注,本例不使用:

1
2
3
4
5
6
7
8
9
# 故障转移期间,当warn级别的Sentinel事件发生时(指重要事件,如master不可达,sentinel判断master下线),会触发对应路径的脚本,向脚本发送相应的事件参数。
sentinel notification-script $master_name $script_path_and_name
例如
sentinel notification-script mymaster /data/home/qa/redis_config/notify.sh
# 故障转移结束后,触发应对路径的脚本,并向脚本发送故障转移结果参数
sentinel client-reconfig-script $master_name $script_path_and_name
例如
sentinel client-reconfig-script mymaster /data/home/qa/redis_config/reconfig.sh

验证

root模式下

启动主从节点

1
2
3
redis-server redis-6379.conf
redis-server redis-6380.conf
redis-server redis-6381.conf

此时从属关系如下图
sentinel_master_slave
登入主从节点,查看主从状态如下图
redis_master_slave_status

启动sentinel

启动三个sentinel,可用两种方式,如

1
2
redis-sentinel sentinel-36379.conf
redis-server sentinel-36380.conf --sentinel

redis sentinel部署完成后,sentinel配置文件发生如下变化:

  • sentinel节点自动发现了从节点、其余Sentinel节点
  • 去掉了默认配置,如:parallel-syncs、failover-timeout
  • 新添加了参数,如epoch

此时查看sentinel-36379.conf配置文件如下
sentinel_change

故障转移实验

干掉主节点后,查看sentinel节点监控的主节点信息,如下图,可以看到,节点6381成为主节点
redis_kill_master
查看从节点信息如下图,原本的主节点(端口6379)已经断开了连接
redis_masterdown_slave1
redis_masterdown_slave2
重启端口为6379的数据节点后,查看从节点信息,可以看到6379节点复活,不过此时已经降级为端口6381的从节点,如下图
redis_reboot

延迟测试

同一网段下的其他机器连接redis,平均耗时为2ms, 如下所示:

1
redis-cli --latency -h $host -p $port

redis_latency
通过如下命令可查看当前连接数及配置的最大连接数

1
2
info clients
config get maxclients

Springboot 接入

开始接入

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>1.5.10.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.3.2.RELEASE</version>
</dependency>

配置示例

配置从哪里读取无所谓,本例的配置从apollo读取,如下图:
rediscon_apollo

Java类配置类

RedisCon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import com.ctrip.framework.apollo.spring.annotation.EnableApolloConfig;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "redisCon")
@Component("redisCon")
@EnableApolloConfig(value = "RedisCon")
public class RedisCon {
/**
* 主节点名称,mymaster
*/
private String masterName;
/**
* 哨兵集群,ip:port,ip:port
*/
private String sentinelAddress;
/**
* 连接池建立最大连接数
*/
private int maxActive;
/**
* 最大空闲数
*/
private int maxIdle;
/**
* 最小空闲数
*/
private int minIdle;
/**
* 最大阻塞时间, 毫秒
*/
private int maxWaitTime;
/**
* redis数据节点密码
*/
private String password;
/**
* 连接超时时间,单位毫秒
*/
private int timeout;
/**
* 数据库索引
*/
private int dbIndex;
public String getMasterName() {
return masterName;
}
public void setMasterName(String masterName) {
this.masterName = masterName;
}
public String getSentinelAddress() {
return sentinelAddress;
}
public void setSentinelAddress(String sentinelAddress) {
this.sentinelAddress = sentinelAddress;
}
public int getMaxActive() {
return maxActive;
}
public void setMaxActive(int maxActive) {
this.maxActive = maxActive;
}
public int getMaxIdle() {
return maxIdle;
}
public void setMaxIdle(int maxIdle) {
this.maxIdle = maxIdle;
}
public int getMinIdle() {
return minIdle;
}
public void setMinIdle(int minIdle) {
this.minIdle = minIdle;
}
public int getMaxWaitTime() {
return maxWaitTime;
}
public void setMaxWaitTime(int maxWaitTime) {
this.maxWaitTime = maxWaitTime;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getTimeout() {
return timeout;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
public int getDbIndex() {
return dbIndex;
}
public void setDbIndex(int dbIndex) {
this.dbIndex = dbIndex;
}
}
RedisConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisSentinelConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.stereotype.Component;
import redis.clients.jedis.JedisPoolConfig;
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400)
public class RedisConfig {
private final RedisCon redisCon;
@Autowired
public RedisConfig(RedisCon redisCon) {
this.redisCon = redisCon;
}
@Bean
public RedisSentinelConfiguration sentinelConfig() {
RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration().master(redisCon.getMasterName());
String[] sentinelNodes = redisCon.getSentinelAddress().split(",");
for (String node : sentinelNodes) {
String[] ipAndPort = node.split(":");
String nodeIp = ipAndPort[0];
Integer nodePort = Integer.valueOf(ipAndPort[1]);
sentinelConfig.sentinel(nodeIp, nodePort);
}
return sentinelConfig;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(redisCon.getMaxActive());
jedisPoolConfig.setMaxIdle(redisCon.getMaxIdle());
jedisPoolConfig.setMinIdle(redisCon.getMinIdle());
jedisPoolConfig.setMaxWaitMillis(redisCon.getMaxWaitTime());
JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory(sentinelConfig(), jedisPoolConfig);
jedisConnectionFactory.setDatabase(redisCon.getDbIndex());
jedisConnectionFactory.setPassword(redisCon.getPassword());
if(redisCon.getPassword() != null && !"".equals(redisCon.getPassword())){
jedisConnectionFactory.setPassword(redisCon.getPassword());
}
jedisConnectionFactory.setTimeout(redisCon.getTimeout());
jedisConnectionFactory.afterPropertiesSet();
return jedisConnectionFactory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
return template;
}
}

至此,完成集群配置及接入。

调用封装类:RedisUtil
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.kodgames.bus.component.config.redis;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
@Component
public class RedisUtil {
private static RedisTemplate redisTemplate;
private static ValueOperations<String, Object> valueOperations;
private static HashOperations<String, String, Object> hashOperations;
@Autowired
@SuppressWarnings("unchecked")
public RedisUtil(RedisTemplate redisTemplate) {
RedisUtil.redisTemplate = redisTemplate;
RedisUtil.valueOperations = RedisUtil.redisTemplate.opsForValue();
RedisUtil.hashOperations = RedisUtil.redisTemplate.opsForHash();
}
/**
* 获取指定的值
*/
public static Object getValue(String key) {
return RedisUtil.valueOperations.get(key);
}
/**
* 写入指定值
*/
public static boolean setValue(String key, Object value) {
RedisUtil.valueOperations.set(key, value);
return true;
}
/**
* 获取指定hash值
*
* @param hashName hash表名称
* @param hashKey hash表key
* @return hash value, 若不存在返回null
*/
public static Object getHashValue(String hashName, String hashKey) {
return RedisUtil.hashOperations.get(hashName, hashKey);
}
/**
* 写入指定的hash值
*
* @param hashName hash表名称
* @param hashKey hash表key
* @param hashValue hash value
* @return 写入成功返回true
*/
public static boolean setHashValue(String hashName, String hashKey, Object hashValue) {
RedisUtil.hashOperations.put(hashName, hashKey, hashValue);
return true;
}
/**
* 增加指定的HashKey
*/
public static Long incrementHashValue(String hashName, String hashKey, Long increment) {
return RedisUtil.hashOperations.increment(hashName, hashKey, increment);
}
/**
* 删除指定数据
*/
@SuppressWarnings("unchecked")
public static void deleteKey(String key) {
RedisUtil.redisTemplate.delete(key);
}
}

问题备注

1.使用redis作为session,需在logback.xml中更改日志级别不为debug,否则有日志打印过多的报警

1
<logger name="org.springframework.session.web.http.SessionRepositoryFilter.SESSION_LOGGER" level="WARN"/>

2.若构建RedisConnectionFactory Bean时(即RedisConfig类的redisConnectionFactory方法)不加 jedisConnectionFactory.afterPropertiesSet();,那么在连接redis时,抛异常Cannot get Jedis connection; nested exception is java.lang.NullPointerException。
查源码,从JedisSentinelPool类的构造函数追溯,被JedisConnectionFactory类的createRedisSentinelPool方法调用,逐步向上推,最后可得关键方法afterPropertiesSet(),源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* (non-Javadoc)
* @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
*/
public void afterPropertiesSet() {
if (shardInfo == null) {
shardInfo = new JedisShardInfo(hostName, port);
if (StringUtils.hasLength(password)) {
shardInfo.setPassword(password);
}
if (timeout > 0) {
setTimeoutOn(shardInfo, timeout);
}
}
if (usePool && clusterConfig == null) {
this.pool = createPool();
}
if (clusterConfig != null) {
this.cluster = createCluster();
}
}

3.本地window开发环境没有问题,linux环境启动报错如下

1
2
3
4
Error : redis clients jedis HostAndPort cant resolve localhost address
java.net.UnknownHostException: tencent_QA_test11
···
···

解决:
①.查看linux系统主机名,shell输入hostname返回tencent_QA_test11
②.查看/etc/hosts文件中是否有127.0.0.1对应主机名,如果没有则添加

1
127.0.0.1 localhost localhost.localdomain VM_100_8_centos tencent_QA_test11

扩展

1.以上RedisCon的配置从配置中心apollo获取;也可以从K8s Secret获取,首先注释掉@EnableApolloConfig(value = “RedisCon”),然后读取secret文件内容,加载到内存中,启动SpringBoot服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//获取Secret配置项目
final String secretDirectory = "DIR_SECRET";
String secretFileDirStr = System.getenv(secretDirectory);
Properties p = new Properties();
try {
File secretFileDir = new File(secretFileDirStr);
for (File file : secretFileDir.listFiles()) {
if (!file.isDirectory()) {
InputStream in = new BufferedInputStream(new FileInputStream(file));
p.load(in);
in.close();
}
}
} catch (IOException e) {
logger.error("get secretFile, IOException: {}", e);
return;
}
//项目启动
SpringApplicationBuilder builder = new SpringApplicationBuilder(MainApplication.class);
builder.properties(p).build();
builder.run(args);

2.以上服务通过哨兵节点连接自建Redis;若Redis使用云服务(如腾讯云),哨兵地址内部封装,不对外提供,仅提供数据节点地址,配置类更改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
@ConfigurationProperties(prefix = "redisCon")
@Component("redisCon")
//@EnableApolloConfig(value = "RedisCon")
public class RedisCon {
/**
* 连接地址
*/
private String address;
/**
* 连接端口号
*/
private int port;
/**
* 授权密码
*/
private String password;
/**
* 连接池建立最大连接数
*/
private int maxActive;
/**
* 最大空闲数
*/
private int maxIdle;
/**
* 最小空闲数
*/
private int minIdle;
/**
* 最大阻塞时间, 毫秒
*/
private int maxWaitTime;
/**
* 连接超时时间,单位毫秒
*/
private int timeout;
/**
* 数据库索引
*/
private int dbIndex;
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getMaxActive() {
return maxActive;
}
public void setMaxActive(int maxActive) {
this.maxActive = maxActive;
}
public int getMaxIdle() {
return maxIdle;
}
public void setMaxIdle(int maxIdle) {
this.maxIdle = maxIdle;
}
public int getMinIdle() {
return minIdle;
}
public void setMinIdle(int minIdle) {
this.minIdle = minIdle;
}
public int getMaxWaitTime() {
return maxWaitTime;
}
public void setMaxWaitTime(int maxWaitTime) {
this.maxWaitTime = maxWaitTime;
}
public int getTimeout() {
return timeout;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
public int getDbIndex() {
return dbIndex;
}
public void setDbIndex(int dbIndex) {
this.dbIndex = dbIndex;
}
}
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400)
public class RedisConfig {
private final RedisCon redisCon;
@Autowired
public RedisConfig(RedisCon redisCon) {
this.redisCon = redisCon;
}
@Bean
public static ConfigureRedisAction configureRedisAction() {
return ConfigureRedisAction.NO_OP;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
JedisConnectionFactory factory = new JedisConnectionFactory();
factory.setHostName(redisCon.getAddress());
factory.setPort(redisCon.getPort());
factory.setPassword(redisCon.getPassword());
factory.setTimeout(redisCon.getTimeout());
factory.setDatabase(redisCon.getDbIndex());
factory.afterPropertiesSet();
return factory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
return template;
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory());
return redisMessageListenerContainer;
}
}