套缈 发表于 2025-6-20 21:07:16

敏感词过滤 + 限流

社交场景设计

本文我们来做一个小场景:
【注意,本文借鉴内容偏多,引用的内容较多,如果想看原文,可以点击参考里面的链接查看原文】
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]
查看完整版本: 敏感词过滤 + 限流