前言
中台化是为了提质增效的。我们把一个单机支持几百并发的系统,重构到单机能跑3w-5w并发,团队承担了很大的责任和挑战。
项目做完,可以节省80%的机器。线上稳定压测到50w qps。P99线3ms以下。
一.背景
- passport是翼课网的核心服务。用户调用http请求量中,依赖Passport服务的请求占全站总请求量的 90%。
- passport目前可支撑的并发量低。用户流量高涨时系统有不可用的风险,目前达不到产品用户的高并发稳定性要求。
- 目前的使用php开发的passport,硬件资源利用的效能差。很高的服务器硬件配置,跑不出更高的性能。
二.目标
使用go重构目前的php-passport。达到很高的性能、顺滑切换的目标。
- 并发翻10倍,成本减半。用更少的硬件成本,实现能够满足10万用户高并发高性能的pass系统,并为php转go做好模范。
- 平滑切换。对现有依赖passport的业务影响 做弱化处理,达到上游调用方可平滑接入go-passport。 上游无感知,流量无损失。
三.策略
1. 如何做重构的第一步?
策略1 定边界
对于这么复杂的在线系统,与线上其它业务关联度这么高,当初开发的同学,也不在公司了。这种系统是非常复杂的。
我们首先做的就是定边界。梳理出passport的功能/模块,按照接口的调用量做排序,最后选择出调用量大的topN接口。经过分析,对这些接口(而不是全部)做重构。就能达到99%的passport流量,使用go-passport来承担。从而达到并发翻10倍,成本减半的效果。
策略2 跨职能组团
要达到比较稳定高效高质量的完成这个项目,需要业务知识、架构知识、测试知识,不能简单的一个业务职能团队去做。所以,我们组织一个项目组,里面包含php-passport业务同学,调用php-passport的业务同学,运维,测试。以保证团队中,包含 即熟悉2方业务(调用方和服务方),又熟悉多方技能(开发、测试、架构) 的同学。
2. 如何提升并发性能?
策略1 异步化
之前的系统由php实现,读写数据,特别是写mq、redis、mysql、log。大部分都是同步操作,虽然也有异步操作,但是只经过RMQ转发,遇到高并发,依旧存在性能瓶颈。本次使用go,异步操作采用channel+redis list处理,加快了异步信息处理速度。以下为两者对比:
-
以前直接从passport投递数据到RMQ,之后由消费者获取数据写到到mysql
-
重构演化过程
刚开始,我们让app直接写token到chan,但是chan 配置的是阻塞方式。压测发现性能差,跑2000~3000 qps。
之后,把阻塞chan,改成非阻塞chan,并发直接跑到 2w qps以上,2w-5w qps 很稳定(取决于cpu配置)。
但是压测进行一段时间后,发现进程被OOM,分析压测过程中,异步流程中,生成数据写入channel的速率远远大于消费数据的速率。channel占用内存多,协程被OOM,所以进程挂掉。
所以,我们再一次优化异步流程,改成读channel ,先写 Redis Cluster。再启动协程rPOP Redis Cluster,再写 rabbitMQ。轻松解决了异步情况下写入吞吐率低,进程被OOM的问题。达到并发高、延时低、不被OOM。
峰值压测 qps 3w-5w ,P99线 <20ms。avg 3ms。错误率0%。 -
最终确定异步信息处理过程如下:
我们叫两次异步,先异步chan,再异步redis
策略2 少用反射
压测过程中,发现使用jaeger记录trace信息,会减少1-2w qps 的并发。以前压测,轻松能达到2w qps。使用了jaeger。只能跑1w qps。经过pprof分析,发现在jaeger中,构造trace ,生成父子 span,打log,更tag过程中。jaeger client用了大量的反射来更新结构体对象。
我们的系统是要支持全链路压测的,全链路压测中要对压测的流量做染色,也要记录trace信息,以便分析整个链路的分布式调用链的功能和性能问题。但是jaeger打trace,执行了大量的反射。我们只能采用方式,以一定比例,打开trace。而不是所有流量都开trace。
后续计划,实现一个高性能的go-jaeger-client。
策略3 连接池
以前php,读写mysql、redis、rmq都需要用短连接的方式进行。每次请求都需要进行连接建立和断开,耗时很长。
以php->mysql为例:
php <---> mysql(3次tcp握手)
php <---> mysql(1次 字符集+账号密码验证)
本次go-passport,直接app与所有有状态服务,都要建立一个连接池,比如mysql、redis cluster、rabbitmq。这样一个请求,响应时间是百微秒级别。并且,降低了app到mysql/redis/rmq的连接数。
策略4 随机种子全局化。
压测中,发现如果每个请求初始化一次随机种子,特别消耗cpu。所以把随机种子的初始化在app启动时初始化为全局变量,减少了cpu的开销。用pprof查看效果非常明显。
策略5 读操作多使用缓存
出是压测case,发现login接口的QPS只能压到5k QPS。其主要原因是登录时需要读取用户信息表(passport.user)获取用户信息。MySQL单SQL执行时长,并发性能与Redis对比差距悬殊。
对于用户信息此类变更较少的信息,我们选择使用缓存的方式来优化。
但因线上用户信息修改相关接口,在v0.1版本仍由php-passport实现,所以线上当前版本暂时先注释该部分缓存。
3. 如何做到平滑特性?
策略1 协议不变
为了让重构后,调用方调用go-passport,能够平滑的调用,不给调用方带来麻烦。我们让go-passport的rpc接口还是支持php-yar协议。因为调用方之前调用php-passport,使用yar框架和yar协议。我们这次还是让go-passport来支持yar协议。
重构前
php(yar_client)-------->php-passport(yar_server)
重构后
php(yar_client)-------->go-passport(yar_server)
因为Go语言不支持yar协议的php serialize的打包方式,我们升级了各项目调用方的RPC类库,使用了json打包方式,但是协议仍旧是不变的。
整体上保持了,对调用方而言,调用函数不变,参数不变,返回值不变。完全兼容旧的调用方式。
策略2 参数兼容
PHP是弱类型语言,Go是强类型语言。PHP调用Go-passport接口的时候,传参是不固定的,例如ID之类的参数,部分调用者传int类型,部分调用传string类型。
我们要保证调用方参数不变,而Go是强类型语言,类型不匹配会触发panic。为了提高代码鲁棒性,我们采用对外接收参数使用interface类型,然后在rpc入口外层,统一做断言、格式化处理后,再传递给业务层处理,确保技能兼容弱类型入参,同时对Go业务代码也无感知。
策略3 记录调用方标识
虽然我们做了兼容,但总有部分较为特殊的入参是强类型语言无法处理的。
例如:线上触发到,有调用方传递“id as uid”的字符串给RPC接口,希望数据库执行后,能将返回值id的键改叫uid,这在强类型语言的类库是不允许的,结构体的键就已经固定为id,没有uid。
Passport服务作为一个底层服务,同个接口调用者有多个,即使我们从日志中找到错误入参,也无法区分是哪个服务调用的。所以,我们在RPC参数extra中加了一个consumer的键,用来存储标识不同调用者,并在RPC接口请求日志中,对每个请求记录各个来源调用方。这个策略帮助我们在线下开发和上线过程中,快速定位到极个别go-passport服务无法处理的入参来源,让入参调用方服务协助修改。
策略4 代理策略
go-passport重构,在时间、资源有限的情况下,不可能把原有的php-passport的所有功能、接口都重构。
我们必须在有限资源下,让产出最大化。所以我们选择了passport服务中,调用量最大的一些接口/功能,用go来重构。
但是,很多其它的go-passport不支持的功能,怎么办?
使用代理策略。如果本次请求,go-passport能处理,则正常处理,不能,则转发到php-passport处理。
go-passport不能支持的功能转发至php-passport处理, 支持的则有go-passport处理完成返回。
策略5 平滑重启
如果go-passport 升级,重启,必然会影响当前的请求。比如在重启过程中,会有几个问题:
- 重启期间,新req连接怎么办
- 重启期间,旧的长连接怎么办
- 重启期间,旧进程内存中的未写入的数据怎么办
我们使用 kill -HUB 信号给父进程,然后父进程创建子进程,子进程会和父进程使用同一个端口reuseport+fd地址。
父进程退出前要保证所有channel为空,并延时一段时间退出的方式来解决。
经过测试,可以随便给进程做重启,整个过程 不影响 qps 和latency,也没有任何错误。
如果升级过程中,新进程创建不成功,老进程不退出怎么办?
上线之后遇到以上问题。一次升级之后,进程需要重启。结果新的子进程在初始化过程中失败,没有启动成功。导致成为僵死进程。
由于父进程创建子进程失败,操作系统管理的子进程PCB 没有释放,导致子进程成为僵死进程。
这种情况在低峰期间kill -9把父进程关闭,子进程才退出,释放了PCB。
这个问题,后来我们增加了一个逻辑,如果父进程创建子进程不成功,父进程也退出。
策略6 微服务化
之前的项目比较老,服务注册、发现、降级、限流、熔断做的少,或者做的不够。部署、配置、升级是很复杂的。
这次我们直接微服务化。
服务注册和发现。
自定义了服务需要的配置信息,并结合consul,设计了配置信息的schema。自定义服务注册和发现时的原数据信息。
目前做到了,服务停启,完成服务的注册和发现。所有go-passport服务,不需要自定义修改配置文件。直接启动一个进程就可以了。对于上线,迁移,部署,变更,非常方便。限流和熔断。
自定义了限流器和断路器。保证对服务请求方+请求资源,做限流。比如有A、B、C三个服务调用go-passport。可以依照压测结果
配置分别对一个资源(uri/函数),给A、B、C 做限流。
对于go-passport,如果需要调用下游服务,如mysql,redisCluster,rabbitmq等。通过若干circuit,做断路保护。
以防止下游服务出故障,上游还不断调用的情况发送。从而有效保护下游服务。
限流和断路器状态,具有可观测性,通过web可观测到断路器状态图形变化。降级。
-
调用方(其他服务)降级:
对于 ACall--->go-passport---->php-passport
如果go-passport出现故障,验证token功能将失败。用户就会掉线,即使ACall服务正常,用户仍无法使用ACall服务。所以,我们对调用方(ACall)提供了一个临时用的降级sdk,如果用户使用的是已登录的token,来验证合法性时,判断调用passport服务的RPC返回状态码是连接失败,网关502/504、宕机类错误。
允许一段时间内,由ACall自己使用降级SDK本地解码token,通过哈希校验签名的方式验证。如果token签名正确,同时在有效期内,允许继续访问ACall服务不掉线(此时未登录的用户无法再登录系统)。这样做到了。go-passport出故障后,一段时间内,已经登录的用户,还是可以访问系统。
-
服务方(go-passport)降级:
我们生成token到存储的过程是这样的:
验证token:
client----->go-passport(这时如果redisCluster还没有写入token,那么验证token就失败了)
问题:
如果 token写到channel之后,client已经获得了token ,而client 拿token访问passport,这时redis中没有怎么办。
因为是异步的,这种情况一定存在。解决:
我们测试过,概率非常低,万分之一以下,但是还会发生。比如go-passport与redisCluster有网络抖动。
所以,我们做了降级处理,如果是验证token合法性,读Redis没读到数据,则通过解码对比签名来验证token合法性。只要token没过期,可以继续用。返回正确结果。但是这个时候存在多点登录的可能性。这就是做了降级。
4. 如何做到流量染色识别?
策略1 Context传递上下文
我们的所有系统,支持全链路压测。我们需要识别压测流量,将压测流量,使用不同的影子库表队列log。比如如果是压测流量,我们写到对应的影子mysql、redis、rabbitmq、log中。
所以,我们在请求中,构造一个key shadow_test。go-passport首先从rpc请求参数extra中,判断shadow_test是否存在,存在,则标记这个请求是压测请求。
与这个http请求相关的content.Context中,添加这个标识。
之后所有后续处理层,都要先判断这个ctx是不是压测流量,是则走压测处理逻辑。
最终到读写mysql,redis,rabbitmq,log,选择不同。
对于压测流量,我们使用另一个shadow连接池对象,完成对mysql,redis,rabbitmq操作。
另一个问题,对于一个请求req,同时存在多个协程并发/并行处理这个请求的情况。只要协程请求涉及到db操作,我们也需要在参数透传context参数
策略2 全局reqID
问题:因为一个请求,比如生成token操作,会异步写chan,然后返回给client。但是这个请求并没有处理完。
比如消费chan时,处理失败了,怎么去追踪这个请求的处理链。这种情况就是一个trace结束了,但是这个trace所包含的span还没结束,如下图中异步流程的链路如何记录并与主流程相关联。
我们使用了reqID(全局请求ID),添加一个中间层middleware,每个请求添加一个唯一标识ID。后续channel之间的数据传递,把reqID赋值传递到其中。
这样,虽然这个请求的处理过程中,这个trace结束了,但是根据reqID,能查出来有几个trace包含这个req。需要注意的是.这个reqID,必须在 jaeger的 tag中,不能在log中,不然搜索不到。
同时,我们在日志组件中,也使用这个reqID来作为这个日志标识logID。所以,同一个req请求,包括异步处理,也都可以通过这个reqID标识从日志中串起来,用于线上异常排查。所以我们提倡,在使用go关键字开启协程时,如果协程处理的逻辑复杂,需要打log,也应该传递context参数,这样日志信息才能同主流程使用同一个reqID,方便排查错误。
策略3 资源隔离
对于压测流量,如何保证不污染线上数据。
我们使用两套连接池。一套是shadow连接池,一套是正常连接池。
5. 如何做到集成测试?
策略1 自动化测试所有api
自动化是在代码提交gitlab的特定分支(dev),触发webhook到jenkins, jenkins进行拉取代码,并执行流水线中的一环集成测试
测试环境隔离,如使用docker/k8s将测试环境单独部署,避免影响其他环境。
测试用例编写,需要包含边测试编号或别名,描述,前置条件,执行步骤,执行时间,预期结果,实际结果。
测试所有的边界条件是否正确,如反向关联,交叉检查,强制产生错误条件等。
测试覆盖率,覆盖90%以上代码,覆盖所有的接口(核心接口还是建议让测试进行单独测试)。
测试并输出可视化结果,测试覆盖率,测试用例结果。
6. 如何做到编码正确?
策略1 sonarQueue+jenkins+gitlab CI
使用go vet、golint 结合 jenkins 和 sonarQ 再结合gitlab 做代码静态扫描和规范。尽量做自动化来解决。
对于新手,一些典型的错误,比如没有defer close req、比如sync.WaitGroup以结构体而不是指针方式传递到协程,很容易编译成功,运行错误。go的工具库却能很好解决这类问题。
7. 如何做到系统稳定?
策略1 压力测试
一个服务是否性能好,必须要看 并发量、响应时间、错误率、资源饱和度,这4个黄金指标。除此之外,我们还要看系统的高可用,特别是持续稳定服务时间。
所以,我们 准备了一套环境,给go-passport 数十万的qps压力,让他跑了一段时间,直到跑到数千亿次的调用量。没有出现故障。
我们认为这个服务,至少可以不停服务的稳定运行1年是没问题的。
策略2 混沌工程测试
我们在阿里开源的混沌工程工具基础上,自己再封装实现一个可视化的工具echaos。通过这个工具,使用者可以直接通过web界面,以方便的方式,灵活的模拟上下游服务故障,包括mysql延时、mysql拒绝服务、redis、rabbitmq、php-passport延时高、服务拒绝的故障、服务器内存高、cpu使用高、网络io高、磁盘io高的故障。
在go的环境中,很容易出现协程泄漏,特别是app要调用上下游,由于上下游网络延时、网络拥塞、服务响应慢,会导致go app的协程不断创建,最后gc负担重,stw时间长,服务卡顿,进而导致进程被OOM。
我们通过混沌工程测试,大量模拟这种故障,来观察在上下游异常的情况下,go-passport的状态。
我们发现,即使当前下游故障,go-passport也能快速的quick-fail,而不至于协程泄漏。
当下游恢复后,go-passport也自然恢复,而不用人为处理(比如重启)。
如何做到系统安全性
策略1 token混淆加签
因为降级功能的引入,降级的时候,我们无法去redis中校验token。无法确认token是否由go-passport系统下发。存在token被伪造的可能。
所以,我们的token在保留php-passport对外部系统下发前进行编码混淆的同时。增加了加盐哈希生成签名串的方式。
降级时,我们解码得到用户信息后,还要比对签名正确,才能使用降级服务。否则,就会认定为伪造的token,拒绝服务
四.结果
- 以前单机跑php-passport大约500 qps,现在单机跑go-passport大约2w qps。大部分延时<1ms。错误率0。
- 以前php-passport上线,上线期间会有影响。现在go上线,重启进程也无损流量。
- 以前php上线,上很多文件。现在go 只上一个文件。
- 以前php连mysql超多连接(几百个以上)。现在一个go 连几十个连接。
- 以前配置文件很复杂。现在直接自注册、自发现。
- 降级、限流、熔断 也支持了。
- 整体上,达到了性能翻10倍,服务器减少一半以上的结果。上线中平滑过渡。