如何使用滑动窗口限流优化网站性能 —— 安企CMS中的实践

如何优雅地处理高频访问?

今天早上,我收到了一条客户反馈,说网站打开很卡。我立刻打开服务器进行监控,发现服务器的负载异常高。经过一番排查,我发现在极短的时间内,某个IP以非常规律的频率访问着网站的多个页面。几乎一眼就能看出,网站被恶意的采集工具盯上了。这个IP通过不断请求页面,极大地消耗了服务器资源,导致正常用户无法访问。

面对此类高频请求问题,如果不采取有效的限流措施,网站不仅会出现性能问题,还可能在遭受持续攻击的情况下直接崩溃。于是,我决定着手处理这个问题,设计一套高效的请求限流方案。

传统限流方式面临的挑战

很多现有的限流方案都基于固定时间窗口逐次记录请求时间戳。这些方法虽然能解决部分问题,但在实际项目中有几个明显的缺点:

  1. 固定时间窗口:它按固定时段(如1分钟、5分钟)计数,当时间窗口切换时,所有请求记录清零。这样很容易导致“突发请求”问题:刚刚进入新窗口时,计数器归零,瞬间允许大量请求通过。

  2. 时间戳记录:逐次记录每次请求的时间戳虽然精确,但它要求对每个IP记录大量请求数据,内存占用较大,特别是在高流量网站上,容易出现性能瓶颈。

显然,这些传统方法难以应对我的需求。于是,我开始寻找一种更高效且灵活的方案。

如何选择最优解?

在进行方案对比时,我考虑了以下几种解决方案:

  • 漏桶算法(Leaky Bucket):将请求当成水滴,滴入“桶”中,按照固定速率“漏”出。这个方法虽然能平滑处理请求流量,但对于高频突发的请求依然存在难以控制的情况。

  • 令牌桶算法(Token Bucket):类似于漏桶算法,但它允许在短时间内处理请求的“突发”,只要有足够的“令牌”。然而,令牌桶算法相对复杂,且需要不断生成令牌,管理难度较大。

  • 滑动窗口计数法(Sliding Window):通过动态滑动的时间窗口统计请求,不仅能够灵活应对突发流量,还能保证整个窗口期内的请求量精确计算,避免传统固定时间窗口的缺点。

经过对比,我最终选择了滑动窗口计数法。这种方法既能有效限制请求频率,又不会像记录时间戳那样占用大量内存。

滑动窗口 + 时间桶

为了优化滑动窗口的内存使用,我设计了一个基于时间桶的滑动窗口算法。该方案不需要逐次记录每个请求的时间戳,而是将整个窗口期分成多个“时间桶”,每个桶记录1分钟内的请求总数。通过动态滑动这些桶,我们可以精准控制5分钟内的请求总量。

核心思路:

  1. 滑动窗口:将时间窗口分成5个1分钟的桶,每当新的一分钟开始时,移除最早的1分钟数据,动态计算最新的5分钟请求总量。
  2. 时间桶:每个桶存储该分钟内的请求数量,而不是每个请求的时间戳。这极大降低了内存占用。
  3. IP白名单:同时,我还引入了IP白名单,内网和本地IP无需受到限流控制,确保正常流量不受影响。
  4. UA白名单:同样,我引入了UA白名单,对特定UA的请求不做限流控制,避免了搜索引擎蜘蛛被误伤。

实现代码:

定义数据结构

type VisitInfo struct {
    Buckets     [5]int    // 每分钟一个桶,共5个桶
    LastVisit   int64     // 上次请求的时间戳
    CurrentIdx  int       // 当前时间对应的桶索引
    TotalCount  int       // 当前窗口期内的请求总数
}

var ipVisits = make(map[string]*VisitInfo)
var blockedIPs = make(map[string]time.Time)
var mu sync.Mutex

const WindowSize = 5 * time.Minute  // 窗口大小为5分钟
const MaxRequests = 100             // 5分钟内最大请求次数
const BlockDuration = 1 * time.Hour // 封禁时长为1小时

var whiteListIPs = []string{"127.0.0.1", "192.168.0.0/16"} // 内网和本地IP白名单

func isWhitelisted(ip string) bool {
    for _, cidr := range whiteListIPs {
        _, subnet, _ := net.ParseCIDR(cidr)
        if subnet.Contains(net.ParseIP(ip)) {
            return true
        }
    }
    return false
}

记录请求并清理过期记录

