纯 golang 实现精确实现滑动窗口限流,支持本地持久化,性能远超用 redis 实现的方案 (约 250 万/s)

yudeguang · 2021-04-12 16:27:18 · 5432 次点击 · 大约8小时之前 开始浏览    置顶
这是一个创建于 2021-04-12 16:27:18 的主题,其中的信息可能已经有所发展或是发生改变。

网站的运营中,经常会遇到需要对用户访问次数做限制的情况,比如非常典型的是对于某些付费访问服务,需要对访问频率做比较精确的限制,比如单个用户每天只允许访问多少次,然后每小时只允许访问多少次等等,ratelimit就是专门针对这种情况而设计。

对于这种需求,目前相对较为简单的是用rdeis来实现,与采用redis的方案相比:

1)性能:每秒约能处理250万次,与之对比redis大约在10万左右。

2)简单,无依赖,无需安装任何第三方软件(诸如redis),可以以包的形式直接嵌入项目。

3)同样支持持久化,可定期把历史数据备份到本地磁盘。

##使用案例如下

package main

import (
    "fmt"
    "log"
    "strconv"
    "sync"
    "time"

    "github.com/yudeguang/ratelimit"
)

var num int //因并发问题num比实际数量小

func main() {
    log.SetFlags(log.Lshortfile | log.Ltime)
    test1()
    test2()
}

func test1() {
    fmt.Println("\r\n测试1,加载备份数据并自动定期备份以及性能测试:")
    //初始化访问规则,支持用多个规则形成的复杂规则,规则必须至少包含一条规则
    //支持从本地磁盘加载备份好的历史记录,并支持定期自动备份
    r := ratelimit.NewRule()
    r.AddRule(time.Second*10, 5)                               //每10秒只允许访问5次
    r.AddRule(time.Minute*30, 50)                              //每30分钟只允许访问50次
    r.AddRule(time.Hour*24, 500)                               //每天只允许访问500次
    err := r.LoadingAndAutoSaveToDisc("test1", time.Second*10) //设置10秒备份一次(不填写则默认60秒备份一次),备份到程序当前文件夹下,文件名为test1.ratelimit
    if err == nil {
        log.Println("加载历史访问记录成功")
    } else {
        log.Println(err)
    }
    //构建若干个用户,模拟用户访问
    var users = make(map[string]bool)
    for i := 1; i < 1000; i++ {
        users["user_"+strconv.Itoa(i)] = true
    }
    begin := time.Now()
    //模拟多个协程访问
    chanNum := 200
    var wg sync.WaitGroup
    wg.Add(chanNum)
    for i := 0; i < chanNum; i++ {
        go func(i int, wg *sync.WaitGroup) {
            for ii := 0; ii < 50; ii++ {
                for user := range users {
                    for {
                        if r.AllowVisit(user) {
                            num++
                        } else {
                            num++
                            break
                        }
                    }
                }
            }
            wg.Done()
        }(i, &wg)
    }
    //所有线程结束,完工
    wg.Wait()

    log.Println("性能测试完成,每秒约完成:", num/int(time.Now().Sub(begin).Seconds()), "次操作")
    err = r.SaveToDiscOnce("test2") //在自动备份的同时,还支持手动备份,一般在程序要退出时调用此函数
    if err == nil {
        log.Println("完成手动数据备份")
    } else {
        log.Println(err)
    }
}
func test2() {
    fmt.Println("\r\n测试2,模拟用户访问并打印:")
    r := ratelimit.NewRule()
    r.AddRule(time.Second*10, 5)                        //每10秒只允许访问5次
    r.AddRule(time.Minute*30, 50)                       //每30分钟只允许访问50次
    r.AddRule(time.Hour*24, 500)                        //每天只允许访问500次
    r.LoadingAndAutoSaveToDisc("test2", time.Second*10) //设置10秒备份一次(不填写则默认60秒备份一次),备份到程序当前文件夹下,文件名为test2.ratelimit
    //构建若干个用户,模拟用户访问
    users := []string{"andy", "小余", "130x"}
    for _, user := range users {
        fmt.Println("\r\n开始模拟以下用户访问:", user)
        for {
            if r.AllowVisit(user) {
                log.Println(user, "访问1次,剩余:", r.RemainingVisits(user))
            } else {
                log.Println(user, "访问过多,稍后再试")
                break
            }
            time.Sleep(time.Second * 1)
        }
    }

    //打印所有用户访问数据情况
    fmt.Println("\r\n开始打印所有用户在相关时间段内详细的剩余访问次数情况:\r\n")
    for _, user := range users {
        fmt.Println(user)
        fmt.Println("     概述:", r.RemainingVisits(user))
        fmt.Println("     具体:")
        r.PrintRemainingVisits(user)
        fmt.Println("")
    }
}

