二零一七年六月二十一日,就是年中大促刚结束的那一天,我午饭时间独在办公室里徘徊,遇见X君,前来问我道,“可曾为这次大促写了一点什么没有?”我说“没有”。他就正告我,“还是写一点罢;小伙伴们很想了解支撑起这么大的用户支付流量所采用的技术。”

「摘要」由于设计时我跟小伙伴们把系统的定位更偏向于具有用户支付事务处理能力的消息总线。业务深度耦合涉及比较广,感觉一次性到位说清楚不太可能。故本篇分为上下两篇,上篇仅对支付网关架构和支付业务流程进行基本介绍,采用的全时在线技术里的基础部分说明也放在上篇,下篇则着重介绍全时在线技术的具体化应用以及下级系统重构和迁移过程中的备份切量逻辑。更详尽或代码层级的文章将后续单独推出。

  • 上  篇
    支付网关架构和支付业务流程基本简介
    UUID并发序列生成器
    平行迁移
    本地化存储
    缓存双备

  • 下  篇
    自行收单、
    补单
    异步交互
    路由分流功能

    支付结算平台的交互细节

支付网关作为支付目前的总入口,在最近一次618大促的实战检验中:

  承载的峰值用户支付流量TPS为2.2万以上
  承载了期间用户支付的全部流量

经过持续的优化,整个京东支付在用户层就具备了完整的自我闭环能力,完全解决业内普遍存在依赖单支付清算机构的瓶颈问题。在日常和大促期间我们的系统定位主要集中在3个方面:
一、提供高效稳定的扣款支付能力
二、保障友好化的商城客户支付体验
三、高并发场景下的全时在线服务:
    a)        业务模块降级
    b)        基础工具模块热插拔
    c)        流量分布式平行转移
本次618预先启用了分流和缓冲机制,在保证支付体验的情况下,尽可能的预防了核心业务系统、各支付机构被流量冲击打垮的情况。 

支付网关架构和支付业务流程基本简介

1、支付网关的架构模块体系:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第1张图片

按照功能主要分为:业务模块、支付渠道模块、体验保障与高速化模块、基础工具模块。大体的纵向各个功能模块的组织谱系关系如下:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第2张图片

2、支付渠道(以下称为:渠道)主要为支付网关(以下称为:网关)提供了支付工具的扣款能力,网关综合各渠道的扣款能力屏蔽机构间的差异,生成核心支付组件。再结合核心的业务控制能力(风控、路由、商品订单)进行流程化封装生成对支付接口。收银台增添一些先决认证条件:生物识别,手机认证(短信、尾号)、SSO登录、设备认证通过后调用网关的支付接口进行支付。


3、详细的业务交互时序流程,以其中一个支付流程为例,大体时序为:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第3张图片

根据业务时序图,做过大型企业ERP、BOSS系统的人看来并不复杂。但在互联网最大的挑战就是大并发的情况。
4、网关的技术架构思想为:
1. 保障核心业务逻辑稳定或无损平行转移,极端情况的下的failback
2. 拆解非核心业务逻辑到异步分支流程.
3. 非核心业务逻辑性能差或故障时降级,并实现failover
4.   取消数据库依赖
5.   基础工具组件双备甚至多备下的热插拔


UUID并发序列生成器

UUID是于2016年2月完全自主研发的业务单号生成系统,对代码进行了开源,完全遵循LGPL协议。支付单号生成是我们收单最重要的一步,由支付单号来流转整个支付流程。

它理论基础是按照能有效组织资源的最细粒度拆分服务单元,根据服务单元的属性差异进行唯一化。唯一化的服务单元互相隔离,由于具有唯一性各节点构成分布式,服务单元内部再按照能有效组织的最细粒度资源进行功能克隆拆分子服务单元,共享资源需要被其中一个子服务单元使用是进行排它独占。

