什么才是软件开发中最佳实践呢?

“描述一个事物,唯有一个名词定义它的概念,唯有一个动词揭露它的行为,唯有一个形容词表现它的特征。要做的,就是用心去寻找那个名词、那个动词、那个形容词……”

—— 福楼拜 (Gustave Flaubert)

  我想讲个故事。

  很久很久以前(一般讲故事都是这样开头吧), 两个老工程师在一起聊天,谈各自生涯中最自豪的工程。其中一个先讲述了他的杰作:

  “ 我们建造的桥,横跨一个峡谷,峡谷很宽很深。我们花了两年时间研究地质,选择材料。聘请了最好的工程师团队来设计方案,而这又花了五年时间。 我们签下了最大的工程队,委托他们建造基础结构、塔墩、收费亭,以及用于连接桥梁和高速公路的道路。桥面下层是铁路,我们甚至还修了自行车道。 那座桥花费了我数年的心血。”

 

  另外一个听完之后,陷入了沉思,过了一会儿,说到:

  “ 有一天晚上,我和一个朋友喝了点伏特加,然后我俩扔了一根绳子,越过一个河谷。呃…… 就是一根绳子,两头系在两颗树上。 河谷两边各有一个村庄,起初,有人加了个滑轮,用来传递包裹。然后,有人拉起了第二根绳子,勉强可以走走,虽然很危险,但小伙子们很喜欢。 后来,一群人重新修建了一下,使得更牢固。于是,女人们也开始从上面走,每天带着她们的农产品过桥。 就这样,在桥的另一边形成了一个市场。因为地方开阔,造了很多房子,慢慢地发展成了一个镇子。 绳索桥被木桥替代,这样就可以走马车了。 后来,镇上的人们修了一座真正的石桥。再然后,人们又把石料改成了钢材。 如今,那座钢构悬索桥依然伫立在那里。”

 

  前一个工程师沉默良久,说到:“ 有意思。我那座桥建成大约十年后,被拆除了。事实证明我们选错了地点,建好的桥没人用。据说有几个野路子的家伙,在下游几英里处,拉了一根绳子,所有人都从那走。”

 

什么才是软件开发中最佳实践呢?

金门大桥(旧金山)

 

  我很喜欢这个故事。故事的出处,在一款消息队列产品—— ZeroMQ 的官方指南第6章里。

  

  说完故事,我想聊聊软件开发中,常常可以听到的一个概念 —— Best Practice :最佳实践。Wikipedia 上对其解释为:

A best practice is a method or technique that has been generally accepted as superior to any alternatives because it produces results that are superior to those achieved by other means or because it has become a standard way of doing things. 

  (最佳实践是一种:因其产生的结果优于其它选择下的结果,或其已经成为一种做事的标准,从而被普遍认可优于任何替代方案的方法或技术。)

 

  这个概念源于管理学,然后在 IT 界泛滥。简而言之,就是所谓“正确的做法”。

  最佳实践本身是美好的存在,犹如夜空中的一轮明月,照亮黑暗中的方向,指引着摸索前行的凡人。

  但凡事有度,子曰:“过犹不及。”

  

  我今天想说的,就是这月亮的背面。(传说中,月球背面隐藏着…… 嘘~)

