心跳检测一般存在于建立长连接 或者 需要保活的场景。

心跳的使用场景

长连接的应用场景非常的广泛,比如监控系统,IM系统,即时报价系统,推送服务等等。像这些场景都是比较注重实时性,如果每次发送数据都要进行一次DNS解析,建立连接的过程肯定是极其影响体验。

而长连接的维护必然需要一套机制来控制。比如 HTTP/1.0 通过在 header 头中添加 Connection:Keep-Alive参数,如果当前请求需要保活则添加该参数作为标识,否则服务端就不会保持该连接的状态,发送完数据之后就关闭连接。HTTP/1.1以后 Keep-Alive 是默认打开的。

Netty 是 基于 TCP 协议开发的,在四层协议 TCP 协议的实现中也提供了 keepalive 报文用来探测对端是否可用。TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。

tcp-keepalive,操作系统内核支持,但是不默认开启,应用需要自行开启,开启之后有三个参数会生效,来决定一个 keepalive 的行为。

net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_intvl = 75

tcp_keepalive_time: 在 TCP 保活打开的情况下,最后一次数据交换到 TCP 发送第一个保活探测包的间隔,即允许的持续空闲时长,或者说每次正常发送心跳的周期,默认值为7200s(2h);

tcp_keepalive_probes: 在 tcp_keepalive_time 之后,没有接收到对方确认,继续发送保活探测包次数,默认值为9(次);

tcp_keepalive_intvl:在 tcp_keepalive_time 之后,没有接收到对方确认,继续发送保活探测包的发送频率,默认值为75s。

TCP KeepAlive 是用于检测连接的死活,而心跳机制则附带一个额外的功能:检测通讯双方的存活状态。两者听起来似乎是一个意思,但实际上却大相径庭。

考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态一直向当前服务器发送些必然会失败的请求。

可以通过如下命令查看系统tcp-keepalive参数配置:

sysctl -a | grep keepalive

cat /proc/sys/net/ipv4/tcp_keepalive_time

sysctl net.ipv4.tcp_keepalive_time

Netty 中也提供了设置 tcp-keepalive 的设置:

设置:ChannelOption.SO_KEEPALIVE, true 表示打开 TCP 的 keepAlive 设置。

所以基础协议对应用来说不是那么尽善尽美,一个 Netty 服务端可能会面临上万个连接,如何去维护这些连接是应用应该去处理的事情。在 Netty 中提供了 IdleStateHandler 类专门用于处理心跳。

IdleStateHandler 的构造函数如下:

public IdleStateHandler(long readerIdleTime, long writerIdleTime,
long allIdleTime,TimeUnit unit){ }

第一个参数是隔多久检查一下读事件是否发生,如果 channelRead() 方法超过 readerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;

第二个参数是隔多久检查一下写事件是否发生,writerIdleTime 写空闲超时时间设定,如果 write() 方法超过 writerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;

第三个参数是全能型参数,隔多久检查读写事件;

第四个参数表示当前的时间单位。

所以这里可以分别控制读,写,读写超时的时间,单位为秒,如果是0表示不检测,所以如果全是0,则相当于没添加这个 IdleStateHandler,连接是个普通的短连接。

Netty 中的心跳逻辑

下面演示一下在 Netty 中如果使用 IdleStateHandler, 整体代码流程请见 :

gitHub

先上代码:

Server端:

package com.rickiyang.learn.keepAlive;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description: server 端
*/
@Slf4j
public class KpServer { private int port; public KpServer(int port) {
this.port = port;
} public void start(){
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap().group(bossGroup,workGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ServerChannelInitializer()); try {
ChannelFuture future = server.bind(port).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("server start fail",e);
}finally {
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
}
} public static void main(String[] args) {
KpServer server = new KpServer(7788);
server.start();
}
}

Initializer:

package com.rickiyang.learn.keepAlive;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler; import java.util.concurrent.TimeUnit; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
public class ServerChannelInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new IdleStateHandler(5, 0, 0, TimeUnit.SECONDS)); // 字符串解码 和 编码
pipeline.addLast("decoder", new StringDecoder());
pipeline.addLast("encoder", new StringEncoder()); // 自己的逻辑Handler
pipeline.addLast("handler", new KpServerHandler());
}
}

