资讯动态

拉黑功能如何避免沉默故障?Spring Boot实现黑名单与紧急升级闭环

发布时间:2026/9/8 16:49:06 来源:尧图企业网站定制
“拉黑”在产品逻辑里往往只是一行状态但在真实通信系统里它决定了一次消息的生死。一次典型的线上事故可以这样描述用户 A 对用户 B 设置了拉黑B 在一周内通过呼叫渠道触达了 125 次系统按黑名单规则全部拦截没有放行也没有升级B 的关键通知始终无法送达。等到人工介入时只能从通信日志里把 125 条记录逐条翻出来才定位到黑名单判断逻辑。这个场景虽然不一定每个项目都会遇到但“拉黑”“紧急联系人”“未接升级”放在一起已经能说明一个工程问题黑名单如果只做拦截不做例外和升级很容易在真实业务里制造沉默故障。在开始编码之前先明确要解决的技术问题如何在屏蔽关系中保留一条紧急可达通道如何用升级机制把被拦截的尝试转成有效通知以及如何通过日志和监控验证整个链路。下面用一个最小 Spring Boot 工程复现这个闭环并把参数、边界、排错和生产化建议一起讲清楚。1. 拉黑功能不该只是一张表先理解它到底拦住了什么1.1 从“125 通电话全部被拦”的现象说起可以设想一个通信平台场景用户苏冉把某个联系人加进了黑名单之后这个联系人陆续发起了 125 次呼叫。系统侧没有报错网关也正常返回但这 125 次呼叫全部命中黑名单规则被静默丢弃。业务方直到用户投诉才从日志中确认问题不是网关故障而是黑名单策略覆盖了一切包括本该被特殊情况放行的紧急联系通道。这个现象的工程含义很清楚黑名单拦截如果进入“拦截即结束”的短路径就不再是一次简单的功能判断而是整条消息可达性链路上的最高优先级规则。任何规则一旦拥有最高优先级如果没有例外处理、升级机制和审计数据就可能在关键时刻阻断一条原本应该被发现的链路。“125 通电话”真正暴露的不是电话太多而是系统没有回答一个问题当被拦截的尝试达到一定次数时系统到底应该继续拦截还是转成一次更高优先级的触达如果仍然选择继续拦截至少应该有明确规则、有记录、有告警而不是让所有尝试在沉默中消失。1.2 拉黑功能的业务定义与技术边界从技术上讲拉黑是一种通信隔离策略系统在发起方触达接收方之前先根据接收方设置的规则做一次判定命中规则则拒绝下发或拒绝接续。它和“删除联系人”“注销账号”“关闭推送权限”不同拉黑的目标是定向隔离某一个人或某一类来源而不是把接收方整个账号变成不可达状态。一个完整的拉黑功能至少包含四个环节规则配置。谁在什么条件下拉黑谁是否存在过期时间是否区分渠道。判定执行。在通信入口统一读取规则并返回“放行、拦截、放行但记录”等明确结果。动作落地。拦截后是否通知发起方、是否静默、是否产生告警。审计留痕。每次判定都能追溯到命中哪条规则、为什么拦截、升级是否触发。理想的实现应该把判定逻辑收敛到一个服务中而不是在每个业务控制器里重复写if (blackList.contains(from))。收敛的好处是规则变更只影响一处日志格式统一后续加紧急白名单、加渠道级配置也不会动到业务代码。1.3 把拉黑做成黑名单字段的典型坏味道很多项目会从最简单的方式开始在用户表上加一个black_list字段或者在业务表里存一个以逗号分隔的 ID 字符串。这种方式在演示阶段很直观进入生产后会出现几个明显问题。第一个问题是无法回答“谁拉黑了谁”。如果接收方和发起方是两个独立用户黑名单必须是一张双方关系表而不是接收方身上的一个聚合字符串。接收方可能拉黑多个人每个人也可能被不同的人拉黑只有关系型结构才能支持查询和审计。第二个问题是没有渠道维度。很多业务中“电话”被拉黑和“短信”被拉黑可能是两种诉求。如果黑名单是所有渠道共用发起方换一个渠道就可能仍然打扰接收方反过来接收方可能只希望拦截陌生呼叫但不想拦截对方的文字消息。第三个问题是缺少优先级概念。普通黑名单和紧急联系人必须能够在同一套判定链路里比较优先级否则就会出现“紧急联系人加不进白名单或者加了也被黑名单覆盖”的边界问题。更合理的模型是让规则带有类型和优先级判定时按优先级取第一条命中的规则而不是黑名单优先、白名单之后检查。实现方式适合阶段进入生产的主要问题用户表加黑名单字段原型验证无法表达双方关系无法审计无法按渠道区分中间表记录屏蔽关系小规模业务需要补充优先级、到期时间、渠道维度规则化关系模型正式业务需要为兼容老数据做迁移需要监控规则命中率2. 需求拆解从“屏蔽联系人”到“可达性保障体系”2.1 先列角色再定规则拉黑功能看起来简单是因为大多数示例只关注“发起方、接收方、是否拦截”三个要素。真实业务至少还要考虑运营方和紧急联系人两个角色否则规则就是封闭的无法覆盖例外场景。在这个示例里可以拆出四类角色接收方。设置屏蔽规则决定哪些来源不可达。发起方。尝试通过电话、短信、站内信等渠道触达接收方。紧急联系人。接收方主动标记的例外人员即使其他来源被拦截这类来源也可以放行。运营/客服。需要看到拦截日志、升级记录和统计数据以便处理投诉或安全风险。需求拆解后拉黑就不再是单条block_user_id的查询而是接收方视角的“可达性规则表”每个联系人对应一种规则类型规则类型又决定系统在通信链路中执行的动作。动作可能不是简单二选一而是“放行、拦截、拦截并升级、拦截并通知接收方”等多种组合。2.2 黑白名单、优先级与紧急联系人如何协同一个常见的错误做法是让黑名单和白名单在不同表里分别存储然后在代码里先查白名单再查黑名单。问题在于规则一旦多了两个表中的数据很难保持一致团队也容易因为“到底哪张表优先”产生分歧。更合理的方式是把规则统一放入一张表用rule_type区分普通、黑名单、紧急联系人用priority字段决定命中顺序。判定时系统按priority倒序取出接收方的规则找到第一个匹配来源的规则并执行对应动作。这样黑名单和白名单就不是两套独立逻辑而是同一条规则链上的不同优先级节点。示例规则设计接收方联系人规则类型优先级动作su-ranhusbandBLOCKED80拦截并记录su-ranfather-phoneEMERGENCY10放行并记录su-ran*NORMAL100默认放行判定逻辑可以表达为先按优先级从高到低找命中的规则找到后看规则类型。EMERGENCY直接放行BLOCKED拦截NORMAL放行但不做特殊处理。这样即使接收方把某个联系人拉黑只要同一个联系人也被标记为紧急联系人系统内部不会出现“黑名单一定胜利”的硬编码。2.3 升级机制为什么比单纯提示更重要升级机制解决的是“静默拦截”问题。当一个来源被拦截多次系统不能只在日志里增加一条记录而应该根据阈值判断是否需要唤醒接收方。唤醒方式可以是短信、App 推送、客服工单也可以是给接收方发送一条“你有高频被拦截电话是否调整名单”的提示。升级机制的价值在于把被动缓存变成主动告警。如果接收方真的在等一个重要电话但那个电话已经被拉黑接收方本人并不会有感知。等到主动翻看黑名单时时间可能已经过去了。系统主动升级可以在“拦截后仍然重要”和“纯粹骚扰”之间做出权衡。升级也不是越多越好。每通被拦截电话都发通知接收方很快就会疲劳甚至关闭通知。合理的做法是设置时间窗口、阈值和最大升级次数。比如在一个小时内同一来源被拦截超过 5 次只升级 1 次之后继续拦截但不再打扰接收方。这样既保留关键通知能力又不会产生消息风暴。3. 最小可复现闭环Spring Boot 实现屏蔽、呼叫与紧急升级3.1 环境准备与依赖示例使用 Spring Boot 3 作为技术验证。本地需要 JDK 17 以上版本、Maven 3.9 以上版本数据库使用 H2 便于直接运行。实际项目接入 MySQL 或 PostgreSQL 时只需要替换驱动和连接配置。先创建一个普通 Spring Boot 工程在pom.xml中加入以下依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency依赖说明spring-boot-starter-web提供 REST 接口用于模拟呼叫请求。spring-boot-starter-data-jpa提供实体映射和仓库查询。H2 作为内存数据库适合本地验证和单元测试不推荐直接用于生产。3.2 数据库结构与实体设计在src/main/resources下准备schema.sql初始化两张表contact_rule保存联系人规则communication_attempt保存每次通信尝试的审计记录。CREATE TABLE contact_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, contact_id VARCHAR(64) NOT NULL, rule_type VARCHAR(20) NOT NULL, priority INT NOT NULL DEFAULT 100, reason VARCHAR(255), expire_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_contact_rule_user ON contact_rule(user_id); CREATE TABLE communication_attempt ( id BIGINT PRIMARY KEY AUTO_INCREMENT, caller_id VARCHAR(64) NOT NULL, called_id VARCHAR(64) NOT NULL, channel VARCHAR(20) NOT NULL, action VARCHAR(20) NOT NULL, matched_rule VARCHAR(20), escalated INT DEFAULT 0, trace_id VARCHAR(64), attempt_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_attempt_called_time ON communication_attempt(called_id, attempt_time);实体类示例Entity Table(name contact_rule) public class ContactRule { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name user_id) private String userId; Column(name contact_id) private String contactId; Column(name rule_type) private String ruleType; private Integer priority; private String reason; Column(name expire_at) private LocalDateTime expireAt; public boolean notExpired() { return expireAt null || expireAt.isAfter(LocalDateTime.now()); } }审计实体类似这里省略重复的getter/setter代码。实际项目里可以使用自己团队习惯的实体和持久化方案这里只需要把关系模型表达清楚。3.3 核心服务先查规则再发通知核心判定服务是整条链路的枢纽。它接收发起方、接收方、渠道三个参数先从规则表查出接收方的规则再按优先级命中第一条规则最后根据规则类型决定是放行、拦截还是升级。Service public class ContactGuardService { private final ContactRuleRepository ruleRepository; private final CommunicationAttemptRepository attemptRepository; private final EscalationNotifier escalationNotifier; private final GuardProperties props; public ContactGuardService(ContactRuleRepository ruleRepository, CommunicationAttemptRepository attemptRepository, EscalationNotifier escalationNotifier, GuardProperties props) { this.ruleRepository ruleRepository; this.attemptRepository attemptRepository; this.escalationNotifier escalationNotifier; this.props props; } Transactional public Decision handle(String callerId, String calleeId, String channel) { CommunicationAttempt attempt new CommunicationAttempt(); attempt.setCallerId(callerId); attempt.setCalledId(calleeId); attempt.setChannel(channel); attempt.setTraceId(UUID.randomUUID().toString().substring(0, 8)); ContactRule rule ruleRepository .findTopByUserIdOrderByPriorityAsc(calleeId); if (rule null || !rule.getContactId().equals(callerId)) { attempt.setAction(ACCEPT); attempt.setMatchedRule(NORMAL); attemptRepository.save(attempt); return Decision.accept(attempt.getTraceId()); } if (EMERGENCY.equals(rule.getRuleType())) { attempt.setAction(ACCEPT); attempt.setMatchedRule(EMERGENCY); attemptRepository.save(attempt); return Decision.accept(attempt.getTraceId()); } if (BLOCKED.equals(rule.getRuleType())) { attempt.setAction(BLOCK); attempt.setMatchedRule(BLOCKED); boolean escalated tryEscalate(callerId, calleeId); attempt.setEscalated(escalated ? 1 : 0); attemptRepository.save(attempt); return Decision.block(attempt.getTraceId(), escalated); } attempt.setAction(ACCEPT); attempt.setMatchedRule(NORMAL); attemptRepository.save(attempt); return Decision.accept(attempt.getTraceId()); } private boolean tryEscalate(String callerId, String calleeId) { LocalDateTime start LocalDateTime.now() .minusSeconds(props.getEscalationWindowSeconds()); long blockedCount attemptRepository .countByCalledIdAndActionAndAttemptTimeAfter(calleeId, BLOCK, start); if (blockedCount props.getEscalationThreshold()) { long escalatedCount attemptRepository .countByCalledIdAndEscalatedAndAttemptTimeAfter(calleeId, 1, start); if (escalatedCount props.getEscalationMaxPerWindow()) { return false; } escalationNotifier.notify(calleeId, callerId); return true; } return false; } }这段代码的关键点是规则查询返回的是接收方所有规则中优先级最高的一条。示例里为了简洁只取了同一条规则真实场景需要遍历规则列表并匹配多个联系人。对于“紧急联系人优先于黑名单”的业务规则表设计已经决定优先级而不是在代码里到处写if。Decision是一个简单结果对象包含traceId、action、escalated等字段方便控制器统一返回。3.4 发出 125 次呼叫并用脚本验证准备初始数据在data.sql中写入示例规则INSERT INTO contact_rule(user_id, contact_id, rule_type, priority, reason) VALUES (su-ran, husband, BLOCKED, 80, test block); INSERT INTO contact_rule(user_id, contact_id, rule_type, priority, reason) VALUES (su-ran, father-phone, EMERGENCY, 10, emergency contact);启动应用后用循环脚本模拟同一个来源连续呼叫 125 次for i in $(seq 1 125); do curl -s -X POST http://localhost:8080/v1/call \ -H Content-Type: application/json \ -d {from:husband,to:su-ran,channel:CALL} done控制台输出应该是 125 条 JSON。正常情况下前 5 次被拦截并触发一次升级后续 120 次仍然被拦截但因为窗口内已经升级过不再重复升级{traceId:a1b2c3d4,action:BLOCK,escalated:true} {traceId:e5f6g7h8,action:BLOCK,escalated:false}再模拟紧急联系人呼叫curl -s -X POST http://localhost:8080/v1/call \ -H Content-Type: application/json \ -d {from:father-phone,to:su-ran,channel:CALL}返回结果应该是{traceId:9f8e7d6c,action:ACCEPT,escalated:false}查询统计接口确认审计数据完整curl -s http://localhost:8080/v1/stats?userIdsu-ran预期统计类似{blockCount:125,escalatedCount:1,acceptCount:1}这里blockCount对应“整整 125 个电话”escalatedCount对应系统主动升级的次数acceptCount对应紧急联系人成功触达的次数。读清楚这组数字就能解释整个黑名单升级链路是否工作正常。4. 参数与边界时间窗口、频率限制、重试次数怎么配置才合理4.1 关键参数速查表示例里把可调参数放到了application.yml中实际生产环境需要根据自己的消息量级和用户习惯调整。contact-guard: escalation-threshold: 5 escalation-window-seconds: 3600 escalation-max-per-window: 1 rule-cache-ttl-seconds: 300参数含义调整影响错误配置表现escalation-threshold一个时间窗口内被拦截多少次后触发升级设置过小用户容易被频繁打扰设置过大关键通知可能被延迟阈值设为 1 时每通被拦电话都可能触发升级escalation-window-seconds升级统计窗口长度窗口过短会让升级频率变高窗口过长会让统计失真窗口设为 86400 时12 小时前的拦截也会参与计数escalation-max-per-window窗口内最多升级几次控制消息风暴推荐保持为 1 或 2设为 0 时功能等于关闭设很大时可能产生大量通知rule-cache-ttl-seconds规则缓存过期时间过短增加数据库压力过长导致规则修改延迟生效TTL 设为 86400用户解除拉黑后一整天不生效参数不是越大或越小越好需要结合具体业务评估。高频骚扰场景下升级阈值可以调低让接收方尽快感知低频重要通信场景下窗口可以适当拉长避免短时间内被同一来源连续唤醒。4.2 缓存与并发带来的边界问题规则表在真实项目中不会每次请求都查数据库常见做法是先放 Redis 缓存。缓存带来一个经典问题用户解除拉黑后缓存里仍存着旧规则呼叫可能继续被拦截。解决思路是在更新规则时主动删除缓存而不是依赖 TTL 自然过期。示例代码如下CacheEvict(value contactRule, key #userId) public void saveRule(String userId, ContactRule rule) { ruleRepository.save(rule); }如果使用 Redis还需要考虑缓存预热和降级。缓存服务不可用时必须保证至少能走数据库查询否则整个拉黑判断链路会完全中断。这里的取舍是一致性优先时关掉缓存性能优先时接受几秒内修改不生效。4.3 幂等与防抖升级通知最怕重复发送。用户在页面上快速操作或者发起方通过自动化工具连续重试同一条升级可能在一个时间窗口内触发多次。升级逻辑里用escalation_max_per_window控制次数但这不是防抖的全部。更严格的方案是在 Redis 里设置一个短过期 keyBoolean firstTime redisTemplate.opsForValue() .setIfAbsent(escalation: calleeId : windowKey, 1, Duration.ofSeconds(props.getEscalationWindowSeconds())); if (Boolean.TRUE.equals(firstTime)) { escalationNotifier.notify(calleeId, callerId); }这种方式的优点是即使多个请求并发进入只有第一个请求能成功写入 key后续请求直接跳过升级。它和数据库计数配合能同时解决并发和重复两条问题。注意升级通知本身属于用户触达最好在通知前先查询接收方是否已经关闭了对应通知渠道否则可能出现“用户被拉黑电话提醒轰炸”的新问题。5. 常见故障排查拉黑功能上线后会遇到的四种现场5.1 紧急联系人也被拦截现象联系人已经标记为EMERGENCY但呼叫仍然返回BLOCK。排查顺序检查规则表里该联系人的rule_type是否真的是EMERGENCY而不是在页面展示层做了映射。检查priority是否被错误设置成高于黑名单的数字比如黑名单是 10紧急联系人也是 10两条规则存在冲突。查看通信审计表确认命中规则字段matched_rule的值如果显示BLOCKED说明没有命中紧急规则。检查缓存确认规则修改后是否主动清除了缓存。解决方式是在规则表设计阶段就保证紧急联系人优先级更高例如黑名单优先级 80紧急联系人优先级 10。判定时按priority升序取第一条而不是先查黑名单再查白名单。5.2 升级消息重复发送或洪泛现象同一来源被拦截后接收方收到了多条重复升级通知。可能原因升级统计没有时间窗口每次拦截都触发。Redis 防抖 key 设置了expireAt但没有用setIfAbsent并发请求同时进入通知分支。重试机制和正常呼叫共用同一个入口没有设置业务幂等键。检查方式查看communication_attempt表里escalated字段为 1 的记录。查看升级通知服务日志里的traceId确认是否同一批请求生成了多条通知。检查escalation_max_per_window配置是否未生效。解决方式是在升级逻辑中加入时间窗口计数和 Redis 防抖两者同时使用。不要把防抖完全交给数据库因为高并发下数据库计数存在延迟和竞态。5.3 缓存刷新导致旧黑名单短暂生效现象用户刚刚解除拉黑但同一来源的呼叫仍然被拦截过了一段时间才恢复。原因分析规则更新时没有删除缓存。多实例部署时只删除了其中一个实例的本地缓存。TTL 设置过长用户修改规则后需要等待缓存过期。检查方式在 Redis 中直接查看规则 key 的值。检查规则变更接口是否调用了CacheEvict。检查更新接口是否只更新了数据库没有清理分布式缓存。解决方式是更新和删除操作放在同一个事务边界内缓存清理失败时记录告警。如果规则变更是低频操作也可以直接采用数据库查询为主、缓存辅助的方式。5.4 并发呼叫导致任务重复创建现象125 次呼叫同时打到入口数据库里产生了一批升级记录甚至触发了多个通知。原因分析服务没有对同一业务请求做幂等控制。网关重试机制与业务重试机制叠加。升级防抖只依赖attempt_time的数据库计数无法挡住同一毫秒内的并发请求。检查方式查看traceId是否相同如果相同说明入口层没做幂等。查看数据库升级记录的时间是否集中在同一秒。检查 Nginx 或网关层是否设置了重试策略。解决方式是入口层以callerId calleeId channel requestId作为幂等键重复请求直接返回第一次结果。升级防抖使用 Redis 原子操作确保同一时间窗口内只有一个线程能进入通知分支。故障现象优先检查项处理建议紧急联系人也被拦截规则类型、优先级、缓存统一规则模型紧急优先级最高升级通知重复时间窗口、Redis key、幂等键数据库计数 Redis 防抖解除拉黑不生效缓存 key、TTL、多实例更新时主动清理缓存并监控并发重复任务接口幂等、网关重试用 requestId 做幂等控制6. 生产环境落地清单与可复用设计建议6.1 发布前检查清单拉黑功能进入生产前至少走一遍下面的清单[ ] 规则表是否包含规则类型、优先级、过期时间、原因字段。[ ] 判定逻辑是否集中在一个服务中而不是散落在控制器和工具类里。[ ] 紧急联系人优先级是否明确高于普通黑名单。[ ] 升级通知是否有时间窗口、阈值和最大次数限制。[ ] 每个通信尝试是否都会写入审计表字段是否足够定位问题。[ ] 规则缓存更新后是否会主动清理多实例场景是否同步。[ ] 高频呼叫是否具备幂等控制能否防止重复升级。[ ] 是否有关键指标监控例如拦截量、升级量、紧急联系人触达量。[ ] 是否保留人工运营入口方便客服在必要时临时放行某个来源。[ ] 是否对黑名单规则本身做了权限控制防止普通用户篡改他人规则。清单不是一次就能完善的应该结合每次线上问题补充。重点不是“列了多少项”而是每一项都能找到对应的验证方式。6.2 日志与审计的要求拉黑相关日志至少要包含callerId、calleeId、channel、ruleType、priority、action、traceId、attemptTime这几个字段。只有callerId和calleeId的日志无法解释“为什么拦截”因为缺少命中规则的信息。建议为每次通信尝试生成独立的traceId从入口到通知网关全程透传。用户投诉时客服只需要提供traceId运维就能从日志平台和数据库中查完整链路。反向排查也是一样当用户说“我明明没收到电话”可以通过calleeId和attemptTime查到命中规则、拦截时间、升级是否触发。6.3 从功能黑名单走向联系治理成熟的业务不会只维护一张黑名单表而会逐步走向联系治理把联系人关系、通知偏好、频率控制、渠道开关、紧急例外整合成一套配置。拉黑只是其中一种规则类型紧急联系人是另一种规则类型两者统一管理后才能组合出更复杂的策略。从技术演进看可以先做最小闭环验证再逐步增加能力。第一阶段只做拉黑和审计第二阶段加入紧急联系人和升级通知第三阶段加入渠道维度、频率控制和缓存优化第四阶段再把规则开放给运营后台通过配置页面维护而不是改代码发布。一个值得新手练习的扩展方向是把示例中的 H2 换成 MySQL把ContactGuardService的判定逻辑写成单元测试分别覆盖“普通放行、黑名单拦截、紧急联系人放行、拦截后升级、窗口内不重复升级”五个场景。测试通过后再加入缓存和多实例部署观察规则修改延迟和并发升级问题。这样做完一遍对拉黑功能的理解就不再是“加一条记录”而是能设计一套完整可达性策略。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价