整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

从头认识JavaScript的事件循环模型

. JS的运行机制

介绍

众所周知JavaScript是一门单线程的语言,所以在JavaScript的世界中默认的情况下,同一个时间节点只能做一件事情,这样的设定就造成了JavaScript这门语言的一些局限性,比如在我们的页面中加载一些远程数据时,如果按照单线程同步的方式运行,一旦有HTTP请求向服务器发送,就会出现等待数据返回之前网页假死的效果出现。因为JavaScript在同一个时间只能做一件事,这就导致了页面渲染和事件的执行,在这个过程中无法进行。显然在实际的开发中我们并没有遇见过这种情况。

关于同步和异步

基于以上的描述,我们知道在JavaScript的世界中,应该存在一种解决方案,来处理单线程造成的诟病。这就是同步【阻塞】和异步【非阻塞】执行模式的出现。

同步(阻塞)

同步的意思是JavaScript会严格按照单线程(从上到下、从左到右的方式)执行代码逻辑,进行代码的解释和运行,所以在运行代码时,不会出现先运行4、5行的代码,再回头运行1、3行的代码这种情况。比如下列操作。


var a = 1
var b = 2
var c = a + b
//这个例子总c一定是3不会出现先执行第三行然后在执行第二行和第一行的情况
console.log(c)

接下来通过下列的案例升级一下代码的运行场景:

var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
while(d2-d1<2000){
  d2 = new Date().getTime()
}
//这段代码在输出结果之前网页会进入一个类似假死的状态
console.log(a+b)

当我们按照顺序执行上面代码时,我们的代码在解释执行到第4行时,还是正常的速度执行,但是在下一行就会进入一个持续的循环中。d2d1在行级间的时间差仅仅是毫秒内的差别,所以在执行到while循环的时候d2-d1的值一定比2000小,那么这个循环会执行到什么时候呢?由于每次循环时,d2都会获取一次当前的时间发生变化,直到d2-d1==2000等情况,这时也就是正好过了2秒的时间,我们的程序才能跳出循环,进而再输出a+b的结果。那么这段程序的实际执行时间至少是2秒以上。这就导致了程序阻塞的出现,这也是为什么将同步的代码运行机制叫做阻塞式运行的原因。

阻塞式运行的代码,在遇到消耗时间的代码片段时,之后的代码都必须等待耗时的代码运行完毕,才能得到执行资源,这就是单线程同步的特点。

异步(非阻塞):

在上面的阐述中,我们明白了单线程同步模型中的问题所在,接下来引入单线程异步模型的介绍。异步的意思就是和同步对立,所以异步模式的代码是不会按照默认顺序执行的。JavaScript执行引擎在工作时,仍然是按照从上到下从左到右的方式解释和运行代码。在解释时,如果遇到异步模式的代码,引擎会将当前的任务“挂起”并略过。也就是先不执行这段代码,继续向下运行非异步模式的代码,那么什么时候来执行异步代码呢?直到同步代码全部执行完毕后,程序会将之前“挂起”的异步代码按照“特定的顺序”来进行执行,所以异步代码并不会【阻塞】同步代码的运行,并且异步代码并不是代表进入新的线程同时执行,而是等待同步代码执行完毕再进行工作。我们阅读下面的代码分析:

var a = 1
var b = 2
setTimeout(function(){
  console.log('输出了一些内容')
},2000)
//这段代码会直接输出3并且等待2秒左右的时间在输出function内部的内容
console.log(a+b)

这段代码的setTimeout定时任务规定了2秒之后执行一些内容,在运行当前程序执行到setTimeout时,并不会直接执行内部的回调函数,而是会先将内部的函数在另外一个位置(具体是什么位置下面会介绍)保存起来,然后继续执行下面的console.log进行输出,输出之后代码执行完毕,然后等待大概2秒左右,之前保存的函数再执行。

非阻塞式运行的代码,程序运行到该代码片段时,执行引擎会将程序保存到一个暂存区,等待所有同步代码全部执行完毕后,非阻塞式的代码会按照特定的执行顺序,分步执行。这就是单线程异步的特点。

通俗的讲:

通俗的讲,同步和异步的关系是这样的:

【同步的例子】:比如我们在核酸检测站,进行核酸检测这个流程就是同步的。每个人必须按照来的时间,先后进行排队,而核酸检测人员会按照排队人的顺序严格的进行逐一检测,在第一个人没有检测完成前,第二个人就得无条件等待,这个就是一个阻塞流程。如果排队过程中第一个人在检测时出了问题,如棉签断了需要换棉签,这样更换时间就会追加到这个人身上,直到他顺利的检测完毕,第二个人才能轮到。如果在检测中间棉签没有了,或者是录入信息的系统崩溃了,整个队列就进入无条件挂起状态所有人都做不了了。这就是结合生活中的同步案例。

【异步的例子】:还是结合生活中,当我们进餐馆吃饭时,这个场景就属于一个完美的异步流程场景。每一桌来的客人会按照他们来的顺序进行点单,假设只有一个服务员的情况,点单必须按照先后顺序,但是服务员不需要等第一桌客人点好的菜出锅上菜,就可以直接去收集第二桌第三桌客人的需求。这样可能在十分钟之内,服务员就将所有桌的客人点菜的菜单统计出来,并且发送给了后厨。之后的菜也不会按照点餐顾客的课桌顺序,因为后厨收集到菜单之后可能有1,2,3桌的客人都点了锅包肉,那么他可能会先一次出三份锅包肉,这样锅包肉在上菜的时候1,2,3桌的客人都能得到,并且其他的菜也会乱序的逐一上菜,这个过程就是异步的。如果按照同步的模式点餐,默认在饭店点菜就会出现饭店在第一桌客人上满菜之前第二桌之后的客人就只能等待连单都不能点的状态。

总结:

JavaScript的运行顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前的同步任务执行完毕之后才能执行。请看下面的案例:

var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
setTimeout(function(){
  console.log('我是一个异步任务')
},1000)
while(d2-d1<2000){
  d2 = new Date().getTime()
}
//这段代码在输出3之前会进入假死状态,'我是一个异步任务'一定会在3之后输出
console.log(a+b)

观察上面的程序我们实际运行之后就会感受到单线程异步模型的执行顺序了,并且这里我们会发现setTimeout设置的时间是1000毫秒但是在while的阻塞2000毫秒的循环之后并没有等待1秒而是直接输出了我是一个异步任务,这是因为setTimout的时间计算是从setTimeout()这个函数执行时开始计算的。

JS的线程组成

上面我们通过几个简单的例子大概了解了一下JS的运行顺序,那么为什么是这个顺序,这个顺序的执行原理是什么样的,我们应该如何更好更深的探究真相呢?这里需要介绍一下浏览器中一个Tab页面的实际线程组成。

在了解线程组成前要了解一点,虽然浏览器是单线程执行JavaScript代码的,但是浏览器实际是以多个线程协助操作来实现单线程异步模型的,具体线程组成如下:

  1. GUI渲染线程
  2. JavaScript引擎线程
  3. 事件触发线程
  4. 定时器触发线程
  5. http请求线程
  6. 其他线程

按照真实的浏览器线程组成分析,我们会发现实际上运行JavaScript的线程其实并不是一个,但是为什么说JavaScript是一门单线程的语言呢?因为这些线程中实际参与代码执行的线程并不是所有线程,比如GUI渲染线程为什么单独存在,这个是防止我们在html网页渲染一半的时候突然执行了一段阻塞式的JS代码而导致网页卡在一半停住这种效果。JavaScript代码运行的过程中实际执行程序时,同时只存在一个活动线程,这里实现同步异步就是靠多线程切换的形式来进行实现的

所以我们通常分析时,将上面的细分线程归纳为下列两条线程:

  1. 【主线程】:这个线程用来执行页面的渲染,JavaScript代码的运行,事件的触发等等
  2. 【工作线程】:这个线程是在幕后工作的,用来处理异步任务的执行来实现非阻塞的运行模式

2. JavaScript的运行模型

上图是JavaScript运行时的一个工作流程和内存划分的简要描述,我们根据图中可以得知主线程就是我们JavaScript执行代码的线程,主线程代码在运行时,会按照同步和异步代码将其分成两个去处,如果是同步代码执行,就会直接将该任务放在一个叫做“函数执行栈”的空间进行执行,执行栈是典型的【栈结构】(先进后出),程序在运行的时候会将同步代码按顺序入栈,将异步代码放到【工作线程】中暂时挂起,【工作线程】中保存的是定时任务函数、JS的交互事件、JS的网络请求等耗时操作。

