千万级、亿级数据,如何性能优化?
问题场景介绍
首先,看看VIVO商城的用户数据
截止2021,vivo在全球已覆盖4亿多用户,服务60多个国家和地区,
vivo 在菲律宾、马来、印度等国家的市场份额名列前三,在国内出货量始终保持领先地位,成功跻身2021年第三季度4000+以上高端手机市场份额的Top3。
抱歉,以上是他们2021的数据,但是咱们手上的方案,大概是他们2018年的,那时候,他们的订单只有1000万级别。
那个时候的vivo商城数据量看上去不多,但是刚好是完美的学习型数据。
VIVO商城问题场景
从2017年开始,随着用户量级的快速增长,vivo 官方商城 v1.0 的单体架构逐渐暴露出弊端:
-
模块愈发臃肿
-
开发效率低下
-
性能出现瓶颈
-
系统维护困难。
订单模块是电商系统的交易核心,不断累积的数据即将达到单表存储瓶颈,系统难以支撑新品发布和大促活动期间的流量,服务化改造势在必行。
那么,他们如何做优化呢?
优化措施的宏观介绍
优化1:业务架构解耦
从2017年开始启动的 v2.0 架构升级和全面的解耦,包括 业务模块解耦、服务化改造
-
业务模块解耦,主要是基于业务模块进行垂直的系统物理拆分,
-
服务化改造,就是在业务模块解耦基础上,进一步的微服务化。拆分出来业务线各司其职,提供服务化的能力,共同支撑主站业务。
基于业务模块进行垂直的系统物理拆分,分出来业务线各司其职,提供服务化的能力,共同支撑主站业务。
优化2:数据量大的优化
随着历史订单不断累积,2017年MySQL中订单表数据量已达千万级。之后的订单数据,远远大于亿级。对数据量大的问题,进行了以下优化:
-
数据归档
-
分表
优化3:吞吐量大的优化
商城业务处于高速发展期,下单量屡创新高,业务复杂度也在提升,应用程序对MySQL的访问量越来越高,但是, 单机MySQL的处理能力是有限的,当压力过大时,所有请求的访问速度都会下降,甚至有可能使数据库宕机。并发量高的解决方案有:
-
使用缓存
-
读写分离
-
分库
优化4:高速搜索引擎的数据一致性优化
为了便于订单的聚合搜索,高速搜索,把订单数据冗余存储在Elasticsearch中,那么,如何在MySQL的订单数据和ES中订单数据的增量一致性呢?他们从以下的两种方案中:
1)MQ方案
2)Binlog方案
他们没有选择 业务代码侵入小、不影响服务本身的性能 的Binlog方案。而是选择 更加低延迟的 MQ方案。
优化5:合理的选择数据库迁移措施
何将数据从原来的单实例数据库,迁移到新的数据库集群,也是一大技术挑战。要考虑的问题有二:
-
要确保数据的正确性,
-
还要保证迁移过程中,只要有问题,能快速地回滚。
他们考虑了两种方案:
-
停机迁移
-
不停机迁移
他们比较务实,不追求高大上。考虑到不停机方案的改造成本较高,而夜间停机方案的业务损失并不大,最终选用的是停机迁移方案。
优化6:合理的进行分布式事务方案的选型
从单体架构,到微服务架构,数据的一致性呢?单体架构的 数据库ACID 事务,当然保证不了,需要用到分布式事务。
https://blog.csdn.net/crazymakercircle/article/details/109459593
业界的主流方案中,用于解决强一致性的有两阶段提交(2PC)、三阶段提交(3PC),用于解决最终一致性的有TCC、本地消息、事务消息和最大努力通知等。
他们从高并发的场景出发,选择了本地消息表方案:在本地事务中将要执行的异步操作记录在消息表中,如果执行失败,可以通过定时任务来补偿。
优化7:其他的一些细节优化
-
比如 es 召回优化
-
比如消息的有序性优化
-
比如sharding-jdbc 分页查询优化等等
优化1:业务架构解耦
业务架构解耦,就是基于业务模块,进行垂直的系统物理拆分,拆分出来业务线各司其职,提供服务化的能力,共同支撑主站业务。
所以,之前的订单模块,被从商城拆分出来,独立为订单系统,为商城相关系统提供订单、支付、物流、售后等标准化服务。
模块解耦配合的,就是数据库解耦,所以,订单模块使用独立的数据库,高并发场景下,模块解耦之后,就是服务解耦(微服务化)。
服务化解耦之后,对应的就是团队解耦。拆分出来业务线,各司其职。
总结起来,其实就是四大解耦:
-
模块解耦
-
数据库解耦
-
服务解耦
-
团队解耦(业务线解耦)
四大解耦之后,订单系统架构如下图所示:
那么四大解耦之后,结果是什么呢:
-
拆分出来业务线各司其职,迭代效率大幅提升
-
能更好的应对超高并发、超大规模数据存储难题。各个业务线可以结合领域特性,实施个性化的解决方案,更加有效、更有针对性的生产难题。
优化2:数据量大的优化
随着历史订单不断累积,2017年MySQL中订单表数据量已达千万级。2017年之后的订单数据,远远大于亿级。大家知道,InnoDB存储引擎的存储结构是B+树,单表的查找时间复杂度是O(log n),B+树的问题是:树的高度越大, IO次数越多。而磁盘IO操作,是性能非常低的。因此当数据总量n变大时,检索速度必然会变慢,不论如何加索引或者优化都无法解决,只能想办法减小单表数据量。对数据量大的问题,进行了以下优化:
-
数据归档
-
分表
1)数据归档
根据二八定律,系统绝大部分的性能开销花在20%的业务。数据也不例外,从数据的使用频率来看,经常被业务访问的数据称为热点数据;反之,称之为冷数据。
订单数据具备时间属性,存在热尾效应,在了解的数据的冷、热特性后,便可以指导我们做一些有针对性的性能优化。这里面有业务层面的优化,也有技术层面的优化。
业务层面的优化:
电商网站,一般只能查询3个月内的订单,如果你想看看3个月前的订单,需要访问历史订单页面。
技术层面的优化:
大部分情况下检索的都是最近的订单,而订单表里却存储了大量使用频率较低的老数据。那么就可以将新老数据分开存储,将历史订单移入另一张表中,然后,对代码中的查询模块做一些相应改动,便能有效解决数据量大的问题。
2)数据分表
分表又包含垂直分表和水平分表:
-
水平分表:在同一个数据库内,把一个表的数据按一定规则拆到多个表中;
-
垂直分表:将一个表按照字段分成多表,每个表存储其中一部分字段。
这里主要是减少 IO 的次数,降低B+树的高度,所以,主要考虑的是水平分表。按照业内的参考标准,单表的数据在500-1000W,B+树的高度在2-3层,一般2-3次IO操作,就可以读取到数据记录。但是,分表和措施,通常和分库一起分析和落地。所以,这里稍后结合 第三大优化吞吐量大的优化,一起分析。
优化3:吞吐量大的优化
截止2021,vivo在全球已覆盖4亿多用户,服务60多个国家和地区。从2017年开始,商城业务处于高速发展期,下单量屡创新高,吞吐量猛涨。
-
应用程序吞吐量猛涨
-
MySQL的吞吐量猛涨
但是, 单体MySQL的处理能力是有限的,当压力过大时,首先是 所有请求的RT时间拉长,访问速度下降,最后是拖垮整个数据库,甚至有可能使数据库宕机。吞吐量大的优化的解决方案有:
-
使用缓存
-
读写分离
-
分库
1)使用缓存
高并发架构的三板斧:缓存、池化、异步
第一板斧,首当其冲
首先考虑的是分布式缓存 Redis,使用Redis作为MySQL的前置缓存,可以挡住大部分的查询请求,并降低响应时延。其次,对于热点数据,可以使用二级缓存,甚至三级缓存
但是,缓存对存在局部热点、周期性热点数据友好。比如:商品系统、 优惠券系统、活动系统,这里存在局部热点、周期性热点数据的系统,使用一级缓存、二级缓存、甚至三级缓存。
但是,订单系统不属于这个场景。
订单有一个特点,每个用户的订单数据都不一样,所以,在订单系统中,缓存的缓存命中率不高。不存在太热的数据,所以一级缓存、三级缓存就不用了。
但是,redis 二级缓存,能缓存最近的订单,最近的订单也是用户最近最可能使用的数据,矮个子里边拔将军,所以,redis分布式还是能够为DB分担一下压力。这个还是要用的。
2)读写分离
主库负责执行数据更新请求,然后将数据变更实时同步到所有从库,用多个从库来分担查询请求。问题是:
-
但订单数据的更新操作较多,下单高峰时主库的压力依然没有得到解决。
-
且存在主从同步延迟,正常情况下延迟非常小,不超过1ms,但也会导致在某一个时刻的主从数据不一致。
那就需要对所有受影响的业务场景进行兼容处理,可能会做一些妥协,比如下单成功后先跳转到一个下单成功页,用户手动点击查看订单后才能看到这笔订单。
3)分库
分库又包含垂直分库和水平分库:
-
水平分库:把同一个表的数据按一定规则拆到不同的数据库中,每个库可以放在不同的服务器上;
-
垂直分库:按照业务将表进行分类,分布到不同的数据库上面,每个库可以放在不同的服务器上,它的核心理念是专库专用。
分库能够解决整体 高吞吐的问题。分表能够解决单表 高吞吐的问题。
综合考虑了改造成本、效果和对现有业务的影响,决定直接使用最后一招:分库分表。
4)分库分表技术选型
分库分表的技术选型主要从这几个方向考虑:
-
客户端sdk开源方案
-
中间件proxy开源方案
-
公司中间件团队提供的自研框架
-
自己动手造轮子
参考之前项目经验,并与公司中间件团队沟通后,采用了开源的 Sharding-JDBC 方案。
Sharding-JDBC 方案 已更名为Sharding-Sphere。其官方的地址是:
-
Github:https://github.com/sharding-sphere/
-
文档:官方文档比较粗糙,但是网上资料、源码解析、demo比较丰富
-
社区:活跃
-
特点:jar包方式提供,属于client端分片,支持xa事务
1)分库分表策略
结合业务特性,选取用户标识作为分片键,通过计算用户标识的哈希值再取模,来得到用户订单数据的库表编号。假设共有n个库,每个库有m张表,则库表编号的计算方式为:
-
库序号:Hash(userId) / m % n
-
表序号:Hash(userId) % m
路由过程如下图所示:
2)分库分表的局限性和应对方案
分库分表解决了数据量和并发问题,但它会极大限制数据库的查询能力,有一些之前很简单的关联查询,在分库分表之后可能就没法实现了,那就需要单独对这些Sharding-JDBC不支持的SQL进行改写。除此之外,还遇到了这些挑战:
①全局唯一ID设计
分库分表后,数据库自增主键不再全局唯一,不能作为订单号来使用,但很多内部系统间的交互接口只有订单号,没有用户标识这个分片键,如何用订单号来找到对应的库表呢?
原来,我们在生成订单号时,就将库表编号隐含在其中了。这样就能在没有用户标识的场景下,从订单号中获取库表编号。id的设计,逻辑复杂,既要考虑 高并发高性能,还要考虑时钟回拨等问题。
行业有非常多的解决案例, 推特 snowflake雪花id, 百度 雪花id,shardingjdbc 雪花id 源码,这些案例各有优势。
②历史订单号没有隐含库表信息
用一张表单独存储历史订单号和用户标识的映射关系,随着时间推移,这些订单逐渐不在系统间交互,就慢慢不再被用到。
③管理后台需要根据各种筛选条件,分页查询所有满足条件的订单
将订单数据冗余存储在搜索引擎Elasticsearch中,仅用于后台查询。
优化4:高速搜索引擎的数据一致性优化
为了便于订单的聚合搜索,高速搜索,把订单数据冗余存储在Elasticsearch中,那么,如何在MySQL的订单数据和ES中订单数据的增量一致性呢?
如何在MySQL的订单数据变更后,同步到ES中呢?
上面说到为了便于管理后台的查询,我们将订单数据冗余存储在Elasticsearch中,那么,如何在MySQL的订单数据变更后,同步到ES中呢?
这里要考虑的是数据同步的时效性和一致性、对业务代码侵入小、不影响服务本身的性能等。
1)MQ方案
ES更新服务作为消费者,接收订单变更MQ消息后对ES进行更新
2)Binlog方案
ES更新服务借助canal等开源项目,把自己伪装成MySQL的从节点,接收Binlog并解析得到实时的数据变更信息,然后根据这个变更信息去更新ES。
其中BinLog方案比较通用,但实现起来也较为复杂,我们最终选用的是MQ方案。因为ES数据只在管理后台使用,对数据可靠性和同步实时性的要求不是特别高。考虑到宕机和消息丢失等极端情况,在后台增加了按某些条件手动同步ES数据的功能来进行补偿。
优化5:合理的选择数据库迁移措施
如何将数据从原来的单实例数据库,迁移到新的数据库集群,也是一大技术挑战。不但要确保数据的正确性,还要保证每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤。我们考虑了停机迁移和不停机迁移的两种方案:
1)不停机迁移方案:
-
把旧库的数据复制到新库中,上线一个同步程序,使用 Binlog等方案实时同步旧库数据到新库;
-
上线双写订单新旧库服务,只读写旧库;
-
开启双写,同时停止同步程序,开启对比补偿程序,确保新库数据和旧库一致;
-
逐步将读请求切到新库上;
-
读写都切换到新库上,对比补偿程序确保旧库数据和新库一致;
-
下线旧库,下线订单双写功能,下线同步程序和对比补偿程序。
2)停机迁移方案:
-
上线新订单系统,执行迁移程序将两个月之前的订单同步到新库,并对数据进行稽核;
-
将商城V1应用停机,确保旧库数据不再变化;
-
执行迁移程序,将第一步未迁移的订单同步到新库并进行稽核;
-
上线商城V2应用,开始测试验证,如果失败则回退到商城V1应用(新订单系统有双写旧库的开关)。
考虑到不停机方案的改造成本较高,而夜间停机方案的业务损失并不大,最终选用的是停机迁移方案。
优化6:合理的进行分布式事务方案的选型
电商的交易流程中,分布式事务是一个经典问题,比如:
-
用户支付成功后,需要通知发货系统给用户发货;
-
用户确认收货后,需要通知积分系统给用户发放购物奖励的积分。
我们是如何保证微服务架构下数据的一致性呢?
不同业务场景对数据一致性的要求不同,业界的主流方案中,用于解决强一致性的有两阶段提交(2PC)、三阶段提交(3PC),解决最终一致性的有TCC、本地消息、事务消息和最大努力通知等。
我们正在使用的本地消息表方案:
在本地事务中将要执行的异步操作记录在消息表中,如果执行失败,可以通过定时任务来补偿。
下图以订单完成后通知积分系统赠送积分为例。
优化7:其他的一些细节、具备优化
1)网络隔离
只有极少数第三方接口可通过外网访问,且都会验证签名,内部系统交互使用内网域名和RPC接口,不需要要进行签名,提升性能,也提升安全性。
2)并发锁
分布式场景,可能会出现同一个订单的并发更新。任何订单更新操作之前,会通过数据库行级锁加以限制,防止出现并发更新。
3)幂等性
分布式场景,可能会出现同一个订单的重复更新。所有接口均具备幂等性,不用担心对方网络超时重试所造成的影响。
4)熔断
分布式场景,需要防止故障的扩散,发生由一点牵动全身的系统性雪崩。防止某个系统故障的影响扩大到整个分布式系统中。使用Hystrix组件,对外部系统的实时调用添加熔断保护,防止某个系统故障的影响扩大到整个分布式系统中。
5)全方位监控和告警
通过配置日志平台的错误日志报警、调用链的服务分析告警,再加上公司各中间件和基础组件的监控告警功能,让我们能够能够第一时间发现系统异常。
6)消息的有序性问题
采用MQ消费的方式同步数据库的订单相关数据到ES中,遇到的写入数据不是订单最新数据问题。
上图左边是原方案:
在消费订单数据同步的MQ时,如果线程A在先执行,查出数据,这时候订单数据被更新了,线程B开始执行同步操作,查出订单数据后先于线程A一步写入ES中,线程A执行写入时就会将线程B写入的数据覆盖,导致ES中的订单数据不是最新的。
上图右边是解决方案:
解决方案是在查询订单数据时加行锁,整个业务执行在事务中,执行完成后再执行下一个线程。
7)sharding-jdbc 分组后排序分页查询出所有数据问题
select a from temp group by a,b order by a desc limit 1,10。
执行时Sharding-jdbc里group by 和 order by 字段和顺序不一致时将10置为Integer.MAX_VALUE, 导致分页查询失效。
io.shardingsphere.core.routing.router.sharding.ParsingSQLRouter#processLimit
private void processLimit(final List<Object> parameters, final SelectStatement selectStatement, final boolean isSingleRouting) {
boolean isNeedFetchAll = (!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems();
selectStatement.getLimit().processParameters(parameters, isNeedFetchAll, databaseType, isSingleRouting);
}
io.shardingsphere.core.parsing.parser.context.limit.Limit#processParameters
/**
* Fill parameters for rewrite limit.
*
* @param parameters parameters
* @param isFetchAll is fetch all data or not
* @param databaseType database type
* @param isSingleRouting is single routing or not
*/
public void processParameters(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
fill(parameters);
rewrite(parameters, isFetchAll, databaseType, isSingleRouting);
}
private void rewrite(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
int rewriteOffset = 0;
int rewriteRowCount;
if (isFetchAll) {
rewriteRowCount = Integer.MAX_VALUE;
} else if (isNeedRewriteRowCount(databaseType) && !isSingleRouting) {
rewriteRowCount = null == rowCount ? -1 : getOffsetValue() + rowCount.getValue();
} else {
rewriteRowCount = rowCount.getValue();
}
if (null != offset && offset.getIndex() > -1 && !isSingleRouting) {
parameters.set(offset.getIndex(), rewriteOffset);
}
if (null != rowCount && rowCount.getIndex() > -1) {
parameters.set(rowCount.getIndex(), rewriteRowCount);
}
}
正确的写法应该是
select a from temp group by a desc ,b limit 1,10 ;
两个sql,可以对比一下
select a from temp group by a desc ,b limit 1,10 ; #优化的sql, 去掉了 oderby
select a from temp group by a,b order by a desc limit 1,10 #原始的sql
这里 使用的版本是sharing-jdbc的3.1.1。
8)ES分页查询的召回问题
ES分页查询的召回问题: ES分页查询如果排序字段存在重复的值。
解决方案:最好加一个唯一的字段作为第二排序条件,避免分页查询时漏掉数据、查出重复数据。比如用的是订单创建时间作为唯一排序条件,同一时间如果存在很多数据,就会导致查询的订单存在遗漏或重复,这里,需要增加一个唯一值作为第二排序条件、或者直接使用唯一值作为排序条件。
优化和升级的成果
最后,总结一下,优化和升级的成果
-
一次性上线成功,稳定运行了一年多;
-
核心服务性能提升十倍以上;
-
系统解耦,迭代效率大幅提升;
-
能够支撑商城至少五年的高速发展。
异步的 20 种实现方式
同步:调用方在调用过程中,持续阻塞,一直到返回结果。同步获取结果的方式是: 主动等待。
异步:调用方在调用过程中,不会阻塞, 不直接等待返回结果, 而是执行其他任务。异步获取结果的方式是 : 被动通知或者 被动回调。
方式1:新建线程Thread异步
方式2:线程池化 异步
Thread
线程和OS内核线程,是一一对应的关系,频繁的创建、销毁,浪费系统资源,并且涉及到进行内核态和用户态的切换,这一切的一切,都是低性能的。如何提升性能呢?可以将 线程池化 ,就是线程池。
方式3:Future 阻塞式异步
为了获取异步线程的返回结果,以及更好的对异步线程的干预,Java在1.5版本之后提供了一种新的多线程的创建方式—FutureTask方式。FutureTask方式包含了一系列的Java相关的类,处于java.util.concurrent包中。使用FutureTask方式进行异步调用时,所涉及的重要组件为FutureTask类和Callable接口。
Future 的调用方式,属于阻塞式异步。主要原因在于,在获取异步线程处理结果时,需要主线程主动通过Future.get() 去获取,如果异步线程没有执行完,那么Future.get() 会阻塞 调用线程,一直到超时。
阻塞式异步Future的不足之处
-
无法被动接收异步任务的计算结果:
虽然我们可以主动将异步任务提交给线程池中的线程来执行,但是待异步任务执行结束之后,主线程无法得到任务完成与否的通知,它需要通过get方法主动获取任务执行的结果。
-
Future间彼此孤立:
有时某一个耗时很长的异步任务执行结束之后,你想利用它返回的结果再做进一步的运算,该运算也会是一个异步任务,两者之间的关系需要程序开发人员手动进行绑定赋予,Future并不能将其形成一个任务流(pipeline),每一个Future都是彼此之间都是孤立的,所以才有了后面的CompletableFuture,CompletableFuture就可以将多个Future串联起来形成任务流。
-
Futrue没有很好的错误处理机制:
截止目前,如果某个异步任务在执行发的过程中发生了异常,调用者无法被动感知,必须通过捕获get方法的异常才知晓异步任务执行是否出现了错误,从而在做进一步的判断处理。
伪异步 与 纯异步
异步调用目的在于防止当前业务线程被阻塞。但是 Future 阻塞式异步 属于 伪异步。
伪异步 就是 将任务包装为Runnable/ Callable 作为Biz业务线程(被调用线程)的任务去执行,并调用方阻塞等待,当前Biz 线程不阻塞;
纯异步为回调式 异步。他们的区别不在于是否将请求放入另一个线程池执行,而在于是否有线程阻塞等待Response。
为什么说二者协同的方式是调用方阻塞?调用方线程需要通过join()或Future.get()阻塞式的干预 异步操作或者获取 异步结果,这里,是阻塞模式的异步, 伪异步
这种调用方线程的阻塞,是 线程资源的一种浪费。线程资源,是宝贵的。怎么充分的利用线程资源呢?有效方式之一: 回调模式的 异步。实现 纯纯的异步。
方式4:guava 回调式异步
由于JDK在1.8之前没有 回调式异步组件,于是出现了很多 开源的 回调式异步组件。比较常用的是 guava 的回调式异步。Guava是Google提供的Java扩展包,它提供了一种异步回调的解决方案。Guava中与异步回调相关的源码处于com.google.common.util.concurrent包中。包中的很多类都用于对java.util.concurrent的能力扩展和能力增强。
比如,Guava的异步任务接口ListenableFuture扩展了Java的Future接口,实现了异步回调的的能力。
方式5:Netty 回调式异步
由于JDK在1.8之前没有 回调式异步组件,于是出现了很多 开源的 回调式异步组件。Netty 也算其中之一。Netty 是 一个 著名的高性能NIO王者框架, 是 IO 的王者组件。Netty 除了作为NIO框架之王,其子模也是可以单独使用的,比如说异步回调模块。
Netty 的 回调式异步组件 更加优秀。通过Netty源码可以知道: Netty 的 回调式异步组件不光提供了外部的回调监听设置,而且可以在异步代码中, 通过Promise接口,可以对回调结果进行干预,比如说在进行回调之前,执行一些其他的操作。
Callback Hell(回调地狱)问题
无论是 Google Guava 包中的 ListenableFuture,还是 Netty的 GenericFutureListener,都是需要设置专门的Callback 回调钩子。Guava 包中的 ListenableFuture,设置Callback 回调钩子的实例如下:
ListenableFuture<Boolean> wFuture = gPool.submit(wJob);
Futures.addCallback(wFuture, new FutureCallback<Boolean>() {
public void onSuccess(Boolean r) {
if (!r) {
Print.tcfo("杯子洗不了,没有茶喝了");
} else {
countDownLatch.countDown();
}
}
public void onFailure(Throwable t) {
Print.tcfo("杯子洗不了,没有茶喝了");
}
});
调用方通过 Futures.addCallback() 添加处理结果的回调函数。这样避免获取并处理异步任务执行结果阻塞调起线程的问题。Callback 是将任务执行结果作为接口的入参,在任务完成时回调 Callback 接口,执行后续任务,从而解决纯 Future 方案无法方便获得任务执行结果的问题。
但 Callback 产生了新的问题,那就是代码可读性的问题。因为使用 Callback 之后,代码的字面形式和其所表达的业务含义不匹配,即业务的先后关系到了代码层面变成了包含和被包含的关系。
因此,如果大量使用 Callback 机制,将使大量的存在先后次序的业务逻辑,在代码形式上,转换成层层嵌套,从而导致:业务先后次序在代码维度被打乱,最终造成代码不可理解、可读性差、难以理解、难以维护。
这便是所谓的 Callback Hell(回调地狱)问题。Callback Hell 问题可以从两个方向进行一定的解决:
-
一是链式调用
-
二是事件驱动机制。
前被 CompletableFuture、反应式编程等技术采用,前者被如 EventBus、Vert.x 所使用。
方式6:Servlet 3.0 异步
Callback 真正体现价值,是它与 NIO 技术结合之后。
-
CPU 密集型场景,采用 Callback 回调没有太多意义;
-
IO 密集型场景,如果是使用 BIO模式,Callback 同样没有意义,因为一个连接一个线程,IO线程是因为 IO 而阻塞。
-
IO 密集型场景,如果是使用 NIO 模式,使用Callback 才有意义。 NIO是少量IO线程负责大量IO通道,IO线程需要避免线程阻塞,所以,也必须使用 Callback ,才能使应用得以被开发出来。
所以,高性能的 NIO 框架如 Netty ,都是基于 Callback 异步回调的。但是,在微服务流行的今天,Netty 却没有在WEB服务器中占据统治地位。微服务系统中,多级服务调用很常见,一个服务先调 A,再用结果 A 调 B,然后用结果 B 调用 C,等等。
如果使用Netty 作为底层服务器,IO 线程能大大降低,能处理的连接数(/请求数)也能大大增加,那么,为啥Netty 却没有在WEB服务器中占据统治地位呢?这其中的难度来自两方面:
-
一是 NIO 和 Netty 本身的技术难度,
-
二是 Callback hell:Callback 风格所导致的代码理解和维护的困难。
所以,Servlet 3.0 提供了一个异步解决方案。
什么是servlet异步请求
Servlet 3.0 之前,一个普通 Servlet 的主要工作流程大致如下:
(1)Servlet 接收到请求之后,可能需要对请求携带的数据进行一些预处理;
(2)调用业务接口的某些方法,以完成业务处理;
(3)根据处理的结果提交响应,Servlet 线程结束。
其中第二步处理业务逻辑时候很可以碰到比较耗时的任务,此时servlet主线程会阻塞等待完成业务处理,对于并发比较大的请求可能会产生性能瓶颈,则servlet3.0之后再此处做了调整,引入了异步的概念。
(1)Servlet 接收到请求之后,可能需要对请求携带的数据进行一些预处理;
(2)调用业务接口的某些方法过程中request.startAsync()请求,获取一个AsyncContext
(3)紧接着servlet线程退出(回收到线程池),但是响应response对象仍旧保持打开状态,新增线程会使用AsyncContext处理并响应结果。
(4)AsyncContext处理完成触发某些监听通知结果
@WebServlet(urlPatterns = "/demo", asyncSupported = true)
public class AsyncDemoServlet extends HttpServlet {
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
// Do Something
AsyncContext ctx = req.startAsync();
startAsyncTask(ctx);
}
}
private void startAsyncTask(AsyncContext ctx) {
requestRpcService(result -> {
try {
PrintWriter out = ctx.getResponse().getWriter();
out.println(result);
out.flush();
ctx.complete();
} catch (Exception e) {
e.printStackTrace();
}
});
}
Servlet 3.0 的出现,解决了在过去基于 Servlet 的 Web 应用中,接受请求和返回响应必须在同一个线程的问题,实现了如下目标:
-
可以避免了 Web 容器的线程被阻塞挂起
-
使请求接收之后的任务处理可由专门线程完成
-
不同任务可以实现线程池隔离
-
结合 NIO 技术实现更高效的 Web 服务
除了直接使用 Servlet 3.0,也可以选择 Spring MVC 的 Deferred Result。
示例:Spring MVC DeferredResult
@GetMapping("/async-deferredresult")
public DeferredResult<ResponseEntity<?>> handleReqDefResult(Model model) {
LOG.info("Received async-deferredresult request");
DeferredResult<ResponseEntity<?>> output = new DeferredResult<>();
ForkJoinPool.commonPool().submit(() -> {
LOG.info("Processing in separate thread");
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
}
output.setResult(ResponseEntity.ok("ok"));
});
LOG.info("servlet thread freed");
return output;
}
Servlet 3.0 的技术局限
Servlet 3.0 并不是用来解决前面提到的 Callback Hell 问题的,它只是降低了异步 Web 编程的技术门槛。对于 Callback Hell 问题,使用 Servlet 3.0 或类似技术时同样会遇到。
方式7:回调式异步CompletableFuture
JDK 1.8之前并没有实现回调式的异步,CompletableFuture是JDK 1.8引入的实现类,实现了JDK内置的异步回调模式异步。CompletableFuture的创新是:通过 链式调用,解决 Callback Hell(回调地狱)问题, 让代码变得的可理解行更强,可读性 更强。
CompletableFuture 该类实现了Future和CompletionStage两个接口。
该类的实例作为一个异步任务,可以在自己异步执行完成之后触发一些其他的异步任务,从而达到异步回调的效果。使用CompletableFuture实现实例的实现,参考如下:
public class DrinkTea {
private static final int SLEEP_GAP = 3;//等待3秒
public static void main(String[] args) {
// 任务 1
CompletableFuture<Boolean> washJob =
CompletableFuture.supplyAsync(() ->
{
// doSomething()
return true;
});
// 任务 2
CompletableFuture<Boolean> hotJob =
CompletableFuture.supplyAsync(() ->
{
// doSomething()
return true;
});
// 任务 3:任务 1 和任务 2 完成后执行:泡茶
CompletableFuture<String> drinkJob =
washJob.thenCombine(hotJob, (hotOk, washOK) ->
{
if (hotOk && washOK) {
// doSomething()
}
return "没有喝到茶";
});
// 等待任务 3 执行结果
Print.tco(drinkJob.join());
}
}
方式8:JDK 9 Flow 响应式编程
但是 JDK 8 的 CompletableFuture 属于链式调用,它在形式上带有一些响应式编程的函数式代码风格。因为 Callback Hell 对代码可读性有很大杀伤力,从开发人员的角度来讲,反应式编程技术和链式调用一样,使得代码可读性要比 Callback 提升了许多。
响应式流从2013年开始,作为提供非阻塞背压的异步流处理标准的倡议。
Reactive Stream是一套基于发布/订阅模式的数据处理规范。更确切地说,Reactive流目的是“找到最小的一组接口,方法和协议,用来描述必要的操作和实体以实现这样的目标:以非阻塞背压方式实现数据的异步流”。
响应式流(Reactive Streams)是一个响应式编程的规范,用来为具有非阻塞背压(Back pressure)的异步流处理提供标准,用最小的一组接口、方法和协议,用来描述必要的操作和实体。这里涉及到一个关键概念叫 Backpressure,国内大部分翻译为背压,我们先来了解这是什么。
响应式编程,其实就是对数据流的编程,而对流的处理对数据流的变化进行响应,是通过异步监听的方式来处理的。既然是异步监听,就涉及到监听事件的发布者和订阅者,数据流其实就是由发布者生产,再由一个或多个订阅者进行消费的元素(item)序列。那么,如果发布者生产元素的速度和订阅者消费元素的速度不一样,是否会出现问题呢?其实就两种情况:
-
发布者生产的速度比订阅者消费的速度慢,那生产的元素可以及时被处理,订阅者处理完只要等待发布者发送下一元素即可,这不会产生什么问题。
-
发布者生产的速度比订阅者消费的速度快,那生产的元素无法被订阅者及时处理,就会产生堆积,如果堆积的元素多了,订阅者就会承受巨大的资源压力(pressure)而有可能崩溃。
要应对第二种情况,就需要进行流控制(flow control)。
流控制有多种方案,其中一种机制就是 Back pressure,即背压机制,其实就是下游能够向上游反馈流量需求的机制。
如果生产者发出的数据比消费者能够处理的数据量大而且快时,消费者可能会被迫一直再获取或处理数据,消耗越来越多的资源,从而埋下潜在的风险。为了防止这一点,消费者可以通知生产者降低生产数据的速度。生产者可以通过多种方式来实现这一要求,这时候我们就会用到背压机制。
采用背压机制后,消费者告诉生产者降低生产数据速度并保存元素,知道消费者能够处理更多的元素。使用背压可以有效地避免过快的生产者压制消费者。如果生产者要一直生产和保存元素,使用背压也可能会要求其拥有无限制的缓冲区。生产者也可以实现有界缓冲区来保存有限数量的元素,如果缓冲区已满可以选择放弃。
背压的实现方式
背压的实现方式有两种:
-
一种是阻塞式背压
-
另一种是非阻塞式背压。
1、阻塞式背压
阻塞式背压是比较容易实现的,例如:当生产者和消费者在同一个线程中运行时,其中任何一方都将阻塞其他线程的执行。这就意味着,当消费者被执行时,生产者就不能发出任何新的数据元素。因而也需要一中自然地方式来平衡生产数据和消费数据的过程。
在有些情况下,阻塞式背压会出现不良的问题,比如:当生产者有多个消费者时,不是所有消费者都能以同样的速度消费消息。当消费者和生产者在不同环境中运行时,这就达不到降压的目的了。
2、非阻塞式背压
背压机制应该以非阻塞式的方式工作,实现非阻塞式背压的方法是放弃推策略,采用拉策略。生产者发送消息给消费者等操作都可以保存在拉策略当中,消费者会要求生产者生成多少消息量,而且最多只能发送这些量,然后等到更多消息的请求。
JDK 8 的 CompletableFuture 不算是反应式编程,不遵守Reactive Stream (响应式流/反应流) 规范。JDK 9 Flow 是JDK对Reactive Stream (响应式流/反应流) 的实现,Java 9中新增了反应式/响应式编程的Api-Flow
。
在Flow
中存在Publisher(发布者)
、Subscriber(订阅者)
、Subscription(订阅)
和`Processor(处理器)。Flow结构如下:
JDK 9 Flow 旨在解决处理元素流的问题—如何将元素流从发布者传递到订阅者,而不需要发布者阻塞,或订阅者需要有无限制的缓冲区或丢弃。
当然,实施响应式编程,需要完整的解决方案,单靠 Flow 是不够的,还是需要Netflix RxJava、 Project Reactor 这样的完整解决方案。但是, JDK 层面的技术能提供统一的技术抽象和实现,在统一技术方面还是有积极意义的。
方式9:RxJava 响应式 异步
在JDK 9 Flow 之前,响应式编程 的框架,早就存在。比如说, 席卷了android 端编程的 RxJava 框架。RxJava 是一种响应式编程,来创建基于事件的异步操作库。这个组件,是 Netflix的杰作,也叫作Netflix RxJava。这个框架,在Java 后端的中间件中,也有广泛使用,比如在Hystrix 源码中,就用大量用到。
使用 RxJava 基于事件流的链式调用、代码 逻辑清晰简洁。
package com.crazymakercircle.completableFutureDemo;
import com.crazymakercircle.util.Print;
import io.reactivex.Observable;
import io.reactivex.schedulers.Schedulers;
import org.junit.Test;
import java.util.concurrent.CompletableFuture;
import static com.crazymakercircle.util.ThreadUtil.sleepMilliSeconds;
import static com.crazymakercircle.util.ThreadUtil.sleepSeconds;
public class IntegrityDemo {
/**
* 模拟模拟RPC调用1
*/
public String rpc1() {
//睡眠400ms,模拟执行耗时
sleepMilliSeconds(600);
Print.tcfo("模拟RPC调用:服务器 server 1");
return "sth. from server 1";
}
/**
* 模拟模拟RPC调用2
*/
public String rpc2() {
//睡眠400ms,模拟执行耗时
sleepMilliSeconds(600);
Print.tcfo("模拟RPC调用:服务器 server 2");
return "sth. from server 2";
}
@Test
public void rpcDemo() throws Exception {
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() ->
{
return rpc1();
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> rpc2());
CompletableFuture<String> future3 = future1.thenCombine(future2,
(out1, out2) ->
{
return out1 + " & " + out2;
});
String result = future3.get();
Print.tco("客户端合并最终的结果:" + result);
}
@Test
public void rxJavaDemo() throws Exception {
Observable<String> observable1 = Observable.fromCallable(() ->
{
return rpc1();
}).subscribeOn(Schedulers.newThread());
Observable<String> observable2 = Observable
.fromCallable(() -> rpc2()).subscribeOn(Schedulers.newThread());
Observable.merge(observable1, observable2)
.observeOn(Schedulers.newThread())
.toList()
.subscribe((result) -> Print.tco("客户端合并最终的结果:" + result));
sleepSeconds(Integer.MAX_VALUE);
}
}
方式10:Reactor 响应式 异步
目前,在 Java 领域实现了反应式编程的技术除了 Netflix RxJava ,还有 Spring 的 Project Reactor。Netflix 网飞 的RxJava 出现时间更早,在前端开发领域应用的比后端更要广泛一些。
Spring 的 Project Reactor的 3.0 版本作为 Spring 5 的基础,在17年底发布,推动了后端领域反应式编程的发展。
关于Reactor 响应式异步的 内容,请参考:Flux、Mono、Reactor 实战:https://blog.csdn.net/crazymakercircle/article/details/124120506
方式11:Spring的@Async异步
在Spring中,使用@Async标注某方法,可以使该方法变成异步方法,这些方法在被调用的时候,将会在独立的线程中进行执行,调用者不需等待该方法执行完成。但在Spring中使用@Async注解,需要使用@EnableAsync来开启异步调用。
@Async注解,默认使用系统自定义线程池。
在实际项目中,推荐等方式是是使用自定义线程池的模式。可在项目中设置多个线程池,在异步调用的时候,指明需要调用的线程池名称,比如:@Async("taskName")
自定义异步线程池的代码如下
/**
* 线程池参数配置,多个线程池实现线程池隔离
@EnableAsync
@Configuration
public class TaskPoolConfig {
/**
* 自定义线程池
*
**/
@Bean("taskExecutor")
public Executor taskExecutor() {
//返回可用处理器的Java虚拟机的数量 12
int i = Runtime.getRuntime().availableProcessors();
System.out.println("系统最大线程数 : " + i);
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
//核心线程池大小
executor.setCorePoolSize(16);
//最大线程数
executor.setMaxPoolSize(20);
//配置队列容量,默认值为Integer.MAX_VALUE
executor.setQueueCapacity(99999);
//活跃时间
executor.setKeepAliveSeconds(60);
//线程名字前缀
executor.setThreadNamePrefix("asyncServiceExecutor -");
//设置此执行程序应该在关闭时阻止的最大秒数,以便在容器的其余部分继续关闭之前等待剩余的任务完成他们的执行
executor.setAwaitTerminationSeconds(60);
//等待所有的任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
return executor;
}
}
方式12:EventBus 发布订阅模式异步
实际开发中,常常 通过事件总线EventBus/AsyncEventBus进行JAVA模块解耦 ,比如,在顶级开源组件 JD hotkey的源码中, 就多次用到 EventBus/AsyncEventBus进行JAVA模块解耦。
EventBus 是 Guava 的事件处理机制,是观察者模式(生产/消费模型)的一种实现。EventBus是google的Guava库中的一个处理组件间通信的事件总线,它基于发布/订阅模式,实现了多组件之间通信的解耦合,事件产生方和事件消费方实现解耦分离,提升了通信的简洁性。
观察者模式在我们日常开发中使用非常广泛,例如在订单系统中,订单状态或者物流信息的变更会向用户发送APP推送、短信、通知卖家、买家等等;审批系统中,审批单的流程流转会通知发起审批用户、审批的领导等等。
Observer模式也是 JDK 中自带就支持的,其在 1.0 版本就已经存在 Observer,不过随着 Java 版本的飞速升级,其使用方式一直没有变化,许多程序库提供了更加简单的实现,例如 Guava EventBus、RxJava、EventBus 等
为什么要用 EventBus ,其优缺点 ?
EventBus 优点
-
相比 Observer 编程简单方便
-
通过自定义参数可实现同步、异步操作以及异常处理
-
单进程使用,无网络影响
缺点
-
只能单进程使用
-
项目异常重启或者退出不保证消息持久化
如果需要分布式使用还是需要使用 MQ。
使用事件总线的场景
当一个事件的发生(事件产生方),需要触发很多事件(事件消费方)的时候,我们通常会在事件产生方中,分别的去调用那些事件消费方,这样往往是很浪费资源。事件的产生方与事件的消费方,产生了极大的耦合,如果我们要改动某一个事件消费方,我们很可能还要改动事件的产生方。
在工作中,经常会遇见使用异步的方式来发送事件,或者触发另外一个动作:经常用到的框架是MQ(分布式方式通知); 如果是同一个jvm里面通知的话,就可以使用EventBus 事件总线。
由于EventBus使用起来简单、便捷,因此,工作中会经常用到。EventBus 是线程安全的,分发事件到监听器,并提供相应的方式让监听器注册它们自己。EventBus允许组件之间进行 “发布-订阅” 式的通信,而不需要这些组件彼此知道对方。EventBus是专门设计用来替代传统的Java进程内的使用显示注册方式的事件发布模式。EventBus不是一个通用的发布-订阅系统,也不是用于进程间通信。
EventBus有三个关键要素:
1、事件(Event)
事件是EventBus之间相互通信的基本单位,一个Event可以是任何类型。就是Object,只要你想将任意一个Bean作为事件,这个类不需要做任何改变,就可以作为事件Event。一般会定义特定的事件类,类名以Event作为后缀,里面定义一些变量或者函数等。
2、事件发布者(Publisher)
事件发布者,就是发送事件到EventBus事件总线的一方,事件发布者调用Post()方法,将事件发给EventBus。你可以在程序的任何地方,调用EventBus的post()方法,发送事件给EventBus,由EventBus发送给订阅者们。
3、事件订阅者(Subscriber)
事件订阅者,就是接收事件的一方,这些订阅者需要在自己的方法上,添加@Subscribe注解声明自己为事件订阅者。不过只声明是不够的,还需要将自己所在的类,注册到EventBus中,EventBus才能扫描到这个订阅者。
关于EventBus 的 原理和实操内容,请参考:通过事件总线EventBus/AsyncEventBus进行JAVA模块解耦https://blog.csdn.net/crazymakercircle/article/details/128627663
方法13:Spring ApplicationEvent事件实现异步
Spring内置了简便的事件机制,原理和EventBus 差不多。通过Spring ApplicationEvent事件, 可以非常方便的实现事件驱动,核心类包括
-
ApplicationEvent,具体事件内容,事件抽象基类,可继承该类自定义具体事件
-
ApplicationEventPublisher,事件发布器,可以发布ApplicationEvent,也可以发布普通的Object对象
-
ApplicationListener,事件监听器,可以使用注解
@EventListener
-
TransactionalEventListener,事务事件监听,可监听事务提交前、提交后、事务回滚、事务完成(成功或失败)
使用示例:不定义事件,直接发布Object对象,同步
1、定义发送事件对象
public class UserEntity {
private long id;
private String name;
private String msg;
}
2、定义事件监听器
可以添加条件condition,限制监听具体的事件
@Slf4j
@Component
public class RegisterListener {
@EventListener(condition = "#entity.id != null and #entity.async==false ")
public void handlerEvent(UserEntity entity) {
try {
// 休眠5秒
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("handlerEvent: {}", entity);
}
}
3、定义发送接口以及实现类
public interface IRegisterService {
public void register(String name);
}
@Service
public class RegisterServiceImpl implements IRegisterService {
@Resource
private ApplicationEventPublisher applicationEventPublisher;
@Override
public void publish(String name) {
UserEntity entity = new UserEntity();
entity.setName(name);
entity.setId(1L);
entity.setMsg("新用户注册同步调用");
applicationEventPublisher.publishEvent(entity);
}
}
4、测试Controller类,进行测试
@Slf4j
@Controller
public class TestController {
@Resource
private IRegisterService registerService;
@RequestMapping("test")
@ResponseBody
public void test1(String name) {
registerService.publish(name);
log.info("执行同步调用结束");
}
}
在浏览器中输入地址:http://localhost/test?name=nik 控制台输出:
handlerEvent: UserEntity(id=1, name=nik, msg=新用户注册同步调用)
执行同步调用结束
但是,上面的案例是同步事件,如果需要编程异步事件,还需要加上额外的注解:
1、在启动类添加异步注解 @EnableAsync
2、在监听方法上添加注解 @Async
@Async
@EventListener(condition = "#entity.name != null and #entity.async ")
public void handlerEventAsync(UserEntity entity) {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("handlerEventAsync: {}", entity);
}
方法14: RocketMq 消息队列 分布式 发布订阅模式 异步
上游系统对下游系统的调用若为同步调用,则会大大降低系统的吞吐量与并发度,且系统耦合度太高。而异步调用则会解决这些问题。所以两层之间若要实现由同步到异步的转化,一般性做法就是,在这两层间添加一个MQ层。
RocketMq 消息队列 分布式 发布订阅模式(Pub/Sub),基于事件的系统中,Pub/Sub是目前广泛使用的通信模型,它采用事件作为基本的通信机制,提供大规模系统所要求的松散耦合的交互模式:订阅者(如客户端)以事件订阅的方式表达出它有兴趣接收的一个事件或一类事件;发布者(如服务器)可将订阅者感兴趣的事件随时通知相关订阅者。
上游生产者参考
-
创建producer组
-
设置NameServer地址 : 如果实在安装不上,可以使用这个地址:115.159.88.63:9876
-
start生产者
-
发送消息获取结果
-
结束producer
public class Producer {
public static void main(String[] args) throws Exception {
//1.创建生产者组
DefaultMQProducer producer = new DefaultMQProducer("producer-hello");
//2.设置NameServer地址
producer.setNamesrvAddr("127.0.0.1:9876");
//3.启动producer实例
producer.start();
//4.创建消息
Message message = new Message("log-topic", "info-tag", "这是一个info信息".getBytes(RemotingHelper.DEFAULT_CHARSET));
//5.发送消息
SendResult result = producer.send(message);
//6.关闭producer实例
System.out.println("发送完毕,结果: "+result);
}
}
下游消费者参考
-
创建consumer组
-
设置Name Server地址
-
设置消费位置,从最开始销毁
-
设置消息回调处理监听 -> 处理消息
-
Start consumer
public class Consumer {
public static void main(String[] args) throws MQClientException {
//1.创建消费者组
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer-hello");
//2.设置NameServer地址
consumer.setNamesrvAddr("127.0.0.1:9876");
//3.订阅topic,指定tag标签
consumer.subscribe("log-topic","info-tag");
//4.注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently(){
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s 接收到新的消息: %n", Thread.currentThread().getName());
msgs.stream().forEach(messageExt -> {
String body = null;
try {
body = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
System.out.println(body);
});
//失败消费,稍后尝试消费,会进行多次重试
//return ConsumeConcurrentlyStatus.RECONSUME_LATER;
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
//5.启动消费者
consumer.start();
System.out.println("消费者启动...");
}
}
-
DefaultMQPushConsumer :消费者 , 可以指定 consumerGroupName
-
consumer.setNamesrvAddr : 设置name server 地址
-
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET) :从什么位置开始消费
-
consumer.subscribe(“topic_log”, “tags_error”) :订阅某个topic下的某个tags的消息
-
consumer.registerMessageListener :注册消息监听器,拿到消息后,进行消息处理。
-
ConsumeConcurrentlyStatus :消费者消费结果状态,ConsumeConcurrentlyStatus.CONSUME_SUCCESS代表成功,ConsumeConcurrentlyStatus.RECONSUME_LATER代表消费失败,稍后重试,会进行多次重试
方法15:Redis 消息队列 分布式 发布订阅模式 异步
在springboot项目中,一般分布式 发布订阅模式 异步,都是用RocketMQ的方式,如果集成太麻烦了,而一般系统里面已经有了redis,就用了redis做异步的功能。Redis发布订阅(pub/sub)是一种消息通信模式:
-
发送者(pub)发送消息,
-
订阅者(sub)接收消息。
Redis 发布订阅(pub/sub)实现了消息系统,发送者(在redis术语中称为发布者)在接收者(订阅者)接收消息时发送消息。传送消息的链路称为信道。在Redis中,客户端可以订阅任意数量的信道。
-
消息发布
消息发布者,即publish客户端,无需独占链接,你可以在publish消息的同时,使用同一个redis-client链接进行其他操作(例如:INCR等)
-
消息订阅
消息订阅者,即subscribe客户端,需要独占链接,即进行subscribe期间,redis-client无法穿插其他操作,此时client以阻塞的方式等待“publish端”的消息;这一点很好理解,因此subscribe端需要使用单独的链接,甚至需要在额外的线程中使用。Redis 消息队列特点
-
发送者(发布者)不是计划发送消息给特定的接收者(订阅者)。而是发布的消息分到不同的频道,不需要知道什么样的订阅者订阅。
-
订阅者对一个或多个频道感兴趣,只需接收感兴趣的消息,不需要知道什么样的发布者发布的。
这种发布者和订阅者的解耦合可以带来更大的扩展性和更加动态的网络拓扑。发布及订阅功能通过redis client 使用参考
# 订阅一个redisChat频道
redis 127.0.0.1:6379> SUBSCRIBE redisChat
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "redisChat"
3) (integer) 1
#发布消息到redisChat频道,发布成功后,订阅者会收到信息
redis 127.0.0.1:6379> PUBLISH redisChat "Redis is a great caching technique"
(integer) 1
redis 127.0.0.1:6379> PUBLISH redisChat "Learn redis by yiibai"
(integer) 1
1) "message"
2) "redisChat"
3) "Redis is a great caching technique"
1) "message"
2) "redisChat"
3) "Learn redis by yiibai"
Redis发布订阅命令
PUBLISH channel message #将信息发送到指定的频道。
SUBSCRIBE channel [channel …] #订阅给定的一个或多个频道的信息。
UNSUBSCRIBE [channel [channel …]] #退订给定的频道。
PSUBSCRIBE pattern [pattern …] #订阅一个或多个符合给定模式的频道。根据模式来订阅,可以订阅许多频道
PUNSUBSCRIBE [pattern [pattern …]] #退订所有给定模式的频道。
PUBSUB subcommand [argument [argument …]] #查看订阅与发布系统状态。
关于Java的API,使用封装的Jedis API或者 Redssion API 即可。
方法16: Distruptor 框架异步
Disruptor是一个优秀的并发框架,使用无锁编程+环形队列架构, 是高性能异步队列的王者组件
Disruptor 可以使用在 非常多 的生产者 消费者 异步场景
-
单生产者多消费者场景
-
多生产者单消费者场景
-
单生产者多消费者场景
-
多个消费者串行消费场景
-
菱形方式执行场景
-
链式并行执行场景
-
多组消费者相互隔离场景
-
多组消费者航道执行模式
下面 以 单生产者多消费者并行场景为例,看看场景介绍。
在并发系统中提高性能最好的方式之一就是单一写者原则,对Disruptor也是适用的。如果在生产者单消费者 需求中仅仅有一个事件生产者,那么可以设置为单一生产者模式来提高系统的性能。
方法17:ForkJoin 框架异步
Fork/Join框架是JDK1.7提供的一个并行任务执行框架,它可以把一个大任务分成多个可并行执行的子任务,然后合并每个子任务的结果,得到的大任务的结果。有点类似Hadoop的MapReduce,Fork/Join框架也可以分成两个核心操作:
-
Fork操作:将大任务分割成若干个可以并行执行的子任务
-
Join操作:合并子任务的执行结果
其工作窃取算法,非常有价值。
方法18:RocketMQ源码中ServiceThread 能急能缓的高性能异步
RocketMQ源码中, 实现了一种特殊的,高性能异步: 能急能缓 ServiceThread 异步。能急能缓 ServiceThread 异步 有两个特点:
-
既能周期性的执行异步任务
-
还能紧急的时候,执行应急性的任务
RocketMQ的吞吐量达到 70Wqps,ServiceThread 的异步框架,发挥了重要的价值。ServiceThread 异步框架是RocketMq的精华之一,性能非常高,也 非常好用。
方式19:Kotlin 协程 异步
目前在 Spring 应用中使用 Kotlin 协程还有些小繁琐,但在 Spring Boot 2.2 中,可以直接在 Spring WebFlux 方法上使用 suspend 关键字。
Kotlin 协程最大的意义就是可以用看似指令式编程方式(Imperative Programming,即传统编程方式)去写异步编程代码。并发和代码可读性似乎两全其美了。
Kotlin 协程的局限性
Kotlin 协程依赖于各种基于 Callback 的技术。所以,只有当一段代码使用了 ListenableFuture、CompletableFuture、Project Reactor、RxJava 等技术时,才能用 Kotlin 协程进行改造优化。那对于其它的会阻塞线程的技术,如 Object.wait、Thread.sleep、Lock、BIO 等,Kotlin 协程就无能为力了。
另外一个局限性源于 Kotlin 本身。虽然 Kotlin 兼容 Java,但这种兼容并非完美。因此,对于组件,尤其是基础组件的开发,并不推荐使用 Kotlin,而是更推荐使用 Java。这也导致 Kotlin 协程的使用范围被进一步地限制。
方式20:Project Loom
虽然 Kotlin 协程看上去很好,但在使用上还是有着种种限制。那有没有更好的选择呢?
答案是 Project Loom:https://openjdk.java.net/projects/loom/
不同于之前的方案,Project Loom 是从 JVM 层面对多线程技术进行彻底的改变。
下面这幅图很好地展示了目前 Java 并发编程方面的困境,简单的代码并发、伸缩能力差;并发、伸缩能力强的代码复杂,难以与现有代码整合。
伸缩能力(Scalability),在软件工程和系统设计中,指的是系统、应用程序或进程能够在不影响性能的情况下,处理不断增加的工作负载的能力。具体来说,伸缩能力可以分为以下几种:
垂直伸缩(Scaling Up):
- 垂直伸缩是指通过增强单个节点的处理能力(如增加CPU、内存、存储等)来提升系统的整体性能。
- 当单个节点的处理能力增强后,系统能够处理的并发请求量或数据量也随之增加。
水平伸缩(Scaling Out):
- 水平伸缩是指通过增加更多的节点(例如,服务器或虚拟机)到系统中,使得系统整体能够处理更多的工作负载。
- 在水平伸缩中,系统将负载分散到多个节点上,每个节点处理一部分负载,从而提升整个系统的处理能力。
弹性伸缩(Elasticity):
- 弹性伸缩是指系统能够根据实际负载自动调整资源使用量,以优化性能和成本。
- 这通常涉及到动态地添加或移除资源,以应对负载的波动。
在Java并发编程中,伸缩能力差通常意味着以下情况:
- 代码不是为并发执行设计的:如果代码没有考虑到并发执行的情况,那么在多线程或多处理器环境下运行时,可能会出现性能瓶颈或竞态条件。
- 资源竞争:当多个线程争用同一资源(如共享内存)时,可能会导致系统性能下降。
- 同步开销:过度使用同步机制(如
synchronized
关键字或锁)可能导致线程阻塞,降低系统的并发能力。- 单点故障:如果系统的某个部分成为性能瓶颈或故障点,那么整个系统的伸缩能力将受到限制。
相反,具有良好伸缩能力的代码通常具备以下特点:
- 无锁编程:使用无锁数据结构或算法来减少线程间的同步开销。
- 并发数据结构:使用专门为并发访问设计的数据结构,如并发哈希表、并发队列等。
- 分区:将数据或任务分区到不同的节点或线程上,减少资源争用。
- 异步处理:使用异步I/O和事件驱动模型来提高系统的吞吐量。
总之,伸缩能力是衡量系统或应用程序能否在负载增加时保持良好性能的关键指标。在Java并发编程中,设计具有良好伸缩能力的系统需要在考虑并发控制的同时,保持代码的简洁性和可维护性。
各种框架或其它 JVM 编程语言的解决方案,都在使用场景上有限制。
例如 Kotlin 协程必须基于各种 Callback 技术,而 Callback 技术有存在编写、调试困难的问题。为了使 Java 并发能力在更大范围上得到提升,从底层进行改进便是必然。
为了让简单和高并发这两个目标兼得,我们需要 Project Loom 这个项目。
Project Loom 设计思想与之前的一个开源 Java 协程技术非常相似。而现在 Project Loom 的主要设计开发人员 Ron Pressler 就是来自 Quasar Fiber。
这个开源技术就是 Quasar Fiber :https://docs.paralleluniverse.co/quasar/
Project Loom 的被发起原因也很简单:长期以来,Java 的线程是与操作系统的线程一一对应的,这限制了 Java 平台并发能力的提升。
Project Loom原理
Project Loom 引入了 虚拟线程作为 java.lang.Thread 的实例, 这是一种的轻量级用户模式线程。
虚拟线程 可以理解为 操作系统 平台线程的一个任务。操作系统 平台线程很庞大并且依赖于操作系统。Java 不能随意改进平台线程,操作系统直接将这些线程分配给处理器。
有了虚拟线程后 ,JDK 的调度程序将虚拟线程 提交到 平台线程,操作系统像往常一样将其分配给处理器。
那么,虚拟线程如何工作?
我们都知道阻塞线程是邪恶的,会对应用程序的性能产生负面影响。使用虚拟线程之后,当虚拟线程阻塞 I/O 或 JDK 中的某些阻塞操作时,平台线程就不会阻塞了。
怎么做的呢? 当虚拟线程阻塞 I/O 或 JDK 中的某些阻塞操作时,例如 BlockingQueue.take(),它会自动从平台线程中卸载 (或者说解除合作关系)。JDK 的调度程序可以在这个空闲的平台线程上挂载和运行其他虚拟线程。
当阻塞操作准备好完成时,它会将虚拟线程提交回调度程序,调度程序会将虚拟线程挂载到可用的平台线程上以恢复执行。当然,平台线程 不必 虚拟线程一对应,更像是 原始的线程和任务的关系。
因此,我们现在可以构建具有高吞吐量的高并发应用程序,而无需增加线程数量(默认情况下,虚拟线程的 Executor 将使用与可用处理器数量一样多的平台线程)。
使用方法
在引入 Project Loom 之后,JDK 将引入一个新类:java.lang.Fiber
。
此类与 java.lang.Thread
一起,都成为了 java.lang.Strand
的子类。
即线程变成了一个虚拟的概念,有两种实现方法:Fiber 所表示的轻量线程和 Thread 所表示的传统的重量级线程。
对于应用开发人员,使用 Project Loom 很简单:
Fiber f = Fiber.schedule(() -> {
println("Hello 1");
lock.lock(); // 等待锁不会挂起线程
try {
println("Hello 2");
} finally {
lock.unlock();
}
println("Hello 3");
})
只需执行 Fiber.schedule(Runnable task)
就能在 Fiber 中执行任务。最重要的是,上面例子中的 lock.lock()
操作将不再挂起底层线程。除了 Lock 不再挂起线程以外,像 Socket BIO 操作也不再挂起线程。但 synchronized
,以及 Native 方法中线程挂起操作无法避免。
synchronized (monitor) {
// 在 Fiber 中调用这条语句还是会挂起线程。
socket.getInputStream().read();
}
如上所示,Fiber 的使用非常简单。因此,让现有系统使用 Project Loom 很容易。
像 Tomcat、Jetty 这样的 Web 容器,只需将处理请求操作从使用 ThreadPoolExecutor execute 或 submit 改为使用 Fiber schedule 即可。
这个视频:https://www.youtube.com/watch?v=vbGbXUjlRyQ&t=1240s 中的 Demo 展示了 Jetty 使用 Project Loom 改造之后并发吞吐能力的大幅提升。
参考文献
通过事件总线EventBus/AsyncEventBus进行JAVA模块解耦 :https://blog.csdn.net/crazymakercircle/article/details/128627663
队列之王: Disruptor 原理、架构、源码 一文穿透:https://blog.csdn.net/crazymakercircle/article/details/128264803
https://blog.csdn.net/crazymakercircle/article/details/128264803
https://xie.infoq.cn/article/deba3305beba3838c2cf6c102
MySQL的WAL、LSN、Checkpoint
1. WAL(预写式日志)技术
WAL的全称是 Write-Ahead Logging。修改的数据要持久化到磁盘,会先写入磁盘的文件系统缓存,然后可以由后台线程异步慢慢地刷回到磁盘。所以WAL技术修改数据需要写两次磁盘。
1.1 两次磁盘写
从内存到磁盘文件系统缓存,顺序IO。从文件系统缓存持久化到磁盘,随机IO。
1.2 WAL的好处
节省了随机写磁盘的 IO 消耗(转成顺序写)。
2. LSN(日志序列号)
LSN是Log Sequence Number的缩写,即日志序列号,表示Redo Log 的序号。
2.1 特性
LSN占用8字节,LSN的值会随着日志的写入而逐渐增大,每写入一个 Redo Log 时,LSN 就会递增该 Redo Log 写入的字节数。
2.2 LSN的不同含义
重做日志写入的总量,单位字节。通过 LSN 开始号码和结束号码可以计算出写入的日志量。
-
checkpoint的位置
最近一次刷盘的页,即最近一次检查点(checkpoint),也是通过LSN来记录的,它也会被写入redo log里。
-
数据页的版本号。
在每个页的头部,有一个FIL_PAGE_LSN,记录的该页的LSN。表示该页最后刷新时LSN的大小。其可以用来标记数据页的“版本号”。因此页中的LSN用来判断页是否需要进行恢复操作。
通过数据页中的 LSN 值和redo log中的 LSN 值比较,如果页中的 LSN 值小于redo log中 LSN 值,则表示数据丢失了一部分,这时候可以通过redo log的记录来恢复到redo log中记录的 LSN 值时的状态。
2.3 查看LSN
redo log的LSN信息可以通过 show engine innodb status 命令来查看。
---
LOG
---
Log sequence number 15114138
Log flushed up to 15114138
Pages flushed up to 15114138
Last checkpoint at 15114129
0 pending log flushes, 0 pending chkp writes
10 log i/o's done, 0.00 log i/o's/second
其中:
-
log sequence number就是当前的redo log(in buffer)中的lsn;
-
log flushed up to是刷到redo log file on disk中的lsn;
-
pages flushed up to是已经刷到磁盘数据页上的LSN;
-
last checkpoint at是上一次检查点所在位置的LSN。
3. Checkpoint(检查点)
3.1 背景
缓冲池的容量和重做日志(redo log)容量是有限的。
3.2 目的
Checkpoint所做的事就是把脏页给刷新回磁盘。
3.3 定义
一个时间点,由一个LSN值(Checkpoint LSN)表示的整型值,在checkpoint LSN之前的每个数据页(buffer pool中的脏页)的更改都已经落盘(刷新到数据文件中),checkpoint 完成后,在checkpoint LSN之前的Redo Log就不再需要了。
所以:checkpoint是通过LSN实现的。
3.4 分类
Sharp Checkpont
该机制下,在数据库发生关闭时将所有的脏页都刷新回磁盘。
Fuzzy Checkpoint
在该机制下,只刷新一部分脏页,而不是刷新所有脏页回磁盘。
数据库关闭时,使用 Sharp Checkpont 机制刷新脏页。
数据库运行时,使用 Fuzzy Checkpoint 机制刷新脏页。
3.5 检查点触发时机
-
Master Thread Checkpoint
后台异步线程以每秒或每十秒的速度从缓冲池的脏页列表中刷新一定比例的页回磁盘。
-
FLUSH_LRU_LIST Checkpoint
为了保证LRU列表中可用页的数量(通过参数innodb_lru_scan_depth控制,默认值1024),后台线程定期检测LRU列表中空闲列表的数量,若不满足,就会将移除LRU列表尾端的页,若移除的页为脏页,则需要进行Checkpoint。
show VARIABLES like 'innodb_lru_scan_depth'
-
Async/sync Flush Checkpoint
当重做日志不可用(即redo log写满)时,需要强制将一些页刷新回磁盘,此时脏页从脏页列表中获取。
-
Dirty Page too much Checkpoint
即脏页数量太多,会强制推进CheckPoint。目的是保证缓冲区有足够的空闲页。innodb_max_dirty_pages_pct的默认值为75,表示当缓冲池脏页比例达到该值时,就会强制进行Checkpoint,刷新一部分脏页到磁盘。
show VARIABLES like 'innodb_max_dirty_pages_pct'
3.6 解决的问题
-
缩短数据库的恢复时间。
-
缓冲池不够用时,刷新脏页到磁盘。
-
重做日志满时,刷新脏页。
4. LSN与checkpoint的联系
LSN号串联起一个事务开始到恢复的过程。重启 innodb 时,Redo log 完不完整,采用 Redo log 相关知识。用 Redo log 恢复,启动数据库时,InnoDB 会扫描数据磁盘的数据页 data disk lsn 和日志磁盘中的 checkpoint lsn。
两者相等则从 checkpoint lsn 点开始恢复,恢复过程是利用 redo log 到 buffer pool,直到 checkpoint lsn 等于 redo log file lsn,则恢复完成。如果 checkpoint lsn 小于 data disk lsn,说明在检查点触发后还没结束刷盘时数据库宕机了。
因为 checkpoint lsn 最新值是在数据刷盘结束后才记录的,检查点之后有一部分数据已经刷入数据磁盘,这个时候数据磁盘已经写入部分的部分恢复将不会重做,直接跳到没有恢复的 lsn 值开始恢复。
5. 总结
日志空间中的每条日志对应一个LSN值,而在数据页的头部也记录了当前页最后一次修改的LSN号,每次当数据页刷新到磁盘后,会去更新日志文件中checkpoint,以减少需要恢复执行的日志记录。
极端情况下,数据页刷新到磁盘成功后,去更新checkpoint时如果宕机,则在恢复过程中,由于checkpoint还未更新,则数据页中的记录相当于被重复执行,不过由于在日志文件中的操作记录具有幂等性,所以同一条redo log执行多次,不影响数据的恢复。