IM网关系列(九)单账号流量统计:滑动窗口桶方案设计与Go实现

IM网关单账号流量统计:滑动窗口桶方案设计与Go实现

一、背景

IM网关是长连接接入层(WebSocket为主),需要对上行消息做限流防护:防止真人账号刷屏、机器人刷消息、恶意账号打满带宽、群组消息风暴拖垮后端。 需求约束:

  1. 轻量化:不希望每个账号开定时器,避免海量账号下定时器爆炸;
  2. 低内存:优先本地内存统计,不依赖Redis高频读写;
  3. 分钟粒度统计:统计最近1分钟消息条数、字节流量;
  4. 触发式更新:收到消息的时候才检查+更新统计,无消息时完全无开销;
  5. 区分账号类型:普通用户、机器人、群组,使用不同限流阈值;
  6. 配套幂等去重(网关层短期去重,防止客户端重传重复消息)。

全局服务总流量是高并发计数器,使用无锁方式计算,为了保证不出现"伪共享"需要填充到CacheLine大小,这里的低并发,使用紧凑的结构

方案:

1)按照1秒、5秒、10秒、20秒设置分片桶,桶个数 = 120秒 / 片宽度;

2)每个桶上带启动时候的时间戳;

3)超过120秒没有写入则清空桶;

4)写入间隔超过时间片,则推进一个桶,设置启动时间;

5)每个包调用:inc(字节数),返回:当前片上包个数,片上字节数;

6)如果单个片上流量过大,则统计1秒内的所有片的流量,判断是否超过阈值;

二、阈值参考(主流IM经验值,可etcd动态配置)

账号类型 每分钟消息条数上限 每分钟字节上限 备注
普通个人用户(真人) 60条/分钟 512KB/分钟 正常聊天1秒1条足够,防刷屏,20秒分片
机器人账号(官方/业务bot) 300~1000条/分钟 2MB~10MB/分钟 机器人会批量推送,单独高配,单独灰度;3秒分片
群组(按群维度限流,不是按成员) 200~500条/分钟 2MB/分钟 防止大群疯狂刷屏产生消息风暴;小群5秒分片,大群2秒或者3秒分片
临时风控黑名单账号 5条/分钟 32KB/分钟 处罚降级阈值

说明:

  1. 单条消息建议单独加最大长度限制(比如单条消息上限16KB),避免超大包;
  2. 阈值全部放配置中心etcd,支持灰度分组、动态热更新,不需要重启网关;
  3. 群组限流:两种模式可选:①群总消息限流;②群内每个发言成员单独限流,一般选前者防群风暴。

这里是滑动窗口统计的一个类:

go 复制代码
package main

import (
	"sync"
	"time"
)

const totalSecs = 120
const tmMinute = 60

// 之所以放在外面,是为了测试方便,传入一个虚拟时钟
var tmFunc func() int64 = time.Now().Unix

// 设置时钟
func SetClock(f func() int64) {
	tmFunc = f
}

// slotCounter 单个时间片。
// 非并发安全:所有字段的读写都必须由 LimiterRingWindow.mu 保护。
type slotCounter struct {
	packets int64
	bytes   int64
	ts      int64 // 这个槽位启用的时间,而不是最后一次写入时间
}

// Inc 增加一条消息及字节数。调用方必须持 LimiterRingWindow.mu。
func (s *slotCounter) Inc(msgLen int64) (int64, int64) {
	s.packets++
	s.bytes += msgLen
	return s.packets, s.bytes
}

// Reset 清零。调用方必须持 LimiterRingWindow.mu。
func (s *slotCounter) Reset() {
	s.packets = 0
	s.bytes = 0
	s.ts = 0
}

// SetStart 清空并设置槽启用时间。调用方必须持 LimiterRingWindow.mu。
func (s *slotCounter) SetStart() {
	s.packets = 0
	s.bytes = 0
	s.ts = tmFunc()
}

// Load 读取快照。调用方必须持 LimiterRingWindow.mu。
func (s *slotCounter) Load() (packets, bytes int64) {
	return s.packets, s.bytes
}

// /////////////////////////////////////////////////////////
// LimiterRingWindow 环形队列窗口:N 个槽,每槽 slotSecs 秒
type LimiterRingWindow struct {
	mu        sync.Mutex
	slots     []slotCounter
	slotSecs  uint16 // 窗口大小 1秒,5秒,10秒,
	slotCount uint16 // 当前这个窗口内的槽位个数
	index     uint16 // 当前使用索引: 0 ~ (slotCount-1)
}