当【主线程】将代码块筛选完毕后,进入执行栈的函数会按照从外到内的顺序依次运行,运行中涉及到的对象数据是在堆内存中进行保存和管理的。当执行栈内的任务全部执行完毕后,执行栈就会清空。执行栈清空后,“事件循环”就会工作,“事件循环”会检测【任务队列】中是否有要执行的任务,那么这个任务队列的任务来源就是工作线程,程序运行期间,工作线程会把到期的定时任务、返回数据的http任务等【异步任务】按照先后顺序插入到【任务队列】中,等执行栈清空后,事件循环会访问任务队列,将任务队列中存在的任务,按顺序(先进先出)放在执行栈中继续执行,直到任务队列清空。

从代码片段开始分析

function task1(){
    console.log('第一个任务')
}
function task2(){
    console.log('第二个任务')
}
function task3(){
    console.log('第三个任务')
}
function task4(){
    console.log('第四个任务')
}
task1()
setTimeout(task2,1000)
setTimeout(task3,500)
task4()

刚才的文字阅读可能在大脑中很难形成一个带动画的图形界面来帮助我们分析JavaScript的实际运行思路,接下来我们将这段代码肢解之后详细的研究一下。

按照字面分析:

按照字面分析,我们创建了四个函数代表4个任务,函数本身都是同步代码。在执行的时候会按照1,2,3,4进行解析,解析过程中我们发现任务2和任务3被setTimeout进行了定时托管,这样就只能先运行任务1和任务4了。当任务1和任务4运行完毕之后500毫秒后运行任务3,1000毫米后运行任务2。

那么他们在实际运行时又是经历了怎么样的流程来运行的呢?大概的流程我们以图解的形式分析一下。

图解分析:

如上图,在上述代码刚开始运行的时候我们的主线程即将工作,按照顺序从上到下进行解释执行,此时执行栈、工作线程、任务队列都是空的,事件循环也没有工作。接下来我们分析下一个阶段程序做了什么事情。

结合上图可以看出程序在主线程执行之后就将任务1、4和任务2、3分别放进了两个方向,任务1和任务4都是立即执行任务所以会按照1->4的顺序进栈出栈(这里由于任务1和4是平行任务所以会先执行任务1的进出栈再执行任务4的进出栈),而任务2和任务3由于是异步任务就会进入工作线程挂起并开始计时,并不影响主线程运行,此时的任务队列还是空置的。

我们发现同步任务的执行速度是飞快的,这样一下执行栈已经空了,而任务2和任务3还没有到时间,这样我们的事件循环就会开始工作等待任务队列中的任务进入,接下来就是执行异步任务的时候了。

我们发现任务队列并不是一下子就会将任务2和任务三一起放进去,而是哪个计时器到时间了哪个放进去,这样我们的事件循环就会发现队列中的任务,并且将任务拿到执行栈中进行消费,此时会输出任务3的内容。

到这就是最后一次执行,当执行完毕后工作线程中没有计时任务,任务队列的任务清空程序到此执行完毕。

总结

我们通过图解之后脑子里就会更清晰的能搞懂异步任务的执行方式了,这里采用最简单的任务模型进行描绘复杂的任务在内存中的分配和走向是非常复杂的,我们有了这次的经验之后就可以通过观察代码在大脑中先模拟一次执行,这样可以更清晰的理解JS的运行机制。

关于执行栈

执行栈是一个栈的数据结构,当我们运行单层函数时,执行栈执行的函数进栈后,会出栈销毁然后下一个进栈下一个出栈,当有函数嵌套调用的时候栈中就会堆积栈帧,比如我们查看下面的例子:

function task1(){
  console.log('task1执行')
  task2()
  console.log('task2执行完毕')
}
function task2(){
  console.log('task2执行')
  task3()
  console.log('task3执行完毕')
}
function task3(){
  console.log('task3执行')
}
task1()
console.log('task1执行完毕')

我们根据字面阅读就能很简单的分析出输出的结果会是

/*
task1执行
task2执行
task3执行
task3执行完毕
task2执行完毕
task1执行完毕
*/

那么这种嵌套函数在执行栈中的操作流程是什么样的呢?

第一次执行的时候调用task1函数执行到console.log的时候先进行输出,接下来会遇到task2函数的调用会出现下面的情况:

执行到此时检测到task2中还有调用task3的函数,那么就会继续进入task3中执行,如下图:

在执行完task3中的输出之后task3内部没有其他代码,那么task3函数就算执行完毕那么就会发生出栈工作。

此时我们会发现task3出栈之后程序运行又会回到task2的函数中继续他的执行。接下来会发生相同的事情。

再之后就剩下task1自己了,他在task2销毁之后输出task2执行完毕后他也会随着出栈而销毁。

task1执行完毕之后它随着销毁最后一行输出,就会进入执行栈执行并销毁,销毁之后执行栈和主线程清空。这个过程就会出现123321的这个顺序,而且我们在打印输出时,也能通过打印的顺序来理解入栈和出栈的顺序和流程。

关于递归

关于上面的执行栈执行逻辑清楚后,我们就顺便学习一下递归函数,递归函数是项目开发时经常涉及到的场景。我们经常会在未知深度的树形结构,或其他合适的场景中使用递归。那么递归在面试中也会经常被问到风险问题,如果了解了执行栈的执行逻辑后,递归函数就可以看成是在一个函数中嵌套n层执行,那么在执行过程中会触发大量的栈帧堆积,如果处理的数据过大,会导致执行栈的高度不够放置新的栈帧,而造成栈溢出的错误。所以我们在做海量数据递归的时候一定要注意这个问题。

关于执行栈的深度:

执行栈的深度根据不同的浏览器和JS引擎有着不同的区别,我们这里就Chrome浏览器为例子来尝试一下递归的溢出:

var i = 0;
function task(){
  let index = i++
  console.log(`递归了${index}次`)
  task()
  console.log(`第${index}次递归结束`)
}

task()

我们发现在递归了11378次之后会提示超过栈深度的错误,也就是我们无法在Chrome或者其他浏览器做太深层的递归操作。

如何跨越递归限制

发现问题后,我们再考虑如何能通过技术手段跨越递归的限制。可以将代码做如下更改,这样就不会出现递归问题了。

var i = 0;
function task(){
  let index = i++
  console.log(`递归了${index}次`)
  setTimeout(function(){
    task()
  })
  console.log(`第${index}次递归结束`)
}
task()

我们发现只是做了一个小小的改造,这样就不会出现溢出的错误了。这是为什么呢?

在了解原因之前我们先看控制台的输出,结合控制台输出我们发现确实超过了界限也没有报错。

图解原因:

这个是因为我们这里使用了异步任务去调用递归中的函数,那么这个函数在执行的时候就不只使用栈进行执行了。

先看没有异步流程时候的执行图例:


再看有了异步任务的递归:

有了异步任务之后我们的递归就不会叠加栈帧了,因为放入工作线程之后该函数就结束了,可以出栈销毁,那么在执行栈中就永远都是只有一个任务在运行,这样就防止了栈帧的无限叠加,从而解决了无限递归的问题,不过异步递归的过程是无法保证运行速度的,在实际的工作场景中,如果考虑性能问题,还需要使用 while 循环等解决方案,来保证运行效率的问题,在实际工作场景中,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加,其性能也远远不及指针循环。


3.宏任务和微任务

在明确了事件循环模型以及JavaScript的执行流程后,我们认识了一个叫做任务队列的容器,他的数据结构式队列的结构。所有除同步任务外的代码都会在工作线程中,按照他到达的时间节点有序的进入任务队列,而且任务队列中的异步任务又分为【宏任务】和【微任务】。

举个例子:

在了解【宏任务】和【微任务】前,还是哪生活中的实际场景举个例子:

比如: 在去银行办理业务时,每个人都需要在进入银行时找到取票机进行取票,这个操作会把来办理业务的人按照取票的顺序排成一个有序的队列。假设银行只开通了一个办事窗口,窗口的工作人员会按照排队的顺序进行叫号,到达号码的人就可以前往窗口办理业务,在第一个人办理业务的过程中,第二个以后的人都需要进行等待。

这个场景与JavaScript的异步任务队列执行场景是一模一样的,如果把每个办业务的人当作JavaScript中的每一个异步的任务,那么取号就相当于将异步任务放入任务队列。银行的窗口就相当于【函数执行栈】,在叫号时代表将当前队列的第一个任务放入【函数执行栈】运行。这时可能每个人在窗口办理的业务内容各不相同,比如第一个人仅仅进行开卡的操作,这样银行工作人员就会为其执行开卡流程,这就相当于执行异步任务内部的代码。

如果第一个人的银行卡开通完毕,银行的工作人员不会立即叫第二个人过来,而是会询问第一个人,“您是否需要为刚才开通的卡办理一些增值业务,比如做个活期储蓄。”,这时相当于在原始开卡的业务流程中临时追加了一个新的任务,按照JavaScript的执行顺序,这个人的新任务应该回到取票机拿取一张新的号码,并且在队尾重新排队,这样工作的话办事效率就会急剧下降。所以银行实际的做法是在叫下一个人办理业务前,如果前面的人临时有新的业务要办理,工作人员会继续为其办理业务,直到这个人的所有事情都办理完毕。