什么才是软件开发中最佳实践呢?

 潮汐锁定导致月球永远以同一面朝向地球

 

  首先,最佳实践容易带来思想包袱,让人无法专注于解决问题本身。

  总是希望采用最好的技术方法,不愿意在不正确的做法上浪费时间,导致瞻前顾后,甚至裹足不前。此时的最佳实践,已然成为了一种毒药,一旦偏离了问题本身这个出发点,就会不知不觉走进“宏大构想”的思维陷阱。把简单的问题复杂化,阻碍了迈出第一步,直到能规划出“包罗万象”的解决方案后才肯动手,拖延症就这样来了,时间却走了。

  你想好了未来每一天怎么过吗…… 没想好? 那……不活了?

 

  其次,对最佳实践的执念容易让人钻牛角尖,将目标的重心带偏。

  过度关注实施过程是否符合标准化,忽视了项目中其它重要的东西,比如用户体验,比如实际需求。就像故事里讲的那样:第一座大桥,几乎是教科书般的标准化路数,可产品落地后和客户需求却差了好几英里;第二个看上去很野路子,但精准地解决了痛点,从始自终都是紧紧围绕实际需求迭代,每一次的进步都可以产生效用,这才叫杀手级应用。

  这让我想起了 Plan-9 的传说。

  你听说过 Plan-9 OS 吗? 一款由贝尔实验室的极客们打造的用于完善 UNIX 不足的操作系统。什么不足?在 UNIX 的哲学中,有一条叫做 “一切皆文件” ,但实际上UNIX本身并没有严格遵从这一条。于是,Plan-9 OS 完美实现了这一点。然后呢……? 没有然后了。它从没进过市场,所以如果你没听说过它,一点也不奇怪。Plan-9 OS 没有解决任何现实问题,没人在乎 “一切皆不皆文件”。

 

  这种执念的另一种表现就是工程师思维,沉迷于奇技淫巧中无法自拔,程序员尤其容易中招。

  比如性能优化。“优秀的程序员应该榨干每一字节内存”,听起来很熟悉,不是吗?但经济学上来讲,边际效应决定了一次项目中,越优化性价比越低。有一个很容易被忽略的事实:硬件其实比程序员要便宜

  再比如对设计模式的崇拜。设计模式当然是好东西,但如果像强迫症一样使用它们,坚持用上它们才是正确的编程,就会导致按图索骥,强行让问题去适应设计模式,而不是让解决方案针对问题,这就本末倒置了。

 

  我有个基友,C++ 极客。毕业后入了腾讯,积累了巨额财富后,自己创业了。当然,当老板可比写 C++ 难多了,于是现在又去积累巨额财富了。想当年和那厮聊天,言必出设计模式,没事侃正则,再没事就研究 GC 策略 (好像玩 C++ 的普遍这德性) 。前不久看他代码,差点没认出来,这家伙画风一转,现在连接口都懒得多用(估计看到这,某些狂热分子肯定在破口大骂:你什么意思,你说你没用面向接口编程?)那位兄台甚至都懒得多聊,轻描淡写来一句,“没心思,以后有需要再加。” 

  顺便扯一句,那哥们最近负责开发一款手游,他跟老板汇报的时候,预估的研发周期要12个月,然后老板跟他说:“好,12月出公测。” (哈~ 估计他肯定舌头打结把“12个月”说成了“12月”)。看到这的你,是否回忆起了你的老板?

 

  这也是我接下来想说的关于最佳实践的另一个问题:项目实施。

  工作数年,大小项目经历若干,慢慢体会到,一个项目的开发顺利与否,并不在于技术选型是否为最佳实践,更多的时候,取决于开发方案和技术储备之间的平衡。做项目毕竟是要讲方案落地的,如果最佳实践中的技术成本,超出了开发者的落实能力,那就是坑,这时盲从最佳实践无异于挖坟。如果是一个人的项目,抽时间恶补一通,兴许能填填坑,这取决于IQ。但要是一个团队,那就不是什么 IQ,EQ,QQ 的问题了,这中间产生的学习成本,集体培训成本,反复沟通成本,大量的初级错误,千奇百怪的代码,互相冲突引发的焦躁情绪,等等。这些负面的东西如果不能妥善的处理,足以抵消掉最佳实践带来的好处。别忘了,deadline 正在迫近。

  我自己曾经在一个项目组里,强行推行 Git 做源代码管理,当时组里共9人,有7人只会 SVN,但我坚持 Git 是 “最佳实践”。要不说年少无知少不更事呢,罢了,后来的事情我不想回忆了…… 那次项目之后,我再也不在一群只会 SVN 的队伍里提 Git 了。

  

  一个人做软件已经很难,比这更难的,是一群人做软件。

 

  当尘埃落定,蓦然回首,最佳实践很可能没你想象中那么重要。它更多的是一种精神层面的求道,并非物质世界的必要。

  扎克伯格 ( Mark Zuckerberg ) 于2004年在哈佛柯克兰公寓 ( Kirkland House ) 里写出 TheFacebook 的时候 ( 次年更名为Facebook ) ,用的是 “世界上最好的编程语言” PHP。这门可能是业界被吐槽次数最多的语言一直支撑着FB帝国的诞生,直到席卷全球。Stack Overflow 的联合创始人 Jeff Atwood 曾公开揶揄 Facebook 是一家 “召集全球顶级程序员在 Windows XP 上写 PHP ” 的公司。但这无所谓,十四年前的马克也不纠结。一直等到需要的时候 (2010年),Facebook自己动手研发了一个编程语言 —— Hack,来解决 PHP 带来的危机。

