一、异步设计
1.同步的性能瓶颈
假设一个转账业务,伪代码如下:
Transfer(accountFrom, accountTo, amount) {
// 先从accountFrom的账户中减去相应的钱数
Add(accountFrom, -1 * amount)
// 再把减去的钱数加到accountTo的账户中
Add(accountTo, amount)
return OK
}
假设微服务 Add 的平均响应时延是 50ms,那么微服务 Transfer 的平均响应时延大约是 100ms,并在这 100ms 过程中是需要独占一个线程的。那么,这台服务器每秒钟可以处理的请求上限是: 线程数 * 10 次。
但是,这其中绝大部分线程都在等待 Add 服务返回结果。
采用同步实现的方式,整个服务器的所有线程大部分时间都没有在工作,而是都在等待。
2.采用异步实现解决等待问题
接下来我们看一下,如何用异步的思想来解决这个问题,实现同样的业务逻辑。
TransferAsync(accountFrom, accountTo, amount, OnComplete()) {
// 异步从accountFrom的账户中减去相应的钱数,然后调用OnDebit方法。
AddAsync(accountFrom, -1 * amount, OnDebit(accountTo, amount, OnAllDone(OnComplete())))
}
// 扣减账户accountFrom完成后调用
OnDebit(accountTo, amount, OnAllDone(OnComplete())) {
// 再异步把减去的钱数加到accountTo的账户中,然后执行OnAllDone方法
AddAsync(accountTo, amount, OnAllDone(OnComplete()))
}
// 转入账户accountTo完成后调用
OnAllDone(OnComplete()) {
OnComplete()
}
这里定义 2 个回调方法:
- OnDebit():扣减账户 accountFrom 完成后调用的回调方法;
- OnAllDone():转入账户 accountTo 完成后调用的回调方法。
异步的实现不再需要线程等待执行结果,只需要个位数量的线程,即可实现同步场景大量线程一样的吞吐量。并且在服务器 CPU、网络带宽资源达到极限之前,响应时延不会随着请求数量增加而显著升高,几乎可以一直保持约 100ms 的平均响应时延。
3.JDK8异步框架: CompletableFuture
假设要实现转账业务:
/**
* 账户服务
*/
public interface AccountService {
/**
* 变更账户金额
* @param account 账户ID
* @param amount 增加的金额,负值为减少
*/
CompletableFuture<Void> add(int account, int amount);
}
/**
* 转账服务
*/
public interface TransferService {
/**
* 异步转账服务
* @param fromAccount 转出账户
* @param toAccount 转入账户
* @param amount 转账金额,单位分
*/
CompletableFuture<Void> transfer(int fromAccount, int toAccount, int amount);
}
/**
* 转账服务的实现
*/
public class TransferServiceImpl implements TransferService {
@Inject
private AccountService accountService; // 使用依赖注入获取账户服务的实例
@Override
public CompletableFuture<Void> transfer(int fromAccount, int toAccount, int amount) {
// 异步调用add方法从fromAccount扣减相应金额
return accountService.add(fromAccount, -1 * amount)
// 然后调用add方法给toAccount增加相应金额
.thenCompose(v -> accountService.add(toAccount, amount));
}
}
客户端使用 CompletableFuture 既可以同步调用,也可以异步调用。
public class Client {
@Inject
private TransferService transferService; // 使用依赖注入获取转账服务的实例
private final static int A = 1000;
private final static int B = 1001;
public void syncInvoke() throws ExecutionException, InterruptedException {
// 同步调用
transferService.transfer(A, B, 100).get();
System.out.println("转账完成!");
}
public void asyncInvoke() {
// 异步调用
transferService.transfer(A, B, 100)
.thenRun(() -> System.out.println("转账完成!"));
}
}
异步性能虽好,只有类似在像消息队列这种业务逻辑简单并且需要超高吞吐量的场景下,或者必须长时间等待资源的地方,才考虑使用异步模型。如果系统的业务逻辑比较复杂,在性能足够满足业务需求的情况下,采用易于开发和维护的同步模型。
二、异步网络框架
大部分语言提供的网络通信基础类库都是同步的。一个 TCP 连接建立后,用户代码会获得一个用于收发数据的通道,每个通道会在内存中开辟两片区域用于收发数据的缓存。
发送数据直接往这个通道里面来写入数据,暂存在缓存中,然后操作系统会通过网卡,把发送缓存中的数据传输到对端的服务器上。只要发送数据的速度没有超过网卡传输速度的上限,那这个发送数据的操作耗时,只是一次内存写入的时间。所以,发送数据的时候同步发送就可以了。
1. Netty
// 创建一组线性
EventLoopGroup group = new NioEventLoopGroup();
try{
// 初始化Server
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(group);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.localAddress(new InetSocketAddress("localhost", 9999));
// 设置收到数据后的处理的Handler
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new MyHandler());
}
});
// 绑定端口,开始提供服务
ChannelFuture channelFuture = serverBootstrap.bind().sync();
channelFuture.channel().closeFuture().sync();
} catch(Exception e){
e.printStackTrace();
} finally {
group.shutdownGracefully().sync();
}
首先创建了一个 EventLoopGroup 对象,命名为 group,理解为一组线程,来执行收发数据的业务逻辑。
然后,使用 Netty 提供的 ServerBootstrap 来初始化一个 Socket Server,绑定到本地 9999 端口上。
在真正启动服务之前,我们给 serverBootstrap 传入了一个 MyHandler 对象,这个 MyHandler 是我们自己来实现的一个类,它需要继承 Netty 提供的一个抽象类:ChannelInboundHandlerAdapter
,在这个 MyHandler 里面,我们可以定义收到数据后的处理逻辑。
最后就可以真正绑定本地端口,启动 Socket 服务了。
当收到来自客户端的数据后,Netty 就会在EventLoopGroup 对象中,获取一个 IO 线程,在这个 IO 线程中调用接收数据的回调方法,来执行接收数据的业务逻辑。
像线程控制、缓存管理、连接管理这些异步网络 IO 中通用的、比较复杂的问题,Netty 已经自动帮你处理好了。
2. NIO
在 Java 的 NIO 中,它提供了一个 Selector 对象,来解决一个线程在多个网络连接上的多路复用问题。
Selecor 通过一种类似于事件的机制来解决这个问题。首先你需要把你的连接,也就是 Channel 绑定到 Selector 上,然后你可以在接收数据的线程来调用 Selector.select() 方法来等待数据到来。这个 select 方法是一个阻塞方法,这个线程会一直卡在这儿,直到这些 Channel 中的任意一个有数据到来,就会结束等待返回数据。它的返回值是一个迭代器,你可以从这个迭代器里面获取所有 Channel 收到的数据,然后来执行你的数据接收的业务逻辑。
三、序列化
要想使用网络框架的 API 来传输结构化的数据,必须得先实现结构化的数据与字节流之间的双向转换。这种将结构化数据转换成字节流的过程,我们称为序列化,反过来转换,就是反序列化。
需要权衡这样几个因素:
- 序列化后的数据最好是易于人类阅读的;
- 实现的复杂度是否足够低;
- 序列化和反序列化的速度越快越好;
- 序列化后的信息密度越大越好,也就是说,同样的一个结构化数据,序列化之后占用的存储空间越小越好;
像 JSON、XML 这些序列化方法,可读性最好,但信息密度也最低。像 Kryo、Hessian 这些通用的二进制序列化实现,适用范围广,使用简单,性能比 JSON、XML 要好一些,但是肯定不如专用的序列化实现。
对于一些强业务类系统,比如说电商类、社交类的应用系统,这些系统的特点是,业务复杂,需求变化快,但是对性能的要求没有那么苛刻。这种情况下,推荐使用 JSON 这种实现简单,数据可读性好的序列化实现,这种实现使用起来非常简单,序列化后的 JSON 数据我们都可以看得懂,无论是接口调试还是排查问题都非常方便。
使用专用的序列化方法,可以提高序列化性能,并有效减小序列化后的字节长度。
在专用的序列化方法中,不必考虑通用性。比如,我们可以固定字段的顺序,这样在序列化后的字节里面就不必包含字段名,只要字段值就可以了,不同类型的数据也可以做针对性的优化:
缺点是,需要为每种对象类型定义专门的序列化和反序列化方法,实现起来太复杂了,大部分情况下是不划算的。
思考题
1、实现转账服务时,并没有考虑处理失败的情况。如果调用账户服务失败时,如何将错误报告给客户端?在两次调用账户服务的 Add 方法时,如果某一次调用失败了,该如何处理才能保证账户数据是平的?
答:调用失败可以用exceptionally方法,在方法中写补偿策略,比如第一次调用add失败可以打印日志并返回失败,也可以检查重试并继续。第二次add 失败可以检查加重试,或者undo第一次add。
2、在异步实现中,回调方法 OnComplete() 是在什么线程中运行的?我们是否能控制回调方法的执行线程数?该如何做?
答:CompletableFuture默认是在ForkjoinPool 里执行的,也可以指定一个Executor线程池执行,回调可以指定线程池执行,这样就能控制这个线程池的线程数目了。
3、在内存里存放的任何数据,它最基础的存储单元也是二进制比特,为什么不能直接把内存中,对象对应的二进制数据直接通过网络发送出去,或者保存在文件中呢?为什么还需要序列化和反序列化呢?
答:内存里存的东西,不通用, 不同系统, 不同语言的组织可能都是不一样的, 而且还存在很多引用, 指针,并不是直接数据块。序列化, 反序列化, 其实是约定一种标准,能跨平台 , 跨语言。
4、如果我们的微服务的需求是处理大量的文本,比如说,每次请求会传入一个 10KB 左右的文本,在高并发的情况下,你会如何来优化这个程序,来尽量避免由于垃圾回收导致的进程卡死问题?
答:一般不会要求时延,大部分都会进行异步处理,更加注重服务的吞吐率,服务可以在更大的内存服务器进行部署,然后把新生代的eden设置的更大些,因为这些文本处理完不会再拿来复用,朝生夕灭,可以在新生代Minor GC,防止对象晋升到老年代,防止频繁的Major GC,如果晋升的对象过多大于老年代的连续内存空间也会有触发Full Gc,然后在这些处理文本的业务流程中,防止频繁的创建一次性的大对象,把文本对象做为业务流程直接传递下去,如果这些文本需要复用可以将他保存起来,防止频繁的创建。也为了保证服务的高可用,也需对服务做限流、负载、兜底的一些策略。
参考资料:李玥——消息队列高手课