58速运架构实战:拆分服务与DB,突破“中心化”瓶颈

张凯 2018-08-06 14:58:05
 

本文根据张凯老师在〖2018 Gdevops全球敏捷运维峰会成都站〗现场演讲内容整理而成。

 

(点击“此处”可获取张凯演讲完整PPT)

 

讲师介绍

张凯,58速运后端平台部负责人。7年开发经验,涉及CRM、微信钱包、卡券系统等;参与58到家钱包入口的优化拆分改版,保证了系统平稳过渡;参与58大促,保证了大促期间卡券系统的稳定运行。现在负责后端平台的开发工作。

 

很高兴有这次机会,跟大家分享一下我们58速运微信小程序的事件。我是后端平台的负责人,从17年底开始负责我们58速运的微信小程序的开发工作。

 

本次分享主要从以下几个方面来进行:

  • 58速运模式

  • 小程序的意义

  • 小程序架构实战

  • 总结

 

一、58速运模式

 

58速运是覆盖中国及东南亚地区的同城货运平台,18年开始了全新的速运2.0时代:

 

速运模式

 

 

司机通过司机加盟的流程加入到我们,登录司机端APP,就可以开始接单了;而用户通过APP或者是用户端的H5登录上去,可以跟司机下单,我们的推送系统经过一系列的算法,推送给附近的司机,然后司机抢单,到达目的地,将账单发送给用户,用户确定定单、收款,这个过程就结束了。

 

二、小程序的意义

 

那么问题来了,有了我们司机端的APP和用户端,为什么还要做微信小程序呢,它对我们的58速运究竟有什么意义呢?

 

首先来解释一下什么是速运的2.0。

 

在旧的1.0时代,司机只能通过加盟接单,并且想要成功接单还需要一系列的审核流程,因为我们要保证服务质量,之后再登录我们的APP才能完成。我们对司机有一系列的审核、管理工作。用户登录我们的APP以后,只能给我们的平台司机下单。

 

速运2.0就是要把这个中心化的过程打破,要做去中心化的过程。

 

司机只要登录了司机端的微信小程序就能够接单,不用加盟;用户登录微信小程序,就能给所有的司机下单,不管这个司机是否在我们的平台注册过。

 

三、小程序的架构实战

 

1、现有架构
 

 

这是现有的架构:

 

 

  • 接入层有安卓、IOS、H5;

  • 服务层就是司机端服务、用户端服务,定单服务、派单服务;

  • 数据层比如说ES、DB等等。

 

可以看到,我们的服务层都是一个个大而全的系统,业务发展的过程中,前期的业务功能并不复杂,一个系统一个服务就能够满足我们所有的业务现状,而且开发起来也比较快。

 

当我们的业务达到了一定的量级之后,这一个大而全的服务就会阻碍我们业务的发展,我们很多的团队在维护一个服务,就会出现很多的问题。比如说我们开发的过程中就会有上线冲突;当业务达到一定的量级之后,DB压力也很大。我们现在正在进行的一项工作就是对服务和DB的一个拆分。

 

2、小程序功能
 

 

 

架构肯定是为业务而设计的,那么我们的小程序有哪些功能?

 

对于用户来说,首先就是有一个会员商品的售卖;其次,用户只要购买了我们的会员商品,就会有一些会员等级;此外还有收藏司机的功能、用推广码下单的功能,如果用户扫描了这个码,就可以直接给司机下单。那么针对司机来说有哪些功能?就是登录了微信小程序后会有一个二维码,司机可以自主接单。

 

面对这些功能,我们的思路:

  1. 避免大而全,我们就要对这些功能进行一个个的拆分,拆分成一个个的服务;

  2. 从简单的开始着手,逐步进行细化的过程;

  3. 微服务的架构,方便后续的拓展和维护。

 

会员服务和用户等级

 

来分析一下会员服务和用户等级。为什么把它们两个一块儿说?因为它们的核心功能点比较相似:

 

  • 会员服务就是说买了会员商品后会有等级和特权;

  • 而如果用户一个月下单数达到一定的阶段,就会有用户的等级和特权。

 

 