从取卡到办理追加业务完成的这个过程,就是微任务的实际体现。在JavaScript运行环境中,包括主线程代码在内,可以理解为所有的任务内部都存在一个微任务队列,在每下一个宏任务执行前,事件循环系统都会先检测当前的代码块中是否包含已经注册的微任务,并将队列中的微任务优先执行完毕,进而执行下一个宏任务。所以实际的任务队列的结构是这样的,如图:

宏任务与微任务的介绍

由上述内容得知JavaScript中存在两种异步任务,一种是宏任务一种是微任务,他们的特点如下:

宏任务

宏任务是JavaScript中最原始的异步任务,包括setTimeoutsetIntervalAJAX等,在代码执行环境中按照同步代码的顺序,逐个进入工作线程挂起,再按照异步任务到达的时间节点,逐个进入异步任务队列,最终按照队列中的顺序进入函数执行栈进行执行。

微任务

微任务是随着ECMA标准升级提出的新的异步任务,微任务在异步任务队列的基础上增加了【微任务】的概念,每一个宏任务执行前,程序会先检测其中是否有当次事件循环未执行的微任务,优先清空本次的微任务后,再执行下一个宏任务,每一个宏任务内部可注册当次任务的微任务队列,再下一个宏任务执行前运行,微任务也是按照进入队列的顺序执行的。

总结

JavaScript的运行环境中,代码的执行流程是这样的:

  1. 默认的同步代码按照顺序从上到下,从左到右运行,运行过程中注册本次的微任务和后续的宏任务:
  2. 执行本次同步代码中注册的微任务,并向任务队列注册微任务中包含的宏任务和微任务
  3. 将下一个宏任务开始前的所有微任务执行完毕
  4. 执行最先进入队列的宏任务,并注册当次的微任务和后续的宏任务,宏任务会按照当前任务队列的队尾继续向下排列

常见的宏任务和微任务划分

宏任务

#

浏览器

Node

I/O

setTimeout

setInterval

setImmediate

requestAnimationFrame

有些地方会列出来UI Rendering,说这个也是宏任务,可是在读了HTML规范文档以后,发现这很显然是和微任务平行的一个操作步骤 requestAnimationFrame姑且也算是宏任务吧,requestAnimationFrame在MDN的定义为,下次页面重绘前所执行的操作,而重绘也是作为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行

微任务

#

浏览器

Node

process.nextTick

MutationObserver

Promise.then catch finally

经典笔试题

代码输出顺序问题1

setTimeout(function() {console.log('timer1')}, 0)
 
requestAnimationFrame(function(){
    console.log('UI update')
})
 
setTimeout(function() {console.log('timer2')}, 0)
 
new Promise(function executor(resolve) {
    console.log('promise 1')
    resolve()
    console.log('promise 2')
}).then(function() {
    console.log('promise then')
})
 
console.log('end')

解析:

本案例输出的结果为:猜对我就告诉你,先思考,猜对之后结合运行结果分析。

按照同步先行,异步靠后的原则,阅读代码时,先分析同步代码和异步代码,Promise对象虽然是微任务,但是new Promise时的回调函数是同步执行的,所以优先输出promise 1 和 promise 2。

resolve执行时Promise对象的状态变更为已完成,所以then函数的回调被注册到微任务事件中,此时并不执行,所以接下来应该输出end

同步代码执行结束后,观察异步代码的宏任务和微任务,在本次的同步代码块中注册的微任务会优先执行,参考上文中描述的列表,Promise为微任务,setTimeoutrequestAnimationFrame为宏任务,所以Promise的异步任务会在下一个宏任务执行前执行,所以promise then是第四个输出的结果。

接下来参考setTimeoutrequestAnimationFrame两个宏任务,这里的运行结果是多种情况。如果三个宏任务都为setTimeout的话会按照代码编写的顺序执行宏任务,而中间包含了一个requestAnimationFrame ,这里就要学习一下他们的执行时机了。setTimeout是在程序运行到setTimeout时立即注册一个宏任务,所以两个setTimeout的顺序一定是固定的timer1timer2会按照顺序输出。而requestAnimationFrame是请求下一次重绘事件,所以他的执行频率要参考浏览器的刷新率。

参考如下代码:

let i = 0;
let d = new Date().getTime()
let d1 = new Date().getTime()
function loop(){
  d1 = new Date().getTime()
  i++
  //当间隔时间超过1秒时执行
  if((d1-d)>=1000){
    d = d1
    console.log(i)
    i = 0
    console.log('经过了1秒')
  }
  requestAnimationFrame(loop)
}
loop()

该代码在浏览器运行时,控制台会每间隔1秒进行一次输出,输出的i就是loop函数执行的次数,如下图:

这个输出意味着requestAnimationFrame函数的执行频率是每秒钟60次左右,他是按照浏览器的刷新率来进行执行的,也就是当屏幕刷新一次时该函数就会触发一次,相当于运行间隔是16毫秒左右。

继续参考下列代码:

let i = 0;
let d = new Date().getTime()
let d1 = new Date().getTime()

function loop(){
  d1 = new Date().getTime()
  i++
  if((d1-d)>=1000){
    d = d1
    console.log(i)
    i = 0
    console.log('经过了1秒')
  }
  setTimeout(loop,0)
}
loop()

该代码结构与上面的案例类似,循环是采用setTimeout进行控制的,所以参考运行结果,如图:


根据运行结果得知,setTimeout(fn,0)的执行频率是每秒执行200次左右,所以他的间隔是5毫秒左右。

由于这两个异步的宏任务出发时机和执行频率不同,会导致三个宏任务的触发结果不同,如果我们打开网页时,恰好赶上5毫秒内执行了网页的重绘事件,requestAnimationFrame在工作线程中就会到达触发时机优先进入任务队列,所以此时会输出:UI update->timer1->timer2

而当打开网页时上一次的重绘刚结束,下一次重绘的触发是16毫秒后,此时setTimeout注册的两个任务在工作线程中就会优先到达触发时机,这时输出的结果是:timer1->timer2->UI update

所以此案例的运行结果如下2图所示:


代码输出顺序问题2

document.addEventListener('click', function(){
    Promise.resolve().then(()=> console.log(1));
    console.log(2);
})
 
document.addEventListener('click', function(){
    Promise.resolve().then(()=> console.log(3));
    console.log(4);
})

解析:仍然是猜对了告诉你哈~,先运行一下试试吧。

这个案例代码简单易懂,但是很容易引起错误答案的出现。由于该事件是直接绑定在document上的,所以点击网页就会触发该事件,在代码运行时相当于按照顺序注册了两个点击事件,两个点击事件会被放在工作线程中实时监听触发时机,当元素被点击时,两个事件会按照先后的注册顺序放入异步任务队列中进行执行,所以事件1和事件2会按照代码编写的顺序触发。

这里就会导致有人分析出错误答案:2,4,1,3。

为什么不是2,4,1,3呢?由于事件执行时并不会阻断JS默认代码的运行,所以事件任务也是异步任务,并且是宏任务,所以两个事件相当于按顺序执行的两个宏任务。

这样就会分出两个运行环境,第一个事件执行时,console.log(2);是第一个宏任务中的同步代码,所以他会立即执行,而Promise.resolve().then(()=> console.log(1));属于微任务,他会在下一个宏任务触发前执行,所以这里输出2后会直接输出1.

而下一个事件的内容是相同道理,所以输出顺序为:2,1,4,3。

总结

关于事件循环模型今天就介绍到这里,在NodeJS中的事件循环模型和浏览器中是不一样的,本文是以浏览器的事件循环模型为基础进行介绍,事件循环系统在JavaScript异步编程中占据的比重是非常大的,在工作中可使用场景也是众多的,掌握了事件循环模型就相当于,异步编程的能力上升了一个新的高度。

们都知道 Redis 提供了丰富的数据类型,常见的有五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)

随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)

每种数据对象都各自的应用场景,你能说出它们各自的应用场景吗?

面试过程中,这个问题也很常被问到,又比如会举例一个应用场景来问你,让你说使用哪种 Redis 数据类型来实现。

所以,这次我们就来学习 Redis 数据类型的使用以及应用场景。篇幅比较长,大家收藏慢慢看。

String

介绍

String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M

内部实现

String 类型的底层的数据结构实现主要是 int 和 SDS(简单动态字符串)。

SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:

  • SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
  • **SDS 获取字符串长度的时间复杂度是 O(1)**。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)
  • Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。

字符串对象的内部编码(encoding)有 3 种 :int、raw和 embstr

如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成 long),并将字符串对象的编码设置为int

如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为embstrembstr编码是专门用于保存短字符串的一种优化编码方式:

如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw

可以看到embstrraw编码都会使用SDS来保存值,但不同之处在于embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObjectSDS,而raw编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObjectSDS。Redis这样做会有很多好处:

  • embstr编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次;
  • 释放 embstr编码的字符串对象同样只需要调用一次内存释放函数;
  • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用 CPU 缓存提升性能。

但是 embstr 也有缺点的:

  • 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。

常用指令

普通字符串的基本操作:

# 设置 key-value 类型的值
> SET name lin
OK
# 根据 key 获得对应的 value
> GET name
"lin"
# 判断某个 key 是否存在
> EXISTS name
(integer) 1
# 返回 key 所储存的字符串值的长度
> STRLEN name
(integer) 3
# 删除某个 key 对应的值
> DEL name
(integer) 1

批量设置 :

# 批量设置 key-value 类型的值
> MSET key1 value1 key2 value2 
OK
# 批量获取多个 key 对应的 value
> MGET key1 key2 
1) "value1"
2) "value2"

计数器(字符串的内容为整数的时候可以使用):

# 设置 key-value 类型的值
> SET number 0
OK
# 将 key 中储存的数字值增一
> INCR number
(integer) 1
# 将key中存储的数字值加 10
> INCRBY number 10
(integer) 11
# 将 key 中储存的数字值减一
> DECR number
(integer) 10
# 将key中存储的数字值键 10
> DECRBY number 10
(integer) 0

过期(默认为永不过期):

# 设置 key 在 60 秒后过期(该方法是针对已经存在的key设置过期时间)
> EXPIRE name  60 
(integer) 1
# 查看数据还有多久过期
> TTL name 
(integer) 51

#设置 key-value 类型的值,并设置该key的过期时间为 60 秒
> SET key  value EX 60
OK
> SETEX key  60 value
OK

不存在就插入:

# 不存在就插入(not exists)
>SETNX key value
(integer) 1

应用场景

缓存对象

使用 String 来缓存对象有两种方式:

  • 直接缓存整个对象的 JSON,命令例子:SET user:1 '{"name":"xiaolin", "age":18}'
  • 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值,命令例子:MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20

常规计数

因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。

比如计算文章的阅读量:

# 初始化文章的阅读量
> SET aritcle:readcount:1001 0
OK
#阅读量+1
> INCR aritcle:readcount:1001
(integer) 1
#阅读量+1
> INCR aritcle:readcount:1001
(integer) 2
#阅读量+1
> INCR aritcle:readcount:1001
(integer) 3
# 获取对应文章的阅读量
> GET aritcle:readcount:1001
"3"

分布式锁

SET 命令有个 NX 参数可以实现「key不存在才插入」,可以用它来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

一般而言,还会对分布式锁加上过期时间,分布式锁的命令如下:

SET lock_key unique_value NX PX 10000
  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

而解锁的过程就是将 lock_key 键删除,但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。

List

介绍

List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。

列表的最大长度为 2^32 - 1,也即每个列表支持超过 40 亿个元素。

内部实现

List 类型的底层数据结构是由双向链表或压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

常用命令

# 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面
LPUSH key value [value ...] 
# 将一个或多个值value插入到key列表的表尾(最右边)
RPUSH key value [value ...]
# 移除并返回key列表的头元素
LPOP key     
# 移除并返回key列表的尾元素
RPOP key 

# 返回列表key中指定区间内的元素,区间以偏移量start和stop指定,从0开始
LRANGE key start stop

# 从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BLPOP key [key ...] timeout
# 从key列表表尾弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BRPOP key [key ...] timeout

应用场景

消息队列

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性

Redis 的 List 和 Stream 两种数据类型,就可以满足消息队列的这三个需求。我们先来了解下基于 List 的消息队列实现方法,后面在介绍 Stream 数据类型时候,在详细说说 Stream。

1、如何满足消息保序需求?

List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。

List 可以使用 LPUSH + RPOP (或者反过来,RPUSH+LPOP)命令实现消息队列。

  • 生产者使用 LPUSH key value[value...] 将消息插入到队列的头部,如果 key 不存在则会创建一个空的队列再插入消息。
  • 消费者使用 RPOP key 依次读取队列的消息,先进先出。

不过,在消费者读取数据时,有一个潜在的性能风险点。

在生产者往 List 中写入数据时,List 并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用 RPOP 命令(比如使用一个while(1)循环)。如果有新消息写入,RPOP命令就会返回结果,否则,RPOP命令返回空值,再继续循环。

所以,即使没有新消息写入List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。

为了解决这个问题,Redis提供了 BRPOP 命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。

2、如何处理重复的消息?

消费者要实现重复消息的判断,需要 2 个方面的要求:

  • 每个消息都有一个全局的 ID。
  • 消费者要记录已经处理过的消息的 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和记录的已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。

但是 List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一ID,生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID。

例如,我们执行以下命令,就把一条全局 ID 为 111000102、库存量为 99 的消息插入了消息队列:

> LPUSH mq "111000102:stock:99"
(integer) 1

3、如何保证消息可靠性?

当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。

为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存

这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。

好了,到这里可以知道基于 List 类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息可靠性)。

  • 消息保序:使用 LPUSH + RPOP;
  • 阻塞读取:使用 BRPOP;
  • 重复消息处理:生产者自行实现全局唯一 ID;
  • 消息的可靠性:使用 BRPOPLPUSH

但是,在用 List 做消息队列时,如果生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给 Redis 的内存带来很大压力

要解决这个问题,就要启动多个消费者程序组成一个消费组,一起分担处理 List 中的消息。但是,List 类型并不支持消费组的实现

这就要说起 Redis 从 5.0 版本开始提供的 Stream 数据类型了,Stream 同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。

Hash

介绍

Hash 是一个键值对(key - value)集合,其中 value 的形式入:value=[{field1,value1},...{fieldN,valueN}]。Hash 特别适合用于存储对象。

Hash 与 String 对象的区别如下图所示:

内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

常用命令

# 存储一个哈希表key的键值
HSET key field value   
# 获取哈希表key对应的field键值
HGET key field

# 在一个哈希表key中存储多个键值对
HMSET key field value [field value...] 
# 批量获取哈希表key中多个field键值
HMGET key field [field ...]       
# 删除哈希表key中的field键值
HDEL key field [field ...]    

# 返回哈希表key中field的数量
HLEN key       
# 返回哈希表key中所有的键值
HGETALL key 

# 为哈希表key中field键的值加上增量n
HINCRBY key field n                         

应用场景

缓存对象

Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。

我们以用户信息为例,它在关系型数据库中的结构是这样的:

我们可以使用如下命令,将用户对象的信息存储到 Hash 类型:

# 存储一个哈希表uid:1的键值
> HSET uid:1 name Tom age 15
2
# 存储一个哈希表uid:2的键值
> HSET uid:2 name Jerry age 13
2
# 获取哈希表用户id为1中所有的键值
> HGETALL uid:1
1) "name"
2) "Tom"
3) "age"
4) "15"

Redis Hash 存储其结构如下图:

在介绍 String 类型的应用场景时有所介绍,String + Json也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢?

一般对象用 String + Json 存储,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。

购物车

以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素,如下图所示。

涉及的命令如下:

  • 添加商品:HSET cart:{用户id} {商品id} 1
  • 添加数量:HINCRBY cart:{用户id} {商品id} 1
  • 商品总数:HLEN cart:{用户id}
  • 删除商品:HDEL cart:{用户id} {商品id}
  • 获取购物车所有商品:HGETALL cart:{用户id}

当前仅仅是将商品ID存储到了Redis 中,在回显商品具体信息的时候,还需要拿着商品 id 查询一次数据库,获取完整的商品的信息。

Set

介绍

Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。

一个集合最多可以存储 2^32-1 个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。

Set 类型和 List 类型的区别如下:

  • List 可以存储重复元素,Set 只能存储非重复元素;
  • List 是按照元素的先后顺序存储元素的,而 Set 则是无序方式存储元素的。

内部实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

常用命令

Set常用操作:

# 往集合key中存入元素,元素存在则忽略,若key不存在则新建
SADD key member [member ...]
# 从集合key中删除元素
SREM key member [member ...] 
# 获取集合key中所有元素
SMEMBERS key
# 获取集合key中的元素个数
SCARD key

# 判断member元素是否存在于集合key中
SISMEMBER key member

# 从集合key中随机选出count个元素,元素不从key中删除
SRANDMEMBER key [count]
# 从集合key中随机选出count个元素,元素从key中删除
SPOP key [count]

Set运算操作:

# 交集运算
SINTER key [key ...]
# 将交集结果存入新集合destination中
SINTERSTORE destination key [key ...]

# 并集运算
SUNION key [key ...]
# 将并集结果存入新集合destination中
SUNIONSTORE destination key [key ...]

