当前位置: 首页 > 科技观察

Go语言在扫码支付系统中的成功实践

时间:2023-03-14 19:14:01 科技观察

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