封装一个koa分布式锁中间件来解决幂等或重复请求的问题

在后端并不是写完一个接口的业务逻辑就能投入使用的,接口的优化更是一个难点与麻烦之处(下面的内容我们不考虑前端的处理,因为不能完全靠前端,前后端都需要做自己的处理工作)

1.幂等性:

所谓幂等性是指一个接口不论发送多少个相同请求,最后都会产生相同的结果

例如: 根据Restful API接口规范:把CRUD分为get(查询),post(新增),delete(删除),put(修改)

  • GET:查询条件下,不论用户对数据库查询多少次,都不会对数据库的数据造成,所以这天生就是一个幂等接口
  • POST:新增条件下,如果用户多次发送相同的增加请求,那么数据库将会添加多条相同的记录,所以是一个非幂等接口
  • PUT:分为两种情况

1.绝对修改: 如果是修改绝对值,例如修改一条name为张三的记录,我多次修改最后造成的结果都是一样的(只有一条张三的结果被删除),所以这是一个幂等接口
2.相对修改: 如果是修改相对值,例如修改一张表中score最高的记录(select top 1 score from xxx),我多次修改最后造成的结果是不一样的,你发送几次接口,我就会删除几次最高的,所以这是一个非幂等接口

  • DELETE:也分为两种情况(与PUT相同,就不介绍了,也是相对与绝对的问题)

所以为了安全性,后端会采用许多方式解决幂等问题,将非幂等的接口转化为幂等接口

2.并发:

用户发送请求的时间并不是有规律的,有可能是按顺序一个接一个有序地执行,也有可能在很短时间内发送多个请求抢占同一资源,由于处理请求是异步的,所以不能保证每个都按顺序有序输出,并发也可以细分成两种

1.多个用户抢占同一资源: 例如:100个人短时间内预约同一个医生,但是医生只能被预约一次,这个时候就会产生高并发,我们必须采取措施保证只有第一个发起请求的能预约到这个医生,后面99个都返回预约失败(不是返回请求出错),这时候可以采用阻塞性(多个请求按照顺序排队等待处理)的互斥锁(相同时间内只有一个请求能够获取到锁,其他的请求排队等处理完解锁后再获取),保证这100个请求按顺序转为同步(虽然效率会降低,但是保证了正确性)

1.单个用户抢占自己的同一资源: 这里单个用户的并发一般体现在重复请求,但不是完全的参数相同,比如用户短时间内发起两个参数不同的请求修改自己的个人资料(举个例子,实际情况还是很少的,因为前端会采取遮罩层等措施防止用户的这这种行为),但是请求处理是异步的,可能突然受到网络原因,虽然发送顺序是先1后2,但是返回的顺序是先2后1,这样正确性就有问题了,此时可以设置非阻塞性(只有第一个请求上锁然后进行处理,后面的请求全部报错,同一返回服务器繁忙,且不排队等待处理,直接失败)的互斥锁提醒用户已经有请求在处理,不要发送多个请求

3.高并发:

高并发是并发的是一种程度的体现,极短的时间内产生了海量的并发请求就是高并发,比如双十一抢购,所以就有了分布式架构(分布式系统,就是一个业务拆分成多个子业务,分布在不同的服务器节点,共同构成的系统称为分布式系统)的出现,一个服务器处理海量的并发压力会巨大甚至宕机,所以分布在不同的服务器节点减轻单一服务器的压力

4.进程锁<线程锁<分布式锁:

  • 进程锁:

当某个方法或者代码块使用锁时,在同一时刻之多仅有一个线程在执行该段代码(nodejs的同步代码(异步代码除外)是单进程的,所以无需进程锁)

  • 线程锁:

为了控制同一操作系统中多个进程访问一个共享资源,只是因为程序的独立性,各个进程是无法控制其他进程对资源的访问的,但是可以使用本地系统的信号量控制

  • 分布式锁:

当多个进程不在同一个系统之中时,使用分布式锁控制多个进程对资源的访问(可以理解为线程锁就就是只有单例的分布式锁)

三锁的范围:进程锁<线程锁<分布式锁

三锁作用都是一样的,只是作用的范围大小不同

实战环节:了解了那么多理论知识,下面我来实践一个nodejs中分布式锁的中间件封装解决接口幂等问题

  • 为什么用分布式锁:

1.nodejs已经有现成的redlock(以redlock分布式锁算法名字命名)包来解决分布式锁的问题,就不用自己再写redlock的算法,只需要二次封装为一个中间件,具体redis分布式锁的实现可以去看其他人的文章 2.分布式锁范围最大,既可以用于单例也可以用于分布式,这里我是单例实现,自己的小项目也用不着分布式系统

1.在npm官网找到ioredis和redlock两个包

  • redlock:nodejs中redlock的实现

  • ioredis:集群式redis的实现(上面的redlock必须要ioredis才行,不能用单例的redis包,但是可以在ioredis配置单例redis,总之ioredis就是一个功能更加强大的redis包)

2.配置ioredis和redlock

  • ioredis:

思路:创建一个class类,把所有redis的操作和初始化封装到Redis这个类中,最后实例化导出供其他地方使用

import ioredis from 'ioredis'
import { REDIS_CONF } from '../config/db'
const { password, port, host } = REDIS_CONF
class Redis {
 client
 constructor() {
   this.client = new ioredis({
     port,
     host,
     password
   })
   this.client.on('error', (err) => console.log(err))
 }
 //添加数据
 async set(key: string, value: any, time?: number | string) {
   //判断value值是否是对象类型
   if (typeof value === 'object') {
     value = JSON.stringify(value)
   }
   //time为过期时间,可选
   if (time) {
     await this.client.set(key, value, 'EX', time)
   } else {
     await this.client.set(key, value)
   }
 }
 async get(key: string) {
   const data = await this.client.get(key)
   return data
 }
 async delete(key: string) {
   await this.client.del(key)
 }
}

