今天的内容主要分四个方面。***,金融支付系统的一些特点;第二,我们的扫码支付系统技术选型;第三,系统迭代过程中的架构演进;第四,与Go相关的一些坑。
金融支付系统的一些特点
首先从业务流程入手,其实非常简单。一位消费者结账时,假如选择扫码支付的方式付款 100 元,产生一笔交易信息。如图 1 所示,我们看上面蓝色的线条,通过商家的收款产品,把这 100 元的交易信息送到我们的扫码支付系统,然后传递到后面的微信、支付宝或者其他支持扫码支付的相应钱包,完成这笔交易信息的传递,完成这笔交易处理。
在行业内,通常称蓝色的线条为信息流。信息流是什么意思?就是传递这笔交易的信息。接下来, 完成 信息传递还没有结束,看图中灰色的线条,通常在第二天的时候,即 T+1 时,我们会通过商家的清算银行把这 100 元清到商户的清算账户,这样才算完成了这笔资金的清算,因此我们称下面的灰色线条为资金流。很简单,我们今天更关注的是上面信息流相关的处理。
接下来,我们看一下信息流相关处理。除了我刚刚提到的,实时交易处理 100 元的信息传递外,还会涉及到哪些方面呢?一个是实时交易的服务,另外一个是商户对账的服务。刚刚提到资金流的传递是商家收到 100 元,那么收到的金额对不对,和前一天交易行为是不是匹配呢?商家需要一些对账报表,来核对相应交易行为、到账资金,这就是对账服务。
另外,其他的一些商户服务,包括一些商户信息的维护,商户交易行为的查询,交易记录的查询方面,最基本来说是这三类的服务。另外,可能会有更多附加的服务,包括风控,以及其他的一些增值营销服务等。
今天,我们聚焦在这三类基本服务,看看三类基本服务对应的后台系统的类型是什么样子。
首先是实时交易服务:API Gateway。回到刚刚 100 元的处理,从商家收款产品,到我们扫码交易的处理系统,接口过来以后,在系统中进行一些相应的业务逻辑处理,交易信息落地,之后把交易信息分发到后面相应的接口。整个流程,实际上无非就是接口的转换,中间搀杂一些相应的业务处理。跟我们微服务架构上的 API Gateway 的定位其实非常类似,可以理解为它就是一个加入了一些业务逻辑的 API Gateway 系统。从商家对账服务来说,通常是在交易发生的第二天进行资金流的流转,与之同步的会有相应的对账报表要给到商家,为商家提供对账服务。通常来说,对账服务会涉及到批处理相应系统。商户服务刚才提到的查询、信息维护等,这些可能是相应的 web portal 提供相应的商户服务。
从业务角度出发 , 对我们的系统有什么基本的要求?
***,既然是支付系统,安全性肯定是再怎么强调也不为过的;
第二,稳定性也是一个必不可少要考虑的关键点。包括我们的商家可能会有各种类型的客户,包括餐馆可能白天营业,夜店可能半夜也会有交易发生。整个系统 7*24 小时的稳定性,也是需要考虑的重点;
第三,我们系统的吞吐量,这个就很好理解了,当前面的客户、商家交易量上来的时候,整个系统包括并发处理能力,请求的响应时间等,都是业务上非常关注的重点。
回到最开始提到的问题,我们用 Golang 来搭建支付处理系统,靠不靠谱。 在我理解就是这三方面的考量:安全性有没有保证,稳定性是不是足够稳定,吞吐量是不是能够达到业务的要求。接下来看看到底能还是不能,进入技术选型话题。
技术选型
从技术选型来说,刚才提到的三个方面,主要是业务需求方面的考量。业务需求方面,在 2015 年, 我们 搭建这套系统的时候,还有另一点非常重要的考量。因为 2015 年正好是扫码支付方式快速发展的阶段,当时,业务方面对于系统的快速迭代要求是非常高的。技术选型方面,除了业务需求,还有技术需求和团队需求。技术需求是什么意思?我们知道在软件开发领域,有一句话 叫 “没有银弹” , 不能拿起锤子当什么都是钉子,每一门技术都有它适用的场景和范围。刚才几个系统,几个服务类型显然都是 Golang 所擅长的范围,这是技术需求的角度。
团队需求是什么含义呢?想跟大家分享一个故事。前两年,我朋友的一家公司有一个网站,网站***个版本是请外包团队实现的,拿到验收发现很不错,使用的是"世界上***的语言"。然而问题来了,当这家公司拿到***期交付成果以后,想要自己接过来,在上面叠加一些功能进行版本的迭代。结果发现团队里没有会"***语言"的成员,出于种种原因既没有从外部招到,也没有从内部培养起来。结果是他们又花了几个月,用另一门语言把这个网站重写了一遍,这就是我们在团队需求里面想提到的一点,一门技术不管是编程语言还是系统组件,要引入这门技术,首先团队里要有人会这门技术。另一点也是通常会比较容易忽略的,就是对于一门技术的引入,除了团队中有相应的人能够写这个代码,很重要的是,有没有人能够 hold 这门技术。在遇到技术难题的时候,有人能够处理。这门技术在团队内的培训时,能够有带头人也是一个非常重要的方面。
从技术选型的角度主要是这三方面的考量,结合我们 2015 年搭建这套系统的现状来看一下。当时我们团队的技术栈编程语言方面是 C、Java、Golang。选择扫码支付系统实现的语言的时候,C 是***个被我们否决掉的,开发效率就满足不了我们快速迭代的要求。我们更多的可能是从 Java、Golang 进行权衡。Golang 团队当时人数并不多,只有三位小伙伴,但是三位小伙伴都是非常不错的,对 Golang 的理解也很棒。到现在我们回头来看,从三个人到现在大概有接近两年时间的发展,我们团队中现在有 20 多位,超过整个研发团队一半以上的人数,已经可以熟练的掌握 Golang,用 Golang 来实现我们的业务系统、业务功能, Golang 在我们团队中的普及发展,也是非常迅速的。团队背景大概是这样。
技术需求方面,我们来看 Golang 的技术特点。我这里简单列举一些对我们感受比较深刻的方面:
***,快速上手,学习曲线平滑,开发效率非常高。我们团队发展历程可以充分证明这一点,从三位 Golang 小伙伴到二十多位,一年多不到两年的时间,大多数是从内部转型过来的,不管是 C 还是 Java 转型过来小伙伴,大家转型和上手过程的普遍感受都是学习起来没有什么困难,上手非常快,开发效率方面也非常不错。在 2015 年最初搭建这套系统的时候,行业市场瞬息万变,甚至不夸张的说, 当时这个系统 一天一个版本,开发效率非常高;
第二,天生支持并发编程,对于我们后端普遍需要并发处理的场景也是非常适合的;
第三,简洁的错误处理: panic、recover、defer 。有人可能会喜欢,有人可能会不太习惯,我个人很习惯这样的处理方式。这块我们也遇到一些问题,踩过一些坑,这个在***会跟大家分享。
刚刚是团队需求、技术需求方面。接下来我们更关注业务需求方面。我们刚才提到三点:
***,安全性。从安全性角度出发为什么选择 Golang?选择 Golang 靠不靠谱?一个支付系统,它的整个处理流程的安全性,实际上涉及到方方面面,包括数据传输的安全性,是否有数据泄露的风险,是否有防篡改的措施;数据落地存储的时候,存储是不是关键信息有做加密;网络方面,网络是不是有接入层、防火墙等等。整个系统的安全性,从接入层到应用层、系统组件,再到 Database,每一层可能都会有相应安全性方面的考量。落地到编程语言选择,Golang 的安全性靠不靠谱呢?编程语言的安全性更关注什么呢?很自然的想到一点就是语言的漏洞。
关于漏洞,我们将收集到一个数据和大家分享一下,如图 2 所示是一个漏洞搜集的网站,我用关键词 Golang 搜索漏洞,可以看到五个,然后用关键词 Java 搜索,有 1660 个。没有黑 Java 的意思,解释一下这 1000 多个是什么意思:毕竟 Java 发展这么多年已经非常成熟,JDK 的漏洞其实非常少了,1000 多个绝大多数都是各种框架的漏洞。例如我们一些存量系统,有的 web 平台用的 ssh 框架,众所周知的"万年漏洞王"迫使我们每年都要对这套框架进行一次升级。反过来看 Golang,一方面是因为 Golang 比较新,曝出来的漏洞没有那么多;另一方面,Golang 的安全性也确实没有遇到过什么问题。而且 Golang 背靠 Google,有庞大的社区。所以从编程语言的安全性方面考虑的话,在我们看来选择 Golang 没有必要顾虑太多,这是安全性方面。
第二,稳定性。其实和安全性类似的一点是,同样需要考量在系统整体架构,系统各个层级等方面的稳定性。昨天下午 B 站的老师也跟大家分享了微服务演进过程中,系统稳定性方面的考量,限流、容错、故障隔离等。系统的接入层限流是不是足够好,应用层是不是高可用,缓存、数据库等组件也都需要有稳定性方面相应的考量。同样,落地到我们的编程语言应用实现方面,在稳定性上更关注的是什么内容呢?在我们看来,应用高可用架构,即应用的实现要做到无状态,支持横向扩展。其实这一点,不管是 Golang 还是 Java 还是其他的语言,只要结构、代码设计的好, 做到这一点 没有什么压力。
第三,吞吐量。随着业务发展,交易量逐步增加,客户越来越多,吞吐量是否得到很好的支撑?在这方面,准备了两个并发处理能力方面的例子。这两个也是我们系统中会经常用到的功能。一个是 http 接口的并发处理能力, 另外一个是 RSA 加解密的例子。
如图 3 所示是我的实验环境,是用自己的 MacBook,双核 8G 的环境,Golang 是 1.7 的版本。同时为了有一个对标的效果,单独拿 Golang 看它的效率可能看不出什么,因此拿 Java 做了一个对标,再强调一遍, 只是一个对标的目的, 没有黑 Java 的意思。
如图 4 所示,我们来看***个http 接口,这是 Golang 版本非常简单的 http 接口,这个不用多说,大家看一眼也知道,十几行的代码,启动一个 http 服务,收到一个请求以后,应答,回十个字节,这是 Golang 的版本。Java 版本做的事情是一样,但是 Java 代码比较多,没有截全,因为 Java 的 http 本身没有带多线程的方式,写了一个简单的线程池,用多线程的方式去处理 http 的请求。
接下来是测试的结果,十个用户一万个请求进行测试。上面是一个 Golang 的结果,吞吐量 12000 多,请求 响应 时间是 0.815 毫秒。下面是 Java 版本,吞吐量是 11000 多,响应时间是0.891 毫秒,这两个版本差别不大,非常相近。从 http 接口方面来说,Java 和 Golang 的处理结果相差不是很大。
下一个例子是 RSA 加解密处理。上面是 Golang 的版本,也是采用并发处理的方式,循环 1000 次,每次进行一次加密、一次解密,加密的密钥用 2048 byte 长 度的密钥,待加密的数据是 245 byte。下面是 Java 的版本,也是截了一部分出来,实现方式和 Golang 实现方式一样。
我们直接看结果,如图 5 所示,上面是 Golang 的结果,下面是 Java 的结果, 用 的是 Mac 自带的 time 命令来统计耗时。Golang 的结果,程序实际执行时间(real time)是 2.78 秒 ,J ava 执行时间是 7.74 秒,这里会看到三倍左右的差距。这个只能说明在 RSA 加解密处理的简单场景上面,使用各自的标准库,可能 Golang 的效率会比 Java 实现 效率 更高一些。当然大家也可以很容易的找出一些反例,在某些场景,Java 比 Golang 的实现效率更高一些。什么意思呢?从不同的场景,不同语言的实现效率或者好一些或者坏一些。对于我们选择 Golang 来说,只要我们证明它在 我们常用的一些场景 效率没有问题,我们就可以用 Golang 来做这套系统 。
实际上我们对于 Golang 的吞吐量方面的信心,一方面源于我们的测试结果,另一方面其实在我们搭建这套扫码支付系统之前,我们还使用 Golang 做过另一个秒杀系统。这个秒杀系统***个版本是使用 Java 来做挡板服务器,但是,可能是我们参数调优没有做好,在压力测试的时候,单台压到 500 tps 就上不去了。但由于时间紧任务重,没有来得及做仔细的参数调优,我们换成用 Golang 试了试。结果一晚上的开发时间,轻松几万上去没有什么问题。这也使得我们在 Golang 吞吐量方面建立了很强的信心。
从业务需求角度考虑,不管是安全性、稳定性、吞吐量,选择 Golang 都没有什么压力。
***总结一下,我们最终选择 Golang 的出发点:作为需要快速原型、快速迭代的项目,需要的开发效率非常高,在满足当前和未来可预期的高可用、吞吐量等业务需求的前提下, Golang 的高效开发效率,简单部署和运维,是我们拥抱 Golang 的主要原因。
以上是整个技术选型方面的考量 关键点, 多说一句,刚才多次提到系统的吞吐量、高可用的关键点,其实除了编程语言这一层,更多的可能跟整体的系统架构有很大关系,当然这是另一个话题了。
架构演进
我们来一起看一下系统架构演进的过程。 如图 6 所示, 是 2015 年上线的***个扫码交易处理系统。当时整个后台系统非常简单。因为当时需要版本快速迭代,而且我们更多的主要力量在为商家提供的收款产品。我们可以看到各种各样的收款产品,包括云收银的产品系列,iOS、安卓的 APP,SDK,包括 PC 端的商业软件,右上角的智能 POS 系列产品等。最初上线的时候,需要利用这些产品快速铺开前端市场,所以在后台系统上,是一个非常简单的架构。系统在上线之后的一段时间,其实还是相对稳定的,毕竟越简单越稳定。但是随着业务量的增加,以及业务上需要叠加的功能越来越多,这样的一个单体应用结构很明显是撑不住的。所以,接下来我们进行了一系列的架构调整、演进。
如图 7 所示的架构是我们几个月之前的系统结构,比***个版本复杂很多。主要的扫码处理系统是在图左。我们看应用层,从一个单体应用延伸出了多个,包括我们刚刚提到的不同类型的服务:扫码网关的实时交易处理服务,为商户提供对账报表的 批处理的服务 ,为商户提供信息查询的 平台服务。 另外还搭建了我们的风控系统,因为风控在整个支付交易处理中也是非常重要的方面。APP 后台,是我们 iOS、安卓的 App 提供的单独的后台服务。整个这一套系统,都是 使用 Golang 实现的。Golang 在统一系统实现的技术栈方面,为我们提供了很大的帮助 。 中间件方面 ,为了 解耦和提高稳定性, 我们引入了kafka、redis等系统组件。 为了实现跨机房灾备系统搭建,我们自研了一套数据库的同步工具,可以从 MongoDB 文档型的数据库,将数据实时同步到 MongoDB 或者 MySQL 等其他关系型数据库,这套工具也是使用 Golang 来做的。
在整个架构的完善方面,根据业务职能拆出更多的系统。目前我们的系统在此基础之上,进行了更多的一些往服务化方面的逐步演进,这是整个系统架构演进的过程。
一些坑
如图 8 所示,很简单的 十几行代码,它提供的是 TCP 长连接服务端的服务。每 accept 一条链接,收到客户端的请求之后,开一个 goroutine,通过 handleConnection 这个函数进行客户端连接的处理。针对 handleConnection 这个函数进行了一层包装,称之为 TcpRecoverWrap,包装里做了什么,看名字也知道,是一个 recover 的包装,具体的实现在图右,非常简单,也是 Golang 里面比较常用的处理方式,给一个函数加一个包装,包装里面加一个 defer 进行 recover 处理。
这段代码看起来很简单,那么问题来 了 ,是不是在 handleConnection 函数内发生的任何 panic 都能通过 TcpRecoverWrap 这样的实现方式来解决?既然提出这个问题,答案肯定是否定的。来看一下 handleConnection 的具体 实现,如图 9 右边所示,是一个 TCP 的服务端,已经 accept 一个链接,开始 read,在一个死循环里面进行 read 操作,每 read 一条客户端发来的请求信息,开一个 goroutine 进行实际的业务处理,把读到的消息给到业务处理的函数,当业务处理完成,拿到业务处理结果以后,把结果写到一个 channel 里面,这个结果由另一个 goroutine 来接收,接收到以后把应答的消息回复给 TCP 客户端,也是很简单的一段代码。
我们刚才列出来的两个问题,全都在这一段代码里面了。***个变量作用域,第二个 chan 操作。这几十行代码里,哪一个变量作用域存在问题?右上角的 reqBytes 的变量,我们看到前面在 for 循环外面定义了一个 reqBytes,接下来进入到循环中,每一次读到的东西都放到 reqBytes,我们可以看到,其实每次循环,使用的 reqBytes 是一个变量,它的内存空间是一个。那么问题来了, goroutine 开启方式是一个必包的方式, 外面的变量对于每个子 goroutine 来说是可见的,也就是每读到一条消息,放到同样一个变量里面,有可能的结果是,***个消息开了一个 goro u tine,但这个 goroutine 还没来得及调度的时候,又收到到第二个消息,那么第二个消息会把***个 goroutine 里面的消息覆盖,不同请求之间会产生关联影响。
第二个 chan 操作问题。我们知道在写 chan 的时候,如果 chan 已经被关了,那么对它进行写入操作会 panic。我们看图9中的 writeMsgQueue 这个 chan ,在 defer 里面进行了关闭。当这个 TCP 连接不可用的时候, handleConnection 函数 return 之前关闭这个 chan,目的是 chan 关闭后,通过检查 chan 状态,可以促使 goroutine 退出,避免 goroutine 泄露,但是这个 defer 的 close 引入了另一个问题,如果服务端收到消息、开 goroutine 进行业务处理的过程中,客户端已经断开了连接,那么此时 chan 已经关闭了,当服务端业务处理完成的时候,写入一个已关闭的 chan ,就会panic。更可怕的是,这个 panic 是不能被 TcpRecoverWrap 包装的,因为在执行 defer 结束、关闭了chan 之后,handleConnection 函数就已经 return,也就是这个 panic 是发生在另一个 goroutine 的,和 handleConnection 函数没有关系,很显然刚才的包装方式是没有办法捕捉这样的 panic 进行 recover 的。
今天分享的内容大概就是这些,那么怎么修这些问题呢?很简单,变量作用域拿到 for 循环里面,panic 的问题可以加另外一个 tcp 关闭信号,通过这样的方式,来修复刚才说的两个问题。当然修复方式可能有很多种,仅供大家参考。