赞
踩
图自网络
”10年前的那一天Facebook发生了什么“,本想用这个题目,但不符合本系列的气质,那,那天到底发生了什么呢。
Facebook的事故介绍
2010年9月23日,Facebook遭遇了截止到那时最严重的宕机事件,为什么加个那时呢,你懂得,因为前段时间又发生了一次。我们这次说的是那时,当时网站关闭了4个小时。
当时人们在网上对这一事件的调侃。
是什么导致了在那年已是一家庞然大物的超级互联网技术公司发生了这样严重的问题呢,事后的诊断报告有一段是这样描述的:
今天,我们修改了一个错误的配置,每个客户端都看到这个错误的配置,然后试图更新它。因为更新数据需要查询数据库集群,集群很快就被每秒数十万次的查询拖垮。
可见是遇到了集中式、大访问量、高并发的问题。这里,最集中的暴击点,还是并发的问题。接下来,我们就一起从并发这个角度来看一下,这次事故引起了我们哪些思考。
为什么会产生并发
无论什么时候,只要使用多进程或者多线程操作同一数据,都会遇到并发问题。
并发跟事务有什么关系
事务天然地提供了一个隔离空间,只要所有的数据都在一个事务中进行操作,并发环境中真正严重的问题不太会发生。这也是很多应用想法避开并发问题的手段之一。
换句话说,“事务提供了一个框架,用来在企业应用中帮助避免并发产生的许多棘手问题。”
但是,我们是不可能把所有的操作都放在一个事务中的,尤其是类似数据库这样的系统事务中,因为有不少非常耗时的操作。这个时候,我们就会用到跨多个数据操作的业务事务。
区分系统事务和业务事务有个通用的方法,发生在应用程序到数据库之间的叫做系统事务,发生在用户到应用程序之间的叫做业务事务。
我们实际生产中遇到的大多场景都是业务事务,而其往往会跨多个系统事务。
并发会带来什么问题
从并发的本质上,我们会有两个最基本的并发问题。一个是更新丢失,一个是不一致读。
而这两个问题对应到并发所内含的问题实际上是正确性和灵活性矛盾的问题。
数据的更新丢失和数据的不一致读,是正确性的问题。要解决正确性的问题,也比较简单,我们只要能保证同一时刻只有一个线程能够操作数据就好。但是这就带来了灵活性的问题,因为你那样做,就不允许多个并发活动同时进行了。
想想,很早以前我们使用的版本控制工具VSS,它就是牺牲了灵活性来保障正确性,如果有一个人check出文件,其他任何人都不能更新。后来有了SVN以及GIT这样的版本控制工具,允许了我们可以同时多人更新,但是当合并代码的时候,如果有多人修改了同一个地方产生了冲突,它会提示我们。像SVN这样的工具,就是引入了灵活性,但是呢,在正确性上,就要依靠让我们自己来进行冲突的合并处理。
这也是,我们说的悲观和乐观。在生产实践过程中,人们往往会做出这样的选择。
人们常常需要牺牲一些正确性以获取更多的灵活性,这取决于失败的严重性和可能性以及人们对并发处理数据的需求。
从业务生产研发上,我们还有两个问题。
第一个问题,测试困难。
显式的多线程编程,加上锁和同步阻塞,太复杂了。很容易引入一些极难发现的错误——并发错误几乎是不可重现的——从而得到一个在99%的时间里正常、但偶尔会出些差错的系统。这样的系统调试起来会困难得难以置信,因此我们的策略是尽可能避免显式处理同步和锁。
第二个问题,危害性大。比如,缓存击穿,这里再次回到10年前的Facebook那个问题上,它实际上是遇到了并发对缓存的危害问题。
缓存踩踏
当大量并发线程试图并行访问缓存时,如果缓存的值不存在,那么线程将同时尝试从数据源获取数据,就会发生缓存踩踏(也有说法叫做缓存雪崩)。数据源通常是数据库,也可以是 Web 服务器、第三方 API 或任何其他可以返回数据的东西。
缓存踩踏之所以极具破坏性,一个主要原因是它会导致恶性的失败循环:
1、大量的并发线程无法从缓存中获得数据,然后直接调用数据库;
2、数据库由于巨大的 CPU 峰值发生崩溃,并导致超时错误;
3、收到超时错误后,所有的线程都会发起重试,从而导致另一次踩踏;
4、这个循环不断持续;
解决方案一:增加更多的缓存。
这个还要从操作系统上学习,我个人认为我们很多的方法都是以小见大,比如这个多级缓存,实际上在CPU的架构上就一直是这样的,比如我们的MVC这样的关注点分离式架构,实际上我们的网络分层本身一直就是这样的,你看凡是具备“工匠精神”的研发思维,我们的“出路”都是一致的。
跟着操作系统学习-做多级缓存,操作系统利用了一个缓存层次结构,其中每个组件负责缓存自己的数据,以获得更快的访问速度;
在应用程序中采用类似的模式,其中内存缓存是 Layer 1(L1) 缓存,远程缓存是 Layer 2(L2) 缓存;分散了过期时间,即使 L2 缓存中的一个值过期,L1 缓存中可能仍然有缓存的值,避免了重新计算缓存值;
这种方案会有一个问题:在应用服务器的内存中,缓存数据可能会导致内存不足,特别是在缓存大量数据的情况下。
解决方案二:更新锁。
缓存踩踏最主要的核心问题,是因为有竟态条件——多个线程争夺共享资源。在这里,共享资源就是缓存。
业务线程更新缓存,同时加更新锁:
未能获取更新锁的业务线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值,不再访问数据库。对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,但是对于采用分布式集群的业务系统,由于存在几十上百台服务器,即使单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因此分布式集群的业务系统要实现更新锁机制,需要用到分布式锁。
这种方式下,如果我们不引入一些机制,也会有一个问题:使用锁可以解决竟态条件问题,但它会带来另一个问题,即如何处理所有等待锁释放的线程?使用自旋锁并让线程连续轮询锁?这造成了一种繁忙等待。
惊群效应
这种现象实际上又叫做惊群效应。
惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。
惊群效应消耗了什么?
Linux 内核对用户进程(线程)频繁地做无效的调度、上下文切换等使系统性能大打折扣。上下文切换(context switch)过高会导致 CPU 像个搬运工,频繁地在寄存器和运行队列之间奔波,更多的时间花在了进程(线程)切换,而不是在真正工作的进程(线程)上面。直接的消耗包括 CPU 寄存器要保存和加载(例如程序计数器)、系统调度器的代码需要执行。间接的消耗在于多核 cache 之间的共享数据。为了确保只有一个进程(线程)得到资源,需要对资源操作进行加锁保护,加大了系统的开销。目前一些常见的服务器软件有的是通过锁机制解决的,比如 Nginx(它的锁机制是默认开启的,可以关闭);还有些认为惊群对系统性能影响不大,没有去处理,比如 Lighttpd。
所以就有了方案二的plus版,增加Promise。
我们缓存的不是实际数据,而是最终会提供数据的 Promise。
当访问缓存但获取不到数据时,我们不是立即去访问后端,而是创建一个 Promise 并将其放到缓存中。这个 Promise 会去查询后端。这样做的好处是,其他并发请求也会拿到这个 Promise,而所有这些并发线程都将等待后端请求返回的实际数据。
通过缓存 Promise 而不是实际数据,就不需要自旋锁。
第一个获取缓存数据失败的线程将使用原子操作(例如 Java 的 computeIfAbsent)创建并缓存异步 Promise。所有后续的 fetch 请求都会立即返回这个 Promise。
总结
我们今天从一次Facebook的并发事故谈起,谈到了为什么会有并发问题,并发问题会给我们带来哪些影响,以及我们分析了Facebook事故中的这次缓存踩踏和它的两种解决方案,当然相信还有其它的解决方案,比如通过后台线程更新缓存,有效期设置为永久。在开发生产的道路上这些及时的总结都会给我们带来沉淀的知识,以防止类似事情再次发生。
恭喜你又完成一次思考,这是第36期。这里记录,我每周读到的技术书籍、专栏、文章以及遇到的工作上的技术经历的思考,不见得都对,但开始思考总是好的。
参考资料:
https://mp.weixin.qq.com/s/edX7NuRRGN6C_2kfTwkfiA
https://www.zhihu.com/question/22756773/answer/545048210
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。