百万级数据脱敏方案设计(加速场)
S(情境)
在华为OD负责解决方案工作台加速场模块期间,我们接到一个需求:发布方案模板时,需要支持对模板内的敏感数据进行脱敏处理,且脱敏数据量的最大阈值能达到百万级,单条数据大小最多1KB。同时,脱敏任务和模块的正常业务功能运行在同一台服务器上,不能影响用户正常使用。
T(任务)
我的任务是设计一个脱敏方案,既要保证百万级数据能在合理时间内处理完成,又要防止因正则表达式不当引发安全风险(如ReDoS攻击),同时还要保证服务器运行期间不影响模块的正常功能。
A(行动)
首先,整体业务流程如下:
用户在前端点击”发布方案模板” → 系统将模板当前数据做一份快照 → 用户选择”先脱敏,再发布” → 后端将方案ID封装成消息发送到Kafka → 立即返回前端”脱敏任务已提交” → 消费者从Kafka异步拉取方案ID → 根据方案ID查询阈值达百万级的脱敏数据 → 按每批1000条的粒度分页查询数据 → 提交到独立的脱敏线程池并行处理 → 每处理完一批更新数据库中的任务状态 → 所有批次处理完成后更新任务状态为”已完成” → 通过WebSocket通知前端”脱敏完成,可以发布了” → 用户确认后,将脱敏后的快照数据发布为正式模板。
基于这个流程,我从四个维度来设计方案:
1. 流量削峰——引入消息队列。 多人同时发布多个模板时,脱敏任务可能瞬间涌入。我引入Kafka作为任务缓冲层:用户提交脱敏请求后,只将方案ID封装成消息发送到Kafka,消息体极小,几乎不占资源,立即返回前端”脱敏任务已提交”。消费者每次从Kafka拉取1个方案ID,处理完成后再拉取下一个,天然实现背压。消费者拉取到方案ID后立即手动commit偏移量,再将任务提交到线程池,避免脱敏处理时间过长导致Kafka消费超时触发rebalance。脱敏任务的处理状态通过数据库持久化跟踪,即使消费者重启,也能从断点处恢复。
数据库状态跟踪设计: 创建脱敏任务表,核心字段包括:任务状态(0-待处理、1-处理中、2-已完成、3-失败)、总数据条数、已处理条数、当前批次序号。用户提交脱敏时插入一条status=0的记录;消费者拉取到任务后更新为status=1;每处理完一批数据,更新已处理条数和批次序号;全部完成后更新为status=2;异常且重试3次仍失败则更新为status=3并记录错误信息。消费者重启时,扫描status=1的任务,根据批次序号和已处理条数确定断点,从断点处继续处理,保证幂等性。
2. 性能维度——单模板串行 + 分片并行。 服务器运行期间,同一时间只处理1个模板的脱敏任务,避免多个模板争抢资源影响业务功能。脱敏任务使用独立的线程池,与业务线程池完全隔离。消费者从Kafka拉取到方案ID后,先查询脱敏数据的总条数,然后按每批1000条的粒度分页查询,每次只加载1000条数据到内存,避免OOM。将每批数据作为一个子任务提交到脱敏线程池并行处理。线程池配置:8核服务器,核心线程数4(CPU×0.5),最大线程数8(CPU×1),有界队列容量1000,拒绝策略用CallerRunsPolicy做反压。4个核心线程并行处理,每秒可完成约4000-8000条数据的脱敏,百万级数据整体耗时约2-4分钟。
3. 安全维度——ReDoS防护 + 多层阈值兜底。 脱敏核心逻辑依赖正则匹配,百万级数据中如果存在恶意构造的输入,可能导致正则引擎灾难性回溯。我采取了以下措施:
- 用确定性正则替代回溯型正则,如用
1[3-9]\d{9}替代1[3-9][0-9]+,避免嵌套量词和模糊匹配 - 设置单条匹配超时1s,通过Future.get(timeout)控制,超时则跳过并记录异常
- 设置单批次处理超时5分钟,每批1000条数据超时未完成则触发告警
- 设置整个模板脱敏超时30分钟,百万级数据正常2-4分钟,超时则自动终止并通知运维
- 设置单条数据大小上限10KB,超过则截断或跳过,防止超大文本导致内存溢出
- 设置脱敏失败重试3次,超过则跳过并记录异常日志
4. 可运维维度——脱敏对照表。 脱敏前对原始数据做哈希映射,生成加密存储的对照表,方便线上排查问题时可申请权限追溯原始数据。
R(结果)
最终方案落地后,用户提交脱敏任务即可离开,脱敏完成后收到通知再回来确认发布,体验流畅。单个模板百万级数据的脱敏处理时间控制在2-4分钟,且脱敏期间模块的正常业务功能未受到影响,未出现用户操作卡顿或接口超时的情况。通过Kafka削峰,多人同时提交脱敏任务时,后端也能稳定处理。正则安全防护上线后,未发生过因脱敏导致的CPU飙升或服务异常。这套方案后来也被复用到其他需要批量数据处理的场景中。
二、追问问题汇总
线程池问题
| 序号 | 追问问题 | 考察点 | 建议回答方向 |
|---|---|---|---|
| 1 | 线程池的参数配置 | ||
| 2 | 线程池参数为什么设4和8? | 资源分配意识 | 8核服务器,业务功能预留一半资源,脱敏作为后台任务优先级较低,4核足够支撑百万级数据在几分钟内处理完 |
| 1 | 线程池的核心参数有哪些?分别有什么用? | 是否真正用过线程池 | corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、RejectedExecutionHandler |
| 2 | 线程池的提交流程是怎样的? | 线程池工作原理 | 核心线程 → 工作队列 → 最大线程 → 拒绝策略 |
| 3 | corePoolSize和maximumPoolSize设多少合适?依据是什么? | 参数设计的合理性 | CPU密集型:N+1;IO密集型:2N;混合型:根据实际压测调整。结合您的场景:脱敏是CPU+IO混合型,8核服务器设4和8 |
| 4 | 有界队列和无界队列怎么选? | 队列选型意识 | 有界队列:防止内存溢出,任务超过队列容量触发拒绝策略。无界队列:可能导致内存无限增长。生产环境推荐有界队列 |
| 5 | 拒绝策略有哪些?你们用的哪个? | 是否了解拒绝策略 | AbortPolicy(抛异常)、CallerRunsPolicy(调用者线程执行)、DiscardPolicy(丢弃)、DiscardOldestPolicy(丢弃最旧)。您的场景用CallerRunsPolicy做反压 |
| 6 | 你们脱敏线程池的核心线程数为什么设4而不是8? | 资源分配意识 | 8核服务器还要跑业务功能,脱敏是后台任务优先级较低,预留一半资源给业务。4核并行处理每秒可完成4000-8000条,百万级数据2-4分钟,已经满足需求 |
| 7 | 线程池队列容量为什么设1000?依据是什么? | 参数设计的合理性 | 每批1000条数据,队列容量1000意味着最多积压100万条待处理数据,内存可控。同时结合CallerRunsPolicy,队列满了由提交线程自己处理,起到反压效果 |
| 8 | 脱敏线程池和业务线程池怎么隔离的? | 资源隔离意识 | 两个独立的ThreadPoolExecutor实例,使用不同的线程工厂和线程组,互不干扰。脱敏线程池满不会影响业务接口的响应 |
| 9 | 线程池的参数是写死的还是可动态调整的? | 运维和调优意识 | 通过配置中心(如Nacos、Apollo)动态调整,白天高峰期核心线程数保持4,夜间低峰期调大到8或16加速处理积压任务 |
| 10 | 线程池的线程名前缀怎么设置的?有什么用? | 运维意识 | 设置自定义线程名前缀,如”desensitization-pool-“,方便线上排查问题时通过jstack快速定位线程归属 |
| 11 | 线上怎么查看线程池的运行状态? | 监控和排查能力 | ThreadPoolExecutor提供了getPoolSize()、getActiveCount()、getQueue().size()、getCompletedTaskCount()等方法,可以暴露成Metrics接入监控系统 |
| 12 | 如果线程池的任务队列一直增长,怎么排查? | 线上问题排查能力 | 先看消费者是否正常拉取消息 → 再看线程池活跃线程数是否达到上限 → 检查任务处理是否有阻塞(如数据库慢查询、远程调用超时)→ 查看是否有死锁 |
| 13 | 线程池中的任务抛出异常会怎样? | 异常处理意识 | 如果任务没有捕获异常,线程会退出,线程池会新建一个线程替代它。异常信息会丢失。建议在任务中自行捕获异常并记录日志,或者设置UncaughtExceptionHandler |
| 14 | 怎么监控线程池是否过载? | 监控意识 | 监控活跃线程数、队列积压量、任务拒绝次数。设置告警阈值:队列使用率超过80%告警,拒绝次数大于0立即告警 |
| 15 | jstack命令用过吗?怎么分析线程堆栈? | 线上问题排查能力 | jstack pid查看所有线程堆栈,搜索线程名前缀定位目标线程,查看WAITING/BLOCKED状态判断是否有死锁或长时间阻塞 |
| 16 | 为什么用线程池而不是直接new Thread? | 线程池的价值认知 | 线程池复用线程、控制并发数、减少创建销毁开销、提供队列缓冲和拒绝策略。直接new Thread在高并发下会导致系统资源耗尽 |
| 17 | 脱敏任务为什么分片后还要并行处理?直接单线程不行吗? | 性能优化意识 | 百万级数据单线程处理约13分钟,16线程并行可缩短到1分钟以内。分片并行是在资源可控的前提下最大化利用CPU |
| 18 | 如果脱敏任务中有一个批次一直处理不完,会阻塞后面的批次吗? | 并发设计意识 | 不会。每个批次是独立的子任务提交到线程池,一个批次卡住不会影响其他批次。但需要通过超时机制兜底,防止该批次一直占用线程资源 |
| 19 | CallerRunsPolicy在你们场景里具体怎么工作的? | 反压机制的理解 | 当线程池队列满了,提交任务的线程(消费者线程)会自己执行脱敏任务。此时消费者线程被占用,无法拉取新的Kafka消息,自然实现了背压——让上游慢下来 |
| 20 | 如果业务流量暴增,脱敏线程池会不会影响业务线程池? | 资源隔离的可靠性 | 两个线程池完全隔离,脱敏线程池即使满负荷运行,也不会占用业务线程池的线程。最坏情况下脱敏变慢,但业务接口不受影响 |
| 21 | ThreadLocal用过吗?有什么坑? | 并发工具的理解 | 用于线程上下文传递。坑:线程池复用线程,如果不及时清理,下一个任务会读到上一个任务的数据。解决方案:每次使用后调用remove() |
| 22 | synchronized和ReentrantLock怎么选? | 锁的理解 | synchronized:简单、自动释放、适合低竞争场景。ReentrantLock:可中断、可超时、支持公平锁、支持多个Condition、适合高竞争或复杂同步场景 |
| 23 | volatile和AtomicInteger的区别? | 并发原语的理解 | volatile保证可见性和有序性,但不保证原子性。AtomicInteger通过CAS保证原子性,适合计数器等场景 |
| 24 | 你们遇到过死锁吗?怎么排查和解决的? | 实战经验 | jstack分析线程堆栈,搜索”deadlock”关键字。解决:统一锁的获取顺序、减少锁的持有时间、使用tryLock加超时避免无限等待 |
| 25 | 脱敏任务分片后提交到线程池,怎么知道所有批次都处理完了? | 使用CountDownLatch或CompletableFuture.allOf()等待所有批次完成,或者每处理完一批更新数据库中的processed_count,轮询判断是否全部完成 | |
| 26 | 如果某个批次处理失败了,其他批次已经处理完了,怎么处理? | 记录失败批次的偏移量,继续处理后续批次。全部批次处理完后,统一重试失败的批次。重试3次仍失败,标记任务为”失败”,通知人工介入 | |
| 27 | 线程池中的任务可以取消吗? | 可以。Future.cancel(true)尝试取消正在执行的任务,但依赖任务本身对中断信号的响应。如果任务没有处理中断信号,取消可能无效 |
Kafka问题
| 序号 | 追问问题 | 考察点 | 建议回答方向 |
|---|---|---|---|
| 6 | 立即commit后消费者挂了,任务会不会丢? | 可靠性设计 | 不会丢。任务状态已持久化到数据库,消费者重启后扫描status=1的任务,从断点处继续处理 |
| 7 | 怎么保证重复处理不会导致数据异常? | 幂等性设计 | 每个批次处理前先检查该批次是否已处理完成,已完成的批次跳过。脱敏操作本身是幂等的——同一份数据脱敏两次结果一致 |
| 8 | 如果Kafka挂了怎么办? | 消息队列的高可用意识 | Kafka有副本机制和持久化,配合消费者手动提交offset,重启后可继续消费未处理的消息 |
| 12 | 为什么用Kafka不用RabbitMQ? | 中间件选型能力 | Kafka更适合削峰填谷和海量消息堆积的场景,吞吐量更高,持久化能力更强 |
| 1 | Kafka为什么吞吐量高? | 是否理解Kafka的核心设计 | 顺序写磁盘、零拷贝(sendfile)、页缓存、批量压缩、分区并行 |
| 2 | Kafka怎么保证消息不丢失? | 生产者和消费者的可靠性配置 | 生产者ACK=all(-1)、副本数≥2、min.insync.replicas、消费者手动提交offset |
| 3 | Kafka怎么保证消息顺序消费? | 顺序性的实现方式 | 同一个业务key路由到同一个分区,一个分区只能被同一个消费者组内的一个消费者消费 |
| 4 | Kafka分区数和消费者数的关系? | 并行消费的理解 | 一个分区只能被一个消费者消费;消费者数超过分区数时,多余的消费者闲置 |
| 5 | Kafka的ISR是什么?有什么用? | 副本同步机制 | In-Sync Replicas,与Leader保持同步的副本集合,用于Leader选举和消息可靠性保证 |
| 6 | Kafka的Leader选举机制是怎样的? | 高可用理解 | 从ISR中选举,优先选择与Leader差距最小的副本作为新Leader |
| 7 | 你们Topic设置了多少个分区?依据是什么? | 分区数设计的合理性 | 同一时间只处理1个模板,2-4个分区足够。分区数太多增加Leader选举和文件句柄开销 |
| 8 | 消费者手动提交offset,commit频率怎么控制? | 是否真正用过手动提交 | 拉取到方案ID后立即commit,保证”至少一次”语义,配合数据库状态做幂等处理 |
| 9 | 如果消费者处理消息时抛异常了,offset怎么处理? | 异常处理意识 | 不commit,下次重启时重新消费;或者捕获异常后记录到死信队列,手动补偿 |
| 10 | Kafka消息积压了怎么排查和解决? | 线上问题排查能力 | 先看积压在哪个分区、消费者是否正常、是否有慢消费;临时方案增加消费者或扩大分区;根治优化消费逻辑 |
| 11 | Kafka的rebalance你遇到过吗?什么原因导致的? | 是否遇到过生产问题 | 消费者加入或退出、session超时、max.poll.interval.ms超时。rebalance期间所有消费者暂停消费,影响吞吐量 |
| 12 | max.poll.interval.ms和session.timeout.ms有什么区别? | 对Kafka超时机制的理解 | max.poll.interval.ms控制两次poll的最大间隔,超时认为消费者卡死;session.timeout.ms控制心跳超时,超时认为消费者挂了 |
| 13 | 为什么选Kafka而不是RocketMQ或RabbitMQ? | 中间件选型能力 | Kafka吞吐量最高、消息堆积能力强、适合削峰填谷。脱敏场景需要海量消息堆积和削峰,Kafka最合适 |
| 14 | 如果脱敏任务对实时性有要求,Kafka还合适吗? | 对Kafka局限性的认知 | Kafka实时性不如RabbitMQ或RocketMQ,消息是批量拉取的,延迟在毫秒到秒级。如果要求秒级响应,可考虑RocketMQ |
| 15 | Kafka消息体为什么只放方案ID,而不是全量数据? | 消息体设计的合理性 | 方案ID只有几十字节,消息体小,传输和序列化开销低,Kafka吞吐量更高。全量数据可能几MB,会拖慢Kafka读写性能 |
| 16 | 如果Kafka集群挂了,怎么保证不丢任务? | 高可用设计 | Kafka集群有副本机制和ISR,配合生产者ACK=all保证消息不丢。同时数据库中有任务状态兜底,即使Kafka不可用,也可以从数据库中恢复未处理的任务 |
| 17 | 消费者每次只拉取1个方案ID,那Kafka多分区的优势是不是浪费了? | 场景适配能力 | 不浪费。多分区仍然提供了消息的分布式存储和故障隔离能力。虽然当前场景同一时间只处理1个,但多个分区可以承载不同模板的消息,消费者重启或故障时也能快速切换 |
| 18 | 立即commit后消费者挂了,任务会不会丢? | 可靠性设计 | 不会丢。任务状态已持久化到数据库,消费者重启后扫描status=1的任务,从断点处继续处理 |
正则问题
| 序号 | 追问问题 | 考察点 | 建议回答方向 |
|---|---|---|---|
| 3 | 正则超时在Java里具体怎么实现? | 是否真正落地过 | Future.get(timeout)控制超时,超时则中断任务并记录异常日志 |
| 4 | 单条匹配超时为什么设1s?依据是什么? | 参数设计的合理性 | 1KB文本跑3-5条正则,正常耗时在0.5-1ms,1s是非常保守的上限,足以区分正常数据和恶意构造的输入 |
| 11 | 单条脱敏0.5-1ms这个数据是怎么来的? | 量化数据是否经得起推敲 | 1KB文本跑3-5条正则,基于本地测试和线上压测的综合估算 |
| 13 | 如果某个模板的脱敏任务一直失败怎么办? | 异常处理意识 | 设置重试机制(最多3次),超过重试次数后标记为”脱敏异常”,通知人工介入处理 |
| 14 | 单条数据大小上限10KB,如果业务上确实需要处理更大的数据怎么办? | 边界情况处理 | 可以做成可配置项,根据业务需求动态调整,但需要同步评估对内存和性能的影响 |
其他问题
| 序号 | 追问问题 | 考察点 | 建议回答方向 |
|---|---|---|---|
| 2 | 分页查询用OFFSET,百万级数据越往后越慢怎么办? | 分页优化的实战经验 | 改用基于ID的区间查询:WHERE id > 上次最大id ORDER BY id LIMIT 1000,走索引,性能稳定 |
| 9 | 同一时间只处理1个模板,如果多个用户同时提交,后面的要等多久? | 用户体验意识 | 单个模板2-4分钟,用户提交后即可离开,脱敏完成后通过WebSocket通知,不需要在页面等待 |
| 10 | WebSocket通知如果客户端断连了怎么办? | 消息可达性意识 | 增加兜底机制:脱敏完成后同时发送邮件或站内信,用户下次登录时也能看到通知 |