结果如下:

测试1,加载备份数据并自动定期备份以及性能测试:
22:45:27 t.go:31: 加载历史访问记录成功
22:45:31 t.go:65: 性能测试完成,每秒约完成: 2495806 次操作
22:45:31 t.go:68: 完成手动数据备份

测试2,模拟用户访问并打印:

开始模拟以下用户访问: andy
22:45:31 t.go:86: andy 访问1,剩余: [4 49 499]
22:45:32 t.go:86: andy 访问1,剩余: [3 48 498]
22:45:33 t.go:86: andy 访问1,剩余: [2 47 497]
22:45:34 t.go:86: andy 访问1,剩余: [1 46 496]
22:45:35 t.go:86: andy 访问1,剩余: [0 45 495]
22:45:36 t.go:88: andy 访问过多,稍后再试

开始模拟以下用户访问: 小余
22:45:36 t.go:86: 小余 访问1,剩余: [4 49 499]
22:45:37 t.go:86: 小余 访问1,剩余: [4 48 498]
22:45:38 t.go:86: 小余 访问1,剩余: [3 47 497]
22:45:39 t.go:86: 小余 访问1,剩余: [2 46 496]
22:45:40 t.go:86: 小余 访问1,剩余: [1 45 495]
22:45:41 t.go:86: 小余 访问1,剩余: [0 44 494]
22:45:42 t.go:88: 小余 访问过多,稍后再试

开始模拟以下用户访问: 130x
22:45:42 t.go:86: 130x 访问1,剩余: [4 49 499]
22:45:43 t.go:86: 130x 访问1,剩余: [3 48 498]
22:45:44 t.go:86: 130x 访问1,剩余: [2 47 497]
22:45:45 t.go:86: 130x 访问1,剩余: [1 46 496]
22:45:46 t.go:86: 130x 访问1,剩余: [0 45 495]
22:45:47 t.go:88: 130x 访问过多,稍后再试

开始打印所有用户在相关时间段内详细的剩余访问次数情况:

andy
     概述: [5 45 495]
     具体:
andy 在 10s 内共允许访问 5,剩余 5
andy 在 30m0s 内共允许访问 50,剩余 45
andy 在 24h0m0s 内共允许访问 500,剩余 495

小余
     概述: [1 44 494]
     具体:
小余 在 10s 内共允许访问 5,剩余 1
小余 在 30m0s 内共允许访问 50,剩余 44
小余 在 24h0m0s 内共允许访问 500,剩余 494

130x
     概述: [0 45 495]
     具体:
130x 在 10s 内共允许访问 5,剩余 0
130x 在 30m0s 内共允许访问 50,剩余 45
130x 在 24h0m0s 内共允许访问 500,剩余 495

有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

5432 次点击  ∙  2 赞  
加入收藏 微博
8 回复  |  直到 2022-03-24 16:59:08
liangmanlin
liangmanlin · #1 · 4年之前

你可能还不清楚为什么要用redis,因为要涉及多台物理机,哪个语言做不到进程内存访问数百万qps?

