日志里蹦出用户的手机号、身份证号、银行卡号,这种事在真实项目里一点都不稀奇。我见过太多团队,功能上线跑得飞快,等到要查线上问题、把日志文件拉下来一看,满屏的敏感信息,当场头皮发麻。更麻烦的是,很多日志还会被采集到集中式平台、被不同角色的人查看,一旦泄露,责任根本兜不住。所以日志脱敏这件事,不是"锦上添花",而是"必须做对"的底线工程。
这篇内容聊的是在 Spring Boot 项目里,用 Logback 把日志脱敏和日志截取这两件事做扎实。日志脱敏解决的是"敏感字段不能明文落盘"的问题,日志截取解决的是"超长日志把磁盘撑爆、把采集链路拖垮"的问题。两者一个管"内容安全",一个管"体积可控",配合起来才是一套完整的日志治理方案。适合已经能跑起 Spring Boot 项目、但对日志配置还停留在"抄个 logback-spring.xml 就完事"阶段的同学,也适合想把现有日志方案梳理清楚的老手对照检查。
我下面讲的不是"贴一段配置就收工",而是把每个选择背后的原因、参数怎么算、坑在哪里,都掰开说清楚。你照着做能落地,遇到变体能自己判断。
1. 先想清楚:为什么脱敏和截取必须在 Logback 层做
很多人第一反应是在业务代码里手动处理:打印日志前把手机号替换成138****8888。这个做法在小项目里能凑合,但只要项目稍微大一点,就会失控。原因很直接——日志打印点太分散,你没法保证每个人都记得处理,也没法保证第三方库、框架内部打印的日志里不带敏感信息。靠人自觉的方案,本质上不可靠。
1.1 业务层脱敏的三个致命缺陷
第一个缺陷是覆盖面不可控。你的代码里可能有一百处log.info,框架和依赖包里还有几百处,你只改自己写的那部分,剩下的全是漏洞。第二个缺陷是侵入性强。业务逻辑里混进一堆字符串替换代码,可读性变差,而且一旦脱敏规则变了,要改的地方遍地都是。第三个缺陷是容易漏。异常堆栈里、toString()输出里、参数对象序列化后的内容里,敏感字段可能以你意想不到的形式出现,手动替换根本追不上。
Logback 层做脱敏的好处在于它是统一出口。所有日志最终都要经过 appender 写出去,你在这个出口上做拦截,不管日志来自业务代码、框架还是第三方库,一律过一遍规则。这就是"在正确的层次解决正确的问题"。
1.2 截取为什么也不能靠业务层
日志截取同理。有人觉得超长日志是偶发情况,手动substring一下就行。但真实场景里,超长日志往往来自不可控的输入——比如用户提交了一个巨大的 JSON、某个接口返回了超长的响应体、异常信息里带了一大段 SQL。这些内容在打印时你未必能预判长度,等发现磁盘被撑爆已经晚了。在 appender 层统一做长度截断,才是稳妥的做法。
提示:脱敏和截取都放在 Logback 的 encoder 或 converter 层,属于"日志管道的最后一道闸门"。闸门设在这里,上游怎么打印都逃不掉。
1.3 两者的处理顺序有讲究
这里有个容易被忽略的细节:先脱敏还是先截取。我的建议是先脱敏、后截取。原因在于,如果先截取,可能把一段敏感信息从中间切断,导致脱敏规则匹配不上(比如手机号被截成前 7 位,正则就认不出来了),反而漏掉。先脱敏保证敏感字段被完整替换,再对整体长度做截断,逻辑上更安全。这个顺序在自定义 converter 里体现为处理链的先后,后面会给具体代码。
2. Logback 脱敏的三种实现路径与选型判断
Logback 本身没有内置"脱敏"这个功能,得自己扩展。常见的实现路径有三条,各有适用场景,选错了后面维护会很痛苦。
2.1 路径一:自定义 MessageConverter 替换消息内容
这是最常用的做法。Logback 的%msg(或%message)背后对应一个MessageConverter,你可以继承它,在convert方法里对格式化后的消息做正则替换,然后把自定义的 converter 注册到配置里,用自定义的转换词(比如%desensitizedMsg)替代%msg。
public class DesensitizedMessageConverter extends MessageConverter { private static final Pattern ID_CARD = Pattern.compile("(\\d{6})\\d{8}(\\d{3}[0-9Xx])"); private static final Pattern PHONE = Pattern.compile("(1[3-9]\\d)\\d{4}(\\d{4})"); private static final Pattern BANK_CARD = Pattern.compile("(\\d{4})\\d{10,15}(\\d{4})"); @Override public String convert(ILoggingEvent event) { String msg = super.convert(event); if (msg == null) { return null; } msg = ID_CARD.matcher(msg).replaceAll("$1********$2"); msg = PHONE.matcher(msg).replaceAll("$1****$2"); msg = BANK_CARD.matcher(msg).replaceAll("$1**********$2"); return msg; } }这个方案的优点是简单直接,对消息体里的敏感信息覆盖到位。缺点是它只处理%msg部分,如果敏感信息出现在 MDC、参数数组或者异常堆栈里,它管不到。所以它适合"敏感信息主要在日志消息文本里"的场景。
2.2 路径二:自定义 Layout 或 Encoder 做整体处理
如果你要处理的是整条日志输出(包括时间、线程、logger 名、消息、异常堆栈全部拼起来之后的字符串),那就得在Layout或Encoder层动手。继承PatternLayoutEncoder或者自己实现Encoder<ILoggingEvent>,在encode方法里拿到最终字符串再做替换。
这个方案覆盖面最广,异常堆栈里的敏感信息也能处理到。代价是性能开销略大,因为是对整条日志做正则匹配,日志量大时要留意。我的经验是:如果项目日志量在每秒几千条以内,这个开销可以接受;如果更高,就要考虑把正则预编译、减少匹配次数,或者只对特定 logger 启用。
2.3 路径三:用 TurboFilter 在事件生成阶段拦截
TurboFilter是 Logback 里比较特殊的一类过滤器,它在日志事件创建阶段就介入,比 appender 更早。你可以在这里检查日志内容,决定是否放行、是否修改。不过TurboFilter修改消息内容不太方便(它的设计初衷是过滤而非改写),所以用它做脱敏不是主流选择,更多用于"某些敏感 logger 直接不输出"这类场景。
三条路径的对比我整理成表格,方便你按项目情况选:
| 实现路径 | 覆盖范围 | 性能开销 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| MessageConverter | 仅消息体 | 低 | 低 | 敏感信息集中在消息文本 |
| 自定义 Layout/Encoder | 整条日志含堆栈 | 中 | 中 | 要求全面覆盖、含异常堆栈 |
| TurboFilter | 事件级过滤 | 低 | 中 | 按 logger 或内容做拦截 |
2.4 我的选型建议
大多数业务项目,我推荐路径一为主、路径二为辅的组合:日常消息用MessageConverter处理,同时对异常堆栈这类容易漏的地方,用自定义 encoder 兜底。如果团队对性能极其敏感、日志量巨大,那就只上路径一,把脱敏规则做精,别贪全。
3. 把脱敏规则写对:正则设计与常见字段处理
脱敏的核心是正则。正则写得好,规则稳;写得糙,要么漏、要么误伤。这一节把常见敏感字段的处理方式讲透。
3.1 手机号、身份证、银行卡的正则边界
手机号相对简单,国内手机号是 1 开头、第二位 3-9、共 11 位。正则(1[3-9]\d)\d{4}(\d{4})保留前 3 后 4,中间 4 位打码。这里要注意别用过于宽松的\d{11},否则会把订单号、流水号里恰好 11 位的数字也误伤。
身份证 18 位,结构是 6 位地址码 + 8 位出生日期 + 3 位顺序码 + 1 位校验码(可能是 X)。正则(\d{6})\d{8}(\d{3}[0-9Xx])保留前 6 后 4。这里有个坑:15 位老身份证也要考虑,如果你的系统还有历史数据,得加一条(\d{6})\d{6}(\d{3})的规则。
银行卡长度不固定,常见 16-19 位。用(\d{4})\d{10,15}(\d{4})保留前 4 后 4。但银行卡和身份证、订单号在纯数字形态下容易混淆,实际项目里我建议结合上下文关键词做匹配,比如只在出现"卡号""cardNo"等词附近才启用银行卡规则,降低误伤率。
3.2 姓名、地址这类"非结构化"字段怎么处理
手机号、证件号是结构化数字,正则好写。姓名、地址就麻烦了,因为它们没有固定格式。我的做法是不追求全自动,而是约定日志打印规范:涉及姓名的字段,业务代码里统一用工具方法处理后再打印,比如LogMaskUtil.maskName("张三丰")输出张**。Logback 层只兜底处理结构化字段,非结构化的靠规范约束。这样既不过度设计,又能覆盖主要风险。
3.3 正则性能:预编译与避免回溯灾难
正则一定要预编译成static final Pattern,别在convert方法里每次Pattern.compile,那是性能杀手。另外要警惕灾难性回溯:像(.*)(\d+)这种嵌套量词,遇到长字符串可能指数级耗时。脱敏正则尽量写得"确定"一些,避免.*和量词嵌套。我一般会在测试环境用一段超长日志压一下,确认单条处理在毫秒级以内。
注意:正则替换用
replaceAll时,替换串里的$有特殊含义(表示分组引用),如果你的替换内容本身含$,要用Matcher.quoteReplacement转义,否则会抛异常。
4. 日志截取:长度控制与堆栈处理的实操细节
脱敏解决"内容安全",截取解决"体积可控"。这一节讲怎么在 Logback 里把日志长度管住。
4.1 单条日志长度截断的实现
最直接的做法是在自定义 encoder 里,对最终字符串做长度判断,超过阈值就截断并加省略标记:
public class TruncatingEncoder extends PatternLayoutEncoder { private int maxLength = 4096; @Override public byte[] encode(ILoggingEvent event) { byte[] bytes = super.encode(event); if (bytes == null || bytes.length <= maxLength) { return bytes; } String truncated = new String(bytes, getCharset()); truncated = truncated.substring(0, maxLength) + "...[truncated]"; return truncated.getBytes(getCharset()); } public void setMaxLength(int maxLength) { this.maxLength = maxLength; } }阈值怎么定?我的经验值是单条 4KB 到 8KB。太小会把有用的上下文截掉,排查问题时反而抓瞎;太大起不到控制体积的作用。这个值要结合你的日志采集链路来定——如果采集端对单条日志有大小限制(很多采集组件默认单条上限就是几 KB),那就以采集端限制为准,别让日志在采集环节被丢弃。
4.2 异常堆栈的截取要单独对待
异常堆栈是最容易超长的部分,一个深层嵌套的异常动辄几十上百行。但堆栈又不能简单粗暴地截断,因为栈顶的几行往往最关键。我的做法是:对堆栈做"头部保留 + 总行数限制",比如保留前 30 行,超出部分用...[stack truncated, total N lines]标注。这样既能看到异常发生的直接位置,又不会让日志爆炸。
实现上可以在 encoder 里判断消息是否包含\n\tat这样的堆栈特征,单独走堆栈截取逻辑。也可以更简单——直接对整条日志做行数限制,超过 N 行就截断。后者实现简单,但可能把消息体和堆栈一起截,需要权衡。
4.3 截取和脱敏的协同配置
前面说过顺序是先脱敏后截取。在配置上,如果你用的是自定义 encoder 同时做两件事,就在encode方法里先调脱敏逻辑再调截取逻辑。如果分成两个组件,要确保脱敏的 converter 在 encoder 之前生效。实际配置里,PatternLayout的 pattern 字符串决定了 converter 的调用顺序,把自定义脱敏转换词放在前面即可。
5. 完整落地:logback-spring.xml 配置与自定义组件注册
理论讲完,落到配置上。这一节给一套可以直接参考的完整配置,并解释每个关键节点的作用。
5.1 注册自定义 converter
Logback 支持通过<conversionRule>注册自定义转换词:
<configuration> <conversionRule conversionWord="maskedMsg" converterClass="com.example.log.DesensitizedMessageConverter"/> <conversionRule conversionWord="truncatedMsg" converterClass="com.example.log.TruncatingMessageConverter"/> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <encoder class="com.example.log.SafePatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %maskedMsg%n</pattern> <maxLength>8192</maxLength> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>5GB</totalSizeCap> </rollingPolicy> </appender> <root level="INFO"> <appender-ref ref="FILE"/> </root> </configuration>这里有几个点值得说。conversionRule的conversionWord就是你 pattern 里用的词,起名要见名知义。SafePatternLayoutEncoder是我把脱敏和截取合到一起的自定义 encoder,maxLength通过 setter 注入。滚动策略用SizeAndTimeBasedRollingPolicy,同时按大小和时间切分,totalSizeCap兜住总磁盘占用,防止历史日志无限堆积。
5.2 多环境差异化配置
生产环境要脱敏、要截取,开发环境其实没必要那么严格——本地调试时看到完整信息反而方便。用 Spring Boot 的 profile 机制,把logback-spring.xml里用<springProfile>区分:
<springProfile name="prod"> <root level="INFO"> <appender-ref ref="FILE"/> </root> </springProfile> <springProfile name="dev"> <root level="DEBUG"> <appender-ref ref="CONSOLE"/> </root> </springProfile>开发环境走控制台、不脱敏,生产环境走文件、强制脱敏。这样既保证线上安全,又不影响本地效率。注意文件名要用logback-spring.xml而不是logback.xml,前者才支持<springProfile>标签。
5.3 异步 appender 的取舍
日志量大时,同步写文件会拖慢业务线程。可以套一层AsyncAppender:
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>1024</queueSize> <discardingThreshold>0</discardingThreshold> <appender-ref ref="FILE"/> </appender>queueSize是队列容量,discardingThreshold设为 0 表示队列满时也不丢弃(默认会丢弃 TRACE/DEBUG/INFO)。这里要权衡:设 0 保证不丢日志,但队列满时会阻塞业务线程;设非 0 则可能丢低级别日志。生产环境我一般设 0,宁可慢一点也别丢关键日志。另外注意,异步 appender 里如果用了自定义 encoder,要确认 encoder 是线程安全的——我的实现里没有共享可变状态,是安全的。
6. 踩过的坑:那些配置看起来对但实际不生效的情况
这一节是我和团队实际踩过的坑,配置写得再漂亮,不生效等于零。
6.1 自定义 converter 没生效的排查链路
最常见的现象是:配置里明明写了%maskedMsg,日志里却原样输出或者直接报错。排查顺序我总结成一条链路:
第一步,看类路径和包名。converterClass写的是全限定类名,包名错一个字母就找不到类,Logback 会静默降级或报ClassNotFoundException。第二步,看conversionWord 是否和 pattern 里一致,大小写敏感。第三步,看是否有多个 logback 配置文件冲突,比如同时存在logback.xml和logback-spring.xml,加载顺序可能导致你的配置被覆盖。第四步,确认自定义类是否被 Spring 管理——converter 是 Logback 直接实例化的,不需要 Spring 注入,如果你在里面@Autowired了东西,那是拿不到的,会 NPE。
6.2 脱敏把正常日志也改了
有次上线后发现日志里一堆****,连正常的订单号都被打了码。根因是银行卡正则太宽松,把 16 位纯数字的订单号也匹配了。修复办法是给规则加上下文约束,或者调整正则的锚定条件。这个坑提醒我:脱敏规则上线前,一定要拿真实日志样本跑一遍,统计误伤率,别拍脑袋。
6.3 截取导致日志采集端解析失败
还有一次,日志截断后加了个...[truncated]后缀,结果采集端的解析规则是按固定格式匹配的,后缀导致解析失败,日志在平台上看不到。后来改成截断时不加后缀,或者把后缀放在不影响解析的位置。这个教训是:截取逻辑要和下游采集、解析规则对齐,别只顾自己这边。
6.4 性能问题:正则拖慢了整体吞吐
某次压测发现 QPS 上不去,排查到日志环节,发现脱敏正则里有个.*嵌套,遇到长消息时耗时飙升。把正则改写成确定形式、预编译、并给超长消息加长度前置判断(超过一定长度直接跳过复杂正则,走简单截断),问题解决。经验是:脱敏逻辑本身也要做性能测试,别默认它"很轻"。
7. 上线前的自检清单与长期维护建议
这套方案落地后,怎么确认它真的在工作?我整理了一份自检清单,每次改动日志配置后过一遍。
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 脱敏规则覆盖 | 构造含手机号/证件号的测试日志 | 输出中敏感字段被打码 |
| 误伤率 | 用真实日志样本回放 | 正常业务字段未被误改 |
| 截取生效 | 打印超长字符串 | 单条长度不超阈值 |
| 堆栈处理 | 抛一个深层异常 | 栈顶信息保留、总行数受限 |
| 多环境隔离 | 分别用 dev/prod 启动 | 生产脱敏、开发不脱敏 |
| 性能影响 | 压测对比开启前后 | 吞吐下降在可接受范围 |
| 采集兼容 | 检查采集平台日志完整性 | 无解析失败、无丢失 |
长期维护上,我的建议是把脱敏规则集中管理。别把正则散落在各个 converter 里,抽一个MaskRuleRegistry统一维护,新增敏感字段类型时只改一处。另外,敏感字段的类型会随业务变化增加(今天加个护照号,明天加个车牌号),规则库要留扩展点。还有一点容易被忘:日志脱敏不是一劳永逸,业务迭代、依赖升级都可能引入新的日志打印点,定期(比如每季度)抽查一次线上日志样本,确认没有新的敏感信息泄露,这个习惯比任何工具都管用。
最后分享一个我个人很受用的小技巧:在测试环境加一个"脱敏规则命中统计",用计数器记录每条规则被触发的次数。上线一段时间后看统计,如果某条规则从来没命中,要么是规则写错了,要么是这类敏感信息根本没进日志——两种情况都值得深究。这个统计成本很低,但能帮你持续校准规则的有效性。