分布式锁在koa中的实践:封装中间件来解决幂等或重复请求的问题

3,146 阅读9分钟

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

1.幂等性:

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

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

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

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

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

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

2.并发:

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

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

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

3.高并发:

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

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

  • 线程锁:

线程锁多用于并发时为了提高效率开多个线程分别执行各自的代码并且不会阻塞主线程,如果没有多线程,所有的代码都由一个主线程来阻塞式执行,那么效率会低下,而且如果主线程抛出错误,下面的代码就不会执行了(javascript就是一门单线程的语言,js的执行是单线程的,但是他的宿主不管是node还是浏览器都不是单线程的,所以使得nodejs在并发执行异步io操作的时候nodejs可以调用底层的libuv的线程池实现并发下的异步非阻塞io操作,以及浏览器比如点击事件等不会阻塞主线程,最后利用事件循环与主线程进行通信)只有在主线程的操作会阻塞整个代码的执行,异步io操作出错了并不会影响主线程的执行,一个进程内的多个线程数据共享,如果多个并发操作一个数据的时候会造成数据的混乱,所以利用互斥线程锁实现多个线程阻塞执行一段公共的代码,如java的syncchronized和lock,node也可以基于创建一个事件队列,实现线程锁,比如(node的async-lock),但线程锁必须要保证在同一进程下,否则因为多进程的资源独立性,无法限制其他进程的队列执行,就要采用进程锁了

  • 进程锁:

  • 一个进程至少有一个线程,可以有多个线程,同样的一个系统至少有一个进程,可以有多个进程,比如我们用qq的时候并不会影响微信的同时使用,这里的qq和微信就是两个不同的进程,他们有自己的内存区域,数据不会共享,如果要实现进程间的通信就要利用一个公共的资源池比如mysql,redis等等,或者其他方式实现通信,所以进程锁限制的就是多个进程并发操作同一公共资源池里的数据造成数据混乱,这里可以通过redis等锁通过key关键词给指定资源上锁,只有第一个获取到锁的进程可以进行这个资源的操作,其他进程可以阻塞等到这个进程执行结束了释放锁然后重新尝试获取锁(分公平和非公平锁,公平就是先被阻塞的进程一定先获取锁,非公平就是大家一起被阻塞了等到释放锁时一起重新抢占这个锁,不能保证一开始大家被阻塞的顺序),如果非阻塞就直接抛出错误给用户表示请求繁忙让用户稍后再试,如果要阻塞等待,可以采用轮询的方式直到成功获取锁继续执行,但一定要防止死锁,比如添加过期时间, 进程锁是控制同一时刻至多仅有有一个进程在执行操作同一资源

  • 分布式锁:

当多个进程不在同一个系统之中时,使用分布式锁控制不同系统下多个进程对资源的访问,此时因为是分布式,所以一定会有多个redis实例集群(应该可以理解为进程锁就是只有单例的分布式锁,redlock算法就是通过多个实例当n/2+1个锁获取到实例就算成功获取锁)

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

三锁作用都是让同一时间只有一个线程,进程等在执行操作同一资源,只是作用的范围大小不同

实战环节:了解了那么多理论知识,下面我来实践一个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以undefined输出
    /*这里为什么要解析出id而不是直接拿token呢?因为一个用户可以有多个token,但一个用户只有一个id
    如果拿token作为标识,不同token的同一用户也会成功上锁,就形成了一个用户多次获得了锁的情况
    但由于id的独立性,所以id不同,就表示为不同的用户了
    */
 
    id = ctx?.user?.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, '请求正在进行,请勿重复提交')
    }
    
     try {
    //后面的中间件全部执行完就可以释放锁了
    //这里给next再套一次try finally是因为如果next里面有错误并抛出不做处理的话
    //会直接跳过lock.release()导致根本没有解锁,加一个finally让他不管有没有错误最后都能解锁
    //至于为什么不加catch因为此处next抛出的错误不应该由分布式锁中间件管,而是另外有一个错误处理中间件管理
      await next()
    } finally {
      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这是一个阻塞性的处理方式,可以形成异步队列按顺序执行而不是非阻塞性地直接抛出错误