什么才是软件开发中最佳实践呢?

《社交网络》

  最佳实践,关键在时机(Timing)。

  如果说用 Facebook 这个 “根本不存在” 的网站来举例,纯属虚构的话,那我们来说点真实的例子,Web 技术的基石——HTML。由20世纪最重要的100人之一的 Tim Berners-Lee 创造的 HTML,其发明之伟大,足以单独开篇博文来赞美了,这里就不赘述了。

  这样一个造福全人类的神作,本身的设计结构绝非完美,甚至可以用混乱不堪来形容。没有严格统一的约束,形同虚设的规范,标准化进程的难产。以至于在很长一段时间内,连自身元素的定义,都可以向浏览器厂商妥协。但是,种种被人诟病的存在,丝毫不影响 HTML 改变世界的脚步。你我今天能相会于园,皆仰赖它的诞生。

  同样的例子还发生在 Web 世界另一个巨擎上——JavaScript。当今世界,Web 前端技术已经水银泻地般肆虐整个开发界,前端框架百花齐放、JS 衍生品鳞次栉比。所有这一切的背后,全都源于上世纪90年代横空出世的 JavaScript。

  那么,JavaScript是最佳实践吗?

  别逗了,如果有什么语言可以和刚才说到的 PHP 竞争一下谁被骂的次数更多,那非 JavaScript 莫属。这个仅花了十天设计出来的语言,打一出身就被贴上了怪胎的标签。混乱的标准,多样的实现,安全漏洞,语法随意,反人类…… 总之,JavaScript 和最佳实践半毛钱关系都扯不上,但它却是撑起当今互联网半壁江山的擎天柱。

  所以,用最接地气地话来说,不管黑猫白猫,逮着耗子就是最佳实践猫。

 

  汝之蜜糖,吾之砒霜。所谓最佳实践,其定义本身往往也是分歧的源头。什么是最佳?这个最佳是独一无二的吗?世界上有很多很多现实问题,可能根本就没有所谓的最佳实践。

  请听题,世界上最好的编程语言是哪个?

  第二题,世界上最好的文本编辑器是哪个?

  朋友,这天还聊得下去吗……

  

  最后,说一个我自己的故事。

  很久很久以前,为了找一款满意的文本编辑器,我干了一件可能是前无古人,后不知道有没有来者的蠢事 —— 我打开 Wikipedia,搜索 “ text editor ” ,然后转到一个叫做 “ List of text editors ” 的页面,接下来的一个月,我几乎把当时那个页面上,所有我能下载安装的文本编辑器,全部试用了一遍……

  嗯?你问我为什么这么做?呵呵,不把全世界的文本编辑器遍历一遍,我怎么知道哪个是最好的?

  这事细节我不想再提了,我也不想回忆了。要不说年少无知少不更事呢,时至今日,我想不出比这更愚蠢的事了。WTF~~

什么才是软件开发中最佳实践呢?

这个页面上的表格行数逐年增多

 

  如今,再有人问我最好的编程语言或者最好的文本编辑器的问题的话,我会说: 

 

  “朋友,要打架吗?”  

 

  这两个问题的最佳实践,唯有暴力

高并发之限流,到底限的什么鬼 (精品长文)

你可能知道高并发系统需要限流这个东西,但具体是限制的什么,该如何去做,还是模凌两可。我们接下来系统性的给它归个小类,希望对你有所帮助。

google guava中提供了一个限流实现: RateLimiter,这个类设计的非常精巧,可以适用于我们日常业务中大多数流控的场景,但鉴于使用场景的多样性,使用时也需要相当小心。

前面已经使用两篇简单的文章进行了预热。
信号量限流,高并发场景不得不说的秘密
没有预热,不叫高并发,叫并发高

这次不同。本篇文章将详细的,深入的介绍限流的各种场景和属性,然后分析guava这个限流器的核心源码,并对其特性进行总结。属于稍高级的进阶篇。

限流场景

弄清楚你要限制的资源,是这个过程中最重要的一环。我大体将它分为三类。