它们的核心功能点就是级别的展示、授权以及定期的发券。

 

 

首先是WEB层,还有服务层。有升级就有降级,针对降级,我们使用的是定时任务来处理。如果你单机部署,那这台机器挂了怎么办?如果是部署多台,那同时跑了怎么办?我们有一个自研的基于ZK的调度平台,在跑的时候,首先会出一个临时节点,说明自己在跑,当机器挂掉之后,节点就消失,另一个到达时间节点的时候,就会在另外的一个机器上面跑,保证一个时间点只能有一个机器在跑这个Job。

 

我们的用户等级升级是要求比较高的,比如说用户买了一个商品,立马就希望等级升上去。我们使用了一个消息队列,保证我们收到消息之后,立马把用户的等级升上去。

 

还有定时发券的场景,我们使用了延时消息,我们在用户发券之后去判断是否还需要发券,如果还需要的话,就接着发一个延时消息。

 

用户等级服务的核心功能点之一,是根据用户当月的订单数来实时地更新用户等级。一般情况下,统计当月的订单数,都是使用定时任务每隔一段时间去计算。但是,因为我们对实时性要求较高,这样做并不合适。所以我们使用了接收订单完成的MQ来实时进行计算。我们的做法是,先根据MQ实时更新每天的订单数,保证每天的订单数可查,同时更新每月的订单数。通常情况下,在更新当月的订单数之前或者是之后,只需要清除一次缓存就行了,但是我们清除了两次,为什么?用户访问了自己的用户等级,可能会出现一种情况就是用户看到的这个数据并不对,数据不一致。使用双缓存清除法能解决这个问题。流程如下图所示:

 

 

商品服务

 

会员商品服务的典型场景有四种:

  • 读多写少;

  • 商品不可变;

  • 针对单个的商品和用户是有一个限购的条件的;

  • 商品有可能会有一些库存的限制。

 

 

比如说我就想卖100个,针对这个场景我们能不能很简单的一个Web、一个服务加上存储就搞得定呢?如果商品卖出去了,缓存是不是就失效了?我们如何保证商品缓存的时效性?如果我的库存这一块儿出现了问题,那是不是商品会受到影响?比如说库存导致我们的服务挂了,那商品直接看不到……

 

针对这些问题,我们处理的方式:

 

 

  • 首先就是将这个可变的数据隔离,将商品服务不做成一个服务;

  • 针对消息一经发布不可变,而且访问量很大的问题,可以通过加缓存来缓解压力;

  • 至于怎么保持库存的一致性,就是用CAS乐观锁来保证库存服务的效率。

 

司机的GPS服务

 

我们是一个同城货运平台,大部分的场景是用户下单,司机接单。我们有100万的注册司机,要保证司机实时的GPS位置准确,2秒钟上传一次GPS,这个请求量是特别大的。

 

但GPS的服务对我们的实时性的要求又非常高,所以MySQL的压力非常大,如果再上一个层次,MySQL肯定扛不住;如果放在缓存里面,又有另外的问题,也就是缓存无法搜索的情况;还有怎么样提高处理效率的问题。

 

针对这几个问题,我们的做法:

 

 

使用了生产者消费者模式,生产者发送MQ给消费者,消费者本身是一个Job。接到消息后,先进行时效性的判断,如果超时,直接丢弃。如果没有超时,异步调用GPS服务。GPS服务接到调用后,先放到一个队列里面,然后后台有一个线程,批量地进行ES的存储。

 

订单服务

 

现状:

 

 

我们碰到的问题,主要是老订单因为业务的发展对我们的小程序已经不太适用;老订单的服务根据前台的业务进行了一个分表的处理,后台是一个单表,我们后台的单表查询会非常的慢;前后台是采用了canal的方式同步的,最大的时候前后台订单有6个小时的同步,目前订单已经是40万的数据了,之前前台的订单服务、后台的订单服务包括订单的ES服务,很多人在调用的时候其实根本不知道调用哪些服务。

 

我们的思路,第一,订单服务要统一成一个;第二,我们要采用分库的方式来实现。

 