# 差集运算
SDIFF key [key ...]
# 将差集结果存入新集合destination中
SDIFFSTORE destination key [key ...]

应用场景

集合的主要几个特性,无序、不可重复、支持并交差等操作。

因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。

但是要提醒你一下,这里有一个潜在的风险。Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞

在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。

点赞

Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章id,value 是用户id。

uid:1uid:2uid:3 三个用户分别对 article:1 文章点赞了。

# uid:1 用户对文章 article:1 点赞
> SADD article:1 uid:1
(integer) 1
# uid:2 用户对文章 article:1 点赞
> SADD article:1 uid:2
(integer) 1
# uid:3 用户对文章 article:1 点赞
> SADD article:1 uid:3
(integer) 1

uid:1 取消了对 article:1 文章点赞。

> SREM article:1 uid:1
(integer) 1

获取 article:1 文章所有点赞用户 :

> SMEMBERS article:1
1) "uid:3"
2) "uid:2"

获取 article:1 文章的点赞用户数量:

> SCARD article:1
(integer) 2

判断用户 uid:1 是否对文章 article:1 点赞了:

> SISMEMBER article:1 uid:1
(integer) 0  # 返回0说明没点赞,返回1则说明点赞了

共同关注

Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。

key 可以是用户id,value 则是已关注的公众号的id。

uid:1 用户关注公众号 id 为 5、6、7、8、9,uid:2 用户关注公众号 id 为 7、8、9、10、11。

# uid:1 用户关注公众号 id 为 5、6、7、8、9
> SADD uid:1 5 6 7 8 9
(integer) 5
# uid:2  用户关注公众号 id 为 7、8、9、10、11
> SADD uid:2 7 8 9 10 11
(integer) 5

uid:1uid:2 共同关注的公众号:

# 获取共同关注
> SINTER uid:1 uid:2
1) "7"
2) "8"
3) "9"

uid:2 推荐 uid:1 关注的公众号:

> SDIFF uid:1 uid:2
1) "5"
2) "6"

验证某个公众号是否同时被 uid:1uid:2 关注:

> SISMEMBER uid:1 5
(integer) 1 # 返回0,说明关注了
> SISMEMBER uid:2 5
(integer) 0 # 返回0,说明没关注

抽奖活动

存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。

key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱 :

>SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark
(integer) 5

如果允许重复中奖,可以使用 SRANDMEMBER 命令。

# 抽取 1 个一等奖:
> SRANDMEMBER lucky 1
1) "Tom"
# 抽取 2 个二等奖:
> SRANDMEMBER lucky 2
1) "Mark"
2) "Jerry"
# 抽取 3 个三等奖:
> SRANDMEMBER lucky 3
1) "Sary"
2) "Tom"
3) "Jerry"

如果不允许重复中奖,可以使用 SPOP 命令。

# 抽取一等奖1个
> SPOP lucky 1
1) "Sary"
# 抽取二等奖2个
> SPOP lucky 2
1) "Jerry"
2) "Mark"
# 抽取三等奖3个
> SPOP lucky 3
1) "John"
2) "Sean"
3) "Lindy"

Zset

介绍

Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。

有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。

内部实现

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

常用命令

Zset 常用操作:

# 往有序集合key中加入带分值元素
ZADD key score member [[score member]...]   
# 往有序集合key中删除元素
ZREM key member [member...]                 
# 返回有序集合key中元素member的分值
ZSCORE key member
# 返回有序集合key中元素个数
ZCARD key 

# 为有序集合key中元素member的分值加上increment
ZINCRBY key increment member 

# 正序获取有序集合key从start下标到stop下标的元素
ZRANGE key start stop [WITHSCORES]
# 倒序获取有序集合key从start下标到stop下标的元素
ZREVRANGE key start stop [WITHSCORES]

# 返回有序集合中指定分数区间内的成员,分数由低到高排序。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

# 返回指定成员区间内的成员,按字典正序排列, 分数必须相同。
ZRANGEBYLEX key min max [LIMIT offset count]
# 返回指定成员区间内的成员,按字典倒序排列, 分数必须相同
ZREVRANGEBYLEX key max min [LIMIT offset count]

Zset 运算操作(相比于 Set 类型,ZSet 类型没有支持差集运算):

# 并集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZUNIONSTORE destkey numberkeys key [key...] 
# 交集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZINTERSTORE destkey numberkeys key [key...]

应用场景

Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set。

排行榜

有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。

我们以博文点赞排名为例,小林发表了五篇博文,分别获得赞为 200、40、100、50、150。

# arcticle:1 文章获得了200个赞
> ZADD user:xiaolin:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章获得了40个赞
> ZADD user:xiaolin:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章获得了100个赞
> ZADD user:xiaolin:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章获得了50个赞
> ZADD user:xiaolin:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章获得了150个赞
> ZADD user:xiaolin:ranking 150 arcticle:5
(integer) 1

文章 arcticle:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合key中元素member的分值加上increment):

> ZINCRBY user:xiaolin:ranking 1 arcticle:4
"51"

查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合key中元素个数):

> ZSCORE user:xiaolin:ranking arcticle:4
"50"

获取小林文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从start下标到stop下标的元素):

# WITHSCORES 表示把 score 也显示出来
> ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"

获取小林 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):

> ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES
1) "arcticle:3"
2) "100"
3) "arcticle:5"
4) "150"
5) "arcticle:1"
6) "200"

电话、姓名排序

使用有序集合的 ZRANGEBYLEXZREVRANGEBYLEX 可以帮助我们实现电话号码或姓名的排序,我们以 ZRANGEBYLEX (返回指定成员区间内的成员,按 key 正序排列,分数必须相同)为例。

注意:不要在分数不一致的 SortSet 集合中去使用 ZRANGEBYLEX和 ZREVRANGEBYLEX 指令,因为获取的结果会不准确。

1、电话排序

我们可以将电话号码存储到 SortSet 中,然后根据需要来获取号段:

> ZADD phone 0 13100111100 0 13110114300 0 13132110901 
(integer) 3
> ZADD phone 0 13200111100 0 13210414300 0 13252110901 
(integer) 3
> ZADD phone 0 13300111100 0 13310414300 0 13352110901 
(integer) 3

获取所有号码:

> ZRANGEBYLEX phone - +
1) "13100111100"
2) "13110114300"
3) "13132110901"
4) "13200111100"
5) "13210414300"
6) "13252110901"
7) "13300111100"
8) "13310414300"
9) "13352110901"

获取 132 号段的号码:

> ZRANGEBYLEX phone [132 (133
1) "13200111100"
2) "13210414300"
3) "13252110901"

获取132、133号段的号码:

> ZRANGEBYLEX phone [132 (134
1) "13200111100"
2) "13210414300"
3) "13252110901"
4) "13300111100"
5) "13310414300"
6) "13352110901"

2、姓名排序

> zadd names 0 Toumas 0 Jake 0 Bluetuo 0 Gaodeng 0 Aimini 0 Aidehua 
(integer) 6

获取所有人的名字:

> ZRANGEBYLEX names - +
1) "Aidehua"
2) "Aimini"
3) "Bluetuo"
4) "Gaodeng"
5) "Jake"
6) "Toumas"

获取名字中大写字母A开头的所有人:

> ZRANGEBYLEX names [A (B
1) "Aidehua"
2) "Aimini"

获取名字中大写字母 C 到 Z 的所有人:

> ZRANGEBYLEX names [C [Z
1) "Gaodeng"
2) "Jake"
3) "Toumas"

BitMap

介绍

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景

内部实现

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。

常用命令

bitmap 基本操作:

# 设置值,其中value只能是 0 和 1
SETBIT key offset value

# 获取值
GETBIT key offset

# 获取指定范围内值为 1 的个数
# start 和 end 以字节为单位
BITCOUNT key start end

bitmap 运算操作:

# BitMap间的运算
# operations 位移操作符,枚举值
  AND 与运算 &
  OR 或运算 |
  XOR 异或 ^
  NOT 取反 ~
# result 计算的结果,会存储在该key中
# key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
# 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]

# 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]

应用场景

Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。

签到统计

在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。

假设我们要统计 ID 100 的用户在 2022 年 6 月份的签到情况,就可以按照下面的步骤进行操作。

第一步,执行下面的命令,记录该用户 6 月 3 号已签到。

SETBIT uid:sign:100:202206 2 1

第二步,检查该用户 6 月 3 日是否签到。

GETBIT uid:sign:100:202206 2 

第三步,统计该用户在 6 月份的签到次数。

BITCOUNT uid:sign:100:202206

这样,我们就知道该用户在 6 月份的签到情况了。

如何统计这个月首次打卡时间呢?

Redis 提供了 BITPOS key bitValue [start] [end]指令,返回数据表示 Bitmap 中第一个值为 bitValue 的 offset 位置。