Handler:

package com.rickiyang.learn.keepAlive;

import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
@Slf4j
public class KpServerHandler extends SimpleChannelInboundHandler { @Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("server channelActive");
} @Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String message = (String) msg;
if ("heartbeat".equals(message)) {
log.info(ctx.channel().remoteAddress() + "===>server: " + message);
ctx.write("heartbeat");
ctx.flush();
}
} /**
* 如果5s没有读请求,则向客户端发送心跳
* @param ctx
* @param evt
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (IdleState.READER_IDLE.equals((event.state()))) {
ctx.writeAndFlush("heartbeat").addListener(ChannelFutureListener.CLOSE_ON_FAILURE) ;
}
}
super.userEventTriggered(ctx, evt);
} @Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
ctx.close();
} }

客户端代码:

Client:

package com.rickiyang.learn.keepAlive;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
@Slf4j
public class KpClient { private int port;
private String address; public KpClient(int port, String address) {
this.port = port;
this.address = address;
} public void start(){
EventLoopGroup group = new NioEventLoopGroup(); Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ClientChannelInitializer());
try {
ChannelFuture future = bootstrap.connect(address,port).sync();
future.channel().writeAndFlush("Hello world, i'm online");
future.channel().closeFuture().sync();
} catch (Exception e) {
log.error("client start fail",e);
}finally {
group.shutdownGracefully();
} } public static void main(String[] args) {
KpClient client = new KpClient(7788,"127.0.0.1");
client.start();
}
}

Initializer:

package com.rickiyang.learn.keepAlive;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler; import java.util.concurrent.TimeUnit; public class ClientChannelInitializer extends ChannelInitializer<SocketChannel> { @Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new IdleStateHandler(0, 4, 0, TimeUnit.SECONDS));
pipeline.addLast("decoder", new StringDecoder());
pipeline.addLast("encoder", new StringEncoder()); // 客户端的逻辑
pipeline.addLast("handler", new KpClientHandler());
}
}

Handler:

package com.rickiyang.learn.keepAlive;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.CharsetUtil;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
@Slf4j
public class KpClientHandler extends SimpleChannelInboundHandler { /** 客户端请求的心跳命令 */
private static final ByteBuf HEARTBEAT_SEQUENCE =
Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("heartbeat", CharsetUtil.UTF_8)); @Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String message = (String)msg;
if("heartbeat".equals(message)) {
log.info(ctx.channel().remoteAddress() + "===>client: " + msg);
}
} /**
* 如果4s没有收到写请求,则向服务端发送心跳请求
* @param ctx
* @param evt
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if(evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if(IdleState.WRITER_IDLE.equals(event.state())) {
ctx.writeAndFlush(HEARTBEAT_SEQUENCE.duplicate()).addListener(ChannelFutureListener.CLOSE_ON_FAILURE) ;
}
}
super.userEventTriggered(ctx, evt);
} @Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("client channelActive");
ctx.fireChannelActive();
} @Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("Client is close");
} }

解释一下代码的逻辑:

服务端添加了:

pipeline.addLast(new IdleStateHandler(5, 0, 0, TimeUnit.SECONDS));

每隔5s检查一下是否有读事件发生,如果没有就处罚 handler 中的 userEventTriggered(ChannelHandlerContext ctx, Object evt)逻辑。

客户端添加了:

new IdleStateHandler(0, 4, 0, TimeUnit.SECONDS)

每隔4s检查一下是否有写事件,如果没有就触发 handler 中的 userEventTriggered(ChannelHandlerContext ctx, Object evt)逻辑。

大家可以再本地启动工程,看一下触发的逻辑。

IdleStateHandler逻辑分析

心跳检测也是一种 Handler,在启动时添加到 ChannelPipeline 管道中,当有读写操作时消息在其中传递。首先我们看到 IdleStateHandler 继承了 ChannelDuplexHandler:

public class IdleStateHandler extends ChannelDuplexHandler {

  ...
}

表明 IdleStateHandler 也可以同时处理入站和出站事件,所以可以同时监控读事件和写事件。

IdleStateHandler 的 channelActive() 方法在 socket 通道建立时被触发:

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
initialize(ctx);
super.channelActive(ctx);
}

其中 channelActive() 方法调用 Initialize() 方法,根据配置的 readerIdleTime、writeIdleTIme 等超时事件参数往任务队列 taskQueue 中添加定时任务 task:

private void initialize(ChannelHandlerContext ctx) {
// Avoid the case where destroy() is called before scheduling timeouts.
// See: https://github.com/netty/netty/issues/143
//这里判断状态,避免重复初始化
switch (state) {
case 1:
case 2:
return;
} state = 1; EventExecutor loop = ctx.executor();
//初始化最后一次读写时间
lastReadTime = lastWriteTime = System.nanoTime();
// 根据用户设置的读空闲时间启动一个定时任务,读空闲时间为频率执行
// 这里的 schedule 方法会调用 eventLoop 的 schedule 方法,将定时任务添加进队列中
if (readerIdleTimeNanos > 0) {
readerIdleTimeout = loop.schedule(
new ReaderIdleTimeoutTask(ctx),
readerIdleTimeNanos, TimeUnit.NANOSECONDS);
}
// 根据用户设置的写空闲时间启动一个定时任务,写空闲时间为频率执行
if (writerIdleTimeNanos > 0) {
writerIdleTimeout = loop.schedule(
new WriterIdleTimeoutTask(ctx),
writerIdleTimeNanos, TimeUnit.NANOSECONDS);
}
// 根据用户设置的读写空闲时间启动一个定时任务,读写空闲时间为频率执行
if (allIdleTimeNanos > 0) {
allIdleTimeout = loop.schedule(
new AllIdleTimeoutTask(ctx),
allIdleTimeNanos, TimeUnit.NANOSECONDS);
}
}

看到这里或者没看这里你也应该能想到,这种监控性的任务肯定是使用定时任务类似这种机制来进行。

上面有一个 state 字段:

private byte state;
0:初始状态,1:已经初始化, 2: 已经销毁。

上面的 switch 判断只有当前状态为 0 即初始化状态的时候才执行下面的操作,避免多次提交定时任务。

定时任务添加到对应线程 EventLoopExecutor 对应的任务队列 taskQueue 中,在对应线程的 run() 方法中循环执行:

  • 用当前时间减去最后一次 channelRead 方法调用的时间判断是否空闲超时;
  • 如果空闲超时则创建空闲超时事件并传递到 channelPipeline 中。

只要给定的参数大于0,就创建一个定时任务,每个事件都创建。同时,将 state 状态设置为 1,防止重复初始化。

读事件处理:ReaderIdleTimeoutTask

来看读事件是如何处理的, ReaderIdleTimeoutTask:

private final class ReaderIdleTimeoutTask implements Runnable {

  private final ChannelHandlerContext ctx;

  ReaderIdleTimeoutTask(ChannelHandlerContext ctx) {
this.ctx = ctx;
} @Override
public void run() {
if (!ctx.channel().isOpen()) {
return;
}
// nextDelay = 当前时间-最后一次时间
long nextDelay = readerIdleTimeNanos;
if (!reading) {
nextDelay -= System.nanoTime() - lastReadTime;
} if (nextDelay <= 0) {
// 重新定义readerIdleTimeout schedule,与initialize方法设置的相同,继续执行定时任务
readerIdleTimeout =
ctx.executor().schedule(this, readerIdleTimeNanos, TimeUnit.NANOSECONDS);
try {
// event = new IdleStateEvent(IdleState.READER_IDLE, true),将event设置为读空闲
IdleStateEvent event = newIdleStateEvent(IdleState.READER_IDLE, firstReaderIdleEvent);
if (firstReaderIdleEvent) {
firstReaderIdleEvent = false;
}
//channelIdle的主要工作就是将evt传输给下一个Handler
channelIdle(ctx, event);
} catch (Throwable t) {
ctx.fireExceptionCaught(t);
}
} else {
// 如果nextDelay>0,则说明客户端在规定时间内已经写入数据了
// 重新定义readerIdleTimeout schedule,以nextDelay为执行频率
readerIdleTimeout = ctx.executor().schedule(this, nextDelay, TimeUnit.NANOSECONDS);
}
}
}

nextDelay的初始化值为超时秒数readerIdleTimeNanos,如果检测的时候没有正在读,就计算多久没读了:

nextDelay = nextDelay - 当前时间 - 上次读取时间

如果小于0,说明左边的 readerIdleTimeNanos 小于空闲时间(当前时间 - 上次读取时间),表示已经超时,

创建 IdleStateEvent 事件,IdleState 枚举值为 READER_IDLE,然后调用 channelIdle(ctx, event) 方法分发给下一个 ChannelInboundHandler。

总的来说,每次读取操作都会记录一个时间,定时任务时间到了,会计算当前时间和最后一次读的时间的间隔,如果间隔超过了设置的时间,就触发 UserEventTriggered() 方法。

写事件处理:WriterIdleTimeoutTask

写事件,WriterIdleTimeoutTask:

private final class WriterIdleTimeoutTask implements Runnable {

  private final ChannelHandlerContext ctx;

  WriterIdleTimeoutTask(ChannelHandlerContext ctx) {
this.ctx = ctx;
} @Override
public void run() {
if (!ctx.channel().isOpen()) {
return;
} long lastWriteTime = IdleStateHandler.this.lastWriteTime;
long nextDelay = writerIdleTimeNanos - (System.nanoTime() - lastWriteTime);
if (nextDelay <= 0) {
// Writer is idle - set a new timeout and notify the callback.
writerIdleTimeout = ctx.executor().schedule(
this, writerIdleTimeNanos, TimeUnit.NANOSECONDS);
try {
IdleStateEvent event = newIdleStateEvent(IdleState.WRITER_IDLE, firstWriterIdleEvent);
if (firstWriterIdleEvent) {
firstWriterIdleEvent = false;
} channelIdle(ctx, event);
} catch (Throwable t) {
ctx.fireExceptionCaught(t);
}
} else {
// Write occurred before the timeout - set a new timeout with shorter delay.
writerIdleTimeout = ctx.executor().schedule(this, nextDelay, TimeUnit.NANOSECONDS);
}
}
}

写超时时间:

nextDelay = writerIdleTimeNanos - (System.nanoTime() - lastWriteTime)

写超时也是跟读超时同理,每次写操作都记录操作时间。

IdleStateHandler 心跳检测主要是通过向线程任务队列中添加定时任务,判断 channelRead() 方法或 write() 方法是否调用空闲超时,如果超时则触发超时事件执行自定义 userEventTrigger() 方法。

Netty 通过 IdleStateHandler 实现最常见的心跳机制不是一种双向心跳的 PING-PONG 模式,而是客户端发送心跳数据包,服务端接收心跳但不回复,因为如果服务端同时有上千个连接,心跳的回复需要消耗大量网络资源。

如果服务端一段时间内一直收到客户端的心跳数据包则认为客户端已经下线,将通道关闭避免资源的浪费。在这种心跳模式下服务端可以感知客户端的存活情况,无论是宕机的正常下线还是网络问题的非正常下线,服务端都能感知到,而客户端不能感知到服务端的非正常下线。

要想实现客户端感知服务端的存活情况,需要进行双向的心跳;Netty 中的 channelInactive() 方法是通过 Socket 连接关闭时挥手数据包触发的,因此可以通过 channelInactive() 方法感知正常的下线情况,但是因为网络异常等非正常下线则无法感知。上面的示例只做了客户端和服务端双向心跳测试,大家可以补充一下如果一段时间内都收到的是客户端的心跳包则判定连接无效关闭连接的逻辑。

Netty 中的心跳检测机制的更多相关文章

  1. netty心跳检测机制

    既然是网络通信那么心跳检测肯定是离不开的,netty心跳检测分为读.写.全局 bootstrap.childHandler(new ChannelInitializer<SocketChanne ...

  2. 面试官:Netty心跳检测机制是什么,怎么自定义检测间隔时间?

    哈喽!大家好,我是小奇,一位热爱分享的程序员 小奇打算以轻松幽默的对话方式来分享一些技术,如果你觉得通过小奇的文章学到了东西,那就给小奇一个赞吧 文章持续更新 一.前言 书接上回,昨天在地里干了一天的 ...

  3. AndroidPN中的心跳检测

    在AndroidPN客户端里存在着心跳检测功能.就是每隔一段时间客户端向服务器端发送一个消息,以检测连接是否正常,发送的消息内容为: <presence id="h09Ke-13&qu ...

  4. Netty 中的心跳机制

    在TCP长连接或者WebSocket长连接中一般我们都会使用心跳机制–即发送特殊的数据包来通告对方自己的业务还没有办完,不要关闭链接. 网络的传输是不可靠的,当我们发起一个链接请求的过程之中会发生什么 ...

  5. Netty实践二(心跳检测)

    我们使用Socket通信一般经常会处理多个服务器之间的心跳检测,一般来讲,我们去维护服务器集群,肯定要有一台或几台服务器主机(Master),然后还应该有N台(Slave),那么我们的主机肯定要时时刻 ...

  6. javascript websocket 心跳检测机制介绍

    ====测试代码: ==index.html <!DOCTYPE html> <html lang="en"> <head> <meta ...

  7. 分析dubbo心跳检测机制

    目的: 维持provider和consumer之间的长连接 实现: dubbo心跳时间heartbeat默认是60s,超过heartbeat时间没有收到消息,就发送心跳消息(provider,cons ...

  8. Netty — 心跳检测和断线重连

    一.前言 由于在通信层的网络连接的不可靠性,比如:网络闪断,网络抖动等,经常会出现连接断开.这样对于使用长连接的应用而言,当突然高流量冲击势必会造成进行网络连接,从而产生网络堵塞,应用响应速度下降,延 ...

  9. Netty(六):Netty中的连接管理(心跳机制和定时断线重连)

    何为心跳 顾名思义, 所谓心跳, 即在TCP长连接中, 客户端和服务器之间定期发送的一种特殊的数据包, 通知对方自己还在线, 以确保 TCP 连接的有效性. 为什么需要心跳 因为网络的不可靠性, 有可 ...

随机推荐

  1. 【mysql】索引 回表 覆盖索引 索引下推

    索引类型 索引类型分为主键索引和非主键索引.(一定要牢记,是怎么存储数据的) 主键索引的叶子节点存的是整行数据.在 InnoDB 里,主键索引也被称为聚簇索引(clustered index). 非主 ...

  2. IOS 空字符串报错 解决办法

    NSScanner: nil string argument  NSScanner: nil string argument libc++abi.dylib: terminate_handler un ...

  3. 5.Maven坐标

    而这个坐标也意味着jar包等保存在 C:\Users\用户名.m2\repository\org\apache\tomcat\tomcat-catalina\9.0.2

  4. javax.el.PropertyNotFoundException: 类型[cn.cqsw.pojo.Course]上找不到属性[CourseId]

    今天在JSP利用EL表达式取值报了 "javax.el.PropertyNotFoundException” 1 Caused by: org.apache.jasper.JasperExc ...

  5. windows Sever 2012 远程提示:由于没有远程桌面授权服务器可以提供许可证,远程会话被中断。请跟服务器管理员联系。

    远程windows Sever 2012 时候 远程提示:由于没有远程桌面授权服务器可以提供许可证,远程会话被中断.请跟服务器管理员联系. 原因: windows server可以多用户同时登陆,默认 ...

  6. Python常用模块之json

    本章节我们将为大家介绍如何使用 Python 语言来编码和解码 JSON 对象. JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式,易于人阅读和编写. SO ...

  7. B【SDOI2008】Sandy的卡片

    时间限制 : 5000 MS   空间限制 : 128000 KB 问题描述 Sandy和Sue的热衷于收集干脆面中的卡片.然而,Sue收集卡片是因为卡片上漂亮的人物形象,而Sandy则是为了积攒卡片 ...

  8. Docker基础修炼1--Docker简介及快速入门体验

    本文作为Docker基础系列第一篇文章,将详细阐述和分析三个问题:Docker是什么?为什么要用Docker?如何快速掌握Docker技术? 本系列文章中Docker的用法演示是基于CentOS7进行 ...

  9. Mysql主从搭建(1)

    Master上授权从库: ```grant replication slave on *.* to slave1@ip identified by 'password';``` 逻辑备份: ```my ...

  10. 【Debug记录】Exeption thrown by glCreateVertexArrays

    继在机场丢失笔记本后又一大灾难--小组项目无法在老电脑上运行. 位置:glCreateVertexArrays函数 报错:Exception thrown at 0x00000000 in Clien ...