实际解决的问题:生产单号多采用数据库、随机数生成,在请求量较少时问题不显著。随着请求量的加大出现重复、卡死的概率逐渐增加且部署数据库、运营成本较高。很多公司的数据库也不光给uuid使用,在海量事务处理时是常出现仅为生成id就耗费CPU时间片,造成正常业务处理延时。

具体技术实现:
我总共写过三种实现:netty、tomcat做中间件各一版、linuxC一版。我们目前生产环境中使用tomcat版本:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第4张图片

1、注册中心的实现:因为只起到分配实例号的作用。正常情况下tomcat实例终身也只在第一次启动时获取一次实例号。由于压力不在生成实例号,简单实现的话使用数据库单表,表的自增主键作为实例号,表中的md5值为唯一键。自己也可以生成一个注册中心,只需要注意号记录md5和实例号所在文件的文件锁问题即可。生产环境当中我们使用的是mysql.
2、三种生成id的细节性问题:
第一种方式是性能最高最可靠的方式。但由于加入了时间维度,如果在极短的时间内重启完毕,存在单位时间里内存递增变量归0递增后重复的概率,于是加入了延时等待的功能让单个实例启动后延时一段时间再提供id。延时的时间>=时间的维度步长。如:时间维度为1s则实例启动后至少应该延时1s在提供生成服务。
第二方式:适合id号必须连续的场景,比如会计凭证号.但是第二种方式由于没有操作系统文件文件锁的保护,只能当单台机器上只有一个tomcat实例的情况下使用。
第三种方式:适合id号必须连续的场景,比如会计凭证号。由于有操作系统文件锁保护适合单个机器上存在多个tomcat实例的情况使用。
注意:第二、三种方式linux系统对同时打开的文件句柄有数量限制,由于序列名跟文件名一一对应,存在文件句柄资源池管理的机制控制文件句柄能最大效率的使用和按需关闭。

3、有一些公司往往在一台机器上部署多个tomcat实例,所以向注册中心注册时使用的是$catalina.base 而不是$catalina.home.  由于我们用的是docer和jvm虚拟机,一台虚拟机上只能部署一个tomcat不用顾虑这个问题。当然程序进行通用性兼容可以让PE们部署的时候放心用。当然这也就为什么会存在第二种方式获取id方式的原因。

4、Id分单个获取和批量获取,批量获取时采用共享变量直接+批量步长的方式,而不是for循环。减小cpu时间片占用和减少锁长时间占用导致类似starvation现象的发生.

5、存在堆gc对生成id的性能影响,虽然看来非常细微,但是生成id是持久化的第一步。它的每延迟增加1ms往往带来全链路的延时放大。我们后来找到办法,在大促时段消除了gc影响。这个方法我们在后续的技术文章中会专门说明。

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第5张图片

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第6张图片


平行迁移

平行迁移功能,是我们做故障迁移,流量定位转移的工具。基于UUID生成实例号的原理,所有的实例数量都已经存在了注册中立里,区别在于还要把能标记自己的资源定位符也一起给出来.于是注册中心摇身一变成管理端,起到中介者的角色。以一次业务调用为例:

正常情况下:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第7张图片

 当实例1故障时:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第8张图片

1、调用端并不是每次都到管理中心拿映射关系,正常情况下调用端只在第一次系统启动时到管理中心获取对端的URI并记录到本地,只要对端正常就一直会访问。实际我们调用端有个开关工功能控制出现异常时查询还是每次都查询。

2、如果每次都查询管理中心,那管理中心的性能如何保证。目前我们采用纯本地JVM的K-V类型Map + ReentrantReadWriteLock+数据库 进行解决.

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第9张图片