func recordIPVisit(ip string) bool {
    mu.Lock()
    defer mu.Unlock()

    now := time.Now().Unix() // 当前的Unix时间(秒)
    currentMinute := now / 60 % 5 // 当前在5个桶中的索引

    // 检查是否已有该IP的访问记录
    visitInfo, exists := ipVisits[ip]
    if !exists {
        visitInfo = &VisitInfo{
            Buckets:    [5]int{},
            LastVisit:  now,
            CurrentIdx: int(currentMinute),
        }
        ipVisits[ip] = visitInfo
    }

    // 计算时间差,更新桶的状态
    elapsedMinutes := int(now/60 - visitInfo.LastVisit/60)
    
    // 如果时间超过了窗口大小,重置所有桶
    if elapsedMinutes >= 5 {
        visitInfo.Buckets = [5]int{}
        visitInfo.TotalCount = 0
    } else {
        // 依次清理过期的桶
        for i := 1; i <= elapsedMinutes; i++ {
            idx := (visitInfo.CurrentIdx + i) % 5
            visitInfo.TotalCount -= visitInfo.Buckets[idx]
            visitInfo.Buckets[idx] = 0
        }
    }

    // 更新当前桶的索引和计数
    visitInfo.CurrentIdx = int(currentMinute)
    visitInfo.Buckets[visitInfo.CurrentIdx]++
    visitInfo.TotalCount++

    // 更新最后访问时间
    visitInfo.LastVisit = now

    // 检查是否超过最大请求次数
    if visitInfo.TotalCount > MaxRequests {
        return false // 超过最大请求次数,应该封禁
    }

    return true
}

处理请求逻辑

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ip := r.RemoteAddr

    // 检查并跳过白名单和搜索引擎
    if isWhitelisted(ip) || !isUAWhitelisted(r.UserAgent()) {
      ...
    }

    // 检查IP是否已被封禁
    if isIPBlocked(ip) {
        http.Error(w, "Your IP is blocked.", http.StatusForbidden)
        return
    }

    // 记录IP访问,并检查是否超出阈值
    if !recordIPVisit(ip) {
        blockIP(ip)
        http.Error(w, "Too many requests from this IP.", http.StatusTooManyRequests)
        return
    }

    // 正常处理请求
    ...
}

定时清理封禁的IP

func cleanupExpiredRecords() {
    mu.Lock()
    defer mu.Unlock()

    now := time.Now()

    // 清理过期的封禁记录
    for ip, unblockTime := range blockedIPs {
        if now.After(unblockTime) {
            delete(blockedIPs, ip)
        }
    }

    // 清理过期的IP访问记录,这里只回收最后一次访问超过5分钟的记录
    for ip, visitInfo := range ipVisits {
        if now.After(time.Unix(visitInfo.LastVisit, 0).Add(WindowSize)) {
            delete(ipVisits, ip)
        }
    }
}

func startCleanupTask() {
    ticker := time.NewTicker(1 * time.Minute)
    go func() {
        for range ticker.C {
            cleanupExpiredRecords()
        }
    }()
}

当请求到来时,系统首先检查该IP是否在白名单中。如果是白名单IP,直接放行;如果不是,则使用滑动窗口算法动态统计请求数量。

判断UA,如果是搜索引擎蜘蛛,则也同样跳过后续的检查,直接放行。

将滑动窗口集成到安企CMS中

将滑动窗口限流方案集成到安企CMS时,我主要关注以下几点:

  1. 高效性:确保限流逻辑在高并发情况下依然能够快速处理,不影响正常请求。
  2. 灵活性:通过调节时间桶数量和每个桶的大小,适应不同的流量场景。例如,系统默认5分钟内允许100次请求,但可以根据业务需求灵活调整。
  3. 稳定性:对封禁的IP进行1小时的封禁处理,并定期清理过期的封禁记录,确保系统长时间稳定运行。

总结:滑动窗口限流在安企CMS中的应用

通过滑动窗口和时间桶相结合的方法,我成功解决了安企CMS中的恶意请求问题。该方案不仅显著降低了内存开销,还使得系统在高流量下表现稳定。特别是在集成了IP白名单功能后,内网和本地IP用户可以免受限流影响,保证了系统对内部流量的友好性。

优点:

  • 高效:相比逐次记录请求时间戳的传统方法,内存占用和计算量大幅减少。
  • 灵活:可以根据业务需求灵活调整限流策略和封禁时长。
  • 安全:封禁机制有效防止恶意用户对系统发起过多请求,提升整体安全性。

这次滑动窗口限流方案的实践,不仅提升了安企CMS的性能和稳定性,也为其他开发者提供了一个简单易用的高效限流方案。如果你也在开发过程中遇到类似的高频请求问题,希望这篇文章能为你提供一些参考。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,978评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,954评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,623评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,324评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,390评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,741评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,892评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,655评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,104评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,569评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,254评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,834评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,725评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,950评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,260评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,446评论 2 348

推荐阅读更多精彩内容