6

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

 1 year ago
source link: https://developer.51cto.com/article/713108.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

下面的内容我们不考虑前端的处理,因为不能完全靠前端,前后端都需要做自己的处理工作

1.幂等性:

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

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

GET:查询条件下,不论用户对数据库查询多少次,都不会对数据库的数据造成,所以这天生就是一个幂等接口

POST:新增条件下,如果用户多次发送相同的增加请求,那么数据库将会添加多条相同的记录,所以是一个非幂等接口

PUT:分为两种情况

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

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

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

2.并发:

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

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

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

3.高并发:

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

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

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

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

  • 分布式锁:

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

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

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

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

为什么用分布式锁:

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

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

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

配置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

注意事项:

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

c327c6f51ab04865480814fc10556d2db4d7cd.webp

封装一个分布式锁中间件

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

使用环节(测试验收)

c4b19f9221e1631b03936771020b2d8fea6618.webp

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

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

057b80739db475190ce7977812982aeaa4331c.webp
8372939415d9a4bb1e9274d497dd6a328a7cd1.webp

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

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

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

第一个请求:

969caea7028794f3d9815941ca90ea6317e4be.webp

第二个请求:

a196c48823c2db60efc916c550827d60e7b57b.webp

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

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

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

注意事项:

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK