在开发NestJS的时候,就很好奇,当某个接口有并发请求的时候,表现是怎样的,接下来做下验证
新建一个并发验证的接口,在controller上,定义一个简单的get接口
async sleep() {
return new Promise((resolve) => setTimeout(resolve, 500))
}
async concurrentTest(): Promise<string> {
requestNumber++
console.log('before request', requestNumber, Date.now())
// 模拟一个接口请求,耗时500ms
await this.sleep()
this.logger.log('after request', requestNumber, Date.now())
return 'success'
}
模拟下请求 http://localhost:3000/api/authorize/concurrentTest,接口返回正常,同时log也正常打印
before request 1 1709176114383
[Nest] 22430 - 02/29/2024, 11:08:34 AM LOG after request
[Nest] 22430 - 02/29/2024, 11:08:34 AM LOG 1
[Nest] 22430 - 02/29/2024, 11:08:34 AM LOG 1709176114884
上面打印的时间戳,前后间隔有500ms,说明模拟接口的行为没问题
接下来,开始模拟并发的场景,看下结果
before request 454 1709175712455
before request 455 1709175712484
before request 456 1709175712588
before request 457 1709175712592
before request 458 1709175712639
before request 459 1709175712690
before request 460 1709175712783
before request 461 1709175712825
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG after request
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG 461
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG 1709175712866
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG after request
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG 461
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG 1709175712955
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG after request
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG 461
[Nest] 20717 - 02/29/2024, 11:01:52 AM LOG 1709175712985
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG after request
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG 461
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG 1709175713089
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG after request
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG 461
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG 1709175713093
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG after request
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG 461
[Nest] 20717 - 02/29/2024, 11:01:53 AM LOG 1709175713140
多个请求过来,接口是同时响应,同时处理,而且每个接口的耗时不会增多
总的处理,是在一个线程中处理的,上面的处理过程,其实就是JS的Event Loop机制和Microtasks机制
比如上面的concrrentTest方法,当碰到并发请求的时候,逻辑是这样
首先在JS浏览器,或者Node.JS中,有一个Event Loop的东西,事件循环负责执行代码和处理异步操作
当第一个请求进来,事件循环先处理了concrrentTest函数,执行了一个log,然后碰到await方法,函数被挂起,异步执行await后面的代码,任务队列继续执行下一个任务
event loop就继续处理第二个请求的concrrentTest函数,执行第二个请求的log方法,然后继续碰到await,第二次执行的函数继续被挂起,继续执行下一个task
当第一个函数的await任务执行完成后,它后续处理的函数会被放到microtasks queue中,event loop会首先处理所有的microtasks,然后再执行其他任务,所以await后续的逻辑被执行
以此类推
如果希望模拟在并发的时候,接口响应变慢,要如何模拟 我们可以新建一个耗时的方法,这个方法耗时0.5秒
timeConsumingTask() {
let start = Date.now()
let count = 0
// 这个循环将会消耗一些时间,取决于循环的次数和操作的复杂性
while (Date.now() - start < 500) {
// .5秒钟的耗时操作
count++
}
console.log(`耗时操作完成,循环了 ${count} 次`)
}
在每次接口调用的时候,都会执行这个耗时的方法
async concurrentTest(): Promise<string> {
requestNumber++
console.log('before request', requestNumber, Date.now())
// 模拟一个接口请求,耗时500ms
this.timeConsumingTask()
this.logger.log('after request', requestNumber, Date.now())
return 'success'
}
由于是单线程,当前一个请求由于方法耗时卡住了,后一个并发的请求只能等待,可以看下log
before request 124 1709188248140
耗时操作完成,循环了 5750365 次
[Nest] 43123 - 02/29/2024, 2:30:48 PM LOG after request
[Nest] 43123 - 02/29/2024, 2:30:48 PM LOG 124
[Nest] 43123 - 02/29/2024, 2:30:48 PM LOG 1709188248640
before request 125 1709188248641
耗时操作完成,循环了 9159242 次
[Nest] 43123 - 02/29/2024, 2:30:49 PM LOG after request
[Nest] 43123 - 02/29/2024, 2:30:49 PM LOG 125
[Nest] 43123 - 02/29/2024, 2:30:49 PM LOG 1709188249141
before request 126 1709188249141
耗时操作完成,循环了 9596828 次
[Nest] 43123 - 02/29/2024, 2:30:49 PM LOG after request
[Nest] 43123 - 02/29/2024, 2:30:49 PM LOG 126
[Nest] 43123 - 02/29/2024, 2:30:49 PM LOG 1709188249641
before request 127 1709188249644
耗时操作完成,循环了 8478465 次
[Nest] 43123 - 02/29/2024, 2:30:50 PM LOG after request
[Nest] 43123 - 02/29/2024, 2:30:50 PM LOG 127
[Nest] 43123 - 02/29/2024, 2:30:50 PM LOG 1709188250144
在并发请求的时候,平均接口的响应时间提升到了接近3秒
上面的是接口本身的js代码的耗时,下面继续验证下数据库的并发下的场景情况,项目内,使用的是Prisma ORM,分别验证三个场景的下的数据库表现
先用npx prisma studio命令,查看下目前的测试数据库的数据,截图如下
一共有7条数据,接下来新建查询数据库数据的接口
// controller
@Get('/find')
findChat() {
return this.authorizeService.findFirst()
}
// service
// 查找第一个聊天记录
async findFirst() {
const item = await this.prismaService.chat.findFirst()
console.log('findFirst result', item)
return item
}
第一次请求,耗时110ms,后续的请求耗时30ms左右,估计prisma内部有做了优化缓存
console也打印出查询结果的日志了
findFirst result {
id: 1,
wxUserId: 'YGP2126',
prompt: 'string',
createdAt: 2024-02-20T08:56:13.250Z,
updatedAt: 2024-02-20T08:56:13.250Z
}
接下来,改造下接口,返回随机的任意一条数据,因为一共有7条数据,先这样写
// 查找任意随机一条聊天记录
async findFirst() {
const random = Math.floor(Math.random() * 7) + 1
const item = await this.prismaService.chat.findFirst({
where: {
id: random
}
})
console.log('findFirst result', item)
return item
}
接下来,开始模拟并发请求,看下耗时情况
平均响应时间28ms,单纯的并发读,并不会导致单个接口的请求时间变长
新建一个写的接口
// constroller
@Get('/insert')
insertOne() {
return this.authorizeService.insertOne()
}
// service
// 插入一条聊天记录
async insertOne() {
const wxUserId = 'YGP0711'
let prompt = '测试插入' + Math.floor(Math.random() * 100000)
await this.prismaService.chat.create({
data: {
prompt,
wxUserId
}
})
console.log('insertOne', prompt)
return prompt
}
验证调用,log打印正常
insertOne 测试插入2393
查看数据库,也确实插入了刚才的那条信息
单独调用接口,平均耗时80ms-300ms波动
接下来,验证并发调用写的场景
平均响应时间145ms,经验证,并发写并不会延长接口的耗时
为什么并发写不会延长接口的耗时,经了解,内部逻辑是这样的
连接池(Connection Pool): Prisma 使用连接池来管理与数据库的连接。这意味着,当你的应用程序需要与数据库交互时,它会从池中获取一个已经建立的连接,而不是每次都创建一个新的连接。这种方式可以显著提高性能,因为建立数据库连接是一个资源密集型的操作。 事件循环(Event Loop): 在 Node.js 环境中,Prisma 作为一个库运行在 Node.js 的事件循环中。Node.js 是单线程的,但它使用非阻塞 I/O 操作,这意味着数据库操作不会阻塞事件循环。相反,当数据库操作完成时,回调函数会被放入事件队列中,等待事件循环到达它们时执行。 当你发出一个请求给 Prisma(比如查询或更新数据),Prisma 会生成相应的 SQL 语句,并通过其连接池中的一个连接发送到数据库。数据库系统(MySQL )将在其自己的进程中执行这些查询,这通常涉及多线程,以优化查询的执行。
上面的第二点,是使用了JS的Event Loop和microtasks queue机制,保证所有await后续的逻辑,都可以被执行
首先把读的接口的随机改成1000,前面几次并发测试,已经累积很多数据了
// 查找任意随机一条聊天记录
async findFirst() {
const random = Math.floor(Math.random() * 1000) + 1
const item = await this.prismaService.chat.findFirst({
where: {
id: random
}
})
console.log('findFirst result', item, random)
return item
}
开始模拟并发读写
平均68ms,并发读写的接口效率也是很高
为什么读写也不会延长接口耗时,相关的解释,个人认为跟读的解释是一样的,不做赘述
至于为什么MySQL内部为什么可以高效的处理并发,了解了下 MySQL 是一个多线程的数据库管理系统,它使用多个线程来处理并发连接和查询。这里是 MySQL 在线程和进程方面的一些关键点:
我们这次的例子中,虽然读写并行,由于有MVCC机制,并不会触发数据库的锁定表或者锁定行,从而保持高效的操作
第一次开发后端接口,难免担心,万一碰到高并发请求,接口会不会扛不住,这样一轮验证下来,基本可以放心了,框架跟底层库把很多逻辑都做的很好了,我们只是站在前人巨大的累积沉淀下,做一些微不足道的业务逻辑