高并发之限流,到底限的什么鬼 (精品长文)

代理层

比如SLB、nginx或者业务层gateway等,都支持限流,通常是基于连接数(或者并发数)、请求数进行限流。限流的维度通常是基于比如IP地址、资源位置、用户标志等。更进一步,还可以根据自身负载情况动态调整限流的策略(基准)。

服务调用者

服务调用方,也可以叫做本地限流,客户端可以限制某个远端服务的调用速度,超过阈值,可以直接进行阻塞或者拒绝,是限流的协作方

服务接收方

基本同上,流量超过系统承载能力时,会直接拒绝服务。通常基于应用本身的可靠性考虑,属于限流的主体方。我们常说的限流,一般发生在此处。本文主要结合RateLimiter讨论基于限流主体方的使用方式,其他的都类似。

限流策略

限流策略有时候很简单,有时候又很复杂,但常见的就三种。其他的都是在这之上进行的改进和扩展。

高并发之限流,到底限的什么鬼 (精品长文)

根据并发级别限流

这是一种简单的、易于实施的限流方式,可以使用我们前面提到的java信号量实现。它的使用场景也有着比较鲜明的特点:

1)每次请求,所需要的资源开支都比较均衡,比如,每个请求对CPU的消耗、IO消耗等,都差不多,请求的RT时间都基本接近。
2) 请求密度或稀疏或高频,这个我们不去关注。
3)资源本身不需要繁琐的初始化工作(预热),或者初始化工作的开支可以忽略。(会增加复杂度)
4)对待流量溢出的策略比较简单,通常是直接拒绝而不是等待,因为等待往往意味着故障。

这种策略通常在适用在流量的顶层组件上,比如代理层、中间件等对并发连接数的限制。而尝试获取凭证的超时时间,就叫做溢出等待。很上档次很装b的词,对不对?

漏桶算法

请求流量以不确定速率申请资源,程序处理以恒定的速率进行,就是漏桶算法的基本原理。有点像制作冰激凌的过程。-.- 有关漏桶模型,大家可以去研究一下相关资料。

大体有以下几个概念。

桶 buffer

请求首先尝试进入队列,如果队列溢满,则拒绝此请求。进入队列以后,请求则等待执行。

由此可见,请求究竟何时被执行,还存在一些变数,这直接取决于队列中pending的请求数。有时候,挑剔的设计者会考虑增加有关限制请求等待的时间阈值,这个时间就是请求入队、出队的最大时差。buffer的大小设计,通常与速率有直接关系。

漏:请求出队

这个出队,有些讲究,不同的设计理念实现就有所不同。有抢占式、有调度式。其中“抢占式”就是处理线程(或者进程,比如nginx worker进程)在上一个请求处理完毕之后即从buffer队列中poll新的请求,无论当前线程(或者进程)的处理速率是否超过设定的速率,这种策略下buffer大小就限定了速率的上限。

调度式,就比较易于理解,需要额外的调度线程(进程),并严格按照设定的速率,从buffer中获取请求,并轮训的方式将请求交给其他worker线程,如果已有的worker线程繁忙,则直接创建新线程,目的就是确保速率是有保障的,这种方式下,buffer大小主要取决于等待时间。

溢出

就是因为漏桶的速率限制比较稳定,所以其面临流量突发(bursty)几乎没有应对能力,简单来说,超出buffer,就直接拒绝。

多么可怜的请求们。

流量突发

尽管buffer的设计在一定层面上兼顾流量突发,但是还是太脆弱了,比如某个瞬间,请求密度很高(最尴尬的就是,只大了一点),将buffer溢满,或许buffer再“大一点点”就能够在合理时间内被处理;对于请求方,就会有些迷惑,“我只不过是稍微超了一点,你就给了我一连串无法工作的信息,so nave!!!”。

这种策略,也很常用,但是通常适用在限流的协作方,也是就客户端层面。请求发出之前,做流控,如果有溢出,就要用其他可靠的策略来保障结果,比如重试等;反正 “对面的服务压垮了,别怪我,我很自律”。

令牌桶

设计模型,我就不再介绍,大家可以去wiki深入了解一下。

令牌桶的基本思想,跟老一辈的集体公社时代一样,每个月的供销是限额的,有资源才分配给个人,不足部分下个月再说,你可以排队赊账。