const redis = new Redis()
export default redis

注意事项:

  • 1.redis必须要先安装到你的电脑并配置完并且开启服务才能使用,具体redis安装,配置,开启服务实现自行百度

  • 2.如果你要设置redis密码,必须先把redis配置完密码才能用(自行百度redis如何配置密码),不然直接在nodejs使用连接会报auth错

  • 3.redis 6.0.0以下不支持用户名,只需要设置密码即可,如果你真的要用户名自行百度配置,但是我觉得一个机子一个redis就够了,用户名有点多此一举了

  • redlock:

import Redlock from 'redlock'
import redis from './redis'
const redlock = new Redlock([redis.client], {  retryCount: 0 })
export default redlock

注意事项:
1.new Redlock实例的时候第一个参数传入一个数组,里面每一项是ioredis的实例,如果像我一样不需要分布式,传入一个实例即可,后面是传入的配置具体查看其文档,此处retryCount表示获取锁失败的时候重试的次数,根据官方的解释,这里的retryCount设置为0够用了,如下图官方解释

image.png

3.封装一个分布式锁中间件

import { Middleware } from 'koa'
import { Lock } from 'redlock'
import redlock from '../db/redlock'
import { error } from '../utils/Response'
//这里isByUser为true则由用户id+请求地址作为key上锁,即:此接口不允许一个用户同时更改同一资源(参数不同也不行)
//isByUser默认为false则由全部参数+用户id+地址作为key上锁,即:此接口不允许一个用户同时以同一参数更改同一资源(拦截重复请求)
const idempotent = (isByUser: boolean = false) => {
  const Redlock: Middleware = async (ctx, next) => {
    let id: string
    //这里的ctx.user是我之前配置的中间件,用于解析用户携带token的参数,来辨别用户和获取用户参数,里面存放用户的个人信息
    //有的接口不需要鉴权认证,所以ctx.user.id就会报错则id以空字符串输出
    /*这里为什么要解析出id而不是直接拿token呢?因为一个用户可以有多个token,但一个用户只有一个id
    如果拿token作为标识,不同token的同一用户也会成功上锁,就形成了一个用户多次获得了锁的情况
    但由于id的独立性,所以id不同,就表示为不同的用户了
    */
    try {
      id = ctx.user.id
    } catch (error) {
      id = ''
    }
    let lock: Lock | null = null
    try {
      if (isByUser) {
        //上锁
        lock = await redlock.acquire([`${id}:${ctx.URL}`], 10000)
      } else {
        const body = JSON.stringify(ctx.request.body)
        console.log(`${id}:${ctx.URL}:${body}`)

        lock = await redlock.acquire([`${id}:${ctx.URL}:${body}`], 10000)
      }
    } catch (err) {
      //如果抛出错误表示上锁失败,表示有重复请求正在操作
      //这里的error()函数是我封装的返回错误的函数里面调用了ctx.throw所以报错会立即返回,后面的next不会继续进行
      error(ctx, 500, '请求正在进行,请勿重复提交')
    }
    await next()
    //后面的中间件全部执行完就可以释放锁了
    await lock!.release()
  }
  return Redlock
}
export default idempotent

4.使用环节(测试验收)

image.png

设置了一个测试路由:在路由处理前添加我们设计的中间件idempotent,不传入参数isByUser默认为false,即全部参数相同就拦截,路由处理没什么,就是等待两秒之后成功输出一句话

  • 一个线程发送两次相同请求(等待第一次处理完再发送第二个)

第一次:

image.png 第二次:

image.png

可以看到两次没有任何影响,都是延迟了2s后成功返回

  • 多个线程分别发送一次相同请求(并发)

这里用多个api接口管理工具短时间内轮流发送(处理一个请求需要2s,所以只要在2s之内发送另一个即可)来模拟并发

第一个请求:

image.png 第二个请求:

image.png

两张图你们很难看出真实情况,但是我我能看到,第一次请求两秒后返回了成功,第二次请求很短时间内直接返回错误(获取不到锁了,代表有重复请求在进行)

这里只给你们演示了一下无参数,无token的情况已经成功了,我之后也测试了isByUser和有无token的有效性,只是没有放出来,但也是没有问题的,isByUser是我认为比较常用的两种情况:全部参数和用户id+接口地址的判断方式,如果有其它想法,也可以自定义传入自己想锁定的key由什么参数决定,这里你们就二次封装即可,我个人感觉isByUser已经够用了

5.一个简单的koa分布式锁中间件就封装好了

注意事项:

  • redlock算法并非绝对安全,如果过期时间设置的太短(小于接口处理时间)会出现接口还没处理完就自动释放锁了,然后出现其他线程也可以获取到锁,就失去了安全性(Java中的redisson里有个watchdog自动续期可以解决这个问题,但是这里是nodejs,目前没有发现封装好watchdog机制的分布式锁包,有能力的也可以自己封装,我是能力不够,还是把过期时间设置的稍微长一点好了,但太长也会有其他弊端)
  • 这里的redlock是非阻塞性的,上文已经提到,如果获取不到锁会自动报错,请求直接失效而不是排队等候解锁再执行,如果需要阻塞性,可以自己封装,但是我推荐一个其他的包:async-lock这是一个阻塞性的处理方式,可以形成异步队列按顺序执行而不是非阻塞性地直接抛出错误

猜你喜欢

转载自juejin.im/post/7115669651173949448