一般有水平拆分和垂直拆分:

 

 

第一想到的就是能不能把我们的数据进行一个隔离,比如说按照时间段做一个垂直的,17年的放一个库,18年的放一个库。

 

第二,水平的拆库拆表。首先用户肯定要查询自己的订单列表的,司机也需要查询,然后大部分的场景其实是司机的查看订单详情,还有就是说我们公司自己的后台的运营人员,有一些复杂的查询。

 

那么如何确定我们的分库方案?

 

 

按照时间纬度的优点是订单分到最新时间段的库,直接查就行了,缺点在于如何确定时间纬度,一个月、一个季度或者是一两年。还有一个问题就是说,如果确定了时间纬度之后,订单还有大的增长怎么办?我们的库是提前建好还是动态申请,资源上面也要权衡。

 

 

水平拆分,订单如果再有一个上升的阶段,就直接横向扩展了。我们也要解决跨库查询,也需要订阅方案。

 

我们85%的查询是根据订单的ID来查询的,比如说大部分的司机抢单,查看订单详情;用户下单查看详情之类的。接下来会有10%的用户查看自己的ID,我到底下了哪些单,或者是历史的订单是什么样子的。还有4%是根据司机的场景来查询,只是偶尔空闲的时候他才会查自己今天抢多少单,最后只有1%的后台复杂查询,所以可以往后考虑。

 

如果需要满足85%的场景,根据用户ID来取模行不行?但是问题是司机ID的列表查询怎么来解决呢?方案就是索引表。

 

 

我需要查询任务,根据业务和订单ID来建立索引表,就可以查到所有的订单,然后能确定每一个库,就能搞定场景。

 

但是我们的数据量达到一定的阶段,索引表也需要分库怎么办?这样所有的用户的东西都在一个库里面,查询用户列表的时候就不用分库了,这样解决了我们10%的问题,那80%多的问题怎么解决?就是基因法。

 

 

根据用户ID得到的数字,其实就是我们的分库基因,大家都知道JAVA里面是64位的数字,前40位用做一个时间,这个时间并不是说我们直接调用系统的当前时间,而是拿2018年,拿一个固定的起始时间,比如说2018年1月1号,再用当前的时间减去起始时间的毫秒数,得到的时间左移23位,放到我们的40位的位置。

 

接下来的是我们的机器位,为什么会这样呢?因为我们的ID生成器不可能是在单机上面用的,是在多个机器上面用。

 

接下来的其实是我们的分库基因,还有自动的序列,是为了保证同一毫秒生成的ID不会有重复。比如说同一秒内支持的ID生成是6万多个,如果不够用怎么办?将时间的秒数量再加一就可以了。ID生成搞定了,怎么根据这个生成找到我们所在的库?下面这个其实就是一个反向的过程,能确定到我们的一个库。

 

解决了95%的场景,剩下的怎么搞?使用ES就行了。

 

 

因为司机不会实时的去查看自己的订单,运维人员对订单的实时性要求也并不高,所以说直接使用ES就行了,最后我们的订单服务就是这个样子。

 

做一个总结,就是按照用户的纬度来分布,订单ID使用Snowflake算法生成,订单中记录分库因子,然后复杂查询使用es来解决。

 

老旧服务的兼容

 

这样的话订单服务并没有完,我们还有老旧的服务必须做一个兼容:

 

 

针对订单的写,我们是做了一个双写,写了新订单之后,会去同步写一次老订单;针对订单的读,我们是先查询新订单,如果查不到,会在老订单里面查一遍再返回客户端;我们对历史数据也有一套完整的迁移方案。

 

推送服务的改造

 

微信小程序还涉及到了推送服务的两个方面的改造:

 

 

因为我们新增了两种推送模式:

 

  • 一对一的推送,相对来说比较简单,下单的时候,如果用户选择的是一对一推送的,比如说用户只选了一个司机,我们就默认司机就是中单了,不管这个司机在不在线,如果能推给司机就推给他,如果不能推给他,够给他发一个短信。

  • 一对多的推送,省去了推送的算法,用户选择多个司机,然后我们的系统根据用户筛选的司机,挑选出在线的列表,然后全部推送给那些司机,直到司机又抢单就结束了。

 

