完成上述数据库迁移后,我们赢得了过去数周内第一次喘息之机。
数据库分离获得了成功。网站现在运行地很稳定,响应也很快,不再需要依赖每隔几小时的服务器重启就能搞定当前常规的新用户请求。厨房和储藏室都拥有了各自专属的空间,整个业务流程运行地很顺畅。我和苏米特很高兴。我们经历并成功挺过了第一次架构拓展升级的危机,得以让我们的网站变得更强大。这为我们的发展赢得了宝贵的时间。
但在创业公司的世界里,时间就是最宝贵的资源。每一次打补丁或者每一个被清除的瓶颈其实都预示着接下来还有其它问题正等待被发现和解决。互联网创业公司的规模拓展就像打地鼠游戏,刚解决了一个问题,另一个马上就冒出了头。
我们的新问题有点棘手。它并不是那种灾难性质的崩溃或者火烧眉毛的服务器问题。它是一种不太令人察觉的慢。即使现在已有两台专属的强大服务器,有些页面访问起来感觉还是有点迟缓。我们解决了资源竞争的问题,但同时也引入了一个新的更复杂的问题:网络延时。
深入技术细节:网络请求的开销
当我们的Django应用和PostgreSQL数据库同处一台服务器时(localhost),它们之间的通信瞬间就可以完成。有点像主厨从身后的架子上取某个食材,一个转身就能完成。“往返时延”(Trip Time)无限接近于零。
现在情况有所不同了。我们的厨房(应用服务器)和我们的储藏室/图书馆(数据库服务器)处于两栋不同的建筑中。就像相邻的两栋房子,我们的两台服务器同属班加罗尔的某个数据中心,它们之间通过极速的光纤相连。但无论连接有多快,主厨仍然需要完成以下工作:
- 暂停手头的活儿。
- 走出厨房。
- 穿过即使很短的“过道”走到储藏室/图书馆。
- 找到图书馆管理员并向它请求某本书(所需数据)。
- 等待图书馆管理员找到并取回书。
- 原路穿越“过道”返回厨房。
以上整个过程就是一次完整的网络请求。所花时间被称为网络延时。
对于一次网络请求而言,这个延时可能微乎其微,往往只需1到2毫秒(千分之一秒)。用户几乎不会察觉。但关键在于:对于一个用户看到的网站页面而言,并不是只需要从数据库往返请求一次。比如,为了给卖家展示店铺页面,我们的代码需要完成以下工作:
- 获取店铺诸如名字等的完整信息。(一次往返)
- 获取店铺的全部商品类别。(又一次往返)
- 获取某个类别的全部商品信息。(再一次往返)
- 获取下一个类别的全部商品信息。(再来一次往返)
- 。。以此类推
一个简单的页面加载可以轻松产生10次,20次甚至50次往返于数据库的网络请求。在数据库分离之前,这些请求的耗时可以忽略不计。但现在,它们在物理层面有实实在在的开销:50次网络请求 乘以 2毫秒/次网络延时 等于100毫秒的整体延时。
于是乎,即便不考虑应用服务器和数据库服务器本身处理任务的耗时,光是网络延时就已经达到了0.1秒。这成为了我们新的瓶颈。我们得优化一下我们的Python代码,让它不要过于频繁地向数据库发出请求。
挑战网络延时:更聪明更少的请求
面对昂贵的网络请求,理想的解决办法就是减少请求次数。相比于每次往返只拿回一样物件,主厨应该带上一张购物清单,这样只用跑一次就可以把全部东西都拿回来。对于Django代码来说,这意味着我们需要大幅优化数据库的查询:
- N+1查询难题:我们同样受困于互联网应用开发中最常见的性能杀手:N+1查询问题。设想一下,你需要获得(1)10家店铺的信息以及(2)每家店铺排名第一的商品信息。最无脑的代码可能是这样:
- 先查询一次数据库,获得10家店铺的信息数据。
- 然后每家店铺查询一次获得排名第一的商品信息,即需要查询N次(这里是10次)。 所以一共需要查询11次数据库。这非常低效。
- 解决方案(select_related以及prefetch_related):幸运的是Django有内置的解决方案。它有一个称作prefetch_related的功能,我们可以让Django这样做:“当读取那10家店铺的数据信息时,因为我肯定也需要读取各家店铺的商品数据信息,所以干脆请把店铺的相关商品数据一并取回。”Django很聪明,它明白这个任务只需要2次数据库读取,而不是11次。第一次读取拿到10家店铺的数据信息,第二次读取就把全部10家店铺的所有商品数据信息都拿回来并与前面拿到的店铺数据整合到一起,供我们的应用代码使用。这就如同是我们的“购物清单”。在所有代码中完成这些优化的效果立竿见影,显著降低了网络请求的次数,让整个应用的响应更加迅速。
- 数据库连接池(PgBouncer):与此同时,每一次数据库请求重新创建一个连接也很耗时。就如同主厨得找到图书馆大门的钥匙,然后拿着钥匙走到图书馆,用钥匙打开大门,取回要找的书,再用钥匙把图书馆大门锁上,最后返回厨房。这样一个过程实在太繁琐了。为了解决这个问题,我们需要一个新工具PgBouncer。它提供了一个数据库连接池。可以把它想象成位于厨房和图书馆之间的保安,它来负责进出大门(已提前解锁)的开合。当我们的应用需要找数据库要数据时,只需从PgBouncer拿到一个提前准备好的数据库连接就行了。这就避免了哪怕一次很简单的数据请求也得重新建立连接的麻烦事,从而进一步降低了我们整体的延时。