首先谈到Uber的初心,他们想实现一键打车功能。左边是Uber最早期的界面,大家可以看到跟现在有很大的区别,按一下就可以打到车了。
Uber的最初架构
为了支持这样的服务,Uber最初的架构是怎么样的呢?会很复杂吗?其实不会。
前面是一个手机,中间是PHP来负责业务逻辑,最后是MySQL的数据。司机每隔4秒上传自己的经纬数据来更新自己的位置,而这些数据会存在MySQL里,当用户请求来的时候,PHP会调用MySQL的查询语句来寻找匹配的司机。这是一个非常简单的架构,能够快速的支撑Uber的需求
架构扩展
那这样的架构如何扩展呢?因为Uber非常火爆,用户蜂拥而至,很简单的方法就是将PHP变成多进程多线程,可以同时访问MySQL得到数据。
但是这样的架构有很多缺点:
缺点一:一个司机两个乘客因为每个PHP是独立访问MySQL的,而有些机制没有做好的话,很可能一个司机被两个PHP进程查询到了后台,同时返回给两个用户,所以会出现一个司机被派给两个用户的情况。
缺点二:两个司机一辆车
有时候两个司机公用一辆车,他们用两个APP,这时候只能派遣一次,就会出问题。
缺点三:一个账号两辆车
两个用户登录同一个账号,这时你会看到用户的位置在不断的漂移,你会非常困惑。出现这么多问题,我们应该如何改进呢?
谈到改进,我们再把刚才的服务具体化,会有派遣服务,派遣服务后面是MySQL的存储状态,整体是一个实时逻辑,用户可以通过iphone,Android,SMS连在上面。谈到改进,这是一个逐步的过程,不可能一口吃一个胖子。
我们首先可以把一些影响我们派遣服务的逻辑拿出来。比如商业逻辑,像用户绑定手机号,付款之列,可以通过Python的API进行调用,因此减轻了派遣服务的能力。
再进一步,我们可以在中间加上消息队列,因为有各种请求,不见得所有请求都通过派遣服务走,像商业逻辑可以直接走API。而且消息队列在这里面可以抵挡更大的连接,更多的请求,本身还有个消息队列的机制。
同时为了存储一些比如GPS日志,可以在MongoDB里保存,从而也减轻了派遣服务。我们可以看到逐渐减轻派遣服务的压力。这之后大家如果在各个层级里出问题可以重试。重试很好,因为便于出错以后的恢复。但也存在一个隐患,稍后会讲到。
在这个基础上,我们还可以进一步变化,将派遣服务也变成Node.js,派遣数据变成MongoDB,这是非常好的。因为Javascript越来越全面,不仅可以写前端,而且还能写后端,所以跟Node.js搭配非常好。
MongoDB也用在这里,这时候另外一个优化是可以将很多数据放在内存里,所以派遣服务能够很快的去检索司机的匹配,而不需要通过MySQL的查询来实现。
如何避免单点失败?
什么是单点失败呢?比如我们的派遣服务是只有一个点的,叫Master,如果它挂了就坏了,所以最好的方法就是有几个Slave在热备,如果Master挂了就可以替换它,这叫做单点失败。同时如果大家请求特别多的话,也会有很大的压力,我们可以把不同的区分成三个区,每个区里负责单做自己区的服务,因此就避免了很多问题,而且每个区里都是进行单线程处理,意味着不会出现一个司机同时派给两个人的情况,这也是规避我们之前说的各种问题的诀窍,因此单线程服务是非常好的。
消息如何处理?
为了处理消息,我们需要一个消息管理器。这个管理器的代码很简单,首先监听端口9000,然后每获得一个请求就处理这个消息。这里有个思考题,我们如何能够热升级呢?我们想升级request manager,怎么做呢?这里就有问题存在,如果升级直接把它关掉,那以前处理到一半的消息怎么办?如果不关掉会一直接受请求永远无法关掉,有什么办法不损失消息还能把它重启呢?
答案是不要关掉服务,而是关掉它监听的端口。把端口关掉以后新来的请求进不来,它可以自己再持续服务1分钟,把之前的全部处理掉,然后再重启。在重启自己之后,刚才发过来的所有请求都会在外面进行重试,因为每一层有重试机制。所以这种规避错误的方法能够让请求得到满足,不出现任何问题,但它也是后面一个陷阱的起点。
服务器如何热备?
也是很简单的,我们需要一个SlaveMaster的结构。我们首先有一个server list,就是有很多很多的服务,这些服务启动起来,当每一个Master挂掉以后,大家发现了立马会有一个Slave新的Master来接替。同时你可以修理这个Master,保证这个服务不断地有三台在热备着,这就是一个选举机制。
MongoDB是如何访问呢?
答案很简单,只要外面包一个Wrapper就好了,把访问的重试次数,如何备份,如何恢复数据,内容,配置等写到里面,这样大家统一调用就非常简单。那我们看下2011年数据:
平均请求 25/s
高峰请求 125/s
一般高峰期是平均的5倍左右。如果现在要了解这个架构能不能面对未来,怎么测试呢?用高峰的125/s测试吗?肯定不是的。需要一个更大值,比如1000。
为什么选择1000呢?它大概是高峰的10倍左右。如果你的系统是面向未来编程,一定要考虑至少三个月以后的数据变化情况。Uber是快速发展的服务,所以我们估算1000,是高峰乘以10倍。另外在这个基础上测试发现只占14%的CPU和60M内存,所以是完全可以顶得住这样的服务压力。
如何监控
长期来看,你不能保证系统永远正确,还需要有监控,那怎么监控呢?
出错以后可以立刻重启
邮件通知大家
有统计面板看
所以核心是在错误发生时先解决错误,继续服务是最重要的。所以先重启,之后再校正错误,如果没有这个机制,一发生错误就挂起来然后等待,就会出问题,手动重启更不行了。
最后思考一个问题
在这个架构里会有什么潜在风险呢?其中我们画出了API Python,我们之前也反复提到了各种隐患。这个时候会出现所谓的雪崩现象。比如这个API往往是商业逻辑,用户结算完后会算账去银行拿钱,但这个跟银行之间的对接往往比较慢,因为大家知道银行又慢又贵。由于很慢,别人在等的时候会不断的重试,重试以后会加大它的负担,所以就会挂掉,它挂掉以后别人再重试又不行。而且消息队列就会累积到一块去,于是他们也跟着挂掉。底层挂掉,上面跟着挂掉,所有的服务都挂掉,启动以后也没用,还是会挂掉,这就是雪崩现象。
一个挂了,其他跟着挂掉。那怎么破解呢?答案有两个:从根源上来说肯定是异步调用的问题,因为同步等待才造成问题的。另外总是不断的重试也是压死它的最后一根稻草,所以大家应该学会快速失败的机制。
总结
JavaScript是个好兄弟任何一个能被JavaScript写的代码,最终都能被JavaScript所代替。
面向未来的程序设计要想3个月后会有多少压力,实在想不到先把用户量乘以10再说。
雪崩雪崩雪崩重要的事情说三遍
Uber最早是做一键打车,她的使命是不断地进化把交通变为生活中像水和电一样的基本服务。那在这种使命上,它如何把系统升级呢?下一篇我们为大家分享一下Uber内部原理RingPop。
参考文献:
Distributed Web Architectures: Curtis Chambers, Uber
首先,Uber是什么?Uber是连接乘客和司机的交通平台,它专注的是运输业。当我们输入自己的位置,然后请求一辆车,Uber可以马上满足我们的需求。
Uber面临的挑战是什么?
最核心的有两点:动态的供给,和动态的需求。
顾客不断地从各个地方出现,所以需求的位置都是不一样的,并且需求也会随着时间而变化。此外,供给也是各不相同的,因为司机来自各个不同的位置,并且每个司机的车也是不一样的。
Uber的架构
那么Uber的架构是怎样的呢?首先必然有司机和乘客,他们分别代表着供给和需求。对他们提供服务的过程,我们称为派遣服务。派遣服务会用到一些模块,最基本的是地图和时间预估,因为有了这两者后,我们就能知道对于一个用户的请求,一辆车大概什么时间能到达用户身边,以及距用户不同距离的车辆分别都需要多长时间。这是基于地图和交通的历史信息进行评估的。而在Uber内部,它的派遣服务是用Node.js实现的。
除此之外,还有很多复杂的逻辑,我们称之为业务服务,其采用的是微服务的方式(微服务的架构参见:技术丨解读Microservices)
再往后就是数据库,微服务里会有各种各样的数据库,因为历史原因Uber有大量各种各样的数据。
在传统的服务之后,还有服务后流程,比如用户给出点评、付费、收到一些邮件通知等。
支付的过程也很复杂,因为需要和各个银行合作,而银行之间的各种延迟、不同的协议等也会造成很多困难。
因此在这个架构中存在很多挑战。
能否支持顺风车。因为在Uber的传统架构设计里,他们假设的是一名司机载一名乘客,这是一个简化的模型。但如果出现多个乘客乘一辆车,或者搭顺风车的情况,传统架构就无法满足了。
Uber想做任何东西的运输者,所以送餐就出现了,随之而来也有一些问题。Uber之前假设传输的都是人,所以当司机送的不是人而是货物、食物或其他东西时,该怎么办?这个架构该怎么改?
跨城市运输。以前Uber一直是按照城市来切分数据。但有的城市大,有的城市小,如果这样切分数据,结果就会很不均匀,也会造成流量不均衡,那怎么处理这个问题呢?
最后是多点失败,即系统中有多个单点失败。
应该如何重构架构呢?
我们要重新理解司机和乘客,司机是供给方,乘客是需求方。所谓供给,不仅是指提供车,还包括车上是不是有儿童座椅、剩余座位的数量、车型是什么,这些都属于供给的一部分,所以要建立更强大的Profile。
那需求方的具体需求有哪些呢?乘客是否带着孩子,同行人数或是否愿意和别人共享一辆车,这些都是需求。供给与需求凑到一起成为一个舞会,在舞会上男女会搭配着跳舞。同理,Uber也想把一个供给和一个需求搭配到一起,在Uber里这被称为舞会服务。
之前舞会服务往往是基于当前服务状态来匹配,只用考虑当前情况。但在面向未来时,会出现很多情况,怎么考虑到未来的需求呢?这就是面向未来的匹配。我们之后会讲到这是如何实现的。
当然还有一些特殊的场景,比如在机场要模拟出一个队列的方式来提供服务,这都是由舞会服务来提供的。舞会服务也会调用底层的供给位置信息、地图和时间估计,以及需求方的位置信息,并把它们结合到一起,构成了整个派遣服务。所以派遣服务拆解开来,完成更细粒度的操作。
Uber当前的目标是什么?
2015年8月,他们的目标是写操作1M/s,即每秒100万。如果面向未来设计,实际当前的写操作是每秒10万左右,再结合每4秒一次的写GPS位置信息,那么同时运营的车辆大概在3万左右,这只是一个估算。
如何唯一标识一块空间?
为了实现这些服务,要解决一些基本的问题,其中一个是:如何能够唯一地标志一块空间?
一个地图,如果不按城市切分,那按什么方式切分呢?答案是用Google S2。它是一个基于地理的图数据库。它将地理上的每个空间用一个四边形切分出来,切分时按照从大到小的规则,0级表示全部的空间,而切到最小是1平方厘米的空间。所以它可以标识出任何一个位置,并且形成一个唯一的二进制串,用一个id表示出来。通过这种方式,它能标识出任何一个位置。
标识出地图上的每一块空间后,要选择粒度,在Uber里选择了12这个公里级别的粒度。
如何表示一个区间?
有了这些以后,我们如何表示这个圆形的空间呢?比如用户现在在这个圆形空间中发送需求,我们怎样找到他附近的所有可以满足请求的司机呢?
答案就是切分开来。按照上文我们讲到的地理空间方块的覆盖,把它切分开,只要能覆盖到这个蓝色的空间就算一个,所以它会在这五个红色区域里寻找满足的汽车。
如何匹配供需?
我们再来看第二个基本问题:如何匹配供需?首先我们要想我们的目标是什么。
我们的目标是:
减少乘客的等待时间。
减少司机的空驾,这样司机才能赚更多的钱。
减少乘客在路上的通勤时间。
当满足了这三个目标时,我们就会发现一个场景:尽量将司机连成串,尽量走最近的路径就行了。这其中还有很多细节值得大家去思考。
什么是最优策略?
在最优策略里,除了有面向当前的设计,还有面向未来的设计,二者有什么区别呢?
举一个例子,乘客1发送了请求,我们发现离他最近的司机是1号,距离八分钟,我们也许就会让司机1去接他。但是,也许还有一个司机2,他的当前任务还剩两分钟就完成了,他与顾客的距离是一分钟,如果让司机2先完成当前的派送,然后再接这一单,耗时会比司机1更少,这就是面向未来的策略。如果把这种情况考虑进去,也许就能设计出一些更好的策略,这是我们经常碰到的NP问题。
如何保存供给?
接下来我们具体来看系统上的实现。如何才能把供给保存起来呢?在Uber里,它的难题是,全球有几万或几十万辆汽车,这是一个很大的数据。我们刚才讲过,我们已经通过Google的地图实现了任何区间的切割,不需要单独按照城市保存,那我们怎么进一步来计算呢?
在存储上,他们提出了一个概念叫Ringpop,它的本质类似于Cassandra的分布式平台,里面所有的节点都分布在这里,这些节点之间是完全等价的,每个节点负责某一个区域范围内的位置信息。
当一个供给司机将他的供给位置告诉舞会服务之后,舞会服务会算出他具体的位置区间,然后通知环上的任何一个点,这样就能把位置存放起来了,这就是保存。
另外一个问题是,如何匹配需求?
匹配就是搜索,比如乘客有一个需求,想查他周围五公里之内的汽车。把这个请求发给舞会服务后,舞会服务发现影响到了三个位置。所以它会把这三个位置信息发给环上任何一个节点。节点会把这些信息路由到具体的位置2、5、7,然后这些节点会返回匹配结果,最终返回给这个用户。
所以我们可以看出,任何节点都是等价的,它们能接收任何服务,并且路由到相应的位置上,得到具体的信息,这是一个非常好的Ringpop架构。
如何远程通讯?
有了存储,我们还要解决通讯问题。通讯需要有哪些特点呢?
首先,它需要性能优秀。之所以要重新做一个通讯,就是因为当前的HTTP太慢了,他们希望能有20倍以上的优化。
要能提供消息转发。我们可以看到,Ringpop里每一个节点都能转发消息。
跨语言支持,因为底层用了很多种不同的语言。
希望能有一些消息调度优化,不要因为某些消息就卡死在那里。
校验和追踪,发现问题并且改正问题。
消息封装。比如我的上层跑的是某一个协议,能不能把HTTP也封装到里面,兼容HTTP协议呢?这就是一个封装的问题。
解决方案就是TChannel,大家可以Google一下,了解其具体的设计。
服务的设计原则是什么?
我们现在开始考虑服务,服务即微服务,对于大规模系统,它的错误是常态,所以我们要考虑很多问题。服务的设计原则可以归结为以下三点:
服务可以重试。如果服务经常挂掉而不能重试,就很容易出现错误。比如转账的时候,第一次转错了,再转第二次的时候发现出问题了,这就是不能重试,或重试出错。又或者是执行两次转账,而两次执行的结果不一样,这样也是不行的。所以要保证服务是只执行一次的。
服务可以被杀。因为这个系统的节点特别多,随时可能挂掉,所以需要可以被杀,甚至有时能故意搞坏一些东西来杀掉服务,以测试服务是否鲁棒。
服务要尽量切分。因为细分到原子服务上,服务的耦合性就解开了,因而不会相互影响。
如何负载均衡?
服务设计出来以后,如何解决负载均衡的问题呢?
传统理解中,我们可能认为两边是服务,负载均衡在中间,把他们搭到一块儿。但是在我们这种情况里,如果负载均衡在中间,负载均衡挂掉了以后,是不是也不能服务了?能不能有一个负载均衡的负载均衡呢?答案就是刚才提到的Ringpop。
如何改进负载均衡?
也就是说,Ringpop不仅能存储数据,还能实现路由功能。比如服务A想访问服务B,它就可以找到服务B的某个位置对接,并且找到它。同理,后面有很多服务B,它们可能连到不同的位置,这样就能分布地实现所有的需求,解决所有问题,并且把流量全部分解开来。有兴趣的朋友可以多看看Ringpop相关的论文。
木桶延迟问题
还有一个问题叫木桶延迟问题,很有意思。如果一个大消息只有一个小消息,假设平均延迟1毫秒,从统计数据来看,可能有1%的消息变成了1秒,于是就有1%的时候是失败的,因为超过一秒了。
但是,如果大消息是100个消息的集合,你会发现失败率是63%,因为在这100个消息里,只要有一个消息超过1秒,整个消息最终的延迟肯定会超过1秒,它取决于最慢的那一个,这就是木桶原理。
如何解决木桶延迟问题?
答案就是在服务A和服务B之间加一个服务2,也就是让几个服务器进行重算,不仅是服务1算,服务2也算。
那是同时发吗?不是的,其实是有延迟的。比如,我和服务B1说:完成消息1。同时我也告诉他我会让服务B2也算。同理,过一段时间后我也会和服务B2说:你完成消息1。同时我也会说我告诉过服务B1。正常情况下,B1先收到请求,所以他会先完成,然后他就会告诉服务A他搞定了。但是完成以后,B2算的不就浪费了吗?那B1就会告诉B2:取消消息1的计算。这样就可以节省B2的时间开销。同理,如果B2完成了,他也会告诉服务A他完成了,同时也告诉B1让他取消计算。这种计算两遍的方式,能够极大地加快性能。
数据中心挂掉怎么办?
最后一个问题是,数据中心也会挂,当数据中心挂掉时该怎么办呢?
大家想一想,数据中心挂了的核心是什么?比如司机的App在运行时会不断地给数据中心提供他的位置信息。如果数据中心A挂了,那么数据立即就没有了。为了解决这个问题,我要不停地把数据中心A的数据同步到另外一个数据中心吗?当然可以,但是很麻烦。
Uber用了一个很巧的方法,在运行过程中数据中心A会将司机本身的数据进行摘要,并且加密,返回到司机的App。这样司机的手机本地是存有自己完整的各种重要信息的。
当数据中心A挂了的时候,数据中心B出来了,他是没有任何数据的,但没关系,他可以直接对司机App说:你把信息摘要给我吧。然后司机App就会把信息摘要给数据中心B,数据中心B就拥有所有数据了,就能完成所有的操作。同时,由于现在有了数据中心B,接下来更新地理位置的信息就会发给数据中心B。这就是Uber解决数据中心挂掉问题的方法,非常巧妙。
总结
希望大家能用到以下三个好帮手:
Google S2是你地图的好帮手,在地图问题上大家可以用它。
Ringpop是分布式存储和负载均衡的好帮手。
TChannel是远程调用的好帮手。
参考文献:
《Scaling Uber’s Real-time Market Platform》
https://zhuanlan.zhihu.com/p/51005162
http://highscalability.com/blog/2015/9/14/how-uber-scales-their-real-time-market-platform.html
http://highscalability.com/blog/2012/6/18/google-on-latency-tolerant-systems-making-a-predictable-whol.html