预备知识:
- 设计模式:命令模式、装饰模式
- Java Socket编程
- Apach Commons Pool
- CRC32散列算法(了解)
- Redis命令
- Redis协议规范(了解)
- Redis集群规范
市面上一些参考书籍:
- 《Redis In Action》:偏应用讲解,适合redis入门,可以在学习完官网的命令之后,从这本书中提供的一些案例场景进行一些实战。缺点是写的有点啰嗦,中文版的翻译水平一般
- 《Redis开发与运维》:作者是搜狐的同事,每一块功能都是redisserver的原理、jedis原理、jedis使用一起讲,融会贯通。
- 《Redis设计与实现》:讲redis底层数据结构和原理方面。每个命令怎么执行的,有哪些定义了哪些struct结构。感觉比较适合进一步研究redis源码的同学
Jedis源码分析
一、Jedis使用
1. Jedis的使用
Jedis jedis = null;
try {
jedis = new Jedis("127.0.0.1", 6379);
jedis.get("hello");
} catch (Exception e) {
logger.error(e.getMessage(),e);
} finally {
if (jedis != null) {
jedis.close();
}
}
使用流程如下:
2. Jedis源码分析
Jedis给调用方提供的是简单易读的JAVA API接口,调用方使用API就像使用redis cli一样简单。那么Jedis内部在向redisServer发送请求之前都做了哪些事情呢?
我们通过源码看看Jedis是怎么设计的:
Jedis一共分为四层:
- 协议层Protocol类
- 连接层Connetion
- 原生客户端
- Jedis客户端
具体大家可以参考这篇文章:
http://jimgreat.iteye.com/blog/1586671
这三层之间的调用关系如下图所示:
二、JedisPool
1. JedisPool使用
创建连接池
// common-pool连接池配置,这里使用默认配置,后面小节会介绍具体配置说明
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 初始化Jedis连接池
JedisPool jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379);
从连接池获取对象使用
Jedis jedis = null;
try {
// 1. 从连接池获取jedis对象
jedis = jedisPool.getResource();
// 2. 执行操作
jedis.get("hello");
} catch (Exception e) {
logger.error(e.getMessage(),e);
} finally {
if (jedis != null) {
// 如果使用JedisPool,close操作不是关闭连接,代表归还连接池
jedis.close();
}
}
使用流程如下:
2. JedisPool源码分析
从类关系图中看到JedisPool是通过 Apache Commons Pool 中的GenericObjectPool这个对象池来实现的,掌握JedisPool的关键是掌握GenericObjectPool。
下面我们看一下JedisPool和JedisFactory两个类的源码
直接看项目源码
3. Jedis直连方式和连接池方式对比:
三、JedisCluster
先看一下如何使用JedisCluster:
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("127.0.0.1", 6379));
JedisCluster jc = new JedisCluster(jedisClusterNode, DEFAULT_TIMEOUT, DEFAULT_TIMEOUT,
DEFAULT_REDIRECTIONS, "cluster", DEFAULT_CONFIG);
jc.set("foo", "bar");
assertEquals("bar", jc.get("foo"));
从上面的代码中我们可以看到:
构造JediCluster时只传入了1个redis节点信息,而不是多个,为什么?JediCluster在构造的时候做了哪些事情?带着这个问题我们一起看下初始化关键源码
JedisClusterConnectionHandler类
private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig,
int connectionTimeout, int soTimeout, String password, String clientName) {
//循环取节点初始化,成功即退出
for (HostAndPort hostAndPort : startNodes) {
Jedis jedis = null;
try {
jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout, soTimeout);
if (password != null) {
jedis.auth(password);
}
if (clientName != null) {
jedis.clientSetname(clientName);
}
//使用jedis发现集群节点和插槽关系信息,初始化时只要没有抛出异常,就认为初始化成功,并退出
cache.discoverClusterNodesAndSlots(jedis);
break;
} catch (JedisConnectionException e) {
// try next nodes
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
JedisClusterInfoCache 类
/**
* 负责插槽到JedisPool、节点IP到JedisPool这2个映射关系的初始化、修改、查询
* 并用读写锁保证线程安全
*/
public class JedisClusterInfoCache {
//节点到连接池的映射,节点表示"IP:端口",nodes只在初始化时被清空,后面renewClusterSlots只会增加新的节点到这个map中
private final Map<String, JedisPool> nodes = new HashMap<String, JedisPool>();
//16454个插槽到连接池的映射关系,因为多个插槽会映射到同一个节点,所以多个插槽可能会对应同一个连接池
private final Map<Integer, JedisPool> slots = new HashMap<Integer, JedisPool>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
//使用rw锁对nodes和slots的初始化和reshard时进行保护,需要注意,rw锁会不会阻塞用户线程
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
//rediscovering变量
private volatile boolean rediscovering;
private final GenericObjectPoolConfig poolConfig;
//连接池创建工厂需要的一些属性
private int connectionTimeout;
private int soTimeout;
private String password;
private String clientName;
private static final int MASTER_NODE_INDEX = 2;
public JedisClusterInfoCache(final GenericObjectPoolConfig poolConfig, int timeout) {
this(poolConfig, timeout, timeout, null, null);
}
public JedisClusterInfoCache(final GenericObjectPoolConfig poolConfig,
final int connectionTimeout, final int soTimeout, final String password, final String clientName) {
this.poolConfig = poolConfig;
this.connectionTimeout = connectionTimeout;
this.soTimeout = soTimeout;
this.password = password;
this.clientName = clientName;
}
/**
* 发现集群中插槽和节点的映射关系
*
* @param jedis
*/
public void discoverClusterNodesAndSlots(Jedis jedis) {
w.lock();
try {
reset();
/**根据当前redis实例,获取集群中master,slave节点信息。包括每个master节点 上分配的数据嘈。
* slots结果如下(例子):
*[10923, 16383, [[B@924fda2, 9000], [[B@5b879b5e, 9001]]]
*[[5461, 10922, [[B@3681fe9a, 7001], [[B@10724c6b, 8000]],
*[0, 5460, [[B@3ff70d3c, 7000], [[B@7485fef2, 8001]],
*/
List<Object> slots = jedis.clusterSlots();
for (Object slotInfoObj : slots) {
List<Object> slotInfo = (List<Object>) slotInfoObj;
if (slotInfo.size() <= MASTER_NODE_INDEX) {
continue;
}
//一批插槽一批插槽的分配
List<Integer> slotNums = getAssignedSlotArray(slotInfo);
// hostInfos
int size = slotInfo.size();
for (int i = MASTER_NODE_INDEX; i < size; i++) {
List<Object> hostInfos = (List<Object>) slotInfo.get(i);
if (hostInfos.size() <= 0) {
continue;
}
HostAndPort targetNode = generateHostAndPort(hostInfos);
setupNodeIfNotExist(targetNode);
if (i == MASTER_NODE_INDEX) {
assignSlotsToNode(slotNums, targetNode);
}
}
}
} finally {
w.unlock();
}
}
从源码中我们可以看到JedisCluster在初始化时提前构造好了slot到JedisPool的映射关系,利用这些关系,在后面读写key的时候可以很方便的进行节点的路由。 但后面如果redis节点上下线怎么办呢?客户端缓存的映射关系不就是脏数据了吗?我们继续看源码(命令调用流程run方法)怎么解决的
JedisClusterCommand类
/**
* 该方法采用递归方式,保证在往集群中存取数据时,发生MOVED,ASKing,数据迁移过程中遇到问题,也是一种实现高可用的方式。
* 该方法中调用execute方法,该方法由子类具体实现
*/
private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, boolean asking) {
if (attempts <= 0) {
throw new JedisClusterMaxAttemptsException("No more cluster attempts left.");
}
Jedis connection = null;
try {
//第一次执行该方法,asking为false。只有发生JedisAskDataException,才asking才设置为true
if (asking) {//服务器返回ASK
// TODO: Pipeline asking with the original command to make it
// faster....
connection = askConnection.get();
connection.asking();
// if asking success, reset asking flag
asking = false;
} else {
if (tryRandomNode) {//从源码来看tryRandomNode貌似永远是false,这个分支先不用管
connection = connectionHandler.getConnection();
} else {
/** 根据key获取分配的嘈数,然后根据数据槽从JedisClusterInfoCache 中获取Jedis的实例
**/
connection = connectionHandler.getConnectionFromSlot(slot);
}
}
return execute(connection);
} catch (JedisNoReachableClusterNodeException jnrcne) {
throw jnrcne;
} catch (JedisConnectionException jce) {
// release current connection before recursion
releaseConnection(connection);
connection = null;
if (attempts <= 1) {
//We need this because if node is not reachable anymore - we need to finally initiate slots renewing,
//or we can stuck with cluster state without one node in opposite case.
//But now if maxAttempts = 1 or 2 we will do it too often. For each time-outed request.
//TODO make tracking of successful/unsuccessful operations for node - do renewing only
//if there were no successful responses from this node last few seconds
this.connectionHandler.renewSlotCache();//重建插槽和节点关系
}
return runWithRetries(slot, attempts - 1, tryRandomNode, asking);
} catch (JedisRedirectionException jre) {
// if MOVED redirection occurred,
if (jre instanceof JedisMovedDataException) {
// it rebuilds cluster's slot cache
// recommended by Redis cluster specification
//返回MOVED,插槽读写已经全部移到其他节点,需要先重构插槽到连接池的关系
this.connectionHandler.renewSlotCache(connection);//重建插槽和节点关系
}
// release current connection before recursion or renewing
releaseConnection(connection);
connection = null;
if (jre instanceof JedisAskDataException) {//返回ASK,集群正在进行插槽迁移,jedis客户端不重构集群状态信息,只是本次key读写,连接targetNode节点进行
//
asking = true;
askConnection.set(this.connectionHandler.getConnectionFromNode(jre.getTargetNode()));
} else if (jre instanceof JedisMovedDataException) {
} else {//????
throw new JedisClusterOperationException(jre);
}
return runWithRetries(slot, attempts - 1, false, asking);
} finally {
releaseConnection(connection);
}
}
JedisClusterInfoCache重新发现集群插槽节点关系
/**
* 重新分配插槽到连接池的对应关系
*/
public void renewClusterSlots(Jedis jedis) {
//类似单例模式
//If rediscovering is already in process - no need to start one more same rediscovering, just return
if (!rediscovering) {
try {
w.lock();
rediscovering = true;
if (jedis != null) {
try {
discoverClusterSlots(jedis);
return;
} catch (JedisException e) {
//try nodes from all pools
}
}
//如果上面使用传进来的jedis发现插槽抛出异常,则使用下面的方式,随机一个节点去获取插槽
for (JedisPool jp : getShuffledNodesPool()) {
Jedis j = null;
try {
j = jp.getResource();
discoverClusterSlots(j);
return;
} catch (JedisConnectionException e) {
// try next nodes,可能连接池满了
} finally {
if (j != null) {
j.close();
}
}
}
} finally {
rediscovering = false;
w.unlock();
}
}
}
- 另外和Jedis和JedisPool的使用对比,发现执行完JedisCluster时没有close方法。这些操作JedisCluster内部就帮我们做了,它通过Command模式将这些固定流程进行了抽象和封装。我们看一下代码
@Override
public String set(final String key, final String value) {
return new JedisClusterCommand<String>(connectionHandler, maxAttempts) {
@Override
public String execute(Jedis connection) {
return connection.set(key, value);
}
}.run(key);
}
- 最后我们对各个类的关系和调用顺序做一个总结
四、常见问题
- 当出现网络拥塞,或者Redis服务器宕机情况下,业务系统会出现什么情况?
回答:Jedis内部会抛出JedisConnectionException异常,具体可以看源码 - 使用Pipline批量执行redis命令时,redis命令是一次性全部发到服务端,还是一条一条发送?
回答:Jedis的RedisOutputStream里有一个buf缓冲区。默认大小8KB,发送给服务器端的命令先缓存在buf里,超过8KB就会一次性发送到服务器端,减少系统IO调用,提高性能 - 集群模式下,初始化参数IP地址必须和线上IP地址完全一致吗?集群模式下,初始化参数IP地址必须和线上IP地址完全一致吗?集群模式下,初始化参数IP地址必须和线上IP地址完全一致吗?集群模式下,初始化参数IP地址必须和线上IP地址完全一致吗?
回答:不是,初始化时,只要连上任意一台 redis,通过cluster命令就可以拿到插槽和ip地址的分配关系 - 集群模式下,如果增加、删除节点,能否被jedisCluster识别?
回答:可以,JedisCluster在执行命令请求的时候,会根据RedisServer返回的结果renewClusterSlots - 高并发场景下,会不会有多个线程同时renewClusterSlots,造成大量线程等待w锁,阻塞影响业务系统
回答:不会,jedis使用了rediscovering这个变量,具体可以看源码 - JedisCluster为什么保存键和服务器节点之间的映射信息?
答:虽然客户端可以自由地向集群中的任何一个节点发送命令请求, 并可以在有需要时, 根据转向错误所提供的信息( -MOVED 或者 -ASK), 将命令转发至正确的节点, 所以在理论上来说, 客户端是无须保存集群状态信息的。但是将键和节点之间的映射信息保存起来, 客户端可以有效地减少可能出现的转向次数, 籍此提升命令执行的效率。(见Redis集群规范Redis集群规范) - key散列算法是什么?
回答:CRC16。key到slot:getCRC16(key) & (16384 - 1)。16384是2的14次方.
五、举一反三
- 路由能在哪一层做?
1.1. 客户端路由
1.2. 服务端路由
1.3. proxy代理层路由 - 路由算法。其他路由散列算法原理和特性可以参考梅晨做的技术分享
- 如果一个对象里的数据有初始化,就要考虑是否有变更,如果变更的过程中还有读的操作,要立刻条件反射是否需要加读写锁。可以将这些行为封装在一个类中
- mysql执行流程 VS redis执行流程.
- Nagle算法
其他部分源码,有时间介绍一下:
- RedisException
- JedisDataException:已获得Redis服务器响应,但是结果错误。
- RedisOutputStream