在默认情况下, 命令将检测整个位图, 用户可以通过可选的 start 参数和 end 参数指定要检测的范围。所以我们可以通过执行这条命令来获取 userID = 100 在 2022 年 6 月份首次打卡日期:

BITPOS uid:sign:100:202206 1

需要注意的是,因为 offset 从 0 开始的,所以我们需要将返回的 value + 1 。

判断用户登陆态

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。

只需要一个 key = login_status 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。

假如我们要判断 ID = 10086 的用户的登陆情况:

第一步,执行以下指令,表示用户已登录。

SETBIT login_status 10086 1

第二步,检查该用户是否登陆,返回值 1 表示已登录。

GETBIT login_status 10086

第三步,登出,将 offset 对应的 value 设置成 0。

SETBIT login_status 10086 0

连续签到用户总数

如何统计出这连续 7 天连续打卡用户总数呢?

我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。

key 对应的集合的每个 bit 位的数据则是一个用户在该日期的打卡记录。

一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。

结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT 统计 bit = 1 的个数便得到了连续打卡 3 天的用户总数了。

Redis 提供了 BITOP operation destkey key [key ...]这个指令用于对一个或者多个 key 的 Bitmap 进行位元操作。

  • opration 可以是 andORNOTXOR。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。空的 key 也被看作是包含 0 的字符串序列。

举个例子,比如将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计。

# 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
# 统计 bit 位 =  1 的个数
BITCOUNT destmap

即使一天产生一个亿的数据,Bitmap 占用的内存也不大,大约占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。

HyperLogLog

介绍

Redis HyperLogLog 是 Redis 2.8.9 版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。

所以,简单来说 HyperLogLog 提供不精确的去重计数

HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。

在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。

这什么概念?举个例子给大家对比一下。

用 Java 语言来说,一般 long 类型占用 8 字节,而 1 字节有 8 位,即:1 byte = 8 bit,即 long 数据类型最大可以表示的数是:2^63-1。对应上面的2^64个数,假设此时有2^63-1这么多个数,从 0 ~ 2^63-1,按照long以及1k = 1024 字节的规则来计算内存总数,就是:((2^63-1) * 8/1024)K,这是很庞大的一个数,存储空间远远超过12K,而HyperLogLog 却可以用 12K 就能统计完。

内部实现

HyperLogLog 的实现涉及到很多数学问题,太费脑子了,我也没有搞懂。

常见命令

HyperLogLog 命令很少,就三个。

# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]

# 返回给定 HyperLogLog 的基数估算值。
PFCOUNT key [key ...]

# 将多个 HyperLogLog 合并为一个 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]

应用场景

百万级网页 UV 计数

Redis HyperLogLog 优势在于只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。

所以,非常适合统计百万级以上的网页 UV 的场景。

在统计 UV 时,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把访问页面的每个用户都添加到 HyperLogLog 中。

PFADD page1:uv user1 user2 user3 user4 user5

接下来,就可以用 PFCOUNT 命令直接获得 page1 的 UV 值了,这个命令的作用就是返回 HyperLogLog 的统计结果。

PFCOUNT page1:uv

不过,有一点需要你注意一下,HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。

这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。

GEO

Redis GEO 是 Redis 3.2 版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。

在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。

内部实现

GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。

GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。

这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。

常用命令

# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]

# 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
GEOPOS key member [member ...]

# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [m|km|ft|mi]

# 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

应用场景

滴滴叫车

这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。

假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。

执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:

GEOADD cars:locations 116.034579 39.030452 33

当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。

例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。

GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

Stream

介绍

Redis Stream 是 Redis 5.0 版本新增加的数据类型,Redis 专门为消息队列设计的数据类型。

在前面介绍 List 类型实现的消息队列,有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据。

基于 Stream 类型的消息队列就解决上面的问题,它不仅支持自动生成全局唯一 ID,而且支持以消费组形式消费数据。

常见命令

Stream 消息队列操作命令:

  • XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
  • XREAD:用于读取消息,可以按 ID 读取数据;
  • XREADGROUP:按消费组形式读取消息;
  • XPENDING 和 XACK:
    • XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。

应用场景

消息队列

生产者通过 XADD 命令插入一条消息:

# * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID
# 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 xiaolin
> XADD mymq * name xiaolin
"1654254953808-0"

插入成功后会返回全局唯一的 ID:"1654254953808-0"。消息的全局唯一 ID 由两部分组成:

  • 第一部分“1654254953808”是数据插入时,以毫秒为单位计算的当前服务器时间;
  • 第二部分表示插入消息在当前毫秒内的消息序号,这是从 0 开始编号的。例如,“1654254953808-0”就表示在“1654254953808”毫秒内的第 1 条消息。

消费者通过 XREAD 命令从消息队列中读取消息时,可以指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取(注意是输入消息 ID 的下一条信息开始读取,不是查询输入ID的消息)。

# 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。
> XREAD Stream mymq 1654254953807-0
1) 1) "mymq"
   2) 1) 1) "1654254953808-0"
         2) 1) "name"
            2) "xiaolin"

如果想要实现阻塞读(当没有数据时,阻塞住),可以调用 XRAED 时设定 block 配置项,实现类似于 BRPOP 的阻塞读取操作。

比如,下面这命令,设置了 block 10000 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 10000 毫秒(即 10 秒),然后再返回。

# 命令最后的“$”符号表示读取最新的消息
> XREAD block 10000 Stream mymq $
(nil)
(10.00s)

前面介绍的这些操作 List 也支持的,接下来看看 Stream 特有的功能。

Stream 可以以使用 XGROUP 创建消费组,创建消费组之后,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。

创建一个名为 group1 的消费组,这个消费组消费的消息队列是 mymq:

# 创建一个名为 group1 的消费组
> XGROUP create mymq group1 0
OK

消费组 group1 内的消费者 consumer1 从 mymq 消息队列中读取所有消息的命令如下:

# 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。
> XREADGROUP group group1 consumer1 Stream mymq >
1) 1) "mymq"
   2) 1) 1) "1654254953808-0"
         2) 1) "name"
            2) "xiaolin"

消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了。

比如说,我们执行完刚才的 XREADGROUP 命令后,再执行一次同样的命令,此时读到的就是空值了:

> XREADGROUP group group1 consumer1 Stream mymq >
(nil)

使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的

例如,我们执行下列命令,让 group2 中的 consumer1、2、3 各自读取一条消息。

# 让 group2 中的 consumer1 从 mymq 消息队列中消费一条消息
> XREADGROUP group group2 consumer1 count 1 Stream mymq >
1) 1) "mymq"
   2) 1) 1) "1654254953808-0"
         2) 1) "name"
            2) "xiaolin"
# 让 group2 中的 consumer2 从 mymq 消息队列中消费一条消息
> XREADGROUP group group2 consumer2 count 1 Stream mymq >
1) 1) "mymq"
   2) 1) 1) "1654256265584-0"
         2) 1) "name"
            2) "xiaolincoding"
# 让 group2 中的 consumer3 从 mymq 消息队列中消费一条消息
> XREADGROUP group group2 consumer3 count 1 Stream mymq >
1) 1) "mymq"
   2) 1) 1) "1654256271337-0"
         2) 1) "name"
            2) "Tom"

基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息?

Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。

如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息

例如,我们来查看一下 group2 中各个消费者已读取、但尚未确认的消息个数,命令如下:

127.0.0.1:6379> XPENDING mymq group2
1) (integer) 3
2) "1654254953808-0"  # 表示 group2 中所有消费者读取的消息最小 ID
3) "1654256271337-0"  # 表示 group2 中所有消费者读取的消息最大 ID
4) 1) 1) "consumer1"
      2) "1"
   2) 1) "consumer2"
      2) "1"
   3) 1) "consumer3"
      2) "1"

如果想查看某个消费者具体读取了哪些数据,可以执行下面的命令:

# 查看 group2 里 consumer2 已从 mymq 消息队列中读取了哪些消息
> XPENDING mymq group2 - + 10 consumer2
1) 1) "1654256265584-0"
   2) "consumer2"
   3) (integer) 410700
   4) (integer) 1

可以看到,consumer2 已读取的消息的 ID 是 1654256265584-0。

一旦消息 1654256265584-0 被 consumer2 处理了,consumer2 就可以使用 XACK 命令通知 Streams,然后这条消息就会被删除

> XACK mymq group2 1654256265584-0
(integer) 1

当我们再使用 XPENDING 命令查看时,就可以看到,consumer2 已经没有已读取、但尚未确认处理的消息了。

> XPENDING mymq group2 - + 10 consumer2
(empty array)

好了,基于 Stream 实现的消息队列就说到这里了,小结一下:

  • 消息保序:XADD/XREAD
  • 阻塞读取:XREAD block
  • 重复消息处理:Stream 在使用 XADD 命令,会自动生成全局唯一 ID;
  • 消息可靠性:内部使用 PENDING List 自动保存消息,使用 XPENDING 命令查看消费组已经读取但是未被确认的消息,消费者使用 XACK 确认消息;
  • 支持消费组形式消费数据