令牌的个数,就是可以允许获取资源的请求个数(我们假设每个请求只需要一个令牌)。事实上,我们并不会真的去创建令牌实体,因为这是没有必要的,我们使用带有时间特征的计数器来表示令牌的可用个数即可。跟漏桶算法相比,令牌桶的“桶”不是用来buffer请求的、而是用来计量可用资源数量(令牌)的。虽然我们并不会创建令牌实体,但是仍然可以假想,这个桶内每隔X时间就会新增一定数量的令牌,如果没有请求申请令牌,那么这个令牌桶是会溢出的…你会发现,这个设计跟漏桶算法从IO方向上是相反的。

那么漏桶算法的缺点,也正好成为了令牌桶的专长:流量突发;令牌桶成了buffer,如果请求密度低,或者处于冷却状态,那么令牌桶就会溢满,此后如果流量突发,则过去积累的结余资源则可以直接被“借用”。

令牌桶算法,使用场景很多,适应程度很高,现实中流量突发是常见的,而且从设计角度考虑,令牌桶更易于实现。回到正题,RateLimiter,就是一个基于令牌桶思想的实现。

我们的口子越缩越小,终于到正题了。

RateLimiter使用

guava的api已经把它的设计思想阐述的比较清楚了,但是这个注释阅读起来还是稍微有点“哲学派”,我们先看两个栗子,然后从源码层面看下它的设计原理。

//RateLimiter limiter = RateLimiter.create(10,2, TimeUnit.SECONDS);//QPS 100
RateLimiter limiter = RateLimiter.create(10);
long start = System.currentTimeMillis();
for (int i= 0; i < 30; i++) {
    double time = limiter.acquire();
    long after = System.currentTimeMillis() - start;
    if (time > 0D) {
        System.out.println(i + ",limited,等待:" + time + ",已开始" + after + "毫秒");
    } else {
        System.out.println(i + ",enough" + ",已开始" + after + "毫秒");
    }
    //模拟冷却时间,下一次loop可以认为是bursty开始
    if (i == 9) {
        Thread.sleep(2000);
    }
}
System.out.println("total time:" + (System.currentTimeMillis() - start));

此例为简单的流控,只有一种资源,QPS为10;在实际业务场景中,可能不同的资源速率是不同的,我们可以创建N多个limeter各自服务于资源。

acquire()方法就是获取一个令牌(源码中使用permit,许可证),如果permit足够,则直接返回而无需等待,如果不足,则等待1/QPS秒。

此外,你会发现, limiter并没有类似于锁机制中的release()方法 ,这意味着“只要申请,总会成功”、且退出时也无需归还。

RateLimiter内部有两种实现:(下文中,“资源”、“令牌”、“permits”为同一含义)

SmoothBursty

可以支持“突发流量”的限流器,即当限流器不被使用时间,可以额外存储一些permits以备突发流量,当突发流量发生时可以更快更充分的使用资源,流量平稳后(或者冷却期,积累的permits被使用完之后)速率处于限制状态。

其重点就是,冷却期间,permits会积累,且在突发流量时,可以消耗此前积累的permits而且无需任何等待。就像一个人,奔跑之后休息一段时间,再次起步可以有更高的速度。

由此可见,如果你的资源,冷却(不被使用)一段时间之后,再次被使用时可以提供比正常更高的效率,这个时候,你可以使用SmoothBursty。

创建方式

RateLimiter.create(double permitsPerSecond)

结果类似

0,enough,已开始1毫秒
1,limited,等待:0.098623,已开始105毫秒
2,limited,等待:0.093421,已开始202毫秒
3,limited,等待:0.098287,已开始304毫秒
4,limited,等待:0.096025,已开始401毫秒
5,limited,等待:0.098969,已开始505毫秒
6,limited,等待:0.094892,已开始605毫秒
7,limited,等待:0.094945,已开始701毫秒
8,limited,等待:0.099145,已开始801毫秒
9,limited,等待:0.09886,已开始905毫秒
10,enough,已开始2908毫秒
11,enough,已开始2908毫秒
12,enough,已开始2908毫秒
13,enough,已开始2908毫秒
14,enough,已开始2908毫秒
15,enough,已开始2908毫秒
16,enough,已开始2908毫秒
17,enough,已开始2908毫秒
18,enough,已开始2908毫秒
19,enough,已开始2908毫秒
20,enough,已开始2909毫秒
21,limited,等待:0.099283,已开始3011毫秒
22,limited,等待:0.096308,已开始3108毫秒
23,limited,等待:0.099389,已开始3211毫秒
24,limited,等待:0.096674,已开始3313毫秒
25,limited,等待:0.094783,已开始3411毫秒
26,limited,等待:0.097161,已开始3508毫秒
27,limited,等待:0.099877,已开始3610毫秒
28,limited,等待:0.097551,已开始3713毫秒
29,limited,等待:0.094606,已开始3809毫秒
total time:3809

