一、故障背景
2018-04-09 OPS 宿主迁移 cache1下线 (一共2台 memcache 缓存服务器 ),导致网关存储超时报错,进而影响支付成功率8分钟。 报错内容如下:
- Cannot connect to Hessian remote service at [http://***];
- nested exception is com.caucho.hessian.client.HessianConnectionException: 500: java.net.SocketTimeoutException: Read timed
二、故障分析
1. 故障基础分析
日志:业务层请求 memcache 超时时间设为2s ,当时的 QPS:
cache1的 QPS : 2.7左右
cache2的 QPS:2左右
超时故障恢复:当 memcache 客户端 session (配置的50就是连接数目) 全部 close 时不会再向 cache1机器发送请求,4台机器用了6-8分钟, failover 的时间太长了。所有机器和 cache1的 session 都是因为 memcache 的心跳机制关闭。
一台机器50个 session 关闭时间:
2018-04-09 15:15:37.589 2018-04-09 15:15:39.600 ... 2018-04-09 15:16:49.364
2018-04-09 15:19:35.455 2018-04-09 15:19:51.961 2018-04-09 15:22:41.572
前73秒关闭了47个 memcache1的 session (连接)一共用了8分钟,另外3台机器用了5分钟、5分钟、8分钟。 都是大概10%的时间关闭了90%的 session 。
当时线上配置及环境:
每台机器配置50个连接,其中下线机器配置权重为2。
- memcached.server2.host=192.*.*.44 memcached.server2.port=6666 memcached.server2.weight=2
- memcached.server2.host=192.*.*.45 memcached.server2.port=6666 memcached.server2.weight=1
- memcached.connection.pool.size=50
2. 问题分析
2.1 源码依赖:xmemcached-2.0.0.jar
配置:应用层请求 memcache 超时时间设为2S
- <property name="sessionLocator"> <bean class="net.rubyeye.xmemcached.impl.KetamaMemcachedSessionLocator"/> </property>
使用 memcache 客户端的一致性 hash 做负载均衡
2.2 源码分析
2.2.1 一致性 hash 机制(选择 session 策略):
KetamaMemcachedSessionLocator
a、一致性 hash 环的初始化:
- static finalint NUM_REPS =160;//虚拟节点基数//节点使用treeMap存储 key是节点编号 value为该节点下的session列表(session是memcache客户端对服务端一个Tcp连接的封装)
- private transient volatile TreeMap<Long, List<Session>> ketamaSessions =newTreeMap<Long, List<Session>>();//当增加session(初始化时session连接、session重连成功等)、关闭session会调用
- private final void buildMap(Collection<Session> list, HashAlgorithm alg){
- TreeMap<Long, List<Session>> sessionMap =newTreeMap<Long, List<Session>>();
- String sockStr;
- for (Session session : list) {
- if (this.cwNginxUpstreamConsistent) {
- sockStr = String.format("%s:%d",
- session.getRemoteSocketAddress().getAddress()
- .getHostAddress(), session
- .getRemoteSocketAddress().getPort());
- } else {
- sockStr = String.valueOf(session.getRemoteSocketAddress());
- }
- /**
- * Duplicate 160 X weight references
- */
- int numReps = NUM_REPS;
- if (session instanceof MemcachedTCPSession) {
- numReps *= ((MemcachedSession) session).getWeight();
- }
- if (alg == HashAlgorithm.KETAMA_HASH) {
- for (int i = 0; i < numReps / 4; i++) {
- byte[] digest = HashAlgorithm.computeMd5(sockStr + "-" + i);
- for (int h = 0; h < 4; h++) {
- long k = (long) (digest[3 + h * 4] & 0xFF) << 24
- | (long) (digest[2 + h * 4] & 0xFF) << 16
- | (long) (digest[1 + h * 4] & 0xFF) << 8
- | digest[h * 4] & 0xFF;
- this.getSessionList(sessionMap, k).add(session);
- }
- }
- } else {
- for (int i = 0; i < numReps; i++) {
- long key = alg.hash(sockStr + "-" + i);
- this.getSessionList(sessionMap, key).add(session);
- }
- }
- }
- this.ketamaSessions = sessionMap;
- this.maxTries = list.size();
- }
根据客户端 memcache 配置
最终 TreeMap 里160*(2+1)=480个key,其中320个 key (虚拟节点)对应的 sessionList 是50个 session(cache1) 160个 key (虚拟节点)是50个 session(cache2)。
b、根据 key 做 hash 获得 session
- //hash参数根据memcache请求(get、set等operator)的key 通过hash算法求出
- public final Session getSessionByKey(final String key) {
- if (this.ketamaSessions == null || this.ketamaSessions.size() == 0) {
- return null;
- }
- long hash = this.hashAlg.hash(key);
- Session rv = this.getSessionByHash(hash);
- int tries = 0;
- while (!this.failureMode && (rv == null || rv.isClosed())
- && tries++ < this.maxTries) {
- hash = this.nextHash(hash, key, tries);
- rv = this.getSessionByHash(hash);
- }
- return rv;
- }
- public final Session getSessionByHash(final long hash) {
- TreeMap<Long, List<Session>> sessionMap = this.ketamaSessions;
- if (sessionMap.size() == 0) {
- return null;
- }
- Long resultHash = hash;
- if (!sessionMap.containsKey(hash)) {
- // Java 1.6 adds a ceilingKey method, but xmemcached is compatible
- // with jdk5,So use tailMap method to do this.
- SortedMap<Long, List<Session>> tailMap = sessionMap.tailMap(hash);
- if (tailMap.isEmpty()) {
- resultHash = sessionMap.firstKey();
- } else {
- resultHash = tailMap.firstKey();
- }
- }
- ...
- List<Session> sessionList = sessionMap.get(resultHash);
- if (sessionList == null || sessionList.size() == 0) {
- return null;
- }
- int size = sessionList.size();
- return sessionList.get(this.random.nextInt(size));
- }
只有服务节点数目(cache1、cache2)改变(新增服务节点或者一个服务节点的所有session都关闭) treeMap (一致性hash环的虚拟节点)的结构才会改变。从而保证同一个 memcache 请求( get 、 set 操作)会使用同一个服务节点 session 。所以只要故障服务端的 session 没有全部 close ,原先 hash 到故障服务端的请求仍然会选择故障服务端的 session 处理。
2.2.2 心跳机制初始化构造 memcacheClient 是会初始化 connector 、 memcachehandler
- //xmemcacheClient构造函数 public XMemcachedClient( final InetSocketAddress inetSocketAddress,int weight) throws IOException { this.start0(); //启动memcache资源(connector、memcachehanlder等 ) ......}
- private final void start0() throws IOException { this.registerMBean(); this.startConnector(); ...... }
- private final void startConnector() throws IOException { if (this.shutdown) { this.shutdown = false; this.connector.start();//memcachehandler this.memcachedHandler.start();//启动memcachehandler ...... } }
memcachehandler 会初始化心跳线程池
- public void start() {
- final String name = "XMemcached-HeartBeatPool[" + client.getName() + "]";
- final AtomicInteger threadCounter = new AtomicInteger();
- long keepAliveTime = client.getConnector().getSessionIdleTimeout() * 3 / 2;
- this.heartBeatThreadPool = new ThreadPoolExecutor(1,
- MAX_HEARTBEAT_THREADS,//最大线程数为8(availableProcessor的返回值)
- keepAliveTime, TimeUnit.MILLISECONDS,
- new SynchronousQueue<Runnable>(),//任务队列策略为synchronousQueue(直接提交)
- new ThreadFactory() {
- public Thread newThread(Runnable r) {
- Thread t = new Thread(r, name + "-" + threadCounter.getAndIncrement());
- if (t.isDaemon()) {
- t.setDaemon(false);
- }
- if (t.getPriority() != Thread.NORM_PRIORITY) {
- t.setPriority(Thread.NORM_PRIORITY);
- }
- return t;
- }
- },
- new ThreadPoolExecutor.DiscardPolicy()); //任务拒绝策略为丢弃(跳过)
- }
- }
connector 的初始化->selectorManager 初始化
- /** * Start selector manager * @throws IOException */
- protected void initialSelectorManager() throws IOException {
- if (this.selectorManager == null) {
- this.selectorManager = new SelectorManager(this.selectorPoolSize, this, this.configuration);
- this.selectorManager.start();
- }
- }
- public synchronized void start() {
- ......
- for (Reactor reactor : reactorSet) {
- reactor.start();//启动selectormanager里的reactor
- }
- }
reactor 的数目是8个(Runtime.getRuntime().availableProcessors()的返回值)初始化 session 的时候会把 session 连接对应的注册到 reactor 里面的 selector 里。其中所有 session 的 ACCEPT 和 CONNECT 事件由 reactor[0]管理, session 的 read 、 write 等事件由剩下的 reactor 随机分配。
- public final class Reactor extends Thread {
- int selected = selector.select(wait);
- if (selected == 0) { // check tmeout and idle
- nextTimeout = checkSessionTimeout();
- ......
- }
checkIdle 通过触发 Idle 事件
- private final void checkIdle(Session session) { if (controller.getSessionIdleTimeout() > 0) { if (session.isIdle()) { ((NioSession) session).onEvent(EventType.IDLE, selector); } }}
- // synchronized,prevent reactors invoking this method concurrently. protected synchronized void onIdle() { try { // check twice if (isIdle()) { updateTimeStamp();//判断Idle并更新session 最后操作时间 handler.onSessionIdle(this); } } catch (Throwable e) { onException(e); }}//session的read事件和心跳会更新session的lastOpTimestamp public boolean isIdle() { //获得上次操作时间 判断前时间和上次操作时间是否超过了sessionIdletimeout long lastOpTimestamp = getLastOperationTimeStamp(); //触发session的读操作、发送heartbeat心跳会更新updateOperationTimeStamp。 return lastOpTimestamp > 0 && System.currentTimeMillis() - lastOpTimestamp > sessionIdleTimeout; //默认5S }
触发 Idle 事件的机制:当 reactor 里的 selector 返回0对该 selector 管理的所有 channel(selectorkey),当 selector 返回非0,对 selector 管理的非 selectedKeys 判断 session 是否为 Idle 状态并启用心跳线程异步发送心跳。 memcachehandler 的 onSessionIdle 为
- // Start a check thread,avoid blocking reactor thread
- if (this.heartBeatThreadPool != null) {
- this.heartBeatThreadPool.execute(new CheckHeartResultThread( versionCommand, session)); //使用memcachehandler里的心跳线程池启动检测心跳任务。
- }
Default session idle timeout,if session is idle,xmemcached will do a heartbeat action to check if connection is alive. See Also:Constant Field Values
- final static class CheckHeartResultThread implements Runnable {
- private final Command versionCommand;
- private final Session session;
- public CheckHeartResultThread(Command versionCommand, Session session) {
- super();
- this.versionCommand = versionCommand;
- this.session = session;
- }
- public void run() {
- try {
- AtomicInteger heartBeatFailCount = (AtomicInteger) this.session
- .getAttribute(HEART_BEAT_FAIL_COUNT_ATTR);
- if (heartBeatFailCount != null) {
- if (!this.versionCommand.getLatch().await(2000,
- TimeUnit.MILLISECONDS)) {
- heartBeatFailCount.incrementAndGet();
- }
- if (this.versionCommand.getResult() == null) {
- heartBeatFailCount.incrementAndGet();
- } else {
- // reset
- heartBeatFailCount.set(0);
- }
- // 10 times fail
- if (heartBeatFailCount.get() > MAX_HEART_BEAT_FAIL_COUNT) {
- log
- .warn("Session("
- + SystemUtils
- .getRawAddress(this.session
- .getRemoteSocketAddress())
- + ":"
- + this.session.getRemoteSocketAddress()
- .getPort()
- + ") heartbeat fail 10 times,close session and try to heal it");
- this.session.close();// close session
- heartBeatFailCount.set(0);
- }
- }
- } catch (InterruptedException e) {
- // ignore
- }
- }
- }
memcache 客户端流程图:
其中:业务逻辑多线程并发调用 memcache 客户端, memcache 客户端根据一致性 hash 策略选择 session (同一个请求 key 会选择同一个服务器的 session )。 cache1服务挂掉(非 kill 等方式,服务端挂掉后客户端的 tcp 连接状态为 EST ),导致客户端选择的 session 连接服务端超时。当 cache1的所有 session 关闭一致性 hash 环的重构会去掉 cache1节点,流量全部交给 cache2处理,超时故障自动恢复。关闭 session 的途径有:
session 连续两次心跳超时;
session 的连续超时次数,超过1000(默认);
session 过期(没有配置过期时间,不会出现);
服务端主动关闭 tcp 连接(发起4次挥手),线上日志来看所有机器的 session 都是因为心跳关闭。由于 reactor 的心跳线程机制①, session 如果5秒没有操作都会启动心跳线程,并更新 session 的操作时间。根据心跳线程池的配置和策略②。随着 cache1的 session 的关闭, cache1的 session 发起的心跳线程执行的概率越来越小,其中 cache2的请求处理会让 cache1的心跳线程抢占更容易③。
①:8个 reactor 线程的 selector 默认 await()1s 返回,如果返回为0,则对该 selector 监管的所有 key 对应的 session 触发 Idle 事件。如果返回非0,则对该 selector 监管的非 selectionkey 绑定的 session 触发 Idle 事件。 Idle 事件:判断是否空闲(5s没有操作该 session (只有 read 事件和启动心跳线程会修改上次操作时间, write 事件不会)),如果空闲,则启动心跳线程发心跳。注: memcache 的客户端的心跳机制会保证一个 session 如果5s 空闲,一定会启动心跳线程(交给心跳线程池)并更新 session 操作时间,但心跳线程池有可能会丢弃。
②: 服务拒绝策略:
ThreadPoolExecutor.DiscardPolicy()丢弃。阻塞队列策略: SynchronousQueue (直接提交 queue 不存放 task )。corePoolSize:1 maximumPoolSize:8。
③:通过打 log 验证,没有请求的情况下,每分钟的向心跳线程池增加的线程任务为100*60/5=7200。实际执行的心跳线程为1162。关闭一个 session ,需要连续两次心跳,每次需要间隔5s,所以关闭一个 session 的时间范围为(7-12) s 。当 cache1刚挂掉的时候大部分 cache1的 session 都有机会发送心跳,后面剩余少量的 cache1的 session 要和 cache2的 session 抢占心跳线程的执行。可以验证 cache2服务的 QPS 越高, cache1的 session 的心跳执行可能性更大( read 会更新 session 的操作时间,使得 cache2的 session 不会启动心跳线程)。
三、验证
线下验证(IP非真实测试IP):
客户端配置:
- memcached.server1.host=12.22.12.233 memcached.server1.port=6666 memcached.server1.weight=2
- memcached.server2.host=12.22.12.233 memcached.server2.port=6667 memcached.server2.weight=1
- memcached.connection.pool.size=50
session: 每个机器各50个, 虚拟节点480个,320个 cache1、160个 ache2节点,调节对 cache2服务请求的 QPS 。 模拟服务 cache1挂掉: sudo iptables -A OUTPUT -p tcp --dport 6666 -j DROP sudo iptables -A INPUT -p tcp --dport 6666 -j DROP (如果是在服务端 kill memcache 服务进程是不行的。( 内核杀进程会关闭文件(网络连接),会对已经建立的连接执行关闭) 通过脚本模拟 cache 2的 QPS. session (tcp连接EsT)数目。
cache2的 QPS 为2
cache2的 QPS 为0
同样的环境,做了多次试验,每次结果都有些偏差,不过只要 cache2的 QPS 足够高(》=10), cache1的 session 都会很快的关闭。
四、解决方案
线上背景: 系统对 memcache 服务端的的 QPS 平均为6峰值为10,使用两台 memcache 做负载均衡。对于使用 NIO 的 memcache 客户端来说单连接有足够好的性能。 memcache 客户端默认为1(代码注释): /Withjava nio,thereisonly one connection to a memcached.Ina highConcurrentenviroment,you may want to pool memcached clients.Buta xmemcached client has to start a reactor threadandsome thread pools,ifyou create too many clients,the costisvery large.Xmemcachedsupports connection pool instreadof client pool.you can create more connections to oneormore memcached servers,andthese connections share the same reactorandthread pools,it will reduce the cost of system.Defaultpool sizeis1.*/publicstaticfinalintDEFAULTCONNECTIONPOOL_SIZE=1因为 NIO 的 non-Blocking 特性。在不太高 QPS 的情况下1个是足够的。
把 session 超时次数的关闭阈值设到足够低,同时 session 数目设置为2,请求 memcache 服务的超时时间降低(以前为2S)改为200ms 。 验证可行。
李宏林
2017年加入去哪儿,目前就职于金融事业部,主要负责支付交易后端相关工作。