
如果你的服务端收到这样一条消息“你是凑企鹅你是凑企鹅你是凑企鹅”第一反应可能是脚本刷屏、网络抖动、还是用户手滑连点了三下如果不加处理这条消息会原封不动进入你的日志系统、消息队列或者业务数据库看起来只是多占了一点存储但同样的机制放到支付回调、短信通知、定时任务里就是重复扣款、重复报警、重复投递的隐患。重复内容处理本质上不是“字符串比较”这么简单。它分布在文本清洗、接口幂等、分布式去重、存储约束四个层面每一层解决的是不同种类的问题。这篇文章就以“你是凑企鹅”这条循环重复文本作为贯穿示例把重复内容检测与治理从原理讲到代码帮助你判断什么场景用哈希、什么场景用 Redis、什么场景要上布隆过滤器、什么场景必须靠数据库唯一索引兜底。读完你会得到一套可以落地的最小方案先给重复内容一个内容指纹再区分“短时间窗口内不允许重复”还是“历史数据中不允许重复”最后用幂等键守住业务边界。同时也会把布隆过滤器误判、Redis 内存增长、数据库唯一约束冲突这几个常见坑讲清楚。1. 重复内容问题一个看起来简单却无处不在的工程问题很多人第一次接触去重是在写爬虫时同一篇文章被爬了好几次想加个集合判断 URL 是否访问过。再后来接触接口开发发现前端连点两次提交订单就被创建了两笔。再往后排查线上日志发现同一个错误在 10 分钟里上报了 2000 次告警群被刷爆。这些都是重复内容问题但它们的本质并不同。爬虫去重处理的是“已经处理过的 URL/正文”接口防重处理的是“同一业务请求在短时间内重复提交”日志去重处理的是“相同异常信息在告警时段内重复触发”。如果把“你是凑企鹅”当作一条业务消息那么它连发三次至少包含三种可能用户连点了发送按钮客户端网络超时后退重服务端处理了同一条消息某个脚本在恶意刷接口。不同的可能性对应不同的处理策略。只做全量去重会误伤正常用户稍后再次发送相同内容只做短时间去重又拦不住历史级别的重复入库。更麻烦的是如果完全不处理重复数据会沿着调用链一路传递下去日志重复、统计重复、下游接口被重复触发。所以真正值得思考的问题不是“要不要去重”而是“在哪个环节、用什么粒度、去多重”。这会直接决定你的系统能承受多大的重复压力以及在数据最终一致性上付出什么代价。2. 核心概念从字符串比较到内容指纹先看最简单的情况——字符串完全相同。你是凑企鹅 你是凑企鹅这行代码没有任何疑问结果是 True。但把它放到真实系统里问题就来了如果消息有 1 万条每条平均几百字节全放在内存里做两两比较不仅慢而且占用高。更常见的是你只需要判断“这条消息我是否见过”并不想保存完整的消息文本。于是有了内容指纹的概念用哈希算法把任意长度的文本映射成一个固定长度的摘要比如 SHA-256 会得到 64 位十六进制字符串。比较内容时不再比较原文而是比较摘要。原文你是凑企鹅 SHA-256fa1c7fc6...实际输出为长十六进制串哈希函数在这里的工程价值有三个压缩存储空间同等数据量下只需要保存摘要固定长度方便做数据库索引、Redis Key、布隆过滤器位映射计算速度快一条短文本哈希耗时在微秒级。当然哈希去重的前提是“完全重复”。如果内容只是相似比如“你是凑企鹅”和“你是凑企鹅啊”哈希值就完全不同。此时需要的是另一套方法文本归一化、相似度计算、SimHash 或向量语义检索。后面会具体讨论它们各自的边界。2.1 先做文本归一化再做指纹计算很多“明明内容一样哈希却不同”的问题出在文本没有被归一化。例如用户输入你是凑企鹅 你是凑企鹅。 你是凑企鹅如果直接对完整字符串做哈希三条会得到三个完全不同的摘要。原因可能是全半角符号、末尾标点、空白字符甚至是换行符差异。因此在计算内容指纹之前一般先做一步文本规范化处理去掉首尾空白统一全半角字符统一大小写英文场景去除或统一标点符号可选去除停用词但中文场景要根据业务谨慎处理。Python 里可以这样处理import re import unicodedata def normalize(text: str) - str: text unicodedata.normalize(NFKC, text) text text.strip().lower() text re.sub(r[\s。、!?,.], , text) return text需要注意归一化强度取决于业务。比如电商评论用户可能故意用“你是凑企鹅”和“你是凑企鹅 ”区分不同内容但做消息防重过度归一化反而可能误伤。正确做法是先定义“什么程度的差异算相同内容”再确定归一化规则。2.2 内容指纹工具的选型对比方案解决的问题特点典型场景原始字符串比较完全相同最直观但占内存、耗时高极小数据量哈希摘要完全相同速度快、占用小但无法判断相似接口幂等、消息去重布隆过滤器完全相同的存在性判断省内存可判断“绝对不存在”但可能误判存在大规模 URL/内容去重SimHash / MinHash文本相似适合长文本对局部改写有一定容忍度文章去重、搜索引擎抓取向量语义模型语义相似需要模型和向量库成本最高内容推荐、知识库这里有个很重要的判断不要一开始就上“相似度模型”。多数业务去重问题本质是“完全重复的短时间/历史去重”哈希加布隆过滤器已经能解决 90% 的场景。只有需要判断“改了几个词但仍然算重复”的长文本时才需要引入 SimHash 或向量方案。3. 场景与架构在哪个环节去重最合适同一个重复消息从客户端进入服务端会经过网关层、业务服务层、数据持久化层。不同层去做去重效果和代价完全不同。3.1 网关层去重网关层最接近客户端适合按请求级别做幂等拦截。例如前端在请求头里带一个 requestId网关发现相同 requestId 在短时间内重复请求直接拒绝。它的优点是拦截早、成本低纯粹从 HTTP 请求维度判断不关心业务含义。但网关层的问题也明显相同请求经过多个副本时需要借助 Redis 或分布式缓存同时它无法识别“两个不同的请求但业务语义相同”的重复。3.2 业务服务层去重业务层最适合按“业务幂等键”做去重。比如一个支付回调真正应该去重的不是回调请求的完整报文而是其中的 orderId 或 paymentId。用内容指纹去做业务去重往往不可靠同一笔订单可能因为回调内容中带了不同扩展字段导致 hash 不同。所以业务层去重的通用实践是从请求中抽取业务唯一键再对唯一键做幂等控制。3.3 数据持久化层去重数据库唯一索引是最可靠的兜底方案。即使网关层和业务层都被绕过只要插入数据时发现唯一键冲突就可以把重复请求挡在数据落库之前。不过这层去重的缺点是“已经写了脏数据以后才拦截”如果前面已经消费了消息、调用了下游数据库兜底已经来不及。所以生产环境通常是三层组合使用而不是选一层。去重位置去重粒度优点缺点网关层HTTP 请求拦截早、接入简单无法感知业务语义业务层业务唯一键能结合业务判断需要每个业务接口适配数据层唯一索引最可靠兜底发生时机滞后无法阻止下游副作用3.4 小结“你是凑企鹅”连发三次放在网关层可以按“同客户端 10 秒只放行一次”处理放在业务层就需要业务上下文决定“是不是同一条业务消息”放在数据层则只能保证最终不会重复落库。实际项目中推荐的最小组合是业务层按业务幂等键去重 数据层唯一索引兜底网关层可按需开启。4. 环境准备与前置条件本文演示代码以 Python 3 为主附加一个 Java 数据库幂等示例。运行前需要准备以下环境Python 3.8 以上版本差异不影响核心逻辑Redis 服务端本地或远程均可需要支持SET EX NXRedis 2.6.12 以后都支持MySQL 5.7 以上或任意支持唯一索引的关系型数据库redis-py客户端库可通过pip install redis安装如果希望直接使用布隆过滤器库可选安装pybloom_live不过本文会提供基于标准库的简化实现不依赖第三方库也能跑通。不需要额外搭建项目框架。只需新建一个工作目录比如dedup-demo/在目录下创建脚本文件即可。为了减少环境干扰代码里不写死开发机的绝对路径所有脚本直接新建在任意空目录中。5. 核心实现重复消息判定与去重完整示例5.1 实现一基于哈希集合的完全去重先看最简单的场景有一批消息需要把完全重复的内容过滤掉。这里不直接保存原文而是把 SHA-256 摘要放进 set以此降低内存占用。# 文件路径dedup-demo/dedup_by_hash.py import hashlib MESSAGES [ 你是凑企鹅, 你是凑企鹅, 你是凑企鹅, 你是企鹅吗, 你是企鹅, ] def content_hash(text: str) - str: h hashlib.sha256() h.update(text.encode(utf-8)) return h.hexdigest() def dedup_by_hash(messages): seen set() result [] for msg in messages: digest content_hash(msg) if digest in seen: print(f重复消息已过滤{msg}) continue seen.add(digest) result.append(msg) return result if __name__ __main__: unique dedup_by_hash(MESSAGES) print(去重后消息) for i, msg in enumerate(unique, 1): print(f{i}. {msg})这段代码的逻辑并不复杂第一次遇到某段文本时它的摘要不在 set 中就加入 set 并保留原文第二次再遇到相同摘要时直接跳过。运行命令python dedup_by_hash.py预期输出会包含“重复消息已过滤你是凑企鹅”最终去重列表只有 3 条消息。这里的 set 可以是内存结构也可以换成 Redis Set从而支持多个服务实例共享去重判断。值得留意的是内存 Set 只适合单机或数据量可控的场景。一旦服务实例超过一个每个实例都有自己独立的 seen 集合重复消息就可能被不同实例各自放行这时必须把 set 放到 Redis 等共享存储中。5.2 实现二基于 Redis 的短时间窗口去重很多实际场景不是“历史永远不允许重复”而是“短时间内不允许重复”。例如验证码短信、登录失败提醒、消息发送按钮防连点。Redis 的SET key value NX EX seconds能很好地实现这个语义只有当 key 不存在时才能设置成功并且 key 在指定秒数后自动过期。# 文件路径dedup-demo/dedup_redis.py import hashlib import redis r redis.Redis(hostlocalhost, port6379, db0, decode_responsesTrue) def allow_once_by_text(message: str, dedup_seconds: int 10) - bool: digest hashlib.sha256(message.encode(utf-8)).hexdigest() key fdedup:msg:{digest} ok r.set(key, 1, nxTrue, exdedup_seconds) return ok is True if __name__ __main__: msg 你是凑企鹅 for i in range(3): allowed allow_once_by_text(msg) print(f第 {i 1} 次发送{放行 if allowed else 拦截}) # 立刻发送完全不相同的消息应该放行 print(发送不重复文本, 放行 if allow_once_by_text(这是另一条消息) else 拦截)运行前确保 Redis 已启动且redis-py已安装pip install redis python dedup_redis.py第一次执行时Redis 中还没有对应的 keyset返回 True消息放行第二次执行相同文本时key 已存在返回值是 None消息被拦截。10 秒后再次运行脚本key 已经过期消息会再次被放行。这就实现了“短时间窗口内只放行一次”的效果。从工程角度看Redis 方案的优点是对多实例天然生效所有服务共用同一个 Redis因此判断结果是一致的代价是每一次发送都至少发生一次网络往返如果接口 QPS 很高Redis 会成为瓶颈。此时可以把已经放行的 key 在本机内存中增加一级缓存但要接受极短时间窗口内可能出现误放行。5.3 实现三布隆过滤器判断“绝对不存在”当数据量达到千万甚至亿级别时把所有内容摘要存进 Set 就不合适了。每条 SHA-256 摘要要占 64 字节一亿条就是 6.4GB仅内存成本就很难接受。布隆过滤器是常见替代方案它用多个哈希函数把文本映射到一段位数组中判断“不存在”是确定的判断“存在”则有小概率误判。下面是基于 Python 标准库实现的简化版布隆过滤器没有第三方依赖。# 文件路径dedup-demo/simple_bloom_filter.py import math import hashlib class SimpleBloomFilter: def __init__(self, capacity: int, error_rate: float 0.001): # 根据容量和误判率计算位数组长度 self.size int(-capacity * math.log(error_rate) / (math.log(2) ** 2)) # 计算需要的哈希函数个数 self.hash_count int(self.size / capacity * math.log(2)) self.bits bytearray((self.size 7) // 8) self.total_added 0 def _positions(self, item: str): positions [] for i in range(self.hash_count): digest hashlib.sha256(f{i}:{item}.encode(utf-8)).digest() pos int.from_bytes(digest[:8], big) % self.size positions.append(pos) return positions def add(self, item: str): for pos in self._positions(item): self.bits[pos 3] | 1 (pos 7) self.total_added 1 def __contains__(self, item: str) - bool: for pos in self._positions(item): if not (self.bits[pos 3] (1 (pos 7))): return False return True if __name__ __main__: bf SimpleBloomFilter(capacity10000, error_rate0.001) test_message 你是凑企鹅 print(添加前 exists , test_message in bf) bf.add(test_message) print(添加后 exists , test_message in bf) # 没有添加过的内容大概率不存在但存在理论误判可能 print(另一条消息 exists , 你是企鹅 in bf)运行一下python simple_bloom_filter.py输出中添加前的 False 是确定的添加后的 True 是确定的最后的判断大概率是 False但不排除小概率出现 True这就是误判率 error_rate 的体现。这个实现把多个哈希值映射到同一个位数组所以内存占用大幅低于 Set。capacity 是预估的插入总量error_rate 是期望误判率。容量设置过小、插入数量超过 capacity 后误判率会快速上升容量设置过大又会浪费内存。生产环境需要先评估未来一年内可能新增的内容量再留出余量。5.4 实现四数据库唯一索引作为最终兜底业务去重不能只靠缓存。更稳妥的思路是在数据库表中增加唯一索引从存储层保证同一业务唯一键只能出现一次。先创建一张简单幂等记录表CREATE TABLE idempotent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, request_id VARCHAR(64) NOT NULL, message TEXT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里的 request_id 是业务生成的幂等键。大多数情况下不应该拿完整 message 作为唯一键因为一条“用户正常重新发送的相同内容”和“因重试导致重复的业务请求”需要被明确区分。当前端或上游第一次发起请求时生成一个 request_id后续如果因为网络超时重试仍然携带同一个 request_id。判断重复就利用唯一索引。Java 侧伪代码可以这样写// 文件路径IdempotentService.java Service public class IdempotentService { Resource private JdbcTemplate jdbcTemplate; public boolean tryCreate(String requestId, String message) { String sql INSERT INTO idempotent_record(request_id, message) VALUES (?, ?); try { jdbcTemplate.update(sql, requestId, message); return true; } catch (DuplicateKeyException e) { // 唯一索引冲突说明已经处理过 return false; } } }调用方在真正执行业务逻辑前先调用tryCreate。如果返回 true继续执行下单、发消息、转账等业务如果返回 false直接返回“重复请求”不再次触发业务副作用。这里的关键点是一定要先插入幂等记录再执行业务逻辑否则并发场景下可能出现两个线程都通过了检查。实际落地时更推荐把“幂等记录插入”和“业务操作”放在同一个数据库事务里并配合唯一索引才能得到严格的最终一致性。6. 运行结果与效果验证把上面的脚本放到同一个目录执行后可以从输出中确认以下几种效果哈希集合去重脚本会打印被过滤的重复消息最终结果中“你是凑企鹅”只出现一次Redis 短时间去重脚本中前两次相同消息会分别输出“放行”和“拦截”而另一条不同消息输出“放行”布隆过滤器脚本会输出添加前后 exists 的变化Java 幂等示例可以通过两次传入相同 request_id 验证第一次返回 true第二次返回 false观察数据库也能看到只插入了一行记录。如果某个环节没有按预期运行优先按下面的路径排查查看 Python 是否报错缺库就执行pip install redis无法连接 Redis 就检查服务是否启动、端口是否可达如果 Redis 去重一直放行使用redis-cli keys dedup:msg:*查看 key 是否被创建没有 key 说明代码没有真正连接上 Redis布隆过滤器如果大量误判优先检查 capacity 是否远小于实际数据量数据库唯一索引不生效时用SHOW INDEX FROM idempotent_record;查看索引是否存在。7. 常见问题与排查思路问题现象可能原因排查方式解决方案相同内容在 Redis 去重中总是放行没有设置NX参数或 key 未生成用redis-cli查看 key 是否存在改用set(key, value, nxTrue, exseconds)网关层拦截了不同业务的相同文本使用文本摘要作为幂等键查看业务幂等键来源改为使用 requestId、orderId 等业务键布隆过滤器误判率超出预期capacity 设置过小或元素数量超出容量统计已插入数量与容量比值预估容量并至少预留一倍余量数据库唯一索引冲突导致事务失败没有捕获 DuplicateKeyException查看异常堆栈增加唯一索引冲突捕获或改为插入忽略消息内容相似但哈希不同直接对原文做哈希没有归一化比较文本是否因空格/标点不同先归一化再计算指纹Redis 内存增长较快去重 key 设置过期时间太长或前缀过多使用redis-cli info memory查看占用缩短过期时间、定期清理、考虑布隆过滤器这里最容易被忽略的是“用文本本身做幂等键”的误区。当业务含义是“同一请求重试”时必须让客户端在第一次请求时生成幂等键并让重试请求携带相同的幂等键。文本内容只是消息体不是请求的唯一标识。正如“你是凑企鹅”这句话第二次出现可能是用户想再发一次也可能是同一事件被重试没有 requestId 作为语义锚点时系统无法区分两者。8. 最佳实践与工程建议8.1 Key 命名与生命周期Redis 去重 Key 建议使用统一前缀和业务场景字段。例如dedup:sendmsg:{requestId} dedup:alarm:{alarmType}:{messageHash}这样既方便排查又能按业务维度设置不同的过期时间。短时间窗口建议使用 10 秒到 10 分钟跨天去重则建议配合定时任务清理避免大量无用 key 长时间占用内存。8.2 不要过度依赖文本指纹文本指纹只适合“内容完全相同”的重复场景。如果需要识别“内容相似但表达不同”的重复文本摘要无法胜任。这时应先把问题拆清是短文本还是长文本是用户消息还是内容库文章短文本可以做字符级相似度计算或归一化后 n-gram 比较长文本可以考虑 SimHash。随手上向量模型只会增加成本和运维复杂度。8.3 多级去重组合推荐一个保守的组合策略业务服务接收请求先从请求中提取业务幂等键尝试在 Redis 中基于“业务幂等键 文本摘要”做短时间窗口拦截数据库插入时用唯一索引兜底对明显异常的重复请求输出监控指标。这套方案既能应对大多数重复提交又不会因为缓存抖动导致业务完全不可用。缓存不可用时唯一索引仍然是最可靠的防线。8.4 安全与隐私提醒不要把大量用户敏感信息直接作为去重 key 存储。去重记录中出现用户手机号、地址、聊天内容时建议先做哈希摘要同时控制日志输出范围。生产环境操作 Redis 和数据库前应先确认操作命令的授权范围涉及清空数据或删除索引的操作必须在测试环境验证必要时先备份。8.5 监控与回滚上线去重逻辑前要加两类监控去重命中率和业务成功率。命中率突然升高可能是业务侧有异常重试命中率接近于零则要检查实现是否生效。如果上线后发现误杀正常请求应先把去重窗口调小或灰度关闭再分析日志而不是直接删除 Redis 中所有 key 来“恢复”——那可能导致同一批重复请求瞬间全部涌入。9. 总结与后续学习方向重复内容处理看似是小问题拆开以后牵扯到文本预处理、哈希算法选型、缓存过期策略、分布式一致性和数据库索引设计。以“你是凑企鹅”三次连发为起点我们走完了一条完整链路先用文本归一化和哈希摘要精确处理完全重复文本再用 Redis 实现短时间窗口拦截然后用布隆过滤器应对海量存在性判断最后用数据库唯一索引保证最终不落重复数据。真正适合你的方案取决于业务语义如果你在写工具脚本哈希 set 就够如果你在接支付回调优先设计好 requestId 和数据库唯一约束如果你的系统每天要处理千万级 URL 或内容消息再考虑布隆过滤器如果要识别改几个词的重复文章才去接触 SimHash 或向量模型。别让“热门方案”成为提前引入复杂度的理由。接下来可以继续思考三个方向一是把布隆过滤器与 Redis 结合设计增量去重集群二是对业务接口的幂等键生成规范做统一约束三是在消息队列消费者中处理“至少一次投递”带来的重复消息风险。理解这次示例中的“重复”分层后再去接触分布式锁、事务消息、幂等框架会发现它们解决的是同一个问题的不同侧面。