58速运架构实战:拆分服务与DB,突破“中心化”瓶颈
很高兴有这次机会,跟大家分享一下我们 58 速运微信小程序的事件。我是后端平台的负责人,从 2017 年底开始负责我们 58 速运的微信小程序的开发工作。
本次分享主要从以下几个方面来进行:
- 58 速运模式
- 小程序的意义
- 小程序架构实战
- 总结
58 速运模式
58 速运是覆盖中国及东南亚地区的同城货运平台,2018 年开始了全新的速运 2.0 时代:
速运模式
司机通过司机加盟的流程加入到我们,登录司机端 APP,就可以开始接单了;而用户通过 APP 或者是用户端的 H5 登录上去,可以跟司机下单,我们的推送系统经过一系列的算法,推送给附近的司机,然后司机抢单,到达目的地,将账单发送给用户,用户确定定单、收款,这个过程就结束了。
小程序的意义
那么问题来了,有了我们司机端的 APP 和用户端,为什么还要做微信小程序呢,它对我们的 58 速运究竟有什么意义呢?
首先来解释一下什么是速运的 2.0。
在旧的 1.0 时代,司机只能通过加盟接单,并且想要成功接单还需要一系列的审核流程,因为我们要保证服务质量,之后再登录我们的 APP 才能完成。
我们对司机有一系列的审核、管理工作。用户登录我们的 APP 以后,只能给我们的平台司机下单。
速运 2.0 就是要把这个中心化的过程打破,要做去中心化的过程。
司机只要登录了司机端的微信小程序就能够接单,不用加盟;用户登录微信小程序,就能给所有的司机下单,不管这个司机是否在我们的平台注册过。
小程序的架构实战
现有架构
这是现有的架构,如上图:
- 接入层有安卓、iOS、H5;
- 服务层就是司机端服务、用户端服务,定单服务、派单服务;
- 数据层比如说 ES、DB 等等。
可以看到,我们的服务层都是一个个大而全的系统,业务发展的过程中,前期的业务功能并不复杂,一个系统一个服务就能够满足我们所有的业务现状,而且开发起来也比较快。
当我们的业务达到了一定的量级之后,这一个大而全的服务就会阻碍我们业务的发展,我们很多的团队在维护一个服务,就会出现很多的问题。
比如说我们开发的过程中就会有上线冲突;当业务达到一定的量级之后,DB 压力也很大。我们现在正在进行的一项工作就是对服务和 DB 的一个拆分。
小程序功能
架构肯定是为业务而设计的,那么我们的小程序有哪些功能?
对于用户来说,首先就是有一个会员商品的售卖;其次,用户只要购买了我们的会员商品,就会有一些会员等级;此外还有收藏司机的功能、用推广码下单的功能,如果用户扫描了这个码,就可以直接给司机下单。
那么针对司机来说有哪些功能?就是登录了微信小程序后会有一个二维码,司机可以自主接单。
面对这些功能,我们的思路是:
- 避免大而全,我们就要对这些功能进行一个个的拆分,拆分成一个个的服务;
- 从简单的开始着手,逐步进行细化的过程;
- 微服务的架构,方便后续的拓展和维护。
会员服务和用户等级
来分析一下会员服务和用户等级。为什么把它们两个一块儿说?因为它们的核心功能点比较相似:
- 会员服务就是买了会员商品后会有的等级和特权;
- 而如果用户一个月下单数达到一定的阶段,就会有用户的等级和特权。
它们的核心功能点就是级别的展示、授权以及定期的发券。
首先是 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 服务,很多人在调用的时候其实根本不知道调用哪些服务。
我们的思路,第一,订单服务要统一成一个;第二,我们要采用分库的方式来实现。
一般有水平拆分和垂直拆分:
第一想到的就是能不能把我们的数据进行一个隔离,比如说按照时间段做一个垂直的,2017 年的放一个库,2018 年的放一个库。
第二,水平的拆库拆表。首先用户肯定要查询自己的订单列表的,司机也需要查询,然后大部分的场景其实是司机的查看订单详情,还有我们公司自己的后台的运营人员,有一些复杂的查询。
那么如何确定我们的分库方案?
按照时间纬度的优点是订单分到最新时间段的库,直接查就行了,缺点在于如何确定时间纬度,一个月、一个季度或者是一两年。
还有一个问题就是说,如果确定了时间纬度之后,订单还有大的增长怎么办?我们的库是提前建好还是动态申请,资源上面也要权衡。
水平拆分,订单如果再有一个上升的阶段,就直接横向扩展了。我们也要解决跨库查询,也需要订阅方案。
我们 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 采集之后展现出来就行了。
总结
最后总结一下,准确的理解需求很重要,架构是为业务服务的;碰到一个大的需求,对需求进行拆分,由简单到复杂的拆分;根据业务需求进行合适的技术选型,任何脱离业务的架构设计都是耍流氓,监控特别的重要,谢谢大家。
对Java微服务、分布式、高并发、高可用、大型互联网架构技术、面试经验交流感兴趣的。可以关注我的头条号,我会在微头条不定期的发放免费的资料链接,这些资料都是从各个技术网站搜集、整理出来的,如果你有好的学习资料可以私聊发我,我会注明出处之后分享给大家。欢迎分享,欢迎评论,欢迎转发!