SmoothWarmingUp

具有warming up(预热)特性,即突发流量发生时,不能立即达到最大速率,而是需要指定的“预热时间”内逐步上升最终达到阈值;它的设计哲学,与SmoothBursty相反,当突发流量发生时,以可控的慢速、逐步使用资源(直到最高速率),流量平稳后速率处于限制状态。

其重点是,资源一直被使用,那么它可以持续限制稳定的速率;否则,冷却时间越长(有效时长为warmup间隔)获取permits时等待的时间越长,需要注意,冷却时间会积累permits,但是获取这些permits仍然需要等待。

由此可见,如果你的资源,冷却(不被使用)一段时间之后,再次被使用时它需要一定的准备工作,此时它所能提供的效率比正常要低;比如链接池、数据库缓存等。

创建方式

RateLimiter.create(double permitsPerSecond,long warnupPeriod,TimeUnit unit)

执行结果如下,可以看到有一个明显的增长过程。

0,enough,已开始1毫秒
1,limited,等待:0.288847,已开始295毫秒
2,limited,等待:0.263403,已开始562毫秒
3,limited,等待:0.247548,已开始813毫秒
4,limited,等待:0.226932,已开始1041毫秒
5,limited,等待:0.208087,已开始1250毫秒
6,limited,等待:0.189501,已开始1444毫秒
7,limited,等待:0.165301,已开始1614毫秒
8,limited,等待:0.145779,已开始1761毫秒
9,limited,等待:0.128851,已开始1891毫秒
10,enough,已开始3895毫秒
11,limited,等待:0.289809,已开始4190毫秒
12,limited,等待:0.264528,已开始4458毫秒
13,limited,等待:0.247363,已开始4710毫秒
14,limited,等待:0.225157,已开始4939毫秒
15,limited,等待:0.206337,已开始5146毫秒
16,limited,等待:0.189213,已开始5337毫秒
17,limited,等待:0.167642,已开始5510毫秒
18,limited,等待:0.145383,已开始5660毫秒
19,limited,等待:0.125097,已开始5786毫秒
20,limited,等待:0.109232,已开始5898毫秒
21,limited,等待:0.096613,已开始5999毫秒
22,limited,等待:0.096321,已开始6098毫秒
23,limited,等待:0.097558,已开始6200毫秒
24,limited,等待:0.095132,已开始6299毫秒
25,limited,等待:0.095495,已开始6399毫秒
26,limited,等待:0.096352,已开始6496毫秒
27,limited,等待:0.098641,已开始6597毫秒
28,limited,等待:0.097883,已开始6697毫秒
29,limited,等待:0.09839,已开始6798毫秒
total time:6798

acquire方法源码分析

上面两个类都继承自SmoothRateLimiter,最终继承自RateLimiter;RateLimiter内部核心的方法:

1)double acquire():获取一个permit,如果permits充足则直接返回,否则等待1/QPS秒。此方法返回线程等待的时间(秒),如果返回0.0表示未限流、未等待。

2)double acquire(int n):获取n个permits,如果permits充足则直接返回,否则限流并等待,等待时间为“不足的permits个数 / QPS”。(暂且这么解释)

下面就是这个方法的伪代码啦。