jarlyyn
jarlyyn · #2 · 4年之前

…………

你不用redis我能理解。

但是,这种功能肯定是一个网关啊……

不带http的反向代理测出来的数据毫无意义。

带了http的话,会发现走内存没啥意义。

而且这种功能肯定是实现一个接口,然后写一个内存驱动一个redis驱动……

yudeguang
yudeguang · #3 · 4年之前

这个可以自行封装一个HTTP服务,或者其它协议,比较简单,此处并没有实现。 相比redis方案,此方案主要在于: 1)简单,无依赖,至少你不用再装一个redis; 2)内存波动很小,不会有频繁的gc操作(但缺点也有,与此对应的是,初始化的时候,就预先分配了较大的内存); 3)性能并不是问题的关键,虽然确实比redis快很多。

yudeguang
yudeguang · #4 · 4年之前
liangmanlinliangmanlin #1 回复

你可能还不清楚为什么要用redis,因为要涉及多台物理机,哪个语言做不到进程内存访问数百万qps?

自行封装一个HTTP服务或者其它协议的服务即可解决多台服务机的问题。性能并不是问题的关键,虽然确实比redis快很多。主要是为了简单,并且服务稳定。

jarlyyn
jarlyyn · #5 · 4年之前
yudeguangyudeguang #3 回复

这个可以自行封装一个HTTP服务,或者其它协议,比较简单,此处并没有实现。 相比redis方案,此方案主要在于: 1)简单,无依赖,至少你不用再装一个redis; 2)内存波动很小,不会有频繁的gc操作(但缺点也有,与此对应的是,初始化的时候,就预先分配了较大的内存); 3)性能并不是问题的关键,虽然确实比redis快很多。

但从我角度看,你这个功能,比常规做法慢很多,还引入了不必要的gc……

这种功能本身就应该应该外围服务器,不再应用服务器本身的,你这是加强了耦合啊……

这种功能撇开你要做一个waf,一般都是加在nginx之类的地方的……

stormeyes
stormeyes · #6 · 3年之前

如果要封装成http服务, 那么这个http服务会变成单点, 在高并发下这个关键路径会成为系统瓶颈.

其次, 性能会比业务直接连 redis 性能上要差, 因为 退化成了 http调用, 一次http调用明显比一次 redis call 要差.

再其次, 虽然标榜不需要redis, 但是还是要平白无故多搞一个独立的 http 服务

再者, 都是把单机的算法进行封装, 为什么不用golang自带的ratelimit呢, 连第三方包都不用引用, 文档齐全查资料多经受住了国内外项目的考验. 而且你这个包中 把历史数据备份到本地磁盘 的功能其实 nobody care. 实在需要看这个东西直接存日志就行了

感觉 LZ 实际的经验还是不太足够啊.....

jiuker
jiuker · #7 · 3年之前

加一个http | tcp包起来看看,在内存玩啥也不是

NuoMinMin
NuoMinMin · #8 · 3年之前
stormeyesstormeyes #6 回复

如果要封装成http服务, 那么这个http服务会变成单点, 在高并发下这个关键路径会成为系统瓶颈. 其次, 性能会比业务直接连 redis 性能上要差, 因为 `退化`成了 http调用, 一次http调用明显比一次 redis call 要差. 再其次, 虽然标榜不需要redis, 但是还是要平白无故多搞一个独立的 http 服务 再者, 都是把单机的算法进行封装, 为什么不用golang自带的ratelimit呢, 连第三方包都不用引用, 文档齐全查资料多经受住了国内外项目的考验. 而且你这个包中 `把历史数据备份到本地磁盘` 的功能其实 nobody care. 实在需要看这个东西直接存日志就行了 感觉 LZ 实际的经验还是不太足够啊.....

LZ 有点那种,为了造轮子而造轮子。 像大学时学习程序时造轮子写着玩的感觉

添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传