敏感词过滤 + 限流
社交场景设计本文我们来做一个小场景:
【注意,本文借鉴内容偏多,引用的内容较多,如果想看原文,可以点击参考里面的链接查看原文】
1.引入
场景一:社交平台实时评论审核
[*]用户在帖子下发表评论,内容需实时审核是否包含敏感词;
[*]为了防止刷屏或恶意评论,需对每个用户或 IP 做限流。
这个时候我们要做一下:
[*]入口限流
[*]在网关层或前端 API 服务,应用令牌桶(Token Bucket)或漏桶(Leaky Bucket)算法,对单个用户(或单个 IP)做 QPS/TPS 限制。
[*]DFA 过滤
[*]请求通过限流后,进入文本审核模块。
[*]利用 DFA 构建一棵敏感词自动机,对评论文字做单次扫描,时间复杂度 O(n)。
[*]若检测到敏感词,则返回“含违规内容”,同时可记录复审日志。
场景二:评论/私信接口防刷与内容合规
[*]电商平台的商品评论、卖家私信接口,既要防止机器刷单,又要保证留言内容不违规。
[*]接口调用量巨大,对审核时延和吞吐都有严格要求。
还有平时我们发弹幕,发得太快了,系统会提示你 “发得太频繁了”【限流降级一下】,如果你还有违禁词,还会给你变成 星星处理【敏感词处理】。
...... 等等等
我相信大家都或多或少遇到过该情况的。那么本文就来探讨一下限流 和 敏感词过滤
2.限流算法
限流,也称流量控制。是指系统在面临高并发,或者大流量请求的情况下,限制新的请求对系统的访问,从而保证系统的稳定性。限流会导致部分用户请求处理不及时或者被拒,这就影响了用户体验。所以一般需要在系统稳定和用户体验之间平衡一下。
比如说秒杀抢购,保护自身系统和下游系统不被巨型流量冲垮等
①固定窗口限流
将时间切分为等长的固定窗口(如每秒、每分钟),对每个窗口内的请求计数,超过阈值即拒绝。
这种算法实现简单,计数操作开销低;适用于对“平均速率”要求不高的场景。
但是要想一下这种问题,那就是边界问题:窗口边界突刺:如限 100 次/分钟,59 秒内 100 次 + 新窗口开始(第61秒) 100 次,这三秒内瞬间可达 200 次。
如上图所示,加入每一秒的请求数最多四个的话,在窗口的边界,上图可以看到这六个请求都是被放行的。
public class FixedWindowLimit implements Limit {
private final ConcurrentHashMap<String, Pair> 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() <= pair.getMaxCount();
}
}
@Data
private static class Pair {
private int maxCount; // 窗口内最大请求数
private int windowSize; // 窗口大小,单位毫秒
private Long windowStart; // 窗口开始时间
private AtomicInteger count; // 窗口内请求数
public Pair( int maxCount, int windowSize, Long windowStart) {
this.maxCount = maxCount; this.windowSize = windowSize;
this.windowStart = windowStart; this.count = new AtomicInteger(0);
}
public void add() {
this.count.incrementAndGet();
}
public void setInit( long newTime) {
this.count.set(1);
this.windowStart = newTime;
}
}
}他有什么缺点呢?
[*]实现复杂:需维护多个时间片的计数器和滑动逻辑。
[*]资源消耗高:时间片越多,内存和计算开销越大。
实际上我们可以借助Redis的zset来辅助实现滑动窗口的限流:
Redis ZSET 特性:
[*]用 ZSET 存储请求的时间戳(score)和唯一标识(member)。
[*]通过 ZREMRANGEBYSCORE 删除过期的请求,通过 ZCOUNT 统计当前窗口内的请求数。
主要是下面两个命令:
ZREVRANGEBYSCORE key min max 移除有序集合中给定的分数区间的所有成员
ZCARD key 获取有序集合的成员数
public class SlidingWindowLimit implements Limit {
private static final ConcurrentHashMap<String, Pair> 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<Integer> 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 < slotCount; i++) {
slots.offer(0);
}
this.lastRefreshTime = System.currentTimeMillis();
}
public void add() {
Integer poll = slots.poll();
poll = poll == null ? 0 : poll;
slots.offer(poll + 1);
}
public int getSum() {
return this.slots.stream().mapToInt(Integer::intValue).sum();
}
public void refreshSlot( long now ) {
long timePassed= now - lastRefreshTime; // 已流逝的时间
if ( timePassed>= slotSize ) { // 超过区间时间 -- 说明要移动窗口
int goAhead = (int) timePassed / slotSize; // 需要往前移动几个区间
int end = Math.min(slotCount, goAhead);
for (int i = 0; i < end; ++i ) {
// 移除窗口
slots.poll();
slots.offer(0); // 添加区间
}
lastRefreshTime += (long) goAhead * slotSize;
}
}
}
}③令牌桶限流
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。【引用的参考链接1中的图片】
优点
[*]支持突发:令牌在空闲时累积,请求可一次性消耗多枚令牌,应对短时高峰
[*]平均速率可控:长期看,令牌生成速率决定了整体吞吐上限,既可平滑亦可灵活
缺点
[*]需令牌维护:要定期“填充”令牌,并维护当前令牌数状态,带来额外逻辑开销
[*]可能延迟突发处理:如果令牌已被消耗,突发请求仍然会被拒绝或延后处理
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<String, String> redisTemplate;
private final String keyPrefix;// 限流Key前缀(如 "rate_limit:api1")
private final long windowMillis; // 时间窗口长度(毫秒)
private final int maxRequests;// 窗口内允许的最大请求数
public SlidingWindowLimiter(RedisTemplate<String, String> 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\n" +
"local now = tonumber(ARGV)\n" +
"local windowStart = tonumber(ARGV)\n" +
"local maxRequests = tonumber(ARGV)\n" +
"local member = ARGV\n" +
"\n" +
"-- 删除窗口外的旧数据\n" +
"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)\n" +
"\n" +
"-- 获取当前窗口内的请求总数\n" +
"local count = redis.call('ZCARD', key)\n" +
"\n" +
"-- 判断是否超过阈值\n" +
"if count >= maxRequests then\n" +
" return 0\n" +
"else\n" +
" -- 添加当前请求\n" +
" redis.call('ZADD', key, now, member)\n" +
" -- 设置Key的过期时间(避免冷数据长期占用内存)\n" +
" redis.call('PEXPIRE', key, windowMillis + 1000)\n" +
" return 1\n" +
"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 占用内存。
*/④漏桶
漏桶算法是一种经典的流量整形(Traffic Shaping)和限流(Rate Limiting)算法,通过固定速率处理请求,无论请求的到达速率如何波动,系统的处理速率始终保持恒定,从而平滑突发流量,保护下游系统不被压垮。其核心思想类似于“水桶漏水”——无论水流多快,漏水的速率是固定的。
[*]漏桶容器:
[*]所有请求先进入漏桶队列(桶的容量固定)。
[*]若桶已满,新请求被拒绝(溢出)。
[*]恒定漏水速率:
[*]漏桶以固定速率(如每秒10次)处理队列中的请求,无论请求的到达速率多快。如下图【引用的参考链接1中的图片】
[*]队列版漏桶
我发现它相较于上面三个限流算法,有一个很不一样的点,那就是中间有一个缓冲区,假设它的容量是k,然后漏水的速率也就是请求放行的速率是v,客户端发送过来的请求速率是v1。如果v1 > v的话,缓冲区就会缓存任务,该请求会存在等待的行为。这就是它区别于上述其他三种限流算法的一点。所以,我认为它严格平滑输出(恒定服务速率),适用于能够接受一定排队延迟的场景。
public class TokenBucketLimit implements Limit {
private static final int MAX_TOKEN = 50; // 每个桶最大50个
private static final int FILL_RATE = 5; // 每秒往桶中放5个令牌
private final ConcurrentHashMap<String, Pair> 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);
}
}
}
[*]计量版漏桶
该版本不维护显式队列,只用一个计数器(桶水位)和固定的泄漏速率来判定请求是否合规:每次到来请求前,先按漏桶速率“泄漏”令水位下降;若水位加上该请求的水量仍不超过桶容量,则通过并累加水量,否则立即拒绝。【但是这个我觉得不是很符合漏桶算法的模型,并且这个很像令牌桶,令牌桶是先放令牌,再拿走令牌;这里是先漏水,再加水】
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.00) { // 替换为相同长度的* for (int j = 0; j < sensitiveWordLength; j++) { result.setCharAt(i + j, FILTER_FLAG); } // 跳过已处理的敏感词长度 i = i + sensitiveWordLength - 1; } } return result.toString(); } // 1.添加敏感词到Trie树 private void addWord(String keyword) { if (keyword == null || keyword.isEmpty()) return; keyword = normalizeText(keyword); // 转换为小写并处理全角字符 TrieNode currentNode = rootNode; for (int i = 0; i < keyword.length(); ++i) { char c = keyword.charAt(i); TrieNode node = currentNode.getSubNode(c); if (node == null) { node = new TrieNode(); currentNode.addSubNode(c, node); } currentNode = node; // 设置结束标识 if (i == keyword.length() - 1) { currentNode.setKeywordEnd(true); } } } // 3.从文本中检查敏感词 private int checkSensitiveWord(String text, int beginIndex) { TrieNode currentNode = rootNode; int length = 0; boolean isSensitiveWord = false; // 从beginIndex开始逐字符检查 for (int i = beginIndex; i < text.length(); i++) { char c = text.charAt(i); // 过滤空格,支持如"傻 狗"这样的敏感词检测 if (c == ' ') { length++; continue; } // 获取下一级节点 currentNode = currentNode.getSubNode(c); if (currentNode == null) { // 没有匹配的继续字符,终止检查 break; } else { // 匹配到一个字符,长度加1 length++; // 如果是关键词结尾,则标记为敏感词 if (currentNode.isKeywordEnd()) { isSensitiveWord = true; } } } // 如果不是敏感词,返回0 if (!isSensitiveWord) length = 0; return length; } // 将文本转换为小写并处理全角字符 private String normalizeText(String text) { StringBuilder sb = new StringBuilder(); for (char c : text.toCharArray()) { // 全角转半角 if (c >= 65281 && c
页:
[1]