func (l *LimiterRingWindow) now() int64 {
	return tmFunc()
}
func (l *LimiterRingWindow) slotStart() int64 {
	return l.slots[l.index].ts
}

// NewLimiterRingWindow 按窗口秒数创建环形窗口
// secs: 槽大小(秒),如 1、5、10、60
// 槽个数根据 secs 算出:totalSecs / secs
func NewLimiterRingWindow(secs uint16) *LimiterRingWindow {

	if secs == 0 {
		secs = 1
	}
	if secs > 60 {
		secs = 60
	}
	winSize := totalSecs / secs
	if winSize == 0 {
		winSize = 1
	}

	return &LimiterRingWindow{
		slots:     make([]slotCounter, winSize),
		slotSecs:  secs,
		slotCount: uint16(winSize),
		index:     0,
	}
}

// 推进一个槽就够了,推进多了也都是0
// 调用方必须持 l.mu。
func (l *LimiterRingWindow) advanceInLocked() uint16 {
	n := int(l.slotCount)

	l.index = uint16((int(l.index) + 1) % n)
	l.slots[l.index].SetStart()

	return l.index
}

func (l *LimiterRingWindow) Inc(msgLen int64) (int64, int64) {
	now := tmFunc()

	l.mu.Lock()
	defer l.mu.Unlock()

	// 通过索引看当前分片的起始时间,构造后是0
	slotStartTs := l.slots[l.index].ts

	delta := now - slotStartTs
	// 首次或长时间(超过1分钟)不活跃:不需要清理所有的分片,只清理这个一个就好了,防止频繁的清零
	if slotStartTs == 0 || delta >= totalSecs {
		l.slots[l.index].SetStart()
		return l.slots[l.index].Inc(msgLen)
	}

	// 推进:跨了几个时间片; 比如10秒一片,1~9秒,推进0个,10~19推进1个,20就是2个
	if delta >= int64(l.slotSecs) {
		l.advanceInLocked()
	}

	return l.slots[l.index].Inc(msgLen)
}

// ValueInMinute 返回最近 1 分钟内的统计。
// 先算分片个数 span,再从当前 index 往回遍历 span 个槽,
// 用 ts 判断该槽是否还在 1 分钟内,过期的跳过(惰性过期,不需要主动清理旧数据)。
func (l *LimiterRingWindow) ValueInMinute() (packets, bytes int64) {

	span := uint16(tmMinute) / l.slotSecs
	if (uint16(tmMinute) % l.slotSecs) > 0 {
		span++
	}
	if span > l.slotCount {
		span = l.slotCount
	}

	now := tmFunc()
	n := int(l.slotCount)

	// 设置锁
	l.mu.Lock()
	defer l.mu.Unlock()

	for i := 0; i < int(span); i++ {
		index := (int(l.index) - i + n) % n
		if now-l.slots[index].ts > tmMinute {
			continue
		}
		p, b := l.slots[index].Load()
		packets += p
		bytes += b
	}
	return packets, bytes
}
相关推荐
武子康2 小时前
Embedding 替换怎么评测:把索引成本算进成功任务
人工智能·后端·agent
紧跟先前的步伐3 小时前
Go AI Agent 实战 11|请求结构、响应结构与 JSON 解析
人工智能·ai·golang·json
大哥43094 小时前
on-failure 不扛重启:一次 Docker restart 策略的反直觉排查
后端
房西杭4 小时前
GM指令:仓库扩容、清空仓库(行会仓库 + 个人仓库,Lua + proto + GMMgr注册)
后端
大哥43094 小时前
不报错 ≠ 生效:三类"静默失效"缺陷与它们的共同指纹
后端
房西杭4 小时前
整理背包 SortBag 完整实现逻辑
后端
Marcus学原理4 小时前
Playwright 入门:让浏览器自动完成重复操作
后端
弦儿4 小时前
后端 Web 实战:部门管理模块全流程(前后端分离 + RESTful + 三层架构 + 日志)
后端·restful
房西杭4 小时前
背包<->仓库转移接口(BagMgr + StoreMgr,Lua)
后端