spymemcached深入分析
author:智深
version:0.7
日志:http://my.oschina.net/astute
QQ:2548921609(技术交流)
一、简介
spymemcached 是一个 memcache 的客户端, 使用 NIO 实现。
分析 spymemcached 需要了解 NIO,memcached使用,memcached协议,参考资料中列出了有用的资源连接。
NIO是New I/O的缩写,Java里边大家一般称为异步IO,实际上对应Linux系统编程中的事件驱动IO(event-driven IO),是对 epoll 的封装。其它的IO模型还包括同步,阻塞,非阻塞,多路复用(select,poll)。阻塞/非阻塞是 fd 的属性,同步会跟阻塞配合,这样的应用会一直 sleep,直到IO完成被内核唤醒;同步非阻塞的话,第一次读取时,如果没有数据,应用线程会立刻返回,但是应用需要确定以什么样的策略进行后面的系统调用,如果是简单的while循环会导致CPU 100%,复杂的类似自旋的策略增加了应用编程的难度,因此同步非阻塞很少使用。多路复用是Linux早期的一个进程监控多个fd的方式,性能比较低,每次调用涉及3次循环遍历,具体分析见 http://my.oschina.net/astute/blog/92433 。event-driven IO,应用注册 感兴趣的socket IO事件(READ,WRITE),调用wait开始sleep,当条件成立时,如数据到达(可读),写缓冲区可用(可写),内核唤醒应用线程,应用线程根据得到的socket执行同步的调用读/写 数据。
二、协议简介
memcachded服务器和客户端之间采用 TCP 的方式通信,自定义了一套字节流的格式。本文分析的文本协议的构建和其它文本协议类似,mc里面分成命令行和数据块行,命令行里指明数据块的字节数目,命令行和数据块后都跟随\r\n。重要的一点是服务器在读取数据块时是根据命令行里指定的字节数目,因此数据块中含有\r或\n并不影响服务器读块操作。数据块后必须跟随\r\n。
存储命令
发送
<command name> <key> <flags> <exptime> <bytes> [noreply]\r\n
cas <key> <flags> <exptime> <bytes> <cas unique> [noreply]\r\n
<data block>\r\n
command name = "set", "add", "replace", "append" or "prepend"
flags - 32位整数 server并不操作这个数据 get时返回给客户端
exptime - 过期时间,可以是unix时间戳或偏移量,偏移量的话最大为30*24*60*60, 超过这个值,服务器会认为是unix时间戳
bytes - 数据块字节的个数
响应
<data block>\r\n
STORED\r\n - 成功
NOT_STORED\r\n - add或replace命令没有满足条件
EXISTS\r\n - cas命令 表明item已经被修改
NOT_FOUND\r\n - cas命令 item不存在
获取命令
发送
get <key>*\r\n
gets <key>*\r\n
<key>* - 空格分割的一个或多个字符串
响应
VALUE <key> <flags> <bytes> [<cas unique>]\r\n
<data block>\r\n
VALUE <key> <flags> <bytes> [<cas unique>]\r\n
<data block>\r\n
END\r\n
本文以 get 操作为例;key = someKey value=abcdABC中文
以字节流的形式最终发送的数据
[103, 101, 116, 32, 115, 111, 109, 101, 75, 101, 121, 13, 10, 0]
103 101 116 - "get"
32 - "" 空格
115 111 109 101 75 101 121 - someKey
13 10 - \r\n
接收到的数据
VALUE someKey 0 13
61 62 63 64 41 42 43 E4 B8 AD E6 96 87\r\n
END\r\n
删除命令
发送
delete <key> [noreply]\r\n
响应
DELETED\r\n - 成功删除
NOT_FOUND\r\n - 删除的条目不存在
其它命令
详见参考资料 mc 协议
三、spymemcached中的重要对象
简介
spy是mc的客户端,因此spy中所有对象需要基于它要完成的 功能 和 到mc服务器的通信协议来进行设计。最重要的MemcachedClient表示mc集群的client,应用中单例即可。spy中的每一个mc节点,用MemcachedNode表示,这个对象内部含有一个channel,网络连接到mc节点。要根据key的哈希值查找某个mc节点,spy中使用NodeLocator,默认locator是ArrayModNodeLocator,这个对象内部含有所有的MemcachedNode,spy使用的hash算法都在对象DefaultHashAlgorithm中,默认使用NATIVE_HASH,也就是String.hashCode()。locator和client中间还有一个对象,叫MemcachedConnection ,它表示到mc集群的连接,内部持有locator。clent内部持有MemcachedConnection(mconn)。spy使用NIO实现,因此有一个selector,这个对象存在于mconn中。要和服务器进行各种操作的通信,协议数据发送,数据解析,spy中抽象为Operation,文本协议的get操作最终实现为net.spy.memcached.protocol.ascii.GetOperationImpl。为了实现工作线程和IO线程之间的调度,spy抽象出了一个 GetFuture,内部持有一个OperationFuture。
TranscodeService执行字节数据和对象之间的转换,spy中实现方式为任务队列+线程池,这个对象的实例在client中。
对象详解
SpyObject - spy中的基类 定义 Logger
MemcachedConnection - 表示到多台 mc 节点的连接
MemcachedConnection - 详细属性
shouldOptimize - 是否需要优化多个连续的get操作 --> gets 默认true
addedQueue - 用来记录排队到节点的操作
selector - 监控到多个 mc 服务器的读写事件
locator - 定位某个 mc 服务器
GetFuture - 前端线程和工作线程交互的对象
--> OperationFuture
ConnectionFactory - 创建 MemcachedConnection 实例;创建操作队列;创建 OperationFactory;制定 Hash 算法。
DefaultConnectionFactory - 默认连接工厂
DefaultHashAlgorithm - Hash算法的实现类
MemcachedNode - 定义到 单个memcached 服务器的连接
TCPMemcachedNodeImpl -
AsciiMemcachedNodeImpl -
BinaryMemcachedNodeImpl -
TCPMemcachedNodeImpl - 重要属性
socketAddress - 服务器地址
rbuf - 读缓冲区 默认大小 16384
wbuf - 写缓冲区 默认大小 16384
writeQ - 写队列
readQ - 读队列
inputQueue - 输入队列 memcachclient添加操作时先添加到 inputQueue中
opQueueMaxBlockTime - 操作的最大阻塞时间 默认10秒
reconnectAttempt - 重连尝试次数 volatile
channel - socket 通道
toWrite - 要向socket发送的字节数
optimizedOp - 优化后的Operation 实现类是OptimizedGetImpl
sk - channel注册到selector后的key
shouldAuth - 是否需要认证 默认 false
authLatch - 认证需要的Latch
reconnectBlocked -
defaultOpTimeout - 操作默认超时时间 默认值 2.5秒
continuousTimeout - 连续超时次数
opFact - 操作工厂
MemcachedClient - 重要属性
mconn - MemcachedConnection
opFact - 操作工厂
transcoder - 解码器
tcService - 解码线程池服务
connFactory - 连接工厂
Operation - 所有操作的基本接口
BaseOperationImpl
OperationImpl
BaseGetOpImpl - initialize 协议解析 构建缓冲区
GetOperationImpl
OperationFactory - 为协议构建操作 比如生成 GetOperation
BaseOperationFactory
AsciiOperationFactory - 文本协议的操作工厂 默认的操作工厂
BinaryOperationFactory - 二进制协议的操作工厂
OperationFactory - 根据 protocol handlers 构建操作
BaseOperationFactory
AsciiOperationFactory - 支持 ascii protocol
BinaryOperationFactory - 支持 binary operations
NodeLocator - 根据 key hash 值查找节点
ArrayModNodeLocator - hash 值和节点列表长度取模,作为下标,简单的数组查询
KetamaNodeLocator - Ketama一致性hash的实现
Transcoder - 对象和字节数组之间的转换接口
BaseSerializingTranscoder
SerializingTranscoder - 默认的transcoder
TranscodeService - 异步的解码服务,含有一个线程池
FailureMode - node失效的模式
Redistribute - 节点失效后移动到下一个有效的节点 默认模式
Retry - 重试失效节点 直至恢复
Cancel - 取消操作
四、整体流程
初始化
客户端执行new MemcachedClient(new InetSocketAddress("192.168.56.101", 11211))。初始化 MemcachedClient,内部初始化MemcachedConnection,创建selector,注册channel到selector,启动IO线程。
线程模型
初始化完成后,把监听mc节点事件的线程,也就是调用select的线程,称为IO线程;应用执行 c.get("someKey"),把应用所在的线程称为工作线程。工作线程通常由tomcat启动,负责创建操作,加入节点的操作队列,工作线程通常有多个;IO线程负责从队列中拿到操作,执行操作。
工作线程
工作线程最终会调用asyncGet,方法内部会创建CountDownLatch(1), GetFuture,GetOperationImpl(持有一个内部类,工作线程执行完成后,最终会调用 latch.countDown()),选择mc节点,操作op初始化(生成写缓冲区),把op放入节点等待队列inputQueue中,同时会把当前节点放入mc连接(mconn)的addedQueue属性中,最后唤醒selector。最终工作线程在latch上等待(默认超时2.5秒)IO线程的执行结果。
IO线程
IO线程被唤醒后
1、handleInputQueue()。移动Operation从inputQueue到writeQ中。对添加到addedQueue中的每一个MemcachedNode分别进行处理。这个函数会处理所有节点上的所有操作,全部发送到mc服务器(之前节点上就有写操作的才这么处理,否则只是注册写事件)。
2、循环过程中,如果当前node中没有写操作,则判断writeQ,readQ中有操作,在SK上注册读/写事件;如果有写操作,需要执行handleWrites函数。这个函数内部首先做的是、填充缓冲区fillWriteBuffer():从writeQ中取出一个可写的操作(remove掉取消的和超时的),改变操作的状态为WRITING,把操作的数据复制到写缓冲区(写缓冲区默认16K,操作的字节数从十几字节到1M,这个地方有复杂的处理,后面会详细分析,现在只考虑简单情况),复制完成后把操作状态变为READING,从writeQ中remove当前操作,把操作add到readQ当中,这个地方会再去复制pending的操作;‚、发送写缓冲区的内容,全部发送完成后,会再次去填充缓冲区fillWriteBuffer()(比如说一个大的命令,一个缓冲区不够)。循环,直到所有的写操作都处理完。ƒ、判断writeQ,readQ是否有操作,更新sk注册的读写事件。get操作的话,现在已经注册了读事件。
3、selector.select()
4、数据到达时,执行handleIO(sk),处理读事件;执行channel.read(rbuf);执行readFromBuffer(),解析数据,读取到END\r\n将操作状态置为COMPLETE。
五、初始化详细流程
1、默认连接工厂为 DefaultConnectionFactory。接着创建TranscodeService(解码的线程池,默认线程最多为10),创建AsciiOperationFactory(支持ascii协议的操作工厂,负责生成各种操作,比如 GetOperationImpl),创建MemcachedConnection,设置操作超时时间(默认2.5秒)。
2、DefaultConnectionFactory创建MemcachedConnection详细过程:创建reconnectQueue,addedQueue,设置shouldOptimize为true,设置maxDelay为30秒,设置opFact,设置timeoutExceptionThreshold为1000(超过这个值,关闭到 mc node 的连接),打开 Selector,创建nodesToShutdown,设置bufSize为16384字节,创建到每个node的 MemcachedNode(默认是AsciiMemcachedNodeImpl,这一步创建SocketChannel,连接到mc节点,注册到selector,设置sk为刚注册得到的SelectionKey),最后启动 MemcachedConnection 线程,进入事件处理的循环代码
while(running) handleIO()。
六、核心流程代码
1、工作线程
一切从工作线程调用 c.get("someKey") 方法开始
基本流程是:创建操作(Operation),操作初始化,查找节点,把操作加入节点的等待队列,唤醒IO线程,然后工作线程在Future上等待IO线程的执行结果
2 | return asyncGet(key, tc).get( 2500 , TimeUnit.MILLISECONDS) |
04 | public <T> GetFuture<T> asyncGet( final String key, final Transcoder<T> tc) { |
05 | final CountDownLatch latch = new CountDownLatch( 1 ); |
06 | final GetFuture<T> rv = new GetFuture<T>(latch, operationTimeout, key); |
07 | Operation op = opFact.get(key, new GetOperation.Callback() { |
08 | private Future<T> val = null ; |
09 | public void receivedStatus(OperationStatus status) { |
12 | public void gotData(String k, int flags, byte [] data) { |
13 | val = tcService.decode(tc, new CachedData(flags, data, tc.getMaxSize())); |
15 | public void complete() { |
20 | mconn.enqueueOperation(key, op); |
03 | protected void addOperation( final String key, final Operation o) { |
04 | MemcachedNode placeIn = null ; |
05 | MemcachedNode primary = locator.getPrimary(key); |
06 | if (primary.isActive() || failureMode == FailureMode.Retry) { |
08 | } else if (failureMode == FailureMode.Cancel) { |
11 | for (Iterator<MemcachedNode> i = locator.getSequence(key); placeIn == null |
13 | MemcachedNode n = i.next(); |
18 | if (placeIn == null ) { |
22 | if (placeIn != null ) { |
23 | addOperation(placeIn, o); |
2 | protected void addOperation( final MemcachedNode node, final Operation o) { |
3 | o.setHandlingNode(node); |
6 | addedQueue.offer(node); |
7 | Selector s = selector.wakeup(); |
工作线程和IO线程之间传递的Future对象,结构如下
GetFuture ---> OperationFuture ---> latch
---> 表示依赖关系
02 | public T get( long duration, TimeUnit units) { |
03 | if (!latch.await(duration, units)) { |
04 | MemcachedConnection.opTimedOut(op); |
2、IO线程
IO线程的操作循环
处理输入队列,注册写事件;执行写操作,注册读事件;处理读操作,解析结果。
01 | public void handleIO() throws IOException { |
03 | int selected = selector.select(delay); |
04 | Set<SelectionKey> selectedKeys = selector.selectedKeys(); |
06 | if (selectedKeys.isEmpty() && !shutDown) { |
09 | for (SelectionKey sk : selectedKeys) { |
02 | 处理addedQueue中的所有节点,对每一个节点复制inputQueue中的操作到writeQ中。注册读写事件。 |
03 | private void handleInputQueue() { |
04 | if (!addedQueue.isEmpty()) { |
05 | Collection<MemcachedNode> toAdd = new HashSet<MemcachedNode>(); |
06 | Collection<MemcachedNode> todo = new HashSet<MemcachedNode>(); |
07 | MemcachedNode qaNode = null ; |
08 | while ((qaNode = addedQueue.poll()) != null ) { |
11 | for (MemcachedNode qa : todo) { |
12 | boolean readyForIO = false ; |
14 | if (qa.getCurrentWriteOp() != null ) { |
23 | if (qa.getWbuf().hasRemaining()) { |
24 | handleWrites(qa.getSk(), qa); |
26 | } catch (IOException e) { |
32 | addedQueue.addAll(toAdd); |
02 | readQ不为空注册读事件;writeQ不为空注册写事件;网络没有连接上注册连接事件。 |
03 | public final void fixupOps() { |
05 | if (s != null && s.isValid()) { |
06 | int iops = getSelectionOps(); |
01 | public final int getSelectionOps() { |
03 | if (getChannel().isConnected()) { |
05 | rv |= SelectionKey.OP_READ; |
07 | if (toWrite > 0 || hasWriteOp()) { |
08 | rv |= SelectionKey.OP_WRITE; |
11 | rv = SelectionKey.OP_CONNECT; |
1 | public final boolean hasReadOp() { |
2 | return !readQ.isEmpty(); |
5 | public final boolean hasWriteOp() { |
6 | return !(optimizedOp == null && writeQ.isEmpty()); |
3、handleWrites(SelectionKey sk, MemcachedNode qa)
我能够想到的一些场景,这个状态机代码必须处理的
⑴ 当前队列中有1个操作,操作要发送的字节数目小于16K
⑵ 当前队列中有1个操作,操作要发送的字节数目大于16K(很大的set操作)
⑶ 当前队列中有多个操作,操作要发送的字节数目小于16K
⑷ 当前队列中有多个操作,操作要发送的字节数目大于16K
⑸ 任意一次写操作wrote为0
summary:处理节点中writeQ和inputQueue中的所有操作。每次循环会尽量填满发送缓冲区,然后将发送缓冲区的内容全部发送到网络上,循环往复,没有异常的情况下,直至发送完数据。操作中发送的内容只要放入到发送缓冲区后,就把操作加入到readQ(spy中根据writeQ和readQ中有没有数据,来注册读写事件)。
执行时机:IO线程在select上休眠,被工作线程唤醒后,处理输入队列,把操作复制到writeQ 中,注册写事件;再次调用select,返回后,就会调用handleWrites(),数据全部发送后,会注册读事件。处理输入队列时,如果wbuf还有东西没有发送,那么会在select调用前,调用handleWrites函数。
01 | private void handleWrites(SelectionKey sk, MemcachedNode qa) throws IOException { |
02 | qa.fillWriteBuffer(shouldOptimize); ---> |
03 | boolean canWriteMore = qa.getBytesRemainingToWrite() > 0 ; |
04 | while (canWriteMore) { |
05 | int wrote = qa.writeSome(); ---> |
06 | qa.fillWriteBuffer(shouldOptimize); |
07 | canWriteMore = wrote > 0 && qa.getBytesRemainingToWrite() > 0 ; |
11 | -- 发送数据;执行一次后,wbuf可能还有数据未写完 |
12 | public final int writeSome() throws IOException { |
13 | int wrote = channel.write(wbuf); |
02 | toWrite= 0 表明 写缓冲区以前的内容已经全部写入到网络中,这样才会进行下一次的填充写缓冲区 |
03 | 操作会尽量填满16K的缓冲区(单一操作数据量很大比如500K;或多个操作数据量500K) |
04 | 当一个操作中的数据完全写入缓冲区后,操作的状态变成READING,从writeQ中移除当前操作。 |
05 | public final void fillWriteBuffer( boolean shouldOptimize) { |
06 | if (toWrite == 0 && readQ.remainingCapacity() > 0 ) { |
08 | Operation o=getNextWritableOp(); ---> |
10 | while (o != null && toWrite < getWbuf().capacity()) { |
12 | ByteBuffer obuf = o.getBuffer(); |
13 | int bytesToCopy = Math.min(getWbuf().remaining(), obuf.remaining()); |
14 | byte [] b = new byte [bytesToCopy]; |
17 | if (!o.getBuffer().hasRemaining()) { |
19 | transitionWriteItem(); |
20 | preparePending(); -- copyInputQueue() |
24 | o=getNextWritableOp(); |
26 | toWrite += bytesToCopy; |
02 | 如果操作已经取消(前端线程等待超时,取消操作),或超时(IO线程没有来得及执行操作,操作超时),那么把操作从队列中移除,继续查找下一个操作。把可写的操作的状态从WRITE_QUEUED变成WRITING,同时把操作放入读队列中。 |
03 | private Operation getNextWritableOp() { |
04 | Operation o = getCurrentWriteOp(); --->④ |
05 | while (o != null && o.getState() == OperationState.WRITE_QUEUED) { |
07 | if (o.isCancelled()) { |
08 | Operation cancelledOp = removeCurrentWriteOp();--->⑤ |
09 | } else if (o.isTimedOut(defaultOpTimeout)) { |
10 | Operation timedOutOp = removeCurrentWriteOp(); |
13 | if (!(o instanceof TapAckOperationImpl)) { |
18 | o = getCurrentWriteOp(); |
01 | ④ -- 拿到当前写操作(并不从队列中移除) |
02 | public final Operation getCurrentWriteOp() { |
03 | return optimizedOp == null ? writeQ.peek() : optimizedOp; |
07 | public final Operation removeCurrentWriteOp() { |
08 | Operation rv = optimizedOp; |
02 | handleReads(SelectionKey sk, MemcachedNode qa) |
03 | 从网络中读取数据,放入rbuf。解析rbuf,得到结果; |
04 | private void handleReads(SelectionKey sk, MemcachedNode qa) throws IOException { |
05 | Operation currentOp = qa.getCurrentReadOp(); |
06 | if (currentOp instanceof TapAckOperationImpl) { |
07 | qa.removeCurrentReadOp(); |
10 | ByteBuffer rbuf = qa.getRbuf(); |
11 | final SocketChannel channel = qa.getChannel(); |
12 | int read = channel.read(rbuf); |
18 | while (rbuf.remaining() > 0 ) { |
19 | synchronized (currentOp) { |
20 | currentOp.readFromBuffer(rbuf); |
21 | if (currentOp.getState() == OperationState.COMPLETE) { |
22 | Operation op = qa.removeCurrentReadOp(); |
23 | } else if (currentOp.getState() == OperationState.RETRY) { |
24 | ((VBucketAware) currentOp).addNotMyVbucketNode(currentOp.getHandlingNode()); |
25 | Operation op = qa.removeCurrentReadOp(); |
26 | retryOps.add(currentOp); |
29 | currentOp=qa.getCurrentReadOp(); |
32 | read = channel.read(rbuf); |
02 | public void readFromBuffer(ByteBuffer data) throws IOException { |
03 | while (getState() != OperationState.COMPLETE && data.remaining() > 0 ) { |
04 | if (readType == OperationReadType.DATA) { |
08 | for ( int i = 0 ; data.remaining() > 0 ; i++) { |
12 | } else if (b == '\n' ) { |
13 | assert foundCr : "got a \\n without a \\r" ; |
18 | assert !foundCr : "got a \\r without a \\n" ; |
23 | String line = new String(byteBuffer.toByteArray(), CHARSET); |
25 | OperationErrorType eType = classifyError(line); |
27 | handleError(eType, line); |
03 | public final void handleLine(String line) { |
04 | if (line.equals( "END" )) { |
06 | getCallback().receivedStatus(END); |
08 | getCallback().receivedStatus(NOT_FOUND); |
10 | transitionState(OperationState.COMPLETE); |
12 | } else if (line.startsWith( "VALUE " )) { |
13 | String[] stuff = line.split( " " ); |
14 | currentKey = stuff[ 1 ]; |
15 | currentFlags = Integer.parseInt(stuff[ 2 ]); |
16 | data = new byte [Integer.parseInt(stuff[ 3 ])]; |
17 | if (stuff.length > 4 ) { |
18 | casValue = Long.parseLong(stuff[ 4 ]); |
22 | setReadType(OperationReadType.DATA); |
23 | } else if (line.equals( "LOCK_ERROR" )) { |
24 | getCallback().receivedStatus(LOCK_ERROR); |
25 | transitionState(OperationState.COMPLETE); |
27 | assert false : "Unknown line type: " + line; |
5、那个著名的bug
JAVA NIO bug 会导致 CPU 100%
http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6403933
int selected = selector.select(delay);
Set<SelectionKey> selectedKeys = selector.selectedKeys();
if (selectedKeys.isEmpty() && !shutDown) {
if (++emptySelects > DOUBLE_CHECK_EMPTY) {
for (SelectionKey sk : selector.keys()) {
if (sk.readyOps() != 0) {
handleIO(sk);
} else {
lostConnection((MemcachedNode) sk.attachment());
}
DOUBLE_CHECK_EMPTY = 256,当连续的select返回为空时,++emptySelects,超过256,连接到当前mc节点的socket channel关闭,放入重连队列。
七、调试 spymemcached
调试 spymemcached IO线程的过程中,工作线程放入到节点队列的操作很容易超时,因此需要继承DefaultConnectionFactory 复写相关方法。
01 | public class AstuteConnectionFactory extends DefaultConnectionFactory { |
03 | public boolean shouldOptimize() { |
07 | public long getOperationTimeout() { |
八、参考资料
NIO:http://www.ibm.com/developerworks/cn/education/java/j-nio/index.html
memcached:http://memcached.org/
protocol:https://github.com/memcached/memcached/blob/master/doc/protocol.txt