本文我们来做一个小场景:
【注意,本文借鉴内容偏多,引用的内容较多,如果想看原文,可以点击参考里面的链接查看原文】
场景一:社交平台实时评论审核
这个时候我们要做一下:
场景二:评论/私信接口防刷与内容合规
电商平台的商品评论、卖家私信接口,既要防止机器刷单,又要保证留言内容不违规。
接口调用量巨大,对审核时延和吞吐都有严格要求。
还有平时我们发弹幕,发得太快了,系统会提示你 “发得太频繁了”【限流降级一下】,如果你还有违禁词,还会给你变成 星星处理【敏感词处理】。
...... 等等等
我相信大家都或多或少遇到过该情况的。那么本文就来探讨一下限流 和 敏感词过滤
限流,也称流量控制。是指系统在面临高并发,或者大流量请求的情况下,限制新的请求对系统的访问,从而保证系统的稳定性。限流会导致部分用户请求处理不及时或者被拒,这就影响了用户体验。所以一般需要在系统稳定和用户体验之间平衡一下。
比如说秒杀抢购,保护自身系统和下游系统不被巨型流量冲垮等
将时间切分为等长的固定窗口(如每秒、每分钟),对每个窗口内的请求计数,超过阈值即拒绝。
这种算法实现简单,计数操作开销低;适用于对“平均速率”要求不高的场景。
但是要想一下这种问题,那就是边界问题:窗口边界突刺:如限 100 次/分钟,59 秒内 100 次 + 新窗口开始(第61秒) 100 次,这三秒内瞬间可达 200 次。
如上图所示,加入每一秒的请求数最多四个的话,在窗口的边界,上图可以看到这六个请求都是被放行的。
public class FixedWindowLimit implements Limit {
private final ConcurrentHashMap resourceMap = new ConcurrentHashMap(2);
// 默认每个资源每秒访问50次
@Override
public synchronized boolean isAccess(Resource resource) {
long now = System.currentTimeMillis();
Pair pair = resourceMap.computeIfAbsent(resource.getName(), k -> new Pair(50, 1000, System.currentTimeMillis()));
long start = pair.getWindowStart();
int windowSize = pair.getWindowSize();
if (now - start >= windowSize) { // 超过窗口时间,重置窗口
pair.setInit(now);
return true;
} else {
pair.add();
// 超过窗口内最大请求数,拒绝
return pair.getCount().get()
基本思想
为了平滑限流,将时间窗口动态滑动,通过两个相邻固定窗口的加权计数来近似当前窗口内请求数。
如上图所示,滑动窗口将窗口划分为了多个小的时间片段,只统计当前窗口内的请求数就可以了,窗口移动的时候,将前面的时间槽丢弃掉。
public class SlidingWindowLimit implements Limit {
private static final ConcurrentHashMap map = new ConcurrentHashMap(2);
@Override
public synchronized boolean isAccess(Resource resource) {
long now = System.currentTimeMillis();
Pair pair = map.computeIfAbsent(resource.getName(), k -> new Pair(100, 1000, 10));
pair.refreshSlot(now);
// 窗口内计数器总和超过阈值,则丢弃后续请求
if (pair.getSum() >= pair.getMaxCount()) {
return false;
} else {
pair.add();
return true;
}
}
@Data
private static class Pair {
private int maxCount; // 阈值
private int windowSize; // 窗口大小[毫秒]
private int slotCount; // 窗口内区间个数
private int slotSize; // 区间大小[毫秒] windowSize/slotCount
private long lastRefreshTime;
private Queue slots; // 区间计数器队列
public Pair( int maxCount, int windowSize, int slotCount) {
this.maxCount = maxCount;
this.windowSize = windowSize;
this.slotCount = slotCount;
this.slotSize = windowSize/slotCount;
this.slots = new LinkedList();
for (int i = 0; i = slotSize ) { // 超过区间时间 -- 说明要移动窗口
int goAhead = (int) timePassed / slotSize; // 需要往前移动几个区间
int end = Math.min(slotCount, goAhead);
for (int i = 0; i
他有什么缺点呢?
实际上我们可以借助Redis的zset来辅助实现滑动窗口的限流:
Redis ZSET 特性:
ZSET
存储请求的时间戳(score
)和唯一标识(member
)。ZREMRANGEBYSCORE
删除过期的请求,通过 ZCOUNT
统计当前窗口内的请求数。主要是下面两个命令:
ZREVRANGEBYSCORE key min max
移除有序集合中给定的分数区间的所有成员
ZCARD key
获取有序集合的成员数
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import java.util.concurrent.TimeUnit;
public class SlidingWindowLimiter {
private final RedisTemplate redisTemplate;
private final String keyPrefix; // 限流Key前缀(如 "rate_limit:api1")
private final long windowMillis; // 时间窗口长度(毫秒)
private final int maxRequests; // 窗口内允许的最大请求数
public SlidingWindowLimiter(RedisTemplate redisTemplate, String keyPrefix,
long windowMillis, int maxRequests) {
this.redisTemplate = redisTemplate;
this.keyPrefix = keyPrefix;
this.windowMillis = windowMillis;
this.maxRequests = maxRequests;
}
/**
* 检查是否允许请求
* @param userId 用户标识(用于区分不同用户)
* @return true: 允许;false: 拒绝
*/
public boolean allowRequest(String userId) {
String key = keyPrefix + ":" + userId;
long now = System.currentTimeMillis();
long windowStart = now - windowMillis;
// Lua脚本保证原子性操作
String luaScript =
"local key = KEYS[1]n" +
"local now = tonumber(ARGV[1])n" +
"local windowStart = tonumber(ARGV[2])n" +
"local maxRequests = tonumber(ARGV[3])n" +
"local member = ARGV[4]n" +
"n" +
"-- 删除窗口外的旧数据n" +
"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)n" +
"n" +
"-- 获取当前窗口内的请求总数n" +
"local count = redis.call('ZCARD', key)n" +
"n" +
"-- 判断是否超过阈值n" +
"if count >= maxRequests thenn" +
" return 0n" +
"elsen" +
" -- 添加当前请求n" +
" redis.call('ZADD', key, now, member)n" +
" -- 设置Key的过期时间(避免冷数据长期占用内存)n" +
" redis.call('PEXPIRE', key, windowMillis + 1000)n" +
" return 1n" +
"end";
// 执行Lua脚本
Long result = redisTemplate.execute(
new DefaultRedisScript(luaScript, Long.class),
List.of(key),
String.valueOf(now),
String.valueOf(windowStart),
String.valueOf(maxRequests),
String.valueOf(now) + ":" + UUID.randomUUID() // 唯一标识
);
return result != null && result == 1;
}
}
/*
(1) 原子性保障
Lua脚本:将 ZREMRANGEBYSCORE(清理旧数据)、ZCARD(统计请求数)、ZADD(记录新请求)合并为原子操作,避免并发问题。
(2) 内存管理
自动过期:通过 PEXPIRE 设置 Key 的过期时间(窗口长度 + 缓冲时间),防止长期不用的 Key 占用内存。
*/
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。【引用的参考链接1中的图片】
优点
缺点
public class TokenBucketLimit implements Limit {
private static final int MAX_TOKEN = 50; // 每个桶最大50个
private static final int FILL_RATE = 5; // 每秒往桶中放5个令牌
private final ConcurrentHashMap limitMap;
public TokenBucketLimit() {
limitMap = new ConcurrentHashMap(2);
}
@Override
public synchronized boolean isAccess(Resource resource) {
long now = System.currentTimeMillis();
String name = resource.getName();
Pair pair = limitMap.get(name);
if (pair == null) { // 说明该接口没有被访问过
limitMap.put(name, new Pair(now, MAX_TOKEN));
return true;
} else {
// 1.先放令牌
addToken(now, pair);
// 2.取令牌
if (pair.getToken().get() > 0) {
pair.getToken().decrementAndGet();
return true;
}
}
return false;
}
private void addToken(long now, Pair pair) {
long during = now - pair.lastFillTime;
int v = (int) (during * 1.0 / 1000 * FILL_RATE);
if (v > 0) {
pair.setToken(Math.min(MAX_TOKEN, pair.getToken().get() + v));
pair.lastFillTime = now;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
private static class Pair {
private long lastFillTime; // 上一次填充令牌的时间戳
private AtomicInteger token; // 令牌桶
public Pair(long lastFillTime, int token) {
this.lastFillTime = lastFillTime;
this.token = new AtomicInteger(token);
}
public void setToken(int token) {
this.token.set(this.token.get() + token);
}
}
}
漏桶算法是一种经典的流量整形(Traffic Shaping)和限流(Rate Limiting)算法,通过固定速率处理请求,无论请求的到达速率如何波动,系统的处理速率始终保持恒定,从而平滑突发流量,保护下游系统不被压垮。其核心思想类似于“水桶漏水”——无论水流多快,漏水的速率是固定的。
我发现它相较于上面三个限流算法,有一个很不一样的点,那就是中间有一个缓冲区,假设它的容量是k,然后漏水的速率也就是请求放行的速率是v,客户端发送过来的请求速率是v1。如果v1 > v的话,缓冲区就会缓存任务,该请求会存在等待的行为。这就是它区别于上述其他三种限流算法的一点。所以,我认为它严格平滑输出(恒定服务速率),适用于能够接受一定排队延迟的场景。
public class LeakyBucketLimiter {
private final Queue bucket; // 漏桶队列
private final int capacity; // 漏桶容量
private final int leakRate; // 漏水速率(请求/秒)
private final ScheduledExecutorService scheduler;
public LeakyBucketLimiter(int capacity, int leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
this.bucket = new LinkedBlockingQueue(capacity);
this.scheduler = Executors.newScheduledThreadPool(1);
// 启动漏水任务(固定速率处理)
scheduler.scheduleAtFixedRate(this::leak, 0, 1000 / leakRate, TimeUnit.MILLISECONDS);
}
/** 尝试将请求放入漏桶 */
public boolean tryAccept(Request request) {
if (bucket.size() >= capacity) {
return false; // 桶已满,拒绝请求
}
return bucket.offer(request);
}
/** 漏水(处理请求) */
private void leak() {
if (!bucket.isEmpty()) {
Request request = bucket.poll();
processRequest(request); // 实际处理请求的方法
}
}
private void processRequest(Request request) {
// 实际业务逻辑(如调用下游API)
System.out.println("处理请求: " + request.getId() + " at " + System.currentTimeMillis());
}
/** 关闭限流器 */
public void shutdown() {
scheduler.shutdown();
}
/** 请求对象示例 */
static class Request {
private final String id;
public Request(String id) { this.id = id; }
public String getId() { return id; }
}
public static void main(String[] args) {
LeakyBucketLimiter limiter = new LeakyBucketLimiter(10, 5); // 容量10,速率5请求/秒
// 模拟请求
for (int i = 0; i
该版本不维护显式队列,只用一个计数器(桶水位)和固定的泄漏速率来判定请求是否合规:每次到来请求前,先按漏桶速率“泄漏”令水位下降;若水位加上该请求的水量仍不超过桶容量,则通过并累加水量,否则立即拒绝。【但是这个我觉得不是很符合漏桶算法的模型,并且这个很像令牌桶,令牌桶是先放令牌,再拿走令牌;这里是先漏水,再加水】
public class LeakyBucketLimiter2 {
// 最大容量(可接受的最大突发量)
private final double capacity;
// 泄漏速率:每毫秒可泄漏的“水量”
private final double leakRatePerMillis;
// 当前桶中“水位”
private double water;
// 上次执行泄漏操作的时间戳(毫秒)
private long lastTime;
public LeakyBucketMeter(double capacity, double leakRatePerSecond) {
this.capacity = capacity;
this.leakRatePerMillis = leakRatePerSecond / 1000.0;
this.water = 0.0;
this.lastTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
leak(); // 先按速率泄漏旧水
if (water + 1.0
单机场景:优先选择Guava RateLimiter(简单)
分布式微服务:推荐Sentinel(功能全面、见后续文章)或Redisson(与Redis深度集成)
网关层防护:Nginx或Spring Cloud Gateway内置限流模块,结合黑白名单策略
敏感词过滤旨在从用户生成内容(如社交媒体、聊天室、评论区)中识别并移除涉及暴力、色情、政治敏感、隐私泄露等违规词汇或表达。
现在了解到的比较好的方案大致如下:
机器学习与深度学习:结合自然语言处理(NLP)技术分析上下文语义,减少误判(如区分“苹果”作为水果或品牌);
DFA/Trie树:利用确定有限状态自动机或字典树提升匹配效率,适用于海量敏感词库;
本文就不考虑深度学习了,直接看第二种,我们来手动实现一个简易的dfa算法。
DFA(Deterministic Finite Automaton,确定有限状态自动机)是一种高效的模式匹配算法,尤其在敏感词过滤、文本分析和词法解析领域应用广泛。它的构建基于状态转移树,每个状态对应敏感词中的一个字符,通过嵌套的哈希表或字典树(Trie树)实现层级关系。
具体构建步骤:首先初始化根节点,创建一个初始状态(根节点),通常用Map
表示,作为所有敏感词匹配的起点;然后逐字符构建状态转移,例如敏感词“王八蛋”,按顺序处理字符“王”→“八”→“蛋”,对每个字符检查当前层级是否存在对应的子节点。若不存在,新建节点并标记字符,若存在则直接跳转到该节点;最后在敏感词的最后一个字符节点上设置结束标志(如isEnd: true
),表示匹配完成。如下json表示【词库: ”王八“、”王八蛋“、”王八儿子“、”傻“】
{
"王": {
"isEnd": false,
"八": {
"isEnd": true,
"蛋": {
isEnd: true
},
"儿":{
isEnd: false,
"子": {
isEnd: true
}
}
}
},
"傻": {
"isEnd": true
}
}
/*
根节点
├── 王 → 八 →(结束标记)
│ ├── 蛋 →(结束标记)
│ └── 儿 → 子 →(结束标记)
└── 傻 →(结束标记)
*/
从上面结构可以看出,王八蛋和王八儿子,他们是共享的前缀,以此来节省空间。
那么,匹配是怎么做到的呢?如果来了一句话 “张三是个王八蛋”:
逐字符扫描与状态跳转
匹配终止与处理
可以看到上述算法,只需要将目标字符串遍历一遍就可以了,时间复杂度是O(n),在我们最开始的场景来说效率还行,反正一条评论、聊天消息或者弹幕的话,字数通常也不会太多。
public class TrieNode {
// 子节点(key是下级字符,value是对应的节点)
private final Map subNodes = new HashMap();
// 是否是关键词的结尾
private boolean isKeywordEnd = false;
// 添加子节点
public void addSubNode(Character c, TrieNode node) {
subNodes.put(c, node);
}
// 获取子节点
public TrieNode getSubNode(Character c) {
return subNodes.get(c);
}
// 判断是否是关键词结尾
public boolean isKeywordEnd() {
return isKeywordEnd;
}
// 设置关键词结尾
public void setKeywordEnd(boolean keywordEnd) {
isKeywordEnd = keywordEnd;
}
}
// 过滤器
public class SensitiveWordFilter {
private final TrieNode rootNode = new TrieNode(); // 根节点
private static final char FILTER_FLAG = '*';
// 0.加载敏感词
public void loadSensitiveWords(Set sensitiveWords) {
for (String word : sensitiveWords) addWord(word);
}
// 2.过滤敏感词
public String filter(String text) {
if (text == null || text.isEmpty()) return text;
text = normalizeText(text); // 转换为小写并处理全角字符
StringBuilder result = new StringBuilder(text);
// 文本长度
int length = text.length();
// 遍历文本每个字符作为起始位置
for (int i = 0; i 0) {
// 替换为相同长度的*
for (int j = 0; j = 65281 && c
DFA通过Trie树实现敏感词的高效匹配,其核心在于公共前缀合并和逐字符状态跳转。实际应用中需结合词库动态更新、语义分析等策略提升准确性。现在我们就实现了这一种过滤方法。但是,这肯定是不足够应对我们这些聪明的人类的,首先如果我们的词库里面有“黄色”,假如说有一条评论是“这个香蕉还没有变成黄色的,不能吃”,那么上面这个dfa算法就不适用了,所以这就引入了一种上下文语境的问题。
// 测试一下
public class SensitiveTest {
public static void main(String[] args) {
SensitiveWordFilter filter = new SensitiveWordFilter();
filter.loadSensitiveWords(Set.of("傻", "王八", "王八蛋", "王八儿子", "黄色"));
String text = "张三是个大王八,真的是服了,这个黄色的香蕉是留给他的";
String str = filter.filter(text);
System.out.println(str);
}
}
// 张三是个大**,真的是服了,这个**的香蕉是留给他的
敏感词过滤识别当然不可能会这么简单,实际场景可能比我们想到的复杂千百倍,除了上下文语境问题,还可能会有方言、同音词替换、用首字母骂人等等。最佳的肯定是通过人工智能与自然语言处理技术实现精准识别,结合动态规则与上下文分析降低误判率。本文仅简单探索一下DFA算法的实现。
参与评论
手机查看
返回顶部