3、在第1条中有说过调用端存在开关机制是异常时查询或每次都查询的开关,我们在进行服务端取余结果跟实例对应关系的时候,如果NormalCache赋值的时候以非常小的概率遇到了赋值非原子性操作的问题,无非是两种情况:
一种情况:调用端在利用返回URI访问实例的时候出现异常,这个时候调用端会再去访问管理端查询URI从而避免。
另一种情况:调用端利用返回的URI能正常访问实例,但是我们已经调整映射关系到希望它能访问另外一个实例。其实这个时候场景一般出现在我们密集调整对应关系的时候,这种调整和效果观察的持续时间往往不会短(肯定是秒级以上吧),这个时候我们会打开每次都查询管理中心的开关并持续一段时间,观察访问到了再切回这个开关到异常时查询从而避免。
当然如果你考量这个应用场景还是觉得不放心,那可以在读取的时候用writeLock实现.实际由于全部是内存操作、并且数据库读取在获取Lock之前,这种情况下采用Lock的性能损失接近于无,也非常好。
4、Hash一致性问题,由于这个实例号的生成逻辑是稳定的,由于是实例号是累增不会中间插入,所以目前不存在Hash一致性问题。当然有一些应用场景可能我没遇到,也欢迎大家探讨。


本地化存储

本地存储主要分为两类:
1、一般消息性存储,即把要对外发送的消息出现异常时先存储到本地,然后单独再起线程向原来的接受方进行  传输。由于目前应用场景主要面向消息队列,已经逐渐被我们的部门统一研发的mqSender取代,是对消息队列在客户端的failover机制的一种扩充。如果要是自己实现的话,单就存储而言在采用MappedByteBuffer做内存和刷盘工具+ ReentrantReadWriteLock进行线程隔离就能满足需求,就不多说了。
2、有顺序保障的结构性存储,是我们进行自行收单的基础下篇会详细讲到。如:同一笔支付需要创建支付单(类似财务的应收概念)、写支付结果(类似财务的实收概念)两个动作.业务上要顺序发生并且必须要用数据库进行持久化存储。问题是创建支付单、写支付结果这两个动作实际流程里因为中间涉及用户交互,延时掉单等问题往往存在支付结果先有,而创建支付单延时的情况或者创建支付单的很久以后支付结果才通过别的方式写入(通过银行发异步接口回调,对账单核对)。
首先交易系统层的小伙伴已经把支付结果和支付单放到不同的表内,做insert操作而不是update。其次是如果入库操作出现异常他们首先也会入缓存,等数据库情况变好后再调度入库。等同一笔支付的支付单、支付结果都存在缓存或数据库时再发起下级非实时业务。
而在支付网关的场景是:调用交易系统出现网络失败,写入延时较高的情况下先断掉与交易系统的交互自行发送保存创建支付单和支付结果到缓存和本地。是由于取消了数据库存储,不使用扫描库的方式。而是使用java的io事件selector进行。通过监听SelectionKey.OP_READ事件,根据同一个payid到本地文件和缓存内进行条件判断。判断创建支付单、写支付主任务都成功后再发送消息。


缓存双备双切

在研发体系内有两个自主研发类似redis的缓存系统:JIMDB和R2M,我们同时采用。目的是预防其中中一个出现问题能自动或立即切换到另外一个,采用主从异步模式进行互切:
1、写入时同步写数据到主缓存,异步写数据到从缓存。
2、读取时采用先从主缓存读取,出现异常和超时再在从缓存中读取。如果主缓存使用写入失败,立即调整主从对应的实际缓存。
其实只需要在set和get的时候加一个中间层,与Concurrent框架里的Executor的newCachedThreadPool(ThreadFactory threadFactory)类似,这个结构和实现比较简单:

支付网关 | 京东618、双11用户支付的核心承载系统(上篇)_第10张图片

未完待续

那上篇就到此结束了,下篇将会重点介绍基于这些技术的上层应用功能:
为取消数据库依赖而使用的自行收单、补单功能。
为增加并发量而使用的异步交互功能。
最重要的为预防支付清算机构挂掉而使用的路由分流功能。
为保证下级系统进行重构使用的切量平移功能。
以及为保障历次618,双11活动提前进行的保障和洪峰消解工作。