//伪代码
public double acquire(int requiredPermits) {
    long waitTime = 0L;
    synchronized(mutex) {
          boolean cold = nextTicketTime > now;
          if (cold) {
             storedPermits = 根据冷却时长计算累积的permits;
             nextTicketTime = now;
          }
          //根据storedPermits、requiredPermits计算需要等待的时间
          //bursty:如果storePermits足够,则waitTime = 0
          //warmup:平滑预热,storePermits越多(即冷却时间越长),等待时间越长
          if(storedPermits不足) {
              waitTime += 欠缺的permits个数 / QPS;
          }
          if(bursty限流) {
              waitTime += 0;//即无需额外等待
          }
          if(warmup限流) {
              waitTime += requiredPermits / QPS;
              if(storedPermits > 0.5 * maxPermits) {
                waitTime += 阻尼时间;
              }
          }

nextTicketTime += waitTime
}
if (waitTime > 0L) {
Thread.sleep(waitTime);
}
return waitTime;
}


以下内容会比较枯燥~~~非常枯燥~~~

1、Object mutex:同步锁,如上述伪代码所示,在计算存量permits、实际申请permits(包括计算)的过程中全部是同步的;我们需要知道,RateLimiter内部确实使用了锁同步机制。

2、maxPermits:最大可存储的许可数量(tickets数量),SmoothBursty和SmoothWarimingUp默认实现中,有所不同:
1)SmoothBusty,其值为maxBurstSecond QPS,就是允许“突发流量持续时间” QPS,这种设计可以理解,不过RateLimiter将maxBustSecond硬编码为1.0,最终此值等于QPS。
2)SmoothWarmingUp:默认算法值为warmupPeriod QPS,简单说就是“预热时长” QPS。

此参数主要限制,无论冷却多长时间,其storedPermits不能超过此值;此值在设定QPS之后,则不会再改变。

3、storedPermits:已存储的permits数量,此值取决于冷却时间,简单来说冷却的时间越久,此值越大,但不会超过maxPermits,起始值为0。
1)当一个请求,申请permit之前,将会计算上一次令牌申请(nexFreeTicketTime)的时间与now之间的时差,并根据令牌产生速率(1/QPS)计算此冷却期间可以存储的令牌数,也就是storedPermits。
2)permits申请完毕之后,将当前时间(如果需要等待,额外加上等待时间)作为下一次令牌申请的起始时间,此时冷却时间结束。
3)申请完毕之后,storedPermits将会减去申请的permits个数,直到为0。

冷却时长和申请频次,都决定了storedPermits大小,其中冷却时间会导致storePermits增加,acquire操作将导致storePermits减少。

4、nextFreeTicketMicros(时间戳,微妙单位):下一个可以自由获取的令牌的时间,此值可以为未来的某个时间,此时表示限流已经开始,部分请求已经在等待,此值主要用来标记“冷却状态”。(赊账)
1)如果处于冷却期,那么此值通常是过去式,即此值小于now。
2)如果此时有请求申请permits,则会通过此值与now的时差,计算storedPermits,同时将此值设置为now。
3)如果此值是未来时刻,即大于now,则无需计算storedPermits,也无需重置此值。
4)申请tickets后,从storedPermits减去需要的tickets个数,如果触发限速等待(比如预热期、permits不足),则会将2)操作之后额外增加等待时间作为nextFreeTicketsTime值。
5)基于2),对于warmingUp限流,冷却期之后的首个请求是不需要等待的,只是将此值设置为now + 阻尼性质的等待时间waitTime(),这意味着在此后waitTime期间再有请求,则会触发等待,并继续延续nextFreeTicketMicros值。此值的延续,在warming up期间,阻尼waitTime计算比较复杂,由1/QPS + 额外值,这个额外值,随着预热时间增长而减小。
6)基于2),对于bursty限流,如果storedPermits大于0,则总是不需要等待,只是简单将此值设为为now;否则,则按照正常的1/QPS间隔计算其应该被推延的时间点。

5、对于warming up限流,将maxPermits * 0.5作为一个阈值分割线,当storedPermits小于此分割线时,在限流时使用正常等待时间(申请permits个数 / QPS);在此分割线之上时,则4)增加额外阻尼,即预热阻尼。

6、我们发现,RateLimiter内部并不会真的生成tickets实体,而是根据冷却时长、在申请资源时才计算存量tickets(对应为storedPermits)。无论何种限流,storedPermits都是优先使用。

小总结

是时候总结一下了。

RateLimiter是线程安全的,所以在并发环境中可以直接使用,而无需额外的lock或者同步。