Redis 基于 Stream 消息队列与专业的消息队列有哪些差距?

一个专业的消息队列,必须要做到两大块:

  • 消息不丢。
  • 消息可堆积。

1、Redis Stream 消息会丢失吗?

使用一个消息队列,其实就分为三大块:生产者、队列中间件、消费者,所以要保证消息就是保证三个环节都不能丢失数据。

Redis Stream 消息队列能不能保证三个环节都不丢失数据?

  • Redis 生产者会不会丢消息?生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 ( MQ 中间件) 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,如果返回异常则进行消息重发,那么这个阶段是不会出现消息丢失的。
  • Redis 消费者会不会丢消息?不会,因为 Stream ( MQ 中间件)会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,但是未被确认的消息。消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。等到消费者执行完业务逻辑后,再发送消费确认 XACK 命令,也能保证消息的不丢失。
  • Redis 队列中间件会不会丢消息?,Redis 在以下 2 个场景下,都会导致数据丢失:
    • AOF 持久化配置为每秒写盘,但这个写盘过程是异步的,Redis 宕机时会存在数据丢失的可能
    • 主从复制也是异步的,主从切换时,也存在丢失数据的可能。

可以看到,Redis 在队列中间件环节无法保证消息不丢。像 RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。

2、Redis Stream 消息可堆积吗?

Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。

但 Kafka、RabbitMQ 专业的消息队列它们的数据都是存储在磁盘上,当消息积压时,无非就是多占用一些磁盘空间。

因此,把 Redis 当作队列来使用时,会面临的 2 个问题:

  • Redis 本身可能会丢数据;
  • 面对消息挤压,内存资源会紧张;

所以,能不能将 Redis 作为消息队列来使用,关键看你的业务场景:

  • 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。
  • 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。

参考资料:

  • 《Redis 核心技术与实战》
  • https://www.cnblogs.com/hunternet/p/12742390.html
  • https://www.cnblogs.com/qdhxhz/p/15669348.html
  • https://www.cnblogs.com/bbgs-xc/p/14376109.html
  • http://kaito-kidd.com/2021/04/19/can-redis-be-used-as-a-queue/

总结

Redis 常见的五种数据类型:**String(字符串),Hash(哈希),List(列表),Set(集合)及 Zset(sorted set:有序集合)**。

这五种数据类型都由多种数据结构实现的,主要是出于时间和空间的考虑,当数据量小的时候使用更简单的数据结构,有利于节省内存,提高性能。

这五种数据类型与底层数据结构对应关系图如下,左边是 Redis 3.0版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Github 最新的 Redis 代码的。