这是我们改造之后的一个微信小程序的总体架构图:

 

 

我们分了接入层、业务层、服务层、基础服务、数据层。接入层就是对每一个服务做了一个划分,进行了一层业务的评定之类的,反馈给我们的接入层。

 

我们的程序想要上线,首先要接入我们的服务治理平台:

 

 

我们的服务治理平台会提供一些功能:

  • 动态机器的管理,比如说我们的业务撑不住了,可以通过这个加一点机器;

  • 对整个服务的流量的监控;

  • 访问耗时的监控;

  • 还有我们的抛弃量监控。

 

接下来就是接入我们的监控平台,会有针对的关键字监控,也有URL的监控,针对不同的监控有一些监控的策略。

 

最后就是接入我们的Dtrack调用链,可以知道整个服务之间的调用关系:

 

 

如果服务的量级上来了,那么我们可能自己都不知道是调用了哪个服务,它的层次关系靠人已经分不清了。如果接入调用链的话,会打印出一个服务的清晰的调用关系。

 

 

  • 它给我们提供了全局跟踪,比如说调用了哪些服务,耗时有多少;

  • 哪个服务有问题的,会立马有一个异常的报警;

  • 针对服务之间会有清晰调用结构;

  • 对整个服务也会有一个效果监测。

 

它的技术点在哪儿:

 

 

我们在框架里面提供了插件,每一次调用的时候,就会形成traceid,通过框架传递下去,每调用一个服务,它的ID会+1。将这些调用关系通过日志打印出来,通过Flume采集之后展现出来就行了。

 

四、总结

 

最后总结一下,准确的理解需求很重要,架构是为业务服务的;碰到一个大的需求,对需求进行拆分,由简单到复杂的拆分;根据业务需求进行合适的技术选型,任何脱离业务的架构设计都是耍流氓,监控特别的重要,谢谢大家。

 

Q & A
 

 

Q1:调用链那一块儿我有一点疑问,我们在日常系统级的异常,或者是我们的业务出现问题的时候,可能会很多,而且我们的调用关系很复杂。对于海量日志怎么来判断哪些异常是对于我的业务,或者是对于我的应用,造成真正的影响?

A1实际上是这样的,业务日志和这个调用链的日志是分开打的,肯定不是一个日志,不然你放在一块儿,肯定没有办法区分究竟是哪个异常。调用链的日志是打印在一个单独的框架里面,它会发现你不是一个完整的调用。异常报警是通过监测平台来实现的,是两种不同的业务报警。

 

Q2:是不是我们是以两个纬度来接入,然后优先可能是基于业务的去监控报警,然后具体的去定位,可能会基于那个调用链?

A2这两个是完全不同的东西,所以会不同的报警,如果你的业务上,比如说我们自己打的一些异常的话,那肯定是通过监控平台来报的,是另外一个方向来报的。如果是Dtrack,是另外一个方向的,它会告诉你哪一个调用,因为每一次调用都会有一个ID,把这个ID输到一个查询平台里面,会根据这个ID把你整个访问的请求打印出来,然后把它给打出来。

 

Q3:订单水平拆分的问题,我看上面写了水平拆分的分库基因。当你需要发生更改的时候,订单ID是怎么去改的?

A3其实我们的分库基因并不是直接对分库数取模,比如我有9个bit是用来记录分库基因的。我最大支持512个库,分库基因呢我可以对512进行取模,或者其他任何一致性hash算法都行,只要算出来的数小于512。这时,如果要找库,我就拿出分库基因,然后对我的dbcount,也就是分库数进行一个取模。或者我有一个算法,来计算我这个数对应的是哪个库,用一个环型数组也是可以的。针对对dbcount取模,这样扩库的时候,我就可以以2的倍数进行扩库操作,比如2-4,4-8。这样,扩完库,改一下我们目前的dbcount,然后对历史数据进行清理就行了。如果是环型数组等其他算法来算库,修改一下算法,做相应的数据迁移,清理一样可以达到效果。

活动预告