考虑到RateLimiter内部的同步锁,我们通常在实际业务开发中,每个资源(比如URL)使用各自的RateLimiter而不是公用一个,占用的内存也不大。

这个限流器内部无额外的线程,也没有其他的数据结构用来存储tickets实体,所以它非常的轻量级,这也是优势所在。

RateLimiter最大的问题,就是acquire方法总会成功,内部的tickets时间点会向后推移; 如果并发很高,严重超过rate阈值时,后续被限流的请求,其等待时间将会基于时间线累加,导致等待时间不可控,这和信号量同病相怜。

为了避免上面的问题,我们通常先使用tryAcquired检测,如果可行再去acquire;如果令牌不足,适当拒绝。所以 基于RateLimiter,并没有内置的拒绝策略,这一点需要我们额外开发。

我们不能简单依赖于acquire方法,来实现限流等待,否则这可能带来严重问题。我们通常需要封装RateLimiter,并使用额外的属性记录其是否“处于限流状态”、“已经推延的tickets时间点”,如果“已经推延的时间点非常遥远”且超过可接受范围,则直接拒绝请求。简单来说,封装acquire方法,增加对请求可能等待时间的判断,如果超长,则直接拒绝。

RateLimiter存在一个很大的问题,就是几乎没法扩展:子类均为protected。反射除外哦。

End

可以看到,guava提供了一个非常轻量而全面的限流器。它本身没有使用多线程去实现,但它是线程安全的。相比较信号量,它的使用简单的多。但鉴于限流场景的多样性,使用时同样要非常小心。

什么是脏读、幻读?

一、事务

事务是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。–摘自百科

在MySQL里,事务是在引擎层面实现,比如MyIsam不支持,InnoDB支持

二、ACID

提到事务,肯定会想到 ACID 是吧,自行感受一下概念,然后我们来讲讲隔离性的问题。

  • 原子性( Atomicity):事务的所有操作要么全部成功,要么全部回滚。

  • 一致性( Consistency):总是从一个一致性的状态转换到另一个一致性的状态。

  • 隔离性( Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行

  • 持久性( Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。

三、隔离级别

脏读 幻读 不可重复读,做了多年的 CRUDer,对这几个词真是不陌生,要不是出去面试,也真不会去了解。希望大家看完之后,面试的时候,不要慌,跟他刚。

实际上,这些场景都是出现在多个事务同时执行时的场景。

3.1 脏读(Read Uncommitted)

通俗的讲,一个事务在处理过程中读取了另外一个事务未提交的数据。

你都还没提交,我就读到了你刚操作的数据,万一你回滚了怎么办,你说这脏不脏。

举例:

面试官:什么是脏读、幻读?

假设打赏的逻辑是:① 我的账户+1元;② 你的账户-1元。

当你执行到第一个步骤,我去查询我的账户已经是2元了,很开心!!!宣布请大家去撸串!!!但是最后扣款的时候发现你余额不足了,回滚了,我的1元没了,就很难受!!

面试官:什么是脏读、幻读?

3.2 不可重复读(Non-repeatable Read)

通俗的讲,一个事务范围内,多次查询某个数据,却得到不同的结果。

与脏读的区别:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但实际上是违反了事务的一致性原则。

举例:

面试官:什么是脏读、幻读?

假设我查了下账户余额,看到你们给小编打赏了1块钱,很开心!!!宣布请大家去撸串!!!在付款之前,钱被另外一个人取走,又查询到没钱了,被留下来洗碗了!!!

因为我查询完后,这条数据没锁住,又被别的事务更新了,导致当前事务每次都是读到最新的数据。

面试官:什么是脏读、幻读?

3.3 幻读

在Repeatable Read隔离级别下,一个事务可能会遇到幻读(Phantom Read)的问题。

事务A读取与搜索条件相匹配的若干行。事务B以插入或删除行等方式来修改事务A的结果集,然后再提交。

举例:

面试官:什么是脏读、幻读?

看到了吗,在一个事务A中,第一次查询某条记录,是没有的,但是,当试图更新这条不存在的记录时,竟然能成功,并且,再次读取同一条记录,它就神奇地出现了。

实际上,在InnoDB引擎中,对于索引的扫描,不仅锁住扫描到的索引,而且还锁住这些索引覆盖的范围(gap),因此这个范围是内插入数据是不允许的。