可以看到,Redis 数据类型的底层数据结构随着版本的更新也有所不同,比如:

  • 在 Redis 3.0 版本中 List 对象的底层数据结构由「双向链表」或「压缩表列表」实现,但是在 3.2 版本之后,List 数据类型底层数据结构是由 quicklist 实现的;
  • 在最新的 Redis 代码中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Redis 五种数据类型的应用场景:

  • String 类型的应用场景:缓存对象、常规计数、分布式锁等。
  • List 类型的应用场景:消息队列(有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

针对 Redis 是否适合做消息队列,关键看你的业务场景:

  • 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。
  • 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。


原文链接:https://mp.weixin.qq.com/s/r9_0xpRsp2ubgyvpiyMfuw

、什么是防御式编程

防御性编程是一种细致、谨慎的编程方法(习惯)。我们在写代码时常会有“以防万一”的心态,把以防万一有可能出现的情况提前考虑进去,规避以免以防万一出现带来的问题。

应用防御性编程技术,你可以侦测到可能被忽略的错误,防止可能会导致灾难性后果的“小毛病”的出现,在时间的运行过程中为你节约大量的调试时间。

比如我们在写下面这个效果时,如果只是按设计师设计效果来开发,我们就不会考虑标题内容过长的问题。但是在实际的应用中,数据是从后台加载而来,标题的字数就有可能过长,过长之后就会导致标题溢出折行的效果如下图,带来不好的体验。

如果站在防御式编程的角度来思考,那我们就会提前把这种问题规避掉。如果标题过长,我们可以使用...省略号来处理。而不是等到项目上线,实际问题发生时,再来修改代码。


二、防御式CSS

防御式CSS是一个片段的集合,可以帮助我们规避“以防万一”产生的问题。

我们在CSS布局时,是按照设计师的效果来开发的,但是实际的网页内容是动态的,网页上的内容是可以改变的,如:文字数量,图片尺寸、窗口大小等,再加上用户的一些意想不到的行为和运行环境,从而造成CSS布局的效果并没有按照我们预期的效果显示。

我们可以通过添加某些CSS代码,来避免这种情况带来的问题。防御式CSS是实现项目稳定性建设重要但极其容易忽视的一环。

接下来我们分享9个应用场景下,具有防御式的CSS代码。

三、9个具有防御式的CSS代码

① :场景一:单行文本过长

我们设计时的理想效果是标题文字不超过8个字,正好显示完整。但实际应用时,有可能标题内容过长造成换行显示。我们可以添加文字溢出显示..省略号来解决。



<style>
   body,
   h3 {
       margin: 0;
       padding: 0;
  }
   .box {
       width: 150px;
       height: 150px;
       position: relative;
       margin: 40px auto;
  }
   .box h3 {
       height: 30px;
       line-height: 30px;
       font-weight: 100;
       width: 100%;
       background-color: rgba(0, 0, 0, 0.5);
       font-size: 16px;
       color: #fff;
       position: absolute;
       bottom: 0;
       text-align: center;
       /*以防万一,标题过长时,用...省略号显示*/
       white-space: nowrap;
       overflow: hidden;
       text-overflow: ellipsis;
  }
</style>
<body>
   <div class="box">
       <img src="images/flex-06.jpg" alt="" />
       <h3>"以防万一"标题过长产生的问题</h3>
   </div>
</body>

2、场景二:类别标签中文本过长

在这个效果中,我们并不希望标签延伸到最右侧,我们希望内容过长时,可以在一定的长度时就折行显示。我们可以通过添 max-width属性来避免这种“以防万一”的问题。

同类的应用还有 min-width,在此就不演示了



<style>
     .box {
       width: 250px;
       height: 250px;
       position: relative;
    }
     .box span {
       position: absolute;
       background-color: rgba(119, 245, 197, 0.8);
       line-height: 1.3;
       font-size: 12px;
       padding: 5px 10px;
       border-radius: 0 50px 50px 0px;
       left: 0px;
       top: 5px;
       /*以防万一标签内容过长,控制最大宽度,内容过多折行显示*/
       max-width: 70%;
    }
   </style>
 </head>
 <body>
   <div class="box">
     <img src="images/ms.jpg" alt="" />
     <span>植物奶油 巧克力 草莓 榴莲 花生 芝麻 小米 鸡蛋</span>
   </div>
 </body>

3、场景三:防止图片拉伸或挤压

我们预想的是用户按1:1的大小来上传头像图片,但实际用户上传的头像比例是五花八门,就会造成图片被拉伸或挤压变形。我们可以添加Object-fit:cover来等比例裁剪图片尺寸,这样图片就不会被拉伸或压缩,不过会有一部分图片被裁剪掉。



<style>
   .box {
       width: 200px;
       height: 200px;
       border-radius: 50%;
       overflow: hidden;
  }
   .box img {
       width: 100%;
       height: 100%;
       /*保持图片原有尺寸来裁剪*/
       object-fit: cover;
  }
</style>
<body>
   <div class="box">
       <img src="images/tx2.png" alt="" />
   </div>
</body>

4、场景四:图片加载失败,文字显示问题

当图片上有文字时,如果图片加载失败,而外层容器的背景色和文字颜色接近,那么文字的展示效果就不理想;此时我们可以给图片加上对应的背景色。

这个效果大家只需做个了解就好。通常如果图片上有文字,设计师在设计效果图时都会在图片和文字中间加上一层黑色的半透明遮罩层,这样即使图片加载不出来,也不影响文字的展示效果。

<style>
   .box {
       width: 250px;
       height: 156px;
       position: relative;
  }
   .box img {
       width: 100%;
       height: 100%;
       object-fit: cover;
       /*"以防万一"图片加载失败,加上背景色,保证文字能正常显示*/
       background-color: #666;
  }
   .box h3 {
       width: 100%;
       font-size: 20px;
       text-align: center;
       position: absolute;
       top: 40px;
       color: #fff;
  }
</style>
<body>
   <div class="box">
       <img src="images/rotate3.webp" alt="" />
       <h3>美丽的风景图</h3>
   </div>
</body>

5、场景五:必要时显示滚动条

在内容比较长的情况下,可以通过设置 overflow-y控制滚动条是否展示。但是这里更推荐将overflow-y的值设置为 auto。如果你将overflow-y显式设置为 scroll时,不管容器内容长短,滚动条都会展示出来,这种体验是不好的



<style>
     .box {
       width: 160px;
       padding: 20px;
       height: 200px;
       background-color: skyblue;
       line-height: 2;
       border-radius: 20px;
    }
     .box .content {
       padding-right: 10px;
       max-height: 100%;
       /*以防万一,用户内容不足时,不需要显示滚动条,只有内容溢出时才显示*/
       overflow-y: auto;
    }
    /* 整个滚动条*/
     .content::-webkit-scrollbar {
       width: 5px;
       padding: 0px 20px;
    }
    /* 滚动条轨道*/
     .content::-webkit-scrollbar-track {
       border-radius: 10px;
       background-color: #000;
       margin: 20px 0px;
    }
    /*滚动条上的滚动滑块*/
     .content::-webkit-scrollbar-thumb {
       width: 14px;
       border-radius: 10px;
       background-color: #ddd;
    }
   </style>
 <body>
   <div class="box">
     <div class="content">
      在内容比较长的情况下,可以通过设置
      overflow-y控制滚动条是否展示。但是这里更推荐将
     </div>
   </div>
 </body>

6、场景六:预留滚动条空间,避免重排

当内容不足时不会出现滚动条,文字占据的宽度要宽些。当内容溢出出现滚动条时,因为滚动条要占据一部分空间,则会造成文字占据的空间变窄,因而会造成重排。我们可以元素添加scrollbar-gutter:stable;来避免这个问题。

scrollbar-gutter属性有三个值

属性值

描述

auto

就是默认表现。没有滚动条的时候,内容尽可能占据宽度,有了滚动条,可用宽度减小

stable

如果 overflow 属性计算值不是 visible,则提前预留好空白区域,这样滚动条出现的时候,整个结构和布局都是稳定的。

both-edges

让容器左右两侧同时预留好空白区域,目的是让局部绝对居中对称。

没有加scrollbar-gutter时,未出现滚动条和出现滚动条之间的差别

加上scrollbar-gutter:stable;后,出现滚动条和没有出现滚动,前后文字显示效果没有差异

<style>
   .box {
       width: 160px;
       padding: 20px;
       height: 200px;
       background-color: skyblue;
       line-height: 2;
       border-radius: 20px;
  }
   .box .content {
       max-height: 100%;
       /*以防万一,用户内容不足时,不需要显示滚动条,只有内容溢出时才显示*/
       overflow-y: auto;
       /*预留好滚动条位置,必免引起重排*/
       scrollbar-gutter: stable;
  }
   .content::-webkit-scrollbar {
       width: 10px;
  }
   .content::-webkit-scrollbar-track {
       border-radius: 10px;
       background-color: #000;
       margin: 20px 0px;
  }
   .content::-webkit-scrollbar-thumb {
       width: 14px;
       border-radius: 10px;
       background-color: #ddd;
  }
</style>
<body>
   <div class="box">
       <div class="content">
          当内容不足时不会出现滚动条,文字占据的宽度要宽些。当内容溢出出现滚动条时,因为滚动条要占据一部分空间,则会造成文字占据的空间变窄,因而会造成重排。
       </div>
   </div>
</body>

7、场景七:锁定滚动链

我们会发现当子元素滚动到顶部或底部继续滚动滚轮时,会导致父元素的滚动,但这种行为有时会影响页面体验。在子元素上应用overscroll-behavior: contain就可以禁止掉这一行为。



<style>
   body {
       height: 2000px;
  }
   .box {
       height: 400px;
       width: 200px;
       margin: 0px auto;
       overflow-y: auto;
       background-color: skyblue;
       /*当滚动到滚动条底部和顶部时,会触发父元素的滚动条滚动*/
       overscroll-behavior-y: contain;
  }
</style>
<body>
   <div class="box">
       <p>1</p>
       <p>2</p>
       <p>3</p>
       <p>4</p>
       <p>5</p>
       <p>6</p>
       <p>7</p>
       <p>8</p>
       <p>9</p>
       <p>10</p>
       <p>11</p>
       <p>12</p>
       <p>13</p>
       <p>14</p>
       <p>15</p>
       <p>16</p>
       <p>17</p>
       <p>18</p>
       <p>19</p>
       <p>20</p>
       <p>21</p>
       <p>22</p>
       <p>23</p>
       <p>24</p>
       <p>25</p>
       <p>26</p>
       <p>27</p>
       <p>28</p>
       <p>29</p>
       <p>30</p>
       <p>31</p>
       <p>32</p>
   </div>
</body>

8、场景八:flex布局中,元素使用space-between最后一行两边分布的问题?

如果我们每一行显示的个数为n,那我们可以最后一行子项的后面加上n-2个span元素,span元素的宽度和其它子项元素宽度一样,但不用设置高度。

为什么是添加n-2个span元素呢?

当最后一行只有1个子元素时,他会默认靠左,不用处理

当最后一行子元素正好时,我们就不用关心这个问题。

所以要去掉这两种情况,只需要加n-2个span元素就好



<style>
     .container {
       width: 500px;
       display: flex; /*弹性布局*/
       justify-content: space-between; /*两端对齐*/
       flex-wrap: wrap; /*超出部分换行*/
    }
     .item {
       width: 120px;
       height: 100px;
       background-color: pink;
       margin-top: 10px;
    }
     .container span {
       width: 120px; /*span宽和子项宽一样*/
    }
   </style>
 </head>
 <body>
   <div class="container">
     <div class="item">1</div>
     <div class="item">2</div>
     <div class="item">3</div>
     <div class="item">4</div>
     <div class="item">5</div>
     <div class="item">6</div>
     <div class="item">7</div>
     <!--以防万一,子项个数不够,最后一排出现两端对齐,达不到预期效果-->
     <span></span>
     <span></span>
   </div>
 </body>

9、场景九:grid网格中的响应式断行效果的处理

当我们想尽可能多的在一行显示子项的个数时,有可能会出现子项个数不满一行的情况。那这个时候利用网格布局,使用auto-fill和auto-fit就会是两个完全不同的效果。

  • auto-fill :网格的最大重复次数(正整数),如果有剩余空间,则会保留剩余空间,而不改变网格项目的宽度。
  • auto-fit:网格的最大重复次数(正整数),如果有剩余空间,网格项平分剩余空间来扩展自己的宽度。

以下情况只会出现在子项内容不能占满一行时。也就是说万一内容不能占满一行,则使用auto-fill就会出现空白问题。我们把auto-fill改成auto-fit就解决了这个问题



<style>
   .container {
       width: 100%;
       display: grid;
       /*grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));*/
       /*以防万一,子项不足占据一行时*/
        grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
       grid-template-rows: 250px;
       grid-auto-flow: row;
       grid-auto-rows: 250px;
       gap: 10px;
  }
   .container .item:nth-child(even) {
       background-color: skyblue;
  }
   .container .item:nth-child(odd) {
       background-color: pink;
  }
</style>
<body>
   <div class="container">
       <div class="item"></div>
       <div class="item"></div>
       <div class="item"></div>
       <div class="item"></div>
   </div>
</body>

为帮助到一部分同学不走弯路,真正达到一线互联网大厂前端项目研发要求,首次实力宠粉,打造了《30天挑战学习计划》,内容如下:

HTML/HTML5,CSS/CSS3,JavaScript,真实企业项目开发,云服务器部署上线,从入门到精通

  • PC端项目开发(1个)
  • 移动WebApp开发(2个)
  • 多端响应式开发(1个)

共4大完整的项目开发 !一行一行代码带领实践开发,实际企业开发怎么做我们就是怎么做。从学习一开始就进入工作状态,省得浪费时间。

从学习一开始就同步使用 Git 进行项目代码的版本的管理,Markdown 记录学习笔记,包括真实大厂项目的开发标准和设计规范,命名规范,项目代码规范,SEO优化规范

从蓝湖UI设计稿 到 PC端,移动端,多端响应式开发项目开发

  • 真机调试,云服务部署上线;
  • Linux环境下 的 Nginx 部署,Nginx 性能优化;
  • Gzip 压缩,HTTPS 加密协议,域名服务器备案,解析;
  • 企业项目域名跳转的终极解决方案,多网站、多系统部署;
  • 使用 使用 Git 在线项目部署;

这些内容在《30天挑战学习计划》中每一个细节都有讲到,包含视频+图文教程+项目资料素材等。只为实力宠粉,真正一次掌握企业项目开发必备技能,不走弯路 !

过程中【不涉及】任何费用和利益,非诚勿扰 。

点击进入:30 天挑战学习计划 Web 前端从入门到实战 | arry老师的博客-艾编程