IM网关单账号流量统计:滑动窗口桶方案设计与Go实现
一、背景
IM网关是长连接接入层(WebSocket为主),需要对上行消息做限流防护:防止真人账号刷屏、机器人刷消息、恶意账号打满带宽、群组消息风暴拖垮后端。 需求约束:
- 轻量化:不希望每个账号开定时器,避免海量账号下定时器爆炸;
- 低内存:优先本地内存统计,不依赖Redis高频读写;
- 分钟粒度统计:统计最近1分钟消息条数、字节流量;
- 触发式更新:收到消息的时候才检查+更新统计,无消息时完全无开销;
- 区分账号类型:普通用户、机器人、群组,使用不同限流阈值;
- 配套幂等去重(网关层短期去重,防止客户端重传重复消息)。
全局服务总流量是高并发计数器,使用无锁方式计算,为了保证不出现"伪共享"需要填充到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/分钟 | 处罚降级阈值 |
说明:
- 单条消息建议单独加最大长度限制(比如单条消息上限16KB),避免超大包;
- 阈值全部放配置中心etcd,支持灰度分组、动态热更新,不需要重启网关;
- 群组限流:两种模式可选:①群总消息限流;②群内每个发言成员单独限流,一般选前者防群风暴。
这里是滑动窗口统计的一个类:
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
}