10Wqps评论中台架构
评论系统的业务分析
在B站,UP主每天都会发布海量的视频、动态、专栏等内容,随之而来的是弹幕和评论区的各种讨论。播放器中直接滚动播放的弹幕,如同调味剂,重在提升视频观看体验;而点进评论区,相对而言评论文本更长,内容的观点、形式都更丰富,更像是饭后甜点。随着业务不断发展,B站的评论系统逐渐组件化、平台化;通过持续演进架构设计,管理不断上升的系统复杂度,从而更好地满足各类用户的需求。
评论的基础功能模块是相对稳定的。
-
发布评论:支持无限盖楼回复。
-
读取评论:按照时间、热度排序;显示评论数、楼中楼等。
-
删除评论:用户删除、UP主删除等。
-
评论互动:点赞、点踩、举报等。
-
管理评论:置顶、精选、后台运营管理(搜索、删除、审核等)。
结合B站以及其他互联网平台的评论产品特点,评论一般还包括一些更高阶的基础功能:
-
评论富文本展示:例如表情、@、分享链接、广告等。
-
评论标签:例如UP主点赞、UP主回复、好友点赞等。
-
评论装扮:一般用于凸显发评人的身份等。
-
热评管理:结合AI和人工,为用户营造更好的评论区氛围。
总体的架构设计
评论系统 中台,从总体的架构上来区分,分为:
(1)接入层
(2)服务层
(3)异步任务层
(4)cache层
(5)DB层
接入层架构 reply-interface
reply-interface是评论系统的接入层,主要服务于两种调用者:
一是客户端的评论组件
二是基于评论系统做二次开发或存在业务关联的其他业务后端。
面向移动端/WEB场景,设计一套基于视图模型的API,利用客户端提供的布局能力,接入层负责组织业务数据模型,并转换为视图模型,编排后下发给客户端。
面向服务端场景,接入层设计的API需要体现清晰的系统边界,最小可用原则对外提供数据,同时做好安全校验和流量控制。
接入层整个业务数据模型组装,分为两个步骤:
一是服务编排
二是数据组装。
服务编排拆的架构为:
(1)对服务进行分层,分为若干个层级,
(2)前置依赖通过流水线调用,
(3)同一层级的可以并发调用,结构性提升了复杂调用场景下的接口性能下限;
(4)针对不同依赖服务所提供的SLA不同,设置不同的降级处理、超时控制和服务限流方案,保证少数弱依赖抖动甚至完全不可用情况下评论服务可用。
SLA一般指服务级别协议。服务级别协议是指提供服务的企业与客户之间就服务的品质、水准、性能等方面所达成的双方共同认可的协议或契约。
服务层架构
评论管理服务层 reply-admin
评论管理服务层,为多个内部管理后台提供服务。
运营人员的数据查询具有:
-
组合、关联查询条件复杂;
-
刚需关键词检索能力;
-
写后读的可靠性与实时性要求高等特征。
此类查询需求,ES几乎是不二选择。但是由于业务数据量较大,需要为多个不同的查询场景建立多种索引分片,且数据更新实时性不高。因此,我们基于ES做了一层封装,提供统一化的数据检索能力,并结合在线数据库刷新部分实时性要求较高的字段。
评论基础服务 reply-service 架构设计
评论基础服务层,专注于评论功能的原子功能,例如:
-
查询评论列表
-
删除评论等。
这一层的特点是:
-
较少做业务逻辑变更的
-
极高的可用性
-
极高性能吞吐。
这一层采用了多种高性能方案:
-
多级缓存
-
布隆过滤器
-
热点探测等。
异步任务层reply-job 架构设计
异步任务层,主要有两个职责:
-
为原子的业务操作,提供异步协助
与reply-service协同,为评论基础功能的原子化实现做架构上的补充。
-
异步削峰处理
为 长耗时/高吞吐的调用, 做异步化/削峰处理
职责1:提供异步协助
为原子的业务操作,提供异步协助,最典型的案例就是缓存的更新。
一般采用Cache Aside模式,先读缓存,再读DB;Cache Aside模式下的缓存的重建策略:就是读请求未命中缓存穿透到DB,从DB读取到内容之后反写缓存。
这一套流程对外提供了一个原子化的数据读取功能。但是部分缓存数据项的重建代价较高,比如评论列表。
为什么?
由于列表是分页的,缓存重建时会启用预加载,也就是要多加载几页,如果短时间内大量请求缓存未命中,并且多个服务节点的同时重建缓存,容易造成DB抖动。
解决方案是什么?
利用消息队列+reply-job ,实现单个评论列表异步重建,只重建一次缓存。另外呢,reply-job还作为数据库binlog的消费者,执行缓存的更新操作。
职责2:异步削峰处理
与reply-interface协同,为长耗时/高吞吐的调用,做异步化/削峰处理。诸如评论发布等操作,基于安全/策略考量,会有非常重的前置调用逻辑。
对于用户来说,长耗时几乎是不可接受的。同时,时事热点容易造成发评论的瞬间峰值流量。因此,reply-interface在处理完一些必要校验逻辑之后,会通过消息队列送至reply-job异步处理,包括送审、写DB、发通知等。
那么异步处理后用户体验是如何保证的呢?
首先是当次交互,返回最新数据。C端的发评接口会返回展示新评论所需的数据内容,客户端据此展示新评论,完成一次用户交互。其次,控制延迟时长,如果太长则进行预警和调优。若用户重新刷新页面,因为发评的异步处理端到端延迟基本在2s以内,此时所有数据已准备好,不会影响用户体验。
消息队列的保证有序
利用了消息队列的「有序」特性,将单个评论区内的发评串行处理,避免了并行处理导致的一些数据错乱风险。
一个有趣的问题是,早年间评论显示楼层号,楼层号实际是计数器,且在一个评论区范围内不能出现重复。因此,这个楼层发号操作必须是在一个评论区范围内串行的(或者用更复杂的锁实现),否则两条同时发布的评论,获取的楼层号就是重复的。
而分布式部署+负载均衡的网关,处理发评论请求是无法实现这种串行的,因此需要放到消息队列中处理。
数据存储架构
结构化模型设计
结合评论的产品功能要求,评论需要至少两张表:
(1)首先是评论表,主键是评论id,关键索引是评论区id;
(2)其次是评论区表,主键是评论区id,平台化之后增加一个评论区type字段,与评论区id组成一个”联合主键“。
(3)评论内容表. 由于评论内容是大字段,且相对独立、很少修改,因此独立设计第3张表。主键也是评论id。
评论表和评论区表的字段主要包括4种:
-
关系类,包括发布人、父评论等,这些关系型数据是发布时已经确定的,基本不会修改。
-
计数类,包括总评论数、根评论数、子评论数等,一般会在有评论发布或者删除时修改。
-
状态类,包括评论/评论区状态、评论/评论区属性等,评论/评论区状态是一个枚举值,描述的是正常、审核、删除等可见性状态;评论/评论区属性是一个整型的bitmap,可用于描述评论/评论区的一些关键属性,例如UP主点赞等。
-
其他,包括meta等,可用于存储一些关键的附属信息。
评论回复的树形关系,如下图所示:
以评论列表的访问为例,我们的查询SQL可能是(已简化):
-
查询评论区基础信息:SELECT * FROM subject WHERE obj_id=? AND obj_type=?
-
查询时间序一级评论列表:SELECT id FROM reply_index WHERE obj_id=? AND obj_type=? AND root=0 AND state=0 ORDER BY floor=? LIMIT 0,20
-
批量查询根评论基础信息:SELECT * FROM reply_index,reply_content WHERE rpid in (?,?,...)
-
并发查询楼中楼评论列表:SELECT id FROM reply_index WHERE obj_id=? AND obj_type=? AND root=? ORDER BY like_count LIMIT 0,3
-
批量查询楼中楼评论基础信息:SELECT * FROM reply_index,reply_content WHERE rpid in (?,?,...)
分库分表架构
评论系统对数据库的选型要求,有两个基本且重要的特征:
-
必须有事务;
-
必须容量大。
一开始,B站采用的是MySQL分表来满足这两个需求。MySQL分库分表数据量起来之后,原来的MySQL分表架构很快到达存储瓶颈。
mysql 不停服在线扩容实际非常复杂,很多公司选择停服切换, 估计B站为了不停服, 或者不愿意发生停服的风险, 选择了 专门的商用 分布式 TiDB, 毕竟这个是花了钱的。
于是从2020年起,我们逐步迁移到TiDB,从而具备了在线水平扩容能力。
高并发写入架构,TPS提升10倍+
面对10Wqps的并发写入超大规模吞吐量,做了如下优化:
方案一:内存聚合+ 批量写入
评论区评论计数的更新,先做内存合并再更新,可以减少热点场景下的SQL执行条数;评论表的插入,改成批量写入。
方案二:核心逻辑和非核心异步化,为核心操作瘦身
非数据库写操作的其他业务逻辑,拆分为前置和后置两部分,其他业务逻辑从数据写入主线程中剥离,交由其他的线程池并发执行。
总之,采用新的高并发写入架构之后,性能得到极大提升。
写入架构调整之后,系统的并发处理能力有了极大提升,同时支持配置并行度/聚合粒度,在吞吐方面具备更大的弹性,热点评论区发评论的TPS提升了10倍以上。
缓存层架构
数据的缓存模型架构
主要有3项缓存:
-
subject,对应于「查询评论区基础信息」,redis string类型,value使用JSON序列化方式存入。
-
reply_index,对应于「查询xxx评论列表」,redis sorted set类型。member是评论id,score对应于ORDER BY的字段,如floor、like_count等。
-
reply_content,对应于「查询xxx评论基础信息」,存储内容包括同一个评论id对应的reply_index和reply_content表的两部分字段。
缓存的一致性架构
缓存的一致性依赖binlog刷新,主要两个要点:
-
消息队列,保证 同一个评论区内有序
binlog投递到消息队列,分片key选择的是评论区,保证单个评论区和单个评论的更新操作是串行的,消费者顺序执行,保证对同一个member的zadd和zrem操作不会顺序错乱。
-
采用删除缓存而非直接更新的方式
程序主动写缓存和binlog刷缓存,都采用删除缓存而非直接更新的方式,避免并发写操作时,特别是诸如binlog延迟、网络抖动等异常场景下的数据错乱。
缓存击穿解决方案
那大量写操作后读操作缓存命中率低的问题如何解决呢?
读缓存的时候, 可以利用 锁的机制,进行同步控制,防止缓存击穿。
热点探测架构
除了写热点,评论的读热点也有一些典型的特征:
-
由于大量接口都需要读取评论区基础信息,存在读放大,因此该操作是最先感知到读热点存在的。
-
由于评论业务的下游依赖较多,且多是批量查询,对下游来说也是读放大。此外,很多依赖是体量相对小的业务单元,数据稀疏,难以承载评论的大流量。
-
评论的读热点集中在评论列表的第一页,以及热评的热评。
-
评论列表的业务数据模型也包含部分个性化信息。
在读取评论区基础信息阶段探测热点,并将热点标识传递至服务层;服务层实现了页面请求级的热点本地缓存,感知到热点后即读取本地缓存,然后再加载个性化信息。
热点探测的实现基于单机的滑动窗口+LFU,那么如何定义、计算相应的热点条件阈值呢?
首先,我们进行系统容量设计,列出容量计算的数学公式,主要包括各接口QPS的关系、服务集群总QPS与节点数的关系、接口QPS与CPU/网络吞吐的关系等;然后,收集系统内部以及相应依赖方的一些的热点相关统计信息,通过公式,计算出探测数据项的单机QPS热点阈值。最后通过热点压测,来验证相应的热点配置与代码实现是符合预期的。
高可用架构
包括:
(1)缓存降级与DB降级
(2)同城读双活 + 双机房独立部署 架构
(3)副本数据延迟优化架构
(4)限流熔断策略的优化
缓存降级与DB降级
基础服务层集成了多级缓存,在上一级缓存未命中或者出现网络错误后,降级至下一级缓存,缓存没有命中,就降级到DB,保证系统的的可用性。
同城读双活 + 双机房独立部署 架构
评论系统是同城读双活+双机房独立部署的架构。DB和redis均支持多副本,具备水平扩容的弹性。
双机房独立部署 :
数据库与缓存均是双机房独立部署的,通过 db-proxy,或者 db-redis进行访问。
副本数据延迟优化架构
双机房架构场景下,存在跨机房数据延迟问题,采用如下的策略解决:
-
入口层切流
-
应用层补偿
-
跨机房重试
尽可能保证极端情况下, 用户没有延迟感。
限流熔断策略的优化
为了尽可能 保证系统可用, 在功能层面,做了级别划分:
把依赖划分为强依赖(如审核)、弱依赖(如粉丝勋章)。
首先,在如果强依赖出现异常,下游坚决限流熔断,尽可能 保证 强依赖的可用性。另外,对于弱依赖,通过超时控制、请求预过滤、优化调用编排, 持续优化提升非核心功能的可用性。
Redis 的双机房部署方案
采用 redis-cluster-proxy + redis cluster的架构方案。
redis cluster是redis的官方集群方案,但是他要求客户端自己做重定向,redis-cluster-proxy 是redis的官方集群代理,经过这个proxy的代理后,连接redis集群就和连接单机redis一样了。
proxy+cluster架构
proxy+cluster架构图如下:
架构说明:
1、redis-cluster采用了同城双活架构,其中,图中的AZ1和AZ2表示为可用区1、可用区2,主节点(AZ1)按3主3从部署,备节点(AZ2)作为Cluster的6从,整个集群为3主9从;
2、单个master节点发生故障,redis集群自动感知并进行选主,完成主从切换,不影响业务正常使用;
3、应用服务连接所有Redis集群主从节点,以便自动感知主从切换情况。
其实只要连接到redis-proxy一个节点,应用服务便可以获取到集群信息,某些节点宕机后,客户端不会收到影响。
注意:单AZ1池资源出现问题,可以使用已准备好的脚本进行快速切换AZ2节点,完成集群恢复。当然,上面的方案,可以考虑把master节点打散到两个AZ,避免集群超半数master节点宕机;
proxy+cluster架构问题
redis-proxy模式采用官方的redis-proxy+cluster模式,优点就是应用只需要连接proxy节点即可,不需要配置更多的node节点,生产环境proxy需要考虑高可用,而proxy也可以考虑用lvs+keepalived作为代理取代。
自动切换脚本
先使用 cluster failover force
命令执行强制切库,如果试了3次都不行,就使用 cluster failover takeover
更强制的切库,手动故障转移是一种特殊的故障转移,通常在没有实际故障的情况下执行,我们希望将当前主节点与其中一个 slave 从节点(我们发送该命令的节点)交换(安全地,而不会有数据丢失的窗口)。
当前slave从节点通知主节点停止处理客户端的请求。
主节点回复slave从节点当前的 同步偏移量。
slave从节点等待同步偏移量在slave从节点的侧匹配,以确保它已经处理了所有主节点的数据,然后继续。
slave从节点开始故障转移,从主节点的大多数主节点获取新的配置纪元值epoch,并广播新的配置。
旧的主节点接收配置更新:解除对客户端访问的阻止,并开始回复重定向消息,以便它们继续与新的主节点通信。
两个选项:
FORCE option: manual failover when the master is down
当主节点停止时手动故障转移,如果选择FORCE选项,slave从节点不会与master主节点进行协商(master节点可能不可达),而是直接尽快从上文的故障转移步骤中的第4步开始做故障转移。当主节点不可达时,FORCE选项对于我们做手动故障转移非常有用。
TAKEOVER option: manual failover without cluster consensus
在集群数据不一致的场景下,也要人工故障转移,TACKOVER选项实现了FORCE选项的所有实现,但是无需集群一致性验证来进行故障转移。
如果需要故障转移,在从节点上,可以输入以下命令
redis-cli -p 7001 -c CLUSTER FAILOVER
也可以结合linux上的定时器,使用脚本,配合使用
(1)探活脚本:redis_check.sh
(2)切换脚本:redis_task.sh
redis_check.sh脚本为检测redis节点是否存活
#!/bin/bash
LOGDIR="/root"
BINDIR="/data/redis/7001/bin/redis-cli"
PASSWD="123456"
IPDIR="172.16.0.8:7001 172.16.0.8:7002 172.16.0.8:7003 172.16.0.8:7004 172.16.0.8:7005 172.16.0.8:7006"
DATE=`date`
LOGFILE=${LOGDIR}/logs
ERROR_LOG=${LOGDIR}/error.logs
cd ${LOGDIR}
if [ ! -d bak ] ; then
mkdir -p bak
fi
for i in $IPDIR
do
port=${i#*:}
ip=${i%:*}
ALIVE=`$BINDIR -h $ip -p $port PING`
#ALIVE=`$BINDIR -h $ip -p $port -a $PASSWD PING`
if [ "$ALIVE" == "PONG" ]; then
echo "${DATE} Success: redis-cli -h $ip -p $port PING $ALIVE" >> $LOGFILE 2>&1
else
echo "${DATE} Failed:redis-cli -h $ip -p $port PING $ALIVE " >> $ERROR_LOG 2>&1
fi
done
redis_task.sh为AZ1节点宕机后,执行脚本切换到AZ2节点
#!/bin/bash
IPMASTER="172.16.0.8:7001 172.16.0.8:7002 172.16.0.8:7003"
BINDIR="/data/redis/7001/bin/redis-cli"
PASSWD="123456"
DATE=`date`
LOGDIR="/root"
LOGFILE=${LOGDIR}/logs
ERROR_LOG=${LOGDIR}/error.logs
for i in $IPMASTER
do
port=${i#*:}
ip=${i%:*}
status=`$BINDIR -h $ip -p $port -c cluster failover takeover`
#status=` $BINDIR -h $ip -p $port -a $PASSWD -c cluster failover takeover`
if [ "$status" == "OK" ]; then
echo "${DATE} Success: $i 成功切换成master节点" >> $LOGFILE 2>&1
else
echo "${DATE} Failed: $i 切换master节点失败 " >> $ERROR_LOG 2>&1
fi
done
机房故障的redis 故障转移过程
最开始cluster搭建方式如下图所示
在AZ1机房全部宕机后,我们需要通过执行cluster failover takeover命令将AZ2机房切换为Master,如下图所示:
在AZ1机房恢复正常之后,如下图所示:
安全性架构
一、数据安全
满足数据安全法要求,除了数据安全法所要求的以外,评论系统的数据安全还包括「合规性要求」。评论数据合规,一方面是审核和风控,另一方面对工程侧的要求主要是「状态一致性」。
例如,有害评论被删除后,在客户端不能展现,也不能通过API等对外暴露。
这就对数据一致性,包括缓存,提出了较高要求。在设计层面主要有两方面实践:
-
数据读写阶段均考虑了一致性风险,严格保证时序性。
-
对各类数据写操作,定义了优先级,避免高优先级操作被低优先级操作覆盖,例如审核删除的有害评论,不能被其他普通运营人员/自动化策略放出。
-
通过冗余校验,避免风险数据外泄。
例如评论列表的露出,读取sorted set中的id列表后,还需要校验对应评论的状态,是可见态才允许下发。
二、舆论安全
舆论安全问题更为泛化。接口错误导致用户操作失败、关闭评论区、评论计数不准,甚至新功能上线、用户不满意的评论被顶到热评前排等问题均可能引发舆情问题。
在系统设计层面,我们主要通过几方面规避。
-
不对用户暴露用户无法处理和不值得处理的错误。
例如评论点赞点踩、某个数据项读取失败这一类的轻量级操作,不值得用户重试,此时告知用户操作失败也没有意义。系统可以考虑自行重试,甚至直接忽略。
-
优化产品功能及其技术实现,例如评论计数、热评排序等。
热评设计架构
什么是热评
早期的热评,实际就是按照评论点赞数降序。后来衍生了更为复杂的热评:
既包括类似「妙评」这种用户推荐、运营精选且带logo突出展示的产品形态,也包括各类热评排序算法,且热评排序算法应用场景也不仅局限于评论主列表的热度序,还包括楼中楼(外露子评论)、动态外露评论等。
热评排序逻辑一般包括点赞数、回复数、内容相关、负反馈数、“时间衰退因子”、字数加权、用户等级加权等等。
咬文嚼字来说,我们对「热」的理解,大致分为几个阶段:
-
阶段1 :点赞高,就代表热度高。→ 解决热评的有无问题
-
阶段2 :基于用户正负样本投票的,加权平均高,就代表热度高。→ 解决高赞高踩的负面热评问题
-
阶段3:短时间内点赞率高,就代表热度高。→ 解决高赞永远高赞的马太效应
-
阶段4 :热评用户流量大,社区影响也大。→ 追求用户价值平衡, 要权衡社会价值观引导、公司战略导向、商业利益、UP主与用户的「情绪」等。
阶段1 :按照点赞绝对值排序
按照点赞绝对值排序,即要实现ORDER BY like_count的分页排序。
点赞数是一个频繁更新的值,MySQL,特别是TiDB,由于扫描行数约等于OFFSET,因此在OFFSET较大时查询性能特别差,很难找到一个完美的优化方案。此外,由于like_count的分布可能出现同一个值堆叠多个元素,比如评论区所有的评论都没有赞,我们更多依赖redis的sorted set来执行分页查询,这就要求 缓存命中率非常高。
阶段2 :按照正负样本加权平均排序
按照正负样本加权平均的,即**Reddit:威尔逊排序[6]**,到这个阶段,数据库已经无法实现这样复杂的ORDER BY,热评开始几乎完全依赖sorted set这样的数据结构,预先计算好排序分数并写入。于是在架构设计上,新增了feed-service和feed-job来支撑热评列表的读写。
Reddit最早成立于2005年,两名创始人是史蒂夫·霍夫曼和阿里克西斯·奥哈尼安,当时他们刚刚从弗吉尼亚大学毕业。他们的创业想法获得了美国知名创业孵化器Y Combinator的天使投资。Reddit在美国的影响力非常大,它的信息展示形式像论坛,又像贴吧。是由不同版块下的帖子组成的交流平台,用户可以选择对帖子点击“上涨”或“下沉”来决定帖子的排名顺序。Reddit在2009年公开过自己如何用威尔逊区间(wilson interval)对评论排序的,代码也开源过。
Wilson算法要点有两个:
-
把“所有正负反馈中正反馈的比例”作为对评论质量的考核指标。
在Reddit的情况中,正负反馈分别为点赞和反对。简单来说,就是正为点赞,负为反对
-
对在冷启动过程中的评论(即正负反馈总数很少)做降权处理。
具体算法就是:假设观测到的正反馈率符合真实正反馈率的正态分布,求当前观测得到的正反馈率恰好位于95%置信上区间时的真实反馈率。
阶段3 :按照点赞率排序
按照点赞率排序,需要实现点赞率的近实时计算。
点赞率=点赞数/曝光数,曝光的数据来源是客户端上报的展现日志,量级非常大。
可以说是一个写多读少的场景:只有重算排序的时候才会读取曝光数。
阶段4 :追求用户价值平衡
追求用户价值平衡,需要处理各种细分场景下的差异化需求。热评排序与feed排序很像,但也有一点根本性差异:
feed排序是个性化的,每个人看到的都不相同,但评论排序往往不会如此激进,一般来说会希望大家看到的评论排序都大致相同。
由于排序问题的解决方案是探索型的,因此系统设计层面需要提供更多元、更易扩展的工程化能力,
包括算法和策略的快速迭代、实验能力等,并提升整个热评模块的可观测水平,监控完善、数据报表丰富、排序过程可解释等等。
在架构上,新增了strategy-service和strategy-job来承担这部分策略探索型业务。此外,数据量级规模的增加,也对系统的吞吐能力提出了更高要求:不管热评的算法如何变化,一般来说,热评列表都需要能够访问到全部评论,且基本维持相同的热评排序逻辑。
在评论数过百万甚至千万的评论区,热评排序的挑战点主要在于:
-
大key问题:
例如单个sorted set过大,读写性能都受影响(时间复杂度的基数可以认为都是O(logN));全量更新时,还可能遇到redis pipeline的瓶颈。
-
实时性放大存储压力:
多样化的数据源,对特征的导入与更新都提出了挑战,需要支持较丰富的数据结构,和尽可能高的写吞吐(想象一下曝光数作为排序特征的变态要求);与推荐排序不同,热评排序是全排序,此时需要读取全部评论的全部特征,查询压力也会非常大。
这一阶段,我们仍然在持续优化,在工程落地层面尽可能还原理想的排序算法设计,保障用户的热评浏览体验。目前形成的系统架构总体如下图所示:
图示的「评论策略层」,负责建立一套热评调控体系化能力,通过召回机制来实现想要的“balance“。
即先通过策略工程,召回一批应该沉底的不良评论或者应该进前排的优秀评论,然后在排序分计算阶段根据召回结果实现这样的效果。这样做的好处是,可以保留一套通用的底层排序算法,然后通过迭代细分场景下的召回策略,来实现差异化评论排序的平衡。
召回策略的工程设计,按照分层设计的原则拆分为3个部分:
-
因子机。
主要职责是维护策略所需的全部「因子」,包括一些已有的在线/离线数据,也包括为了策略迭代而需要新开发的流式的窗口聚合数据。因子机的重难点是需要管理各种数据获取的拓扑关系,以及通过缓存来保护下游(数据提供方很难也不应该承受热评业务的巨大流量)。所有的因子可以构成一个有向无环图,通过梳理依赖关系和推导计算,实现并发提效、减少冗余。
-
规则机。
实现了一套声明式规则语法,可以直接引用因子机预定义的因子,结合各种逻辑算子构成一个规则表达式。规则机执行命中后,会向下游传递预先声明的召回决策,例如排序提权。
-
召回处理中心。
这一层的职责就是接收规则机返回的各种决策并执行,需要处理不同决策的优先级PK、不同规则的决策叠加作用、决策豁免等。
热评排序涉及的特征,是多数据源的,数据更新方式、更新频率、查询性能也天差万别。因此我们针对数据源的特点做了多级缓存,通过多级冗余与跨级合并,提升了特征读取的稳定性与性能上限。当然,其中的数据实时性、一致性、可用性,仍然处于一个动态权衡取舍的过程。
参考资料
B站原文:https://www.bilibili.com/read/cv20346888
redis 集群异地双活参考:https://blog.csdn.net/u012171444/article/details/127525169
处理线程池被关闭时还有任务没执行
一:线程池线程池的5种运行状态
ThreadPoolExecutor 使用 runState (运行状态) 变量,管理线程池的生命周期,runState 一共有以下5种取值:
(1)RUNNING:接收新的任务,并对任务队列里的任务进行处理;
(2)SHUTDOWN:不再接收新的任务,但是会对任务队列中的任务进行处理;
(3)STOP:不接收新任务,也不再对任务队列中的任务进行处理,并中断正在处理的任务;
(4)TIDYING:所有任务都已终止,线程数为0,在转向TIDYING状态的过程中,线程会执行terminated()钩子方法,钩子方法是指在本类中是空方法,而在子类中进行具体实现的方法;
(5)TERMINATED:terminated()方法执行结束后会进入这一状态,表示线程池已关闭。
与线程池关闭有关的状态,有4个:
SHUTDOWN:不再接收新的任务,但是会对任务队列中的任务进行处理;
STOP:不接收新任务,也不再对任务队列中的任务进行处理,并中断正在处理的任务;
TIDYING:所有任务都已终止,线程数为0,在转向TIDYING状态的过程中,线程会执行terminated()钩子方法,钩子方法是指在本类中是空方法,而在子类中进行具体实现的方法;
TERMINATED:terminated()方法执行结束后会进入这一状态,表示线程池已彻底关闭。
二:线程池停止相关的五个方法
线程池停止相关的五个方法:
(1)shutdown方法:柔和关闭线程池;
(2)shutdownNow方法:暴力关闭线程池,无论线程池中是否有剩余任务,立刻彻底停止线程池
(3)isShutdown方法:查看线程池是否已进入停止状态了
(4)isTerminated方法:查看线程池是否已经彻底停止了
(5)awaitTermination方法:判断在等待的时间内,线程池是否彻底停止
其中终止线程池主要有2个:
(1)shutdown方法:柔和关闭线程池;
shutdown()后线程池将变成shutdown状态,此时不接收新任务,但会处理完正在运行的 和 在 workQueue 阻塞队列中等待处理的任务。
(2)shutdownNow方法:暴力关闭线程池
无论线程池中是否有剩余任务,shutdownNow()立刻彻底停止线程池。shutdownNow()后线程池将变成stop状态,此时不接收新任务,不再处理在阻塞队列中等待的任务,还会尝试中断正在处理中的工作线程。
其中对线程池关闭状态进行检查的方法,主要有3个:
(3)isShutdown方法:查看线程池是否已进入停止状态了
(4)isTerminated方法:查看线程池是否已经彻底停止了
(5)awaitTermination方法:判断在等待的时间内,线程池是否彻底停止
(1)shutdown柔和关闭线程池;
shutdown柔和关闭线程池,有两个要点:
(1)shutdown方法是关闭线程池;
(2)但是,shutdown只是初始化整个关闭过程, 执行完这个方法后,线程池不一定会立即停止;
所以,在我们调用了shutdown方法后,线程池就知道了 停止线程池的意图;而并不是我们调用shutdown方法后,整个线程池就能停的。比如,线程池在执行到一半时,线程中有正在执行的任务,队列中也可能有等待被执行的任务,线程池需要等这些任务执行完了,才能真正停止。
当然,在我们调用了shutdown方法后,如果还有新的任务过来,线程池就会拒绝。
在超级牛逼的rocketmq 源码中,也是shutdown 关闭线程池,具体如下:
说明:
(1)还是强调一下:我们执行了shutdown方法,isShutdown方法就会返回true;isShutdown方法返回true,仅仅代表线程池处于停止状态了,不代表线程池彻底停止了(因为,线程池进入停止状态后,还要等待【正在执行的任务以及队列中等待的任务】都执行完后,才能彻底终止);
(2)那么怎么看,线程池是否彻底停止了呐?isTerminated()方法,可以实现这个需求;
(2)shutdownNow 粗暴关闭线程
shutdownNow方法:无论线程池中是否有剩余任务,立刻彻底停止线程池;
(1) 正在执行任务的线程会被中断;
(2) 队列中正在排队的任务,会返回;
来看一个例子:向3个线程的固定大小线程池, 提交10个任务,每个任务 500ms!
执行结果如下:
另外还有 7个 任务,没有来得及执行。如果数据和任务都不重要,可以 shutdownNow 粗暴关闭线程,否则就太野蛮。
(3)isShutdown方法:查看线程池是否已进入停止状态了;
当调用shutdown方法关闭线程后,线程不是立即关闭,仅仅是启动了关闭流程,不再接收新的任务;问题是,如何查看线程池是否已进入停止状态呢?难道,我们只有通过 向线程池添加任务的方式 才能看到shutdown确确实实被执行了吗?
可以通过 isShutdown方法 查看线程池是否已进入停止状态了。只要开始执行了shutdown方法,isShutdown方法就会返回true;
(4)isTerminated方法:判停, 注意是阻塞判停
threadPool.isTerminated方法:查看线程池是否已经彻底停止了
threadPool.isTerminated() 常用来判断线程池是否结束,线程池pool的状态是否为Terminated,如果是,表示线程池pool彻底终止, threadPool.isTerminated() 返回为TRUE
当需要用到isTerminated()函数判断线程池中的所有线程是否执行完毕时候,不能直接使用该函数,必须在shutdown()方法关闭线程池之后才能使用,否则isTerminated()永不为TRUE,而且线程将一直阻塞在该判断的地方,导致程序最终崩溃。
(5)awaitTermination 等待停止
awaitTermination方法:判断在等待的时间内,线程池是否彻底停止。awaitTermination第一个参数是long类型的超时时间,第二个参数可以为该时间指定单位。
awaitTermination
的功能如下:
-
阻塞当前线程,等已提交和已执行的任务都执行完,解除阻塞
-
当等待超过设置的时间,检查线程池是否停止,如果停止返回
true
,否则返回false
,并解除阻塞
awaitTermination 一般与shutdown()方法结合使用,下面是一个例子:
执行结果如下:
例子中,线程池的有效执行时间为20S,20S之后不管子任务有没有执行完毕,都要关闭线程池。
注意:
与shutdown()方法结合使用时,尤其要注意的是shutdown()方法必须要在awaitTermination()方法之前调用,该方法才会生效。否则会造成死锁。
关闭线程池的正确姿势
关闭线程池的正确姿势= shutdown方法 +awaitTermination方法 组合关闭。
(1)shutdown方法:柔和的关闭ExecutorService,
当此方法被调用时,pool停止接收新的任务并且等待已经提交的任务(包含提交正在执行和提交未执行)执行完成。当所有提交任务执行完毕,线程池即被关闭。
(2)awaitTermination 方法:
接收人timeout和TimeUnit两个参数,用于设定超时时间及单位。当等待超过设定时间时,会监测ExecutorService是否已经关闭,若关闭则返回true,否则返回false。
三:线程池关闭的源码分析
线程池的5种运行状态
ThreadPoolExecutor使用runState (运行状态) 变量,管理线程池的生命周期,线程池关闭过程中,会涉及到频繁的 runState 运行状态转化,所以,首先需要了解线程池的各种 runState 运行状态及 各种 runState 之间的转化关系,runState 一共有以下5种取值:
(1)RUNNING:接收新的任务,并对任务队列里的任务进行处理;
(2)SHUTDOWN:不再接收新的任务,但是会对任务队列中的任务进行处理;
(3)STOP:不接收新任务,也不再对任务队列中的任务进行处理,并中断正在处理的任务;
(4)TIDYING:所有任务都已终止,线程数为0,在转向TIDYING状态的过程中,线程会执行terminated()钩子方法,钩子方法是指在本类中是空方法,而在子类中进行具体实现的方法;
(5)TERMINATED:terminated()方法执行结束后会进入这一状态,表示线程池已关闭。
运行状态的转化条件和转化关系如下所示:
shutdown操作之后,经历三个状态:
(1)首先最重要的一点变化就是线程池状态变成了SHUTDOWN。该状态是开始关闭线程池之后,从RUNNING改变状态经过的第一个状态
(2)等任务队列和线程数为0之后,进入TIDYING第2个状态
(3)等内部调用的terminated()方法执行结束后,会进入TERMINATED状态,表示线程池已关闭
shutdownNow操作之后,经历3个状态:
(1)直接进STOP,不管任务队列里边是否还有任务要处理,尝试停止所有活动的正在执行的任务,停止等待任务的处理,并排空任务列表
(2)等任务队列和线程数为0之后,进入TIDYING第2个状态,
(3)等内部调用的terminated()方法执行结束后,会进入TERMINATED状态,表示线程池已关闭
源码分析1:shutdown()柔和终止线程池
shutdown()柔和终止线程池的核心流程如下:
step1、抢占线程池的主锁
线程池的主锁是 mainLock ,是可重入锁,当要操作workers set这个保持线程的HashSet时,需要先获取 mainLock,另外,当要处理largestPoolSize、completedTaskCount这类统计数据时需要先获取mainLock
step 2、权限校验
java 安全管理器校验 , 判断调用者是否有权限shutdown线程池
step 3、更新线程池状态为shutdown
使用CAS操作将线程池状态设置为shutdown,shutdown之后将不再接收新任务
step 4、中断所有空闲线程
调用 interruptIdleWorkers() 打断所有的空闲工作线程,即workerQueue.take()阻塞的线程
step 5、onShutdown()
调用子类回调方法,基类默认为空方法,子类回调方法可以在shutdown()时做一些处理,子类 ScheduledThreadPoolExecutor中实现了这个方法
step 6、解锁
step 7、尝试终止线程池 tryTerminate()
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
// step1、抢占线程池的主锁
mainLock.lock();
try {
// step 2、权限校验 java 安全管理器校验
checkShutdownAccess();
//step 3、更新线程池状态为shutdown
advanceRunState(SHUTDOWN);
// step 4、 打断所有的空闲工作线程,即workerQueue.take()阻塞的线程
interruptIdleWorkers();
// 调用子类回调方法,基类默认为空方法
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
//**step 6、解锁**
mainLock.unlock();
}
//step 7、尝试终止线程池 tryTerminate()
tryTerminate();
}
最重要的3个步骤是:
step 3 更新线程池状态为shutdown
step 4 中断所有空闲线程
step 7 tryTerminated()尝试终止线程池
接下来,介绍step 4 、step 7的核心源码
step4:中断所有空闲线程 interruptIdleWorkers()
step4 是 调用 interruptIdleWorkers() 中断所有空闲线程 完成的。有两个问题:
(1)什么是空闲线程?
(2)interruptIdleWorkers() 是怎么中断空闲线程的?
/**
* 中断唤醒后,可以判断线程池状态是否变化来决定是否继续
*
* onlyOne如果为true,最多interrupt一个worker
*
* 只有当终止流程已经开始,但线程池还有worker线程时,tryTerminate()方法会做调用onlyOne为true的调
* 用(终止流程已经开始指的是:shutdown状态 且 workQueue为空,或者 stop状态)
*
* 在这种情况下,最多有一个worker被中断,为了传播shutdown信号,以免所有的线程都在等待,为保证线程池
* 最终能终止,这个操作总是中断一个空闲worker
*
* 而shutdown()中断所有空闲worker,来保证空闲线程及时退出
*/
private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock(); //上锁
try {
for (Worker w : workers) {
Thread t = w.thread;
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne) break;
}
} finally {
mainLock.unlock(); //解锁
}
}
interruptIdleWorkers() 首先会获取mainLock锁,因为要迭代workers 集合 ,然后,中断在等待任务的线程(没有上锁的),在中断每个worker前,需要做两个判断:
1、线程是否已经被中断,是就什么都不做
2、worker.tryLock() 是否成功
第二个判断 worker.tryLock() 比较重要,因为 Worker 类除了实现了可执行的 Runnable,也继承了AQS,也就说,worker 本身也是一把锁。
worker.tryLock() 为什么要获取worker的锁呢?
Woker类在执行任务的工作线程, 都是上了 worker 锁的。在 runWorker() 方法中, worker 从 pool 中获取task 并执行,但是执行的过程中,涉及到锁:
(1)一个worker 每次通过 getTask() 方法从 pool 获取到 task 之后,在执行 task.run() 之前,都需要 worker.lock() 上锁
(2)task 运行结束后 unlock 解锁,
所以说,只要是 正在执行任务的工作线程,都是上了worker锁的
参考的源码如下:
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
回顾一下前面 interruptIdleWorkers 的代码,有一个核心要点:
interruptIdleWorkers 中断 work 线程之前,需要先work.tryLock()获取worker锁
这就 意味着正在执行task的worker线程,不能被中断。
为什么?worker 锁比较特殊:核心的要点是 worker 锁是不可重入的 , 所以不管是不是当前线程,worker.tryLock() 都失败。
怎么证明 worker 锁是不可重入的,可以去看源码:worker 是线程池 ThreadPoolExecutor 的内部类,继承了 AbstractQueuedSynchronizer 抽象队列同步器, 核心的方法如下:
所以说,shutdown() 只有对能获取到worker锁的空闲线程发送中断信号, 对于忙的worker线程, 要等到拿到锁之后,才能去发中断信号。由此可以 将worker划分为:
1、闲的worker:没有执行任务的worker,比如正在从workQueue阻塞队列中获取任务的worker
2、忙的worker:正在task.run()执行任务的worker
线程被中断之后,如何处理
需要注意:对于闲着的但是正在被阻塞在getTask()的worker,是可以被中断的,但是在被中断后会抛出InterruptedException,runWorker的while循环被破坏,从而 不再阻塞获取任务。worker 捕获中断异常后,将跳出 while 循环,进入 processWorkerExit 方法,
runWorker 的核心代码如下:
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
//...... 省略n行,这里 执行拿到的任务,并处理任务异常
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
// InterruptedException 中断发生之后, 走到这里
processWorkerExit(w, completedAbruptly);
}
}
这里特别注意,一旦出了那个while循环,这个thread的执行,即将结束。换句话说,一旦worker 捕获中断异常后,worker所绑定的thread将跳出while循环,即将结束。
具体请参考下面:
虽然worker 绑定的线程,即将结束。但是在结束之前,还要执行一下 processWorkerExit方法
processWorkerExit方法解析
来看看 processWorkerExit(Worker w, boolean completedAbruptly) 方法解析
1.参数说明:
Worker w :工作线程包装器。
boolean completedAbruptly :默认值为true,只有调用getTask()方法,返回null,线程正常退出,会将completedAbruptly设置为false。当task.run()任务运行过程中抛出异常,线程异常退出,completedAbruptly还是默认值true。
2.执行过程:
统计执行完成的任务个数。
tryTerminate() 尝试调用terminated()方法。
RUNNING | SHUTDOWN 状态下,保证工作线程数量 >= corePoolSize,如果不满足,添加新线程。
private void processWorkerExit(Worker w, boolean completedAbruptly) {
// 线程异常退出,修改工作线程数量。
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// 统计执行完成任务个数
completedTaskCount += w.completedTasks;
// 移除当前worker
workers.remove(w);
} finally {
mainLock.unlock();
}
// 尝试调用terminated() 方法
tryTerminate();
int c = ctl.get();
//如果线程 为 RUNNING | SHUTDOWN 状态下 , 要保证最小工作线程数。
if (runStateLessThan(c, STOP)) {
// 线程正常退出,需要退出救急线程
// 线程异常退出,直接添加新线程
if (!completedAbruptly) {
// 判断最小线程数量,一般是核心线程数量。
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
// 最少一个线程
min = 1;
if (workerCountOf(c) >= min)
// 不需要添加新线程
return; // replacement not needed
}
// 添加新线程,在RUNNING | SHUTDOWN 状态下,不至于一个线程也没有了,要保证 剩余的任务干完
addWorker(null, false);
}
}
核心的工作为:
(1) 从pool 的 workers 集合移除当前worker
//Set containing all worker threads in pool. Accessed only when holding mainLock.
private final HashSet<Worker> workers = new HashSet<Worker>();
(2)尝试调用 pool的 terminated() 方法
首先判断 pool 的状态,如果为 RUNNING || (线程池已经被关闭【TIDYING | TERMINATED】) ||(SHUTDOWN && 任务队列不为空),直接返回。
然后判断工作线程数,如果不为0(自己不是最后一个工作线程),随机打断一个空闲线程,直接返回。否则,这一个线程修改线程池状态为TIDYING,修改线程状态为TERMINATED,调用terminated()方法,唤醒等待pool终止的线程 ,也就是awaitTermination() 的线程。
(3) 保证最小工作线程数
使用runStateLessThan(c, STOP) 判断线程的状态 是否比 STOP 小,那么比 STOP 小的是谁呢?
(1)RUNNING状态
(2)SHUTDOWN 状态
ThreadPoolExecutor用一个AtomicInteger字段保存了2个状态
-
workerCount (有效线程数) (占用29位)
-
runState (线程池运行状态) (占用高3位)
//标记线程数和状态的混合值
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
//线程位数
private static final int COUNT_BITS = Integer.SIZE - 3;
//线程最大个数(低29位)00011111111111111111111111111111
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
//(高3位):11100000000000000000000000000000
private static final int RUNNING = -1 << COUNT_BITS;
//(高3位):00000000000000000000000000000000
private static final int SHUTDOWN = 0 << COUNT_BITS;
//(高3位):00100000000000000000000000000000
private static final int STOP = 1 << COUNT_BITS;
//(高3位):01000000000000000000000000000000
private static final int TIDYING = 2 << COUNT_BITS;
//(高3位):01100000000000000000000000000000
private static final int TERMINATED = 3 << COUNT_BITS;
//获取线程池运行状态
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
//获取线程个数
private static int workerCountOf(int c) { return c & COUNT_MASK; }
//计算ctl新值
private static int ctlOf(int rs, int wc) { return rs | wc; }
从上面的源码可以看出 ,比STOP 小的是RUNNING | SHUTDOWN
processWorkerExit方法需要保证:如果pool在 RUNNING | SHUTDOWN 状态下,不能一个线程也没有了,要保证 workQueue 剩余的任务干完。所以,在RUNNING | SHUTDOWN 状态下, 如果有必要,还要添加新线程。
step7: 尝试终止线程池 tryTerminate()
shutdown()的最后也调用了tryTerminated()方法,下面看看这个方法的逻辑:
/**
* 在以下情况将线程池变为TERMINATED终止状态
* shutdown 且 正在运行的worker 和 workQueue队列 都empty
* stop 且 没有正在运行的worker
*
* 这个方法必须在任何可能导致线程池终止的情况下被调用,如:
* 减少worker数量
* shutdown时从queue中移除任务
*
* 这个方法不是私有的,所以允许子类ScheduledThreadPoolExecutor调用
*/
final void tryTerminate() {
//这个for循环主要是和进入关闭线程池操作的CAS判断结合使用的
for (;;) {
int c = ctl.get();
/**
* 线程池是否需要终止
* 如果以下3中情况任一为true,return,不进行终止
* 1、还在运行状态
* 2、状态是TIDYING、或 TERMINATED,已经终止过了
* 3、SHUTDOWN 且 workQueue不为空
*/
if (isRunning(c) ||
runStateAtLeast(c, TIDYING) ||
(runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty()))
return;
/**
* 只有shutdown状态 且 workQueue为空,或者 stop状态能执行到这一步
* 如果此时线程池还有线程(正在运行任务,正在等待任务)
* 中断唤醒一个正在等任务的空闲worker
* 唤醒后再次判断线程池状态,会return null,进入processWorkerExit()流程
*/
if (workerCountOf(c) != 0) { // Eligible to terminate 资格终止
interruptIdleWorkers(ONLY_ONE); //中断workers集合中的空闲任务,参数为true,只中断一个
return;
}
/**
* 如果状态是SHUTDOWN,workQueue也为空了,正在运行的worker也没有了,开始terminated
*/
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//CAS:将线程池的ctl变成TIDYING(所有的任务被终止,workCount为0,
// 为此状态时将会调用terminated()方法),期间ctl有变化就会失败,会再次for循环
if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
try {
terminated(); //需子类实现
}
finally {
ctl.set(ctlOf(TERMINATED, 0)); //将线程池的ctl变成TERMINATED
termination.signalAll(); //唤醒调用了 等待线程池终止的线程 awaitTermination()
}
return;
}
}
finally {
mainLock.unlock();
}
// else retry on failed CAS
// 如果上面的CAS判断false,再次循环
}
}
tryTerminate() 执行流程:
1、判断线程池是否需要进入终止流程(只有当shutdown状态+workQueue.isEmpty 或 stop状态,才需要)
2、判断线程池中是否还有线程,有则interruptIdleWorkers(ONLY_ONE)尝试中断一个空闲线程。正是这个逻辑可以再次发出中断信号,中断阻塞在获取任务的线程
3、如果状态是SHUTDOWN,workQueue也为空了,正在运行的worker也没有了,开始terminated
会先上锁,将线程池置为tidying状态,之后调用需子类实现的 terminated(),最后线程池置为terminated状态,并唤醒所有等待线程池终止这个Condition的线程
源码分析2:shutdownNow() 粗暴终止线程池的核心流程
/**
* 尝试停止所有活动的正在执行的任务,停止等待任务的处理,并返回正在等待被执行的任务列表
* 这个任务列表是从任务队列中排出(删除)的
*
* 这个方法不用等到正在执行的任务结束,要等待线程池终止可使用awaitTermination()
*
* 除了尽力尝试停止运行中的任务,没有任何保证
* 取消任务是通过Thread.interrupt()实现的,所以任何响应中断失败的任务可能永远不会结束
*/
public List <Runnable> shutdownNow() {
List <Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock(); //上锁
try {
//判断调用者是否有权限shutdown线程池
checkShutdownAccess();
//CAS+循环设置线程池状态为stop
advanceRunState(STOP);
//中断所有线程,包括正在运行任务的
interruptWorkers();
tasks = drainQueue();
//将workQueue中的元素放入一个List并返回
} finally {
mainLock.unlock();
//解锁
}
//尝试终止线程池
tryTerminate();
return tasks;
//返回workQueue中未执行的任务
}
shutdownNow() 和 shutdown()的大体流程相似,差别是:
1、将线程池更新为stop状态
2、调用**interruptWorkers()**中断所有线程,包括正在运行的线程
3、将workQueue中待处理的任务移到一个List中,并在方法最后返回,说明shutdownNow()后不会再处理workQueue中的任务
interruptWorkers()
private void interruptWorkers() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (Worker w : workers)
w.interruptIfStarted();
} finally {
mainLock.unlock();
}
}
interruptWorkers() 很简单,循环对所有worker调用 interruptIfStarted(),其中会判断worker的AQS state是否大于0,即worker是否已经开始运作,再调用Thread.interrupt()
注意:
对于运行中的线程调用Thread.interrupt()并不能保证线程被终止,为什么?task.run()内部执行的是业务代码,如果业务代码里边捕获了InterruptException,没有上抛,导致这里的结束机制失效。
怎么处理?其实也无所谓。当runWorker 执行下一次或者任务之后,里边会进行 线程池状态的双重检查,如果线程池的状态变了,变为结束,那么 工作线程 也会被 中断了。
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck (线程池状态的双重检查) in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
源码分析3:awaitTermination() 等待线程池终止的核心流程
awaitTermination() 源码如下
// 参数: timeout:超时时间 unit:timeout超时时间的单位
// 返回:true:线程池终止 , false:超过timeout指定时间
public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (;;) {
// 线程池状态如果已经结束,立即返回,无需等待
if (runStateAtLeast(ctl.get(), TERMINATED))
return true;
if (nanos <= 0)
return false;
//阻塞
nanos = termination.awaitNanos(nanos);
}
} finally {
mainLock.unlock();
}
}
在发出一个shutdown请求后,在以下3种情况发生之前,awaitTermination()都会被阻塞
1、所有任务完成执行
2、到达超时时间
3、当前线程被中断
这里用到一个 锁条件 termination:
/**
* Wait condition to support awaitTermination
*/
private final Condition termination = mainLock.newCondition();
awaitTermination() 循环的判断线程池是否terminated终止或是否已经超过超时时间,然后通过termination这个Condition阻塞等待一段时间。termination 阻塞等待过程中发生以下具体情况会解除阻塞(对上面3种情况的解释):
1、如果发生了 termination.signalAll()(内部实现是 LockSupport.unpark())会唤醒阻塞等待,且由于 ThreadPoolExecutor 只有在 tryTerminated() 尝试终止线程池成功,将线程池更新为terminated 状态后才会 signalAll(),故 awaitTermination() 再次判断状态会 return true 退出。
2、如果达到了超时时间 termination.awaitNanos() 也会返回,此时nano==0,再次循环判断return false,等待线程池终止失败。
3、如果当前线程被 Thread.interrupt(),termination.awaitNanos() 会上抛InterruptException,awaitTermination() 继续上抛给调用线程,会以异常的形式解除阻塞。
故终止线程池并需要知道其是否终止,可以用如下方式:
executorService.shutdown();
try{
while(!executorService.awaitTermination(500, TimeUnit.MILLISECONDS)) {
LOGGER.debug("Waiting for terminate");
}
} catch (InterruptedException e) {
//中断处理
}
10Wqps 超高并发 API网关 架构演进
API 网关的业务架构
从应用程序架构的变迁过程可以发现,随着业务多变性、灵活性的不断提高,应用程序需要以更加灵活的组合来应对。同时为了应对业务的细分以及高并发的挑战,微服务的架构被广泛使用,由于微服务架构中应用会被拆分成多个服务。为了方便客户端对这些服务的调用于是引入了 API 的概念。
网关一词最早出现在网络设备,比如两个相互独立的局域网之间通过路由器进行通信, 中间的路由被称之为网关。落实在开发层面来说,就是客户端与微服务系统之间存在的网关。从业务层面来说,当客户端完成某个业务的时候,需要同时调用多个微服务。
如图 1 所示,当客户端发起下单请求需要调用:商品查询、库存扣减以及订单更新等服务。
图1:API 网关加入前后对比
如果这些服务需要客户端分别调用才能完成,会增加请求的复杂度,同时也会带来网络调用性能的损耗。因此,针对微服务的应用场景就推出了 API 网关的调用。在客户端与微服务之间加入下单 API 网关,客户端直接给这个 API 网关下达命令,由于后者完成对其他三个微服务的调用并且返回结果给客户端。
从系统层面来说,任何一个应用系统如果需要被其他系统调用,就需要暴露 API,这些 API 代表着的功能点。正如上面下单的例子中提到的,如果一个下单的功能点需要调用多个服务的时候,在这个下单的 API 网关中就需要聚合多个服务的调用。
这个聚合的方式有点像设计模式中的门面模式(Facade),它为外部的调用提供了一个统一的访问入口。不仅如此,如图 2 所示,API 网关还可以协助两个系统的通信,在系统之间加上一个中介者协助 API 的调用。
图2:对接两个系统的 API 网关
从客户端类型层面来说,为了屏蔽不同客户端调用差异也可以加入 API 网关。
如图 3 所示,在实际开发过程中 API 网关还可以根据不同的客户端类型(iOS、Android、PC、小程序),提供不同的 API 网关与之对应。
图3:对接客户端和服务端的 API 网关
由于 API 网关所处的位置是客户端与微服务交界的地方,因此从功能上它还包括:路由,负载均衡,限流,缓存,日志,发布等等。
API 网关选型
几种常见网关的对比
设计网关,需要考虑哪些?
路由转发
请求先到达 API 网关,然后经过断言,匹配到路由后,由路由将请求转发给真正的业务服务。
注册发现
各个服务实例需要将自己的服务名、IP 地址和 port 注册到注册中心,然后注册中心会存放一份注册表,Gateway 可以从注册中心获取到注册表,然后转发请求时只需要转发到对应的服务名即可。
负载均衡
一个服务可以由多个服务实例组成服务集群,而 Gateway 配置的通常是一个服务名,如 passjava-member 服务,所以需要具备负载均衡功能,将请求分发到不同的服务实例上。
弹力设计
网关还可以把弹力设计中的那些异步、重试、幂等、流控、熔断、监视等都可以实现进去。这样,同样可以像 Service Mesh 那样,让应用服务只关心自己的业务逻辑(或是说数据面上的事)而不是控制逻辑(控制面)。
安全方面
SSL 加密及证书管理、Session 验证、授权、数据校验,以及对请求源进行恶意攻击的防范。错误处理越靠前的位置就是越好,所以,网关可以做到一个全站的接入组件来对后端的服务进行保护。当然,网关还可以做更多更有趣的事情,比如:灰度发布、API聚合、API编排。
灰度发布
网关完全可以做到对相同服务不同版本的实例进行导流,还可以收集相关的数据。这样对于软件质量的提升,甚至产品试错都有非常积极的意义。
API 聚合
使用网关可以将多个单独请求聚合成一个请求。在微服务体系的架构中,因为服务变小了,所以一个明显的问题是,客户端可能需要多次请求才能得到所有的数据。这样一来,客户端与后端之间的频繁通信会对应用程序的性能和规模产生非常不利的影响。于是,我们可以让网关来帮客户端请求多个后端的服务(有些场景下完全可以并发请求),然后把后端服务的响应结果拼装起来,回传给客户端(当然,这个过程也可以做成异步的,但这需要客户端的配合)。
API 编排
同样在微服务的架构下,要走完一个完整的业务流程,我们需要调用一系列 API,就像一种工作流一样,这个事完全可以通过网页来编排这个业务流程。我们可能通过一个 DSL 来定义和编排不同的 API,也可以通过像 AWS Lambda 服务那样的方式来串联不同的 API。
网关设计重点
网关设计重点主要是三个, 高性能、高可用、高扩展:
高性能
在技术设计上,网关不应该也不能成为性能的瓶颈。对于高性能,最好使用高性能的编程语言来实现,如 C、C++、Go 和 Java。网关对后端的请求,以及对前端的请求的服务一定要使用异步非阻塞的 I/O 来确保后端延迟不会导致应用程序中出现性能问题。C 和 C++ 可以参看 Linux 下的 epoll 和 Windows 的 I/O Completion Port 的异步 IO 模型,Java 下如 Netty、Spring Reactor 的 NIO 框架。
高可用
因为所有的流量或调用经过网关,所以网关必须成为一个高可用的技术组件,它的稳定直接关系到了所有服务的稳定。网关如果没有设计,就会成变一个单点故障。因此,一个好的网关至少要做到以下几点。
-
集群化。网关要成为一个集群,其最好可以自己组成一个集群,并可以自己同步集群数据,而不需要依赖于一个第三方系统来同步数据。
-
服务化。网关还需要做到在不间断的情况下修改配置,一种是像 Nginx reload 配置那样,可以做到不停服务,另一种是最好做到服务化。也就是说,得要有自己的 Admin API 来在运行时修改自己的配置。
-
持续化。比如重启,就是像 Nginx 那样优雅地重启。有一个主管请求分发的主进程。当我们需要重启时,新的请求被分配到新的进程中,而老的进程处理完正在处理的请求后就退出。
高可用性涵盖了内部和外部的各种不确定因素,这里讲一下网关系统在高可用性方面做的努力。
高扩展
因为网关需要承接所有的业务流量和请求,所以一定会有或多或少的业务逻辑。而我们都知道,业务逻辑是多变和不确定的。比如,需要在网关上加入一些和业务相关的东西。因此,一个好的 Gateway 还需要是可以扩展的,并能进行二次开发的。当然,像 Nginx 那样通过 Module 进行二次开发的固然可以。
另外,在运维方面,网关应该有以下几个设计原则。
-
业务松耦合,协议紧耦合。在业务设计上,网关不应与后面的服务之间形成服务耦合,也不应该有业务逻辑。网关应该是在网络应用层上的组件,不应该处理通讯协议体,只应该解析和处理通讯协议头。另外,除了服务发现外,网关不应该有第三方服务的依赖。
-
应用监视,提供分析数据。网关上需要考虑应用性能的监控,除了有相应后端服务的高可用的统计之外,还需要使用 Tracing ID 实施分布式链路跟踪,并统计好一定时间内每个 API 的吞吐量、响应时间和返回码,以便启动弹力设计中的相应策略。
-
用弹力设计保护后端服务。网关上一定要实现熔断、限流、重试和超时等弹力设计。如果一个或多个服务调用花费的时间过长,那么可接受超时并返回一部分数据,或是返回一个网关里的缓存的上一次成功请求的数据。你可以考虑一下这样的设计。
-
DevOps。因为网关这个组件太关键了,所以需要 DevOps 这样的东西,将其发生故障的概率降到最低。这个软件需要经过精良的测试,包括功能和性能的测试,还有浸泡测试。还需要有一系列自动化运维的管控工具。
网关设计注意事项
-
不要在网关中的代码里内置聚合后端服务的功能,而应考虑将聚合服务放在网关核心代码之外。可以使用 Plugin 的方式,也可以放在网关后面形成一个 Serverless 服务。
-
网关应该靠近后端服务,并和后端服务使用同一个内网,这样可以保证网关和后端服务调用的低延迟,并可以减少很多网络上的问题。这里多说一句,网关处理的静态内容应该靠近用户(应该放到 CDN 上),而网关和此时的动态服务应该靠近后端服务。
-
网关也需要做容量扩展,所以需要成为一个集群来分担前端带来的流量。这一点,要么通过 DNS 轮询的方式实现,要么通过 CDN 来做流量调度,或者通过更为底层的性能更高的负载均衡设备。
-
对于服务发现,可以做一个时间不长的缓存,这样不需要每次请求都去查一下相关的服务所在的地方。当然,如果你的系统不复杂,可以考虑把服务发现的功能直接集成进网关中。
-
为网关考虑 bulkhead 设计方式。用不同的网关服务不同的后端服务,或是用不同的网关服务前端不同的客户。
另外,因为网关是为用户请求和后端服务的桥接装置,所以需要考虑一些安全方面的事宜。具体如下:
-
加密数据。可以把 SSL 相关的证书放到网关上,由网关做统一的 SSL 传输管理。
-
校验用户的请求。一些基本的用户验证可以放在网关上来做,比如用户是否已登录,用户请求中的 token 是否合法等。但是,我们需要权衡一下,网关是否需要校验用户的输入。因为这样一来,网关就需要从只关心协议头,到需要关心协议体。而协议体中的东西一方面不像协议头是标准的,另一方面解析协议体还要耗费大量的运行时间,从而降低网关的性能。对此,我想说的是,看具体需求,一方面如果协议体是标准的,那么可以干;另一方面,对于解析协议所带来的性能问题,需要做相应的隔离。
-
检测异常访问。网关需要检测一些异常访问,比如,在一段比较短的时间内请求次数超过一定数值;还比如,同一客户端的 4xx 请求出错率太高……对于这样的一些请求访问,网关一方面要把这样的请求屏蔽掉,另一方面需要发出警告,有可能会是一些比较重大的安全问题,如被黑客攻击。
网关应用
流量网关
流量网关,顾名思义就是控制流量进入集群的网关,有很多工作需要在这一步做,对于一个服务集群,势必有很多非法的请求或者无效的请求,这时候要将请求拒之门外,降低集群的流量压力。
定义全局性的、跟具体的后端业务应用和服务完全无关的策略网关就是上图所示的架构模型——流量网关。流量网关通常只专注于全局的Api管理策略,比如全局流量监控、日志记录、全局限流、黑白名单控制、接入请求到业务系统的负载均衡等,有点类似防火墙。Kong 就是典型的流量网关。
下面是kong的架构图,来自官网(https://konghq.com/):
这里需要补充一点的是,业务网关一般部署在流量网关之后、业务系统之前,比流量网关更靠近业务系统。通常API网指的是业务网关。 有时候我们也会模糊流量网关和业务网关,让一个网关承担所有的工作,所以这两者之间并没有严格的界线。
业务网关
当一个单体应用被拆分成许许多多的微服务应用后,也带来了一些问题。一些与业务非强相关的功能,比如权限控制、日志输出、数据加密、熔断限流等,每个微服务应用都需要,因此存在着大量重复的代码实现。而且由于系统的迭代、人员的更替,各个微服务中这些功能的实现细节出现了较大的差异,导致维护成本变高。另一方面,原先单体应用下非常容易做的接口管理,在服务拆分后没有了一个集中管理的地方,无法统计已存在哪些接口、接口定义是什么、运行状态如何。
网关就是为了解决上述问题。作为微服务体系中的核心基础设施,一般需要具备接口管理、协议适配、熔断限流、安全防护等功能,各种开源的网关产品(比如 zuul)都提供了优秀高可扩展性的架构、可以很方便的实现我们需要的一些功能、比如鉴权、日志监控、熔断限流等。
与流量网关相对应的就是业务网关,业务网关更靠近我们的业务,也就是与服务器应用层打交道,那么有很多应用层需要考虑的事情就可以依托业务网关,例如在线程模型、协议适配、熔断限流,服务编排等。下面看看业务网关体系结构:
从这个图中可以看出业务网关主要职责以及所做的事情, 目前业务网关比较成熟的 API 网关框架产品有三个 分别是:Zuul1、Zuul2 和 SpringCloud Gateway, 后面再进行对比。
网关与服务器集群
回到我们服务器上,下面图介绍了网关(Gateway)作用,可知 Gateway 方式下的架构,可以细到为每一个服务的实例配置一个自己的 Gateway,也可以粗到为一组服务配置一个,甚至可以粗到为整个架构配置一个接入的 Gateway。于是,整个系统架构的复杂度就会变得简单可控起来。
这张图展示了一个多层 Gateway 架构,其中有一个总的 Gateway 接入所有的流量(流量网关),并分发给不同的子系统,还有第二级 Gateway 用于做各个子系统的接入 Gateway(业务网关)。可以看到,网关所管理的服务粒度可粗可细。通过网关,我们可以把分布式架构组织成一个星型架构,由网络对服务的请求进行路由和分发。下面来聊聊好的网关应该具备哪些功能,也就是网关设计模式。
常见网关对比
既然对比,就先宏观上对各种网关有一个了解,后面再挑一些常用的或者说应用广泛的详细了解。
目前常见的开源网关大致上按照语言分类有如下几类:
-
Nginx+lua:OpenResty、Kong、Orange、Abtesting gateway 等
-
Java:Zuul/Zuul2、Spring Cloud Gateway、Kaazing KWG、gravitee、Dromara soul 等
-
Go:Janus、fagongzi、Grpc-gateway
-
Dotnet:Ocelot
-
NodeJS:Express Gateway、Micro Gateway
按照使用数量、成熟度等来划分,主流的有 4 个:
-
OpenResty
-
Kong
-
Zuul/Zuul2
-
Spring Cloud Gateway
OpenResty
OpenResty是一个流量网关,根据前面对流量网关的介绍就可以知道流量网关的职责。
OpenResty基于 Nginx(https://openresty.org/cn/nginx.html) 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。
通过揉和众多设计良好的 Nginx 模块,OpenResty 有效地把 Nginx 服务器转变为一个强大的 Web 应用服务器,基于它开发人员可以使用 Lua 编程语言对 Nginx 核心以及现有的各种 Nginx C 模块进行脚本编程,构建出可以处理一万以上并发请求的极端高性能的 Web 应用。
OpenResty 最早是顺应 OpenAPI 的潮流做的,所以 Open 取自“开放”之意,而Resty便是 REST 风格的意思。虽然后来也可以基于 ngx_openresty 实现任何形式的 web service 或者传统的 web 应用。
也就是说 Nginx 不再是一个简单的静态网页服务器,也不再是一个简单的反向代理了。第二代的 openresty 致力于通过一系列 nginx 模块,把nginx扩展为全功能的 web 应用服务器。
ngx_openresty 是用户驱动的项目,后来也有不少国内用户的参与,从 openresty.org 的点击量分布上看,国内和国外的点击量基本持平。
ngx_openresty 目前有两大应用目标:
-
通用目的的 web 应用服务器。在这个目标下,现有的 web 应用技术都可以算是和 OpenResty 或多或少有些类似,比如 Nodejs, PHP 等等。ngx_openresty 的性能(包括内存使用和 CPU 效率)算是最大的卖点之一。
-
Nginx 的脚本扩展编程,用于构建灵活的 Web 应用网关和 Web 应用防火墙。有些类似的是 NetScaler。其优势在于 Lua 编程带来的巨大灵活性。
Kong
相关连接:官网(https://konghq.com/)、Github(https://github.com/Kong/)
Kong基于OpenResty开发,也是流量层网关, 是一个云原生、快速、可扩展、分布式的Api 网关。继承了OpenResty的高性能、易扩展性等特点。Kong通过简单的增加机器节点,可以很容易的水平扩展。同时功能插件化,可通过插件来扩展其能力。而且在任何基础架构上都可以运行。具有以下特性:
-
提供了多样化的认证层来保护Api。
-
可对出入流量进行管制。
-
提供了可视化的流量检查、监视分析Api。
-
能够及时的转换请求和相应。
-
提供log解决方案
-
可通过api调用Serverless 函数。
Kong解决了什么问题
当我们决定对应用进行微服务改造时,应用客户端如何与微服务交互的问题也随之而来,毕竟服务数量的增加会直接导致部署授权、负载均衡、通信管理、分析和改变的难度增加。
面对以上问题,API GATEWAY是一个不错的解决方案,其所提供的访问限制、安全、流量控制、分析监控、日志、请求转发、合成和协议转换功能,可以解放开发者去把精力集中在具体逻辑的代码,而不是把时间花费在考虑如何解决应用和其他微服务链接的问题上。
图片来自Kong官网:
可以看到Kong解决的问题。专注于全局的Api管理策略,全局流量监控、日志记录、全局限流、黑白名单控制、接入请求到业务系统的负载均衡等。
Kong的优点以及性能
在众多 API GATEWAY 框架中,Mashape 开源的高性能高可用API网关和API服务管理层——KONG(基于 NGINX+Lua)特点尤为突出,它可以通过插件扩展已有功能,这些插件(使用 lua 编写)在API请求响应循环的生命周期中被执行。于此同时,KONG本身提供包括 HTTP 基本认证、密钥认证、CORS、TCP、UDP、文件日志、API请求限流、请求转发及 NGINX 监控等基本功能。目前,Kong 在 Mashape 管理了超过 15,000 个 API,为 200,000 开发者提供了每月数十亿的请求支持。
Kong架构
Kong提供一些列的服务,这就不得不谈谈内部的架构:
首先最底层是基于Nginx, Nginx是高性能的基础层, 一个良好的负载均衡、反向代理器,然后在此基础上增加Lua脚本库,形成了OpenResty,拦截请求, 响应生命周期,可以通过Lua编写脚本,所以插件比较丰富。
关于Kong的一些插件库以及如何配置,可以参考简书:开源API网关系统(Kong教程)入门到精通(https://www.jianshu.com/p/a68e45bcadb6)
Zuul1.0
Zuul是所有从设备和web站点到Netflix流媒体应用程序后端请求的前门。作为一个边缘服务应用程序,Zuul被构建来支持动态路由、监视、弹性和安全性。它还可以根据需要将请求路由到多个Amazon自动伸缩组。
Zuul使用了一系列不同类型的过滤器,使我们能够快速灵活地将功能应用到服务中。
过滤器
过滤器是Zuul的核心功能。它们负责应用程序的业务逻辑,可以执行各种任务。
-
Type : 通常定义过滤器应用在哪个阶段
-
Async : 定义过滤器是同步还是异步
-
Execution Order : 执行顺序
-
Criteria : 过滤器执行的条件
-
Action : 如果条件满足,过滤器执行的动作
Zuul提供了一个动态读取、编译和运行这些过滤器的框架。过滤器之间不直接通信,而是通过每个请求特有的RequestContext共享状态。
下面是Zuul的一些过滤器:
Incoming
Incoming过滤器在请求被代理到Origin之前执行。这通常是执行大部分业务逻辑的地方。例如:认证、动态路由、速率限制、DDoS保护、指标。
Endpoint
Endpoint过滤器负责基于incoming过滤器的执行来处理请求。Zuul有一个内置的过滤器(ProxyEndpoint),用于将请求代理到后端服务器,因此这些过滤器的典型用途是用于静态端点。例如:健康检查响应,静态错误响应,404响应。
Outgoing
Outgoing过滤器在从后端接收到响应以后执行处理操作。通常情况下,它们更多地用于形成响应和添加指标,而不是用于任何繁重的工作。例如:存储统计信息、添加/剥离标准标题、向实时流发送事件、gziping响应。
过滤器类型
下面是与一个请求典型的生命周期对应的标准的过滤器类型:
-
PRE:路由到Origin之前执行
-
ROUTING:路由到Origin期间执行
-
POST:请求被路由到Origin之后执行
-
ERROR:发生错误的时候执行
这些过滤器帮助我们执行以下功能:
-
身份验证和安全性:识别每个资源的身份验证需求,并拒绝不满足它们的请求
-
监控:在边缘跟踪有意义的数据和统计数据,以便给我们一个准确的生产视图
-
动态路由:动态路由请求到不同的后端集群
-
压力测试:逐渐增加集群的流量,以评估性能
-
限流:为每种请求类型分配容量,并丢弃超过限制的请求
-
静态响应处理:直接在边缘构建一些响应,而不是将它们转发到内部集群
Zuul 1.0 请求生命周期
Netflix宣布了通用API网关Zuul的架构转型。Zuul原本采用同步阻塞架构,转型后叫作Zuul2,采用异步非阻塞架构。Zuul2和Zuul1在架构方面的主要区别在于,Zuul2运行在异步非阻塞的框架上,比如Netty。Zuul1依赖多线程来支持吞吐量的增长,而Zuul 2使用的Netty框架依赖事件循环和回调函数。
Zuul2.0
Zuul 2.0 架构图
上图是Zuul2的架构,和Zuul1没有本质区别,两点变化:
-
前端用Netty Server代替Servlet,目的是支持前端异步。后端用Netty Client代替Http Client,目的是支持后端异步。
-
过滤器换了一下名字,用Inbound Filters代替Pre-routing Filters,用Endpoint Filter代替Routing Filter,用Outbound Filters代替Post-routing Filters。
Inbound Filters : 路由到 Origin 之前执行,可以用于身份验证、路由和装饰请求
Endpoint Filters : 可用于返回静态响应,否则内置的ProxyEndpoint过滤器将请求路由到Origin
Outbound Filters : 从Origin那里获取响应后执行,可以用于度量、装饰用户的响应或添加自定义header
有两种类型的过滤器:sync 和 async。因为Zuul是运行在一个事件循环之上的,因此从来不要在过滤中阻塞。如果你非要阻塞,可以在一个异步过滤器中这样做,并且在一个单独的线程池上运行,否则可以使用同步过滤器。
上文提到过Zuul2开始采用了异步模型。
优势是异步非阻塞模式启动的线程很少,基本上一个CPU core上只需启一个事件环处理线程,它使用的线程资源就很少,上下文切换(Context Switch)开销也少。非阻塞模式可以接受的连接数大大增加,可以简单理解为请求来了只需要进队列,这个队列的容量可以设得很大,只要不超时,队列中的请求都会被依次处理。
不足,异步模式让编程模型变得复杂。一方面Zuul2本身的代码要比Zuul1复杂很多,Zuul1的代码比较容易看懂,Zuul2的代码看起来就比较费劲。另一方面异步模型没有一个明确清晰的请求->处理->响应执行流程(call flow),它的流程是通过事件触发的,请求处理的流程随时可能被切换断开,内部实现要通过一些关联id机制才能把整个执行流再串联起来,这就给开发调试运维引入了很多复杂性,比如你在IDE里头调试异步请求流就非常困难。另外ThreadLocal机制在这种异步模式下就不能简单工作,因为只有一个事件环线程,不是每个请求一个线程,也就没有线程局部的概念,所以对于CAT这种依赖于ThreadLocal才能工作的监控工具,调用链埋点就不好搞(实际可以工作但需要进行特殊处理)。
总体上,异步非阻塞模式比较适用于IO密集型(IO bound)场景,这种场景下系统大部分时间在处理IO,CPU计算比较轻,少量事件环线程就能处理。
Zuul 与 Zuul 2 性能对比
图片来源:Zuul's Journey to Non-Blocking(https://www.slideshare.net/artgon/zuuls-journey-to-nonblocking)
Netflix给出了一个比较模糊的数据,大致Zuul2的性能比Zuul1好20%左右,这里的性能主要指每节点每秒处理的请求数。为什么说模糊呢?因为这个数据受实际测试环境,流量场景模式等众多因素影响,你很难复现这个测试数据。即便这个20%的性能提升是确实的,其实这个性能提升也并不大,和异步引入的复杂性相比,这20%的提升是否值得是个问题。Netflix本身在其博文22和ppt11中也是有点含糊其词,甚至自身都有一些疑问的。
Spring Cloud Gateway
SpringCloud Gateway 是 Spring Cloud 的一个全新项目,该项目是基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等技术开发的网关,它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。
SpringCloud Gateway 作为 Spring Cloud 生态系统中的网关,目标是替代 Zuul,在Spring Cloud 2.0以上版本中,没有对新版本的Zuul 2.0以上最新高性能版本进行集成,仍然还是使用的Zuul 2.0之前的非Reactor模式的老版本。而为了提升网关的性能,SpringCloud Gateway是基于WebFlux框架实现的,而WebFlux框架底层则使用了高性能的Reactor模式通信框架Netty。
Spring Cloud Gateway 的目标,不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。
Spring Cloud Gateway 底层使用了高性能的通信框架Netty。
SpringCloud Gateway 特征
SpringCloud官方,对SpringCloud Gateway 特征介绍如下:
(1)基于 Spring Framework 5,Project Reactor 和 Spring Boot 2.0
(2)集成 Hystrix 断路器
(3)集成 Spring Cloud DiscoveryClient
(4)Predicates 和 Filters 作用于特定路由,易于编写的 Predicates 和 Filters
(5)具备一些网关的高级功能:动态路由、限流、路径重写
从以上的特征来说,和Zuul的特征差别不大。SpringCloud Gateway和Zuul主要的区别,还是在底层的通信框架上。
简单说明一下上文中的三个术语:
Filter(过滤器)
和Zuul的过滤器在概念上类似,可以使用它拦截和修改请求,并且对上游的响应,进行二次处理。过滤器为org.springframework.cloud.gateway.filter.GatewayFilter类的实例。
Route(路由)
网关配置的基本组成模块,和Zuul的路由配置模块类似。一个Route模块由一个 ID,一个目标 URI,一组断言和一组过滤器定义。如果断言为真,则路由匹配,目标URI会被访问。
Predicate(断言)
这是一个 Java 8 的 Predicate,可以使用它来匹配来自 HTTP 请求的任何内容,例如 headers 或参数。断言的输入类型是一个 ServerWebExchange。
网关对比总结
基于Spring Cloud Gateway 实现业务网关
API 网关的定义中我们提到了为什么要使用 API 网关,是为了解决客户端对多个微服务进行访问的问题。由于服务的切分导致一个操作需要同时调用多个服务,因此为这些服务的聚合提供一个统一的门面,这个门面就是 API 网关。针对于 API 网关有很多的实现方式,例如:Zuul,Kong 等等。这里我们以 Spring Cloud Gateway 为例展开给大家介绍其具体实现。一般来说,API 网关对内将微服务进行集合,对外暴露的统一 URL 或者接口信息供客户端调用。那么客户端是如何与微服务进行连接,并且进行沟通的,需要引入下面几个重要概念 。
图4:路由、断言和过滤器
如图 4 所示,Spring Cloud Gateway 由三部分组成:
①路由 Route:任何一个来自于客户端的请求都会经过路由,然后到对应的微服务中。每个路由会有一个唯一的 ID 和对应的目的 URL。同时包含若干个断言(Predicate)和过滤器(Filter)。
②断言 Predicate:当客户端通过 Http Request 请求进入 Spring Cloud Gateway 的时候,断言会根据配置的路由规则,对 Http Request 请求进行断言匹配。就是进行一次或者多次 if 判断,如果匹配成功则进行下一步处理,否则断言失败直接返回错误信息。
③过滤器 Filter:简单来说就是对流经的请求进行过滤,或者说对其进行获取以及修改的操作。注意过滤器的功能是双向的,也就是对请求和响应都会进行修改处理 。一般来说 Spring Cloud Gateway 中的过滤器有两种类型:
-
Gateway Filter
-
Global Filter
Gateway Filter 用在单个路由和分组路由上。Global Filter 可以作用于所有路由,是一个全局的 Filter。
Spring Cloud Gateway 工作原理
说完了 Spring Cloud Gateway 定义和要素,再来看看其工作原理。总的来说是对客户端请求的处理过程。
图5:Spring Cloud Gateway 处理请求流程图
如图 5 所示,当客户端向 Spring Cloud Gateway 发起请求,该请求会被 HttpWebHandlerAdapter 获取,并且对请求进行提取,从而组装成网关上下文。
将组成的上下文信息传递到 DispatcherHandler 组件。DispatcherHandler 作为请求分发处理器,主要负责将请求分发到对应的处理器进行处理。
这里请求的处理器包括 RoutePredicate HandlerMapping (路由断言处理映射器) 。
路由断言处理映射器用于路由的查找,以及找到 路由后返回对应的 FilteringWebHandler。
其负责组装 Filter 链表并执行过滤处理,之后再将请求转交给应用服务,应用服务处理完后,最后返回 Response 给客户端 。
其中 FilteringWebHandler 处理请求的时候会交给 Filter 进行过滤的处理。
这里需要注意的是由于 Filter 是双向的所以,当客户端请求服务的时候,会通过 Pre Filter 中的 Filter 处理请求。
当服务处理完请求以后返回客户端的时候,会通过 Post Filter 再进行一次处理。
2W qps Spring Cloud Gateway 网关实践
上面介绍了 Spring Cloud Gateway 的定义和实现原理,下面根据几个常用的场景介绍一下 Spring Cloud Gateway 如何实现网关功能的。
我们会根据基本路由、权重路由、限流、动态路由几个方面给大家展开介绍。
基本路由
基本路由,主要功能就是在客户端请求的时候,根据定义好的路径指向到对应的 URI。这个过程中需要用到 Predicates(断言)中的 Path 路由断言处理器。基本路由 可以通过配置文件实现。
权重路由
这个使用场景相对于上面的简单路由要多一些。
由于每个微服务发布新版本的时候,通常会保持老版本与新版版同时存在。然后通过网关将流量逐步从老版本的服务切换到新版本的服务。这个逐步切换的过程就是常说的灰度发布。
此时,API 网关就起到了流量分发的作用,通常来说最开始的老版本会承载多一些的流量,例如 90% 的请求会被路由到老版本的服务上,只有 10% 的请求会路由到新服务上去。从而观察新服务的稳定性,或者得到用户的反馈。当新服务稳定以后,再将剩下的流量一起导入过去。
图6:灰度发布,路由到新/老服务
在两个配置中对应的 URI 分别是新老两个服务的访问地址,通过http://localhost:8888/v1
和http://localhost:8888/v2
来区别。
在 Predicates(断言)中定义了的 Path 是想通的都是“/gatewaytest”,也就是说对于客户端来说访问的路径都是一样的,从路径上客户不会感知他们访问的是新服务或者是老服务。
主要参数是在 Weight,针对老/新服务分别配置的是 90 和 10。也就是有 90% 的流量会请求老服务,有 10% 的流量会请求新服务。简单点说,如果有 100 次请求,其中 90 次会请求 v1(老服务),另外的 10 次会请求 v2(新服务)。
限流
当服务在短时间内迎来高并发,并发量超过服务承受的范围就需要使用限流。例如:秒杀、抢购、下单服务。通过请求限速或者对一个时间窗口内的请求进行限速来保护服务。当达到限制速率则可以拒绝请求,返回错误代码,或者定向到友好页面。
一般的中间件都会有单机限流框架,支持两种限流模式:
-
控制速率
-
控制并发
这里通过 Guava 中的 Bucket4j 来实现限流操作。
由于需要对于用户请求进行监控,因此通过实现 GatewayFilter 的方式自定义 Filter,然后再通过 Gateway API Application 应用这个自定义的 Filter。
可以基于令牌桶限流,因此需要设置桶的容量(capacity),每次填充的令牌数量(refillTokens)以及填充令牌的间隔时间(refillDuration)。
那么当用户访问 rateLimit 路径的时候就会根据客制化的 Filter 进行限流。
这里的限流只是给大家提供一种思路,通过实现 GatewayFilter,重写其中的 Filter 方法,加入对流量的控制代码,然后在 Spring Cloud Gateway 中进行应用就可以了。
动态路由
由于 Spring Cloud Gateway 本身也是一个服务,一旦启动以后路由配置就无法修改了。无论是上面提到的编码注入的方式还是配置的方式,如果需要修改都需要重新启动服务。
如果回到 Spring Cloud Gateway 最初的定义,会发现每个用户的请求都是通过 Route 访问对应的微服务,在 Route 中包括 Predicates 和 Filters 的定义。只要实现 Route 以及其包含的 Predicates 和 Filters 的定义,然后再提供一个 API 接口去更新这个定义就可以动态地修改路由信息了。
按照这个思路需要做以下几步来实现:
①定义 Route、Predicates 和 Filters
其中 Predicates 和 Filters 包含在 Route 中。实际上就是 Route 实体的定义,针对 Route 进行路由规则的配置。
②实现路由规则的操作,包括添加,更新,删除
有了路由的定义(Route,Predicates,Filters),然后再编写针对路由定义的操作。例如: 添加路由,删除路由,更新路由之类的。编写 RouteServiceImpl 实现ApplicationEventPublisherAware。
主要需要 override 其中的 setApplicationEventPublisher 方法,这里会传入 ApplicationEventPublisher 对象,通过这个对象发布路由定义的事件包括:add,update,delete。
③对外部提供 API 接口能够让用户或者程序动态修改路由规则
从代码上来说就是一个 Controller。这个 Controller 中只需要调用 routeServiceImpl 就行了,主要也是用到客制化路由实现类中的 add,update,delete 方法。就是对其进行了一次包装,让外部系统可以调用,并且修改路由的配置。
④启动程序进行路由的添加和更新操作
假设更新 API 网关配置的服务在 8888 端口上。
于是通过 http://localhost:8888/actuator/gateway/routes 访问当前的路由信息,由于现在没有配置路由这个信息是空。
那么通过 http://localhost:8888/route/add 方式添加一条路由规则。可以设置 Route,当 Predicates 为 baidu 的时候,将请求引导到 www.baidu.com 的网站进行响应。
此时再通过访问 http://localhost:8888/baidu 的路径访问的时候,就会被路由到 www.baidu.com 的网站。
此时如果需要修改路由配置,可以通过访问 http://localhost:8888/route/update 的 API 接口,通过 Post 方式传入 Json 结构
在更新完成以后,再访问 http://localhost:8888/CTO 的时候就会把引导到目标的网站了。
10W qps 天翼网关系统 架构演进历程
原文链接:https://xie.infoq.cn/article/c6703d216c43c2b522b9b4ffa
1、前言
天翼账号是中国电信打造的互联网账号体系产品,利用中国电信管道优势为企业提供用户身份认证能力。 其中网关系统是天翼账号对外能力开放体系的重要组成:业务侧它以集中入口、集中计费、集中鉴权管控为目标,技术侧它支持隔离性、可配置、易开发、动态路由、可降级、高并发等场景。
自 2017 年天翼账号网关系统上线以来,历经数次互联网的营销大促活动,特别是 2021 年的春节红包活动和刚过去双 11 活动,天翼账号网关系统在 10 万级 QPS 和 10 亿级日请求量的情况下,各项指标依然保持平稳,顺利保障合作方活动的开展。在天翼账号产品能力不断提升的背后,是天翼账号网关系统架构技术迭代发展的过程。
2、天翼账号网关演进
2.1 重点演进历程介绍
2017 年~ 2021 年天翼账号网关系统经历数次重要更迭升级,每次升级都给产品带来新的发展机会,系统总体演进过程如下:
2.2 天翼账号网关系统 1.0
2017 年初,天翼账号技术团队基于开源微服务网关 Zuul 组件开展了网关系统 1.0 的建设。
Zuul 是 Spring Cloud 工具包 Netflix 分组的开源微服务网关,它和 Eureka、ribbon、hystrix 等组件配合使用,本质是通过一系列的核心 filter,来实现请求过程的认证安全、动态路由、数据转化、熔断保护等功能。
其系统核心运行流程如下:
2018 年中,随着天翼账号推出免密认证系列产品的快速发展,网关系统 1.0 的缺点日益凸显主要表现在两个方面:
-
性能瓶颈: 微服务网关 Zuul 组件基于 Servlet 框架构建,采用的是阻塞和多线程方式实现,高并发下内部延迟严重,会造成连接增多和线程增加等情况,导致阻塞发生,在实际业务应用中单机性能在 1000 QPS 左右。
-
灵活度有限:为了实现路由和 Filter 动态配置,研发人员需要花费时间去整合开源适配 Zuul 组件控制系统。
为应对业务高峰,技术侧常采用横向扩展实例的策略来实现对高并发的支持,该策略给系统稳定性带来一定的风险,同时部署大量的网关服务器也提高产品的运营维护成本,因此网关系统架构升级迫在眉睫。
2.3 天翼账号网关系统 2.0
2.3.1 技术选型
互联网企业常见的方案有基于 Openresty 的 Kong、Orange,基于 Go 的 Tyk 和基于 Java 的 Zuul:
apiaxle、api-umbrella: 考虑到学习成本和项目后期发展的兼容性,其语言和框架不在团队优先考虑范围。
Zuul:目前正在应用中,Zuul 处理请求的方式是针对每个请求都启用一个线程来处理。通常情况下,为了提高性能,所有请求被放到处理队列中,等待空闲线程来处理。当存在大量请求超时后会造成 Zuul 线程阻塞,目前只能通过横向扩展 Zuul 实例实现对高并发的支持。而 Zuul2.0 将 HTTP 请求的处理方式从同步变成了异步,以此提升处理性能。但团队内部对继续采用 Zuul 比较慎重,原因主要有以下两点:
-
版本稳定性需要斟酌 ,Zuul 的开源社区比较活跃,一直在更新状态
-
应用企业较少,除了 Netflix,目前 Zuul 在企业中的应用还比较少,性能和稳定性方面还有待观察
Nginx: 高性能的 HTTP 和反向代理 Web 服务器,应用场景涉及负载均衡、反向代理、代理缓存、限流等场景。但 Nginx 作为 Web 容器应用场景较少。Nginx 性能优越,而 Nginx 开发主要是以 C/C++ 模块的形式进行,整体学习和开发成本偏高。Nginx 团队开发了 NginxScript,可以在 Nginx 中使用 JavaScript 进行动态配置变量和动态脚本执行。
目前行业应用非常成熟的扩展是由章亦春将 Lua 和 Nginx 黏合的 ngx_Lua 模块,将 Nginx 核心、LuaJIT、ngx_Lua 模块、多功能 Lua 库和常用的第三方 Nginx 模块整合成为 OpenResty。开发人员安装 OpenResty,使用 Lua 编写脚本,部署到 Nginx Web 容器中运行,能轻松地开发出高性能的 Web 服务。OpenResty 具有高性能,易扩展的特点,成为了团队首选。同时也面临两个选项:
-
基于 OpenResty 自建,例如:一个类似于某东 JEN 的系统
-
对开源框架二次开发,例如:Kong、Orange
根据调研结果,团队衡量学习成本和开发周期等因素,最终决定采用对 Kong 框架二次开发的方案。以下是调研后的一些对比总结,仅供参考,如有疏漏,请不吝指出。
2.3.2 架构升级
天翼账号技术团队制定了网关系统 2.0 演进方案,部署架构如图:
-
自研插件
除了 Kong 网关自带的原生组件外,2.0 网关系统还相继研发出:加密鉴权、日志处理、参数转换、接口协议、消息队列、服务稳定、链路追踪及其它等 8 大类共计约 30 多个组件。
丰富的自研组件,保障了系统架构平稳的升级和业务的灵活性:
-
支持产品 Appkey 认证,SSL/TLS 加密,支持针对 IP 或应用的黑、白名单
-
符合自身业务的协议插件,包括了常见的加密、签名算法和国密 sm2,sm3,sm4 等金融级别的算法
-
监控和统计方面增加了基于 Redis、Kafka 的异步日志汇聚、统计方式,并支持 Zipkin、Prometheus 的追踪、监控
-
增加多种按业务精细化分类的限流熔断策略,进一步完善服务保障体系
2.3.3 效果
网关 2.0 通过对 Kong 组件自研插件的开发和改造,实现了符合产品特性、业务场景的相关功能,也抽象了网关的通用功能。相较于 1.0 版本,具备以下优点:
-
支持插件化,方便自定义业务开发
-
支持横向扩展,高性能、高并发、多级缓存
-
高可用、高稳定性,具备隔离、限流、超时与重试、回滚机制
-
插件热启用,即插即拔、动态灵活、无需重启,能快速适用业务变化
为了验证新架构的性能,团队对网关系统 2.0 进行了压测:
-
结果 1:17:26 在当前测试环境下 QPS 在 1.2W 左右
-
结果 2:18:06 取消 Prometheus、流量控制、日志、权限校验等插件,QPS 达到 1.3W+
压测结果表明,天翼账号网关系统 2.0 已经达到单机万级 QPS,自研插件运行效率较高,对于网关性能的影响较小。
天翼账号网关系统 2.0 初期是基于 Kong-v0.14.0 版本开发,运行至 2019 年 5 月时,Kong 已经更新到 v1.1.X 版本,有很多重要的功能和核心代码更新,而且为了便于跟 Kubernetes 集成,团队决定将版本升至 v1.1.X。
通过同步迁移、并行运行的方式天翼账号网关系统 2.1 于 2019 年 9 月完成 Kong-v1.3 版本的升级。期间天翼账号网关系统仍平稳地完成了 2018 年阿里双 11 活动、2019 年春节活动等大型高并发场景的支撑工作。
2020 年 3 月,网关 2.1 及底层微服务完成了镜像化改造,即可通过 Kubernetes 自动编排容器的方式部署,在动态扩容等方面有较大的提升。
2.4 天翼账号网关系统 3.0
随着免密认证逐渐成为互联网应用作为首选登录方式,互联网头部企业要求认证产品 SLA 相关技术指标对齐其内部指标,为了支撑产品精细化运营和进一步的发展,保障产品 SLA 合同及性能指标,技术团队制定了网关系统 3.0 演进方案。
3.0 网关部署架构图如下:
2.4.1 DP(Data Plane) 升级
2.4.1.1 Kong 组件升级
团队摸余(鱼)工程师对开源项目 Kong 的版本发布一直保持着较高的关注度,总结两年来 Kong 主要版本升级新特性:
考虑到 Kong 2.5.0 版本为刚刚发布的版本,采用的企业用户不多,且开源社区对之前发布的 V2.4.0 版有较好的评价,因此团队评审后决定升级到 V2.4.0。
Kong 2.4.0 采用 OpenResty1.19.3.1,基于 Nginx 1.19.3,官方文档测试单 Worker 可达 269,423 QPS。以 2.0 版本同样环境压测,天翼账号网关系统 3.0(Kong 2.4) QPS 可达到 2W+,对比天翼账号网关 2.X(Kong 1.3) QPS 1W+,性能预估可提升 80% 以上。
Kong 2.4.0 组件采用控制面 / 数据面(CP/DP) 混合部署新模式,具备以下优势:
-
功能解耦
混合部署模式,CP 负责将配置数据推动到 DP 节点,DP 节点负责流量数据处理。
-
运行稳定
当 CP 不可用时,DP 可按照本地存储的配置进行流量业务处理,待 CP 恢复,DP 会自动连接更新配置。
-
支持多语言插件
在原有 Lua 插件的基础上,支持使用 JavaScript 、TypeScript、GO 编写插件,后端 GO 语言团队可进行相关扩展。
-
支持 UDP 代理
Routes、Services、Load Balancing、日志插件支持 UDP 代理,满足业务发展需要。
2.4.1.2 精确网关分组
顶层采用分域名业务隔离,同时根据业务特性、保障级别、访问并发量等特点,
对网关集群进行分组,完成子业务关联性解耦,在应对大流量冲击时降低对业务的影响,同时方便运维侧精准扩容。
2.4.1.3 混合云
新建阿里云节点,作为天翼账号产品双活系统的补充节点,在高并发时可由 DNS 调度实现自动切换,确保提供无间断的服务。
2.4.2 CP(Control Plane) 升级
2.4.2.1 优化调用链路
增加 Consul 作为服务发现、配置管理中心服务,替换原有 Nginx 层路由功能。对 Kong 组件提供 DNS 路由及发现服务,通过 Check 方式检查微服务是否可用。
2.4.2.2 新增插件
-
DP 缓存控制插件
Kong 2.4 采用 DP 和 CP 混合部署模式,DP 平面的节点无管理端口,原 Kong 1.3 通过 admin API 管理缓存的模式无法适用现有场景,因此研发了 c-cache-manage 插件,添加后,可通过数据层面 URL 请求对 DP 缓存进行管理。
-
流量复制插件
为了测试网关 3.0 的适用性,团队自研流量复制插件,复制现网流量对网关 3.0 进行测试,整个测试过程不影响现网环境。
插件运行流程如下:
Konga 配置界面参考:
-
Prometheus 插件改造
为了更好的展示 DP 和 CP 层面的数据,对自带 Prometheus 插件进行改造,增加 DP、CP 角色维度,区分并收集数据平面相关监控指标。
2.4.2.3 完善预警监控
在系统原有的监控的基础上,增加业务处理监控,通过业务处理监控,可将异常被动通知,转为主动发现。业务出现异常,可第一时间协助客户处理,提升系统的效能。
2.4.3 效果
演进完成后天翼账号网关系统 3.0 具备以下优势:
-
高并发,可支撑十万级 QPS
-
高可用 ,保障系统 SLA 达到 99.96%
-
灵活性伸缩性、 DNS 调度自动切换,可通过阿里云 ACK 迅速扩容
-
丰富开发和应用场景,支持多语言插件(Go、Javascript、Lua), 支持 UDP 代理
天翼账号网关系统 3.0 的部署,有效地保障了系统服务 SLA 等各项指标的达成。在今年双 11 期间十万级并发高峰时,系统 TP99 保持在 20MS 以内,总体表现非常稳定。
3、后序
天翼账号网关经过多次演进,已日趋完善,总结其优势如下:
-
系统性能大幅度提升,天翼账号网关系统 3.0 相较 1.0 性能提升 20 倍以上
-
统一网关流量入口,超过 90% 以上流量由天翼账号网关系统管控
-
系统可用性得到加强,建立了基于 DNS 调度自动切换的三节点、混合云稳定架构
-
标准化可复用的插件,如频控限流、降级熔断、流量复制、API 协议等标准化插件
-
丰富的多语言插件能力,Lua、Go、Javascript 的插件,可满足不同技术团队的需求
天翼账号网关系统在中国电信统一账号能力开放平台中处于举足轻重的地位,它的迭代升级,为平台十万级高并发提供了坚实的保障,也为系统维护减少了难度、提升了便捷性,顺利支撑业务达成亿级收入规模。
天翼账号技术团队在 follow 业界主流网关技术的同时,也注重强化网关插件的标准化、服务化建设,希望通过网关能力反哺其它产品赋能大网。
随着中国电信服务化、容器化、全面上云的战略推进,天翼账号网关的系统架构也将随之变化,从全传统环境到部分云化再到全量上云,不断的向更贴近业务,更适用技术发展的形态演进。