本节目标:给
libraryd定下三条可执行的日志规则:敏感字段不落原文、用户输入不破坏行结构、日志量与保留期有上限。
适用版本:JDK 25(Temurin 25.0.5)、SLF4J 2.0.20、Logback 1.6.6、logstash-logback-encoder 9.0。
承接的问题
12.2 节把字段放进了 JSON,字段一多,问题就从「查不到」变成「不该留的也留下了」。libraryd 目前有这些日志入口:
HttpMetrics.record记录方法、路径、状态、耗时、requestId。LibraryApi.handleLibraryException记录错误码、消息、requestId。OverdueScanner记录逾期扫描结果。RedisBookCache在连接失败或读写失败时记录原因。- Hikari、Flyway、Jetty、OpenTelemetry 各自的启动与运行日志。
这些入口暂时没有打印会员姓名、邮箱或令牌,但接口一旦扩展,很容易顺手写:
log.info("member login email={} token={}", email, token);
一行日志会把凭据写进磁盘、日志平台、备份和截图。另一个常见问题是用户输入直接进消息:
log.warn("search rejected query={}", rawQuery);
如果 rawQuery 里带换行,文本日志会被伪造出一条新的日志行。最后是成本:访问日志每请求一条,按 500 req/s、每条 1 KiB 计算,一天约 41.2 GiB;如果每条 3 KiB、2000 req/s,一天约 494.4 GiB。日志不是免费的。
这一节按「能放什么、怎么防注入、留多少、花多少钱」四件事展开。
核心机制一:敏感字段分类与脱敏
先把字段分成四类,再决定处理方式:
| 类别 | 例子 | 处理方式 |
|---|---|---|
| 凭据 | 密码、令牌、Cookie、API Key、数据库连接串 | 永不记录原文;最多记录是否存在、长度、前缀 |
| 个人身份信息 | 邮箱、手机号、姓名、地址、身份证号 | 脱敏后记录,或只记录不可逆哈希 |
| 业务标识 | ISBN、借阅 ID、会员 ID | 可记录;会员 ID 视合规要求决定是否脱敏 |
| 内部标识 | requestId、traceId、线程名、错误码 | 原样记录 |
libraryd 用一个工具类集中处理,避免每个调用点各写一套:
package com.plume.library.web;
public final class LogSanitizer {
private static final int VISIBLE_PREFIX = 4;
private LogSanitizer() {
}
public static String maskEmail(String email) {
if (email == null || email.isBlank()) {
return "";
}
int at = email.indexOf('@');
if (at <= 0) {
return "***";
}
return email.charAt(0) + "***" + email.substring(at);
}
public static String maskToken(String token) {
if (token == null || token.length() < 8) {
return "***";
}
return token.substring(0, VISIBLE_PREFIX)
+ "***"
+ token.substring(token.length() - 4);
}
public static String maskQuery(String query) {
if (query == null) {
return "";
}
return query.replaceAll("(?i)(token|password|secret)=([^&\\s]+)", "$1=***");
}
public static String oneLine(String value) {
if (value == null) {
return "";
}
return value.replace("\r", "\\r").replace("\n", "\\n");
}
}
本机运行脱敏方法:
email=a***@example.com
token=eyJh***ture
oneLine=m-001\n2026-10-27T10:00:00Z INFO fake admin login success
maskEmail 保留首字符和域名,足够人工核对,又不暴露完整地址。maskToken 保留前 4 位和后 4 位,便于判断「是不是同一个令牌」,但不足以还原。oneLine 把换行转成字面量 \n,防止一行变两行。
脱敏不是万能的。邮箱脱敏后仍可能通过域名和首字母缩小范围;令牌保留前后各 4 位,对短令牌可能暴露大部分。更严格的做法是只记录 tokenPresent=true、tokenLength=...,完全不记录内容。选择哪种取决于合规要求和排查需要,关键是把决定写进规范,而不是留给每个开发者临场判断。
连接串也要小心。Hikari 启动日志会把 JDBC URL 打印出来,libraryd 的 URL 不含密码(密码单独配置),所以是安全的。如果把密码拼进 URL,例如 jdbc:postgresql://host/db?user=u&password=p,启动日志就会泄露。密码应该只走 LIBRARYD_DB_PASSWORD,不要进 URL。
核心机制二:日志注入与行结构
文本日志按行读取。用户输入里如果有 \n,消费端会把它当成两条日志。攻击者可以构造:
m-001
2026-10-27T10:00:00Z INFO admin login success
第一行是正常的会员 ID,第二行看起来像一条管理员登录成功的日志。如果日志平台按行解析,伪造就成功了。本机的 oneLine 输出把换行转义:
oneLine=m-001\n2026-10-27T10:00:00Z INFO fake admin login success
消费端看到的是一条完整的消息,换行以 \n 两个字符出现,不会拆行。JSON encoder 本身会转义换行,所以 logback-json.xml 下的风险小一些;但文本配置仍然是默认输出,不能只依赖 JSON 模式。
防御分三层:
- 输入边界:请求体、查询参数、Header 都做长度和字符校验,拒绝控制字符。
- 日志边界:所有用户输入经过
oneLine或 JSON encoder,不直接拼进 pattern。 - 消费边界:日志平台按 JSON 字段解析,不按行正则切分;文本模式只作为兜底。
libraryd 的 RequestValidator 已经限制 DTO 字段长度,但没有拒绝控制字符。如果要把用户输入写进日志,先过 LogSanitizer.oneLine;如果只写进数据库,交给 JDBC 参数化查询,不要拼 SQL。
还有一个容易忽略的入口:URL。GET /api/books?q=... 的查询串会出现在访问日志的 path 里。如果查询串带令牌,例如 ?token=...,访问日志就会泄露。HttpMetrics 目前记录的是 context.path(),Javalin 的 path() 不包含查询串,所以 libraryd 暂时安全;如果改成记录完整 URI,必须先脱敏。
核心机制三:采样:按比例、按请求、按级别
日志采样有两种常见做法:应用层计数采样和 Logback 过滤器采样。
应用层计数采样最简单:
static final class Sampler {
private final long every;
private final AtomicLong seen = new AtomicLong();
Sampler(long every) {
this.every = every;
}
boolean allow() {
return seen.incrementAndGet() % every == 0;
}
}
每 100 条放行 1 条。本机跑 1000 次,输出 10 条,最后打印:
sampled log lines=10
这种采样适合「高频、低价值」的日志,例如每次缓存命中都打一条 DEBUG。缺点是采样按调用次数,不按请求;一次请求的多条日志可能只留下其中一条,排查时上下文不完整。
按请求采样更一致:用 requestId 的哈希决定整条请求是否记录。
boolean sampled = Math.floorMod(requestId.hashCode(), 100) == 0;
同一个 requestId 的所有日志要么都记录,要么都不记录。这样既能降低量,又能保留完整链路。hashCode() 的分布对 UUID 足够均匀;如果需要跨语言一致,用 SHA-256 取模,不要依赖 Java 的 String.hashCode()。
Logback 层面的采样用 TurboFilter。它在级别过滤之前执行,可以按 logger、级别、消息内容决定放行或拒绝:
package com.plume.library;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.turbo.TurboFilter;
import ch.qos.logback.core.spi.FilterReply;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Marker;
public final class SamplingTurboFilter extends TurboFilter {
private final AtomicLong seen = new AtomicLong();
@Override
public FilterReply decide(Marker marker, Logger logger, Level level,
String format, Object[] params, Throwable throwable) {
if (level == Level.INFO && seen.incrementAndGet() % 100 != 0) {
return FilterReply.DENY;
}
return FilterReply.NEUTRAL;
}
}
配置里加一行:
<configuration>
<turboFilter class="com.plume.library.SamplingTurboFilter"/>
<!-- appender 与 root 保持不变 -->
</configuration>
本机用 1000 条 INFO 加 1 条 WARN 运行,输出 10 条 INFO 和 1 条 WARN。WARN 不在采样条件里,所以不会被丢弃。这个性质很重要:采样只能作用于 DEBUG、TRACE 或低价值 INFO,ERROR 和 WARN 必须全量保留,否则故障现场会被采样掉。
采样还要区分「日志采样」和「追踪采样」。日志采样决定写不写磁盘,追踪采样决定 span 是否上报。两者可以独立配置:日志全量、追踪 10%,或者反过来。不要因为追踪采样了 10% 就假设日志也只有 10%;排查时以实际配置为准。
核心机制四:成本、保留与异步的代价
日志成本由三个乘数决定:每条大小、每秒条数、保留天数。按本机可复现的算术:
| 请求量 | 每条大小 | 每天体积 |
|---|---|---|
| 100 req/s | 2 KiB | 16.5 GiB |
| 500 req/s | 1 KiB | 41.2 GiB |
| 2000 req/s | 3 KiB | 494.4 GiB |
一天 41.2 GiB 的日志,保留 30 天就是约 1.2 TiB;如果做三副本,还要再乘三。这还没有算索引和压缩。日志平台通常按摄入量计费,成本会直接体现在账单上。
控制成本的手段按优先级排列:
- 降低级别:生产根级别保持 INFO,DEBUG 按需打开,排查完立即关闭。
- 减少重复:访问日志、框架访问日志、业务日志不要记录同一件事三次。
- 采样:对高频 INFO/DEBUG 按比例或按请求采样,ERROR/WARN 全量。
- 缩短保留:访问日志 7 到 30 天,错误日志 30 到 90 天,审计日志按合规要求。
- 压缩与冷存:热存储只留最近几天,历史日志转对象存储。
异步 Appender 经常被当成省成本的手段,但它省的是请求线程的等待时间,不是日志体积。本机用 AsyncAppender、队列 1024、discardingThreshold=0 跑 1000 条 INFO,进程退出时只写出了 339 条。也就是说,异步队列在关闭阶段没有排空,丢掉了大半日志。这个结果不是让你禁用异步,而是提醒:异步日志必须配合优雅关闭和队列排空;审计、借还、资金类日志不能只放在异步队列里。
libraryd 目前的 ConsoleAppender 是同步的。容器收集标准输出时,如果下游管道阻塞,写日志会拖慢请求线程。生产环境可以用 sidecar 收集,或者在确认可以丢日志的前提下换异步 Appender,并在 LibraryApplication.close 之后显式停止 Logback、等待队列排空。
在 libraryd 里落地:一份可执行的规范
把上面的规则收成 libraryd 的日志规范:
| 场景 | 级别 | 采样 | 保留 |
|---|---|---|---|
| 未捕获异常、数据库不可用 | ERROR | 不采样 | 90 天 |
| 业务拒绝、缓存降级、连接池告警 | WARN | 不采样 | 30 天 |
| HTTP 访问日志 | INFO | 全量或按请求 10% | 7 到 30 天 |
| 启动、迁移、扫描结果 | INFO | 不采样 | 30 天 |
| SQL 参数、缓存逐条命中 | DEBUG | 按请求 1% 或关闭 | 1 到 3 天 |
| 框架内部细节 | TRACE | 关闭 | 不保留 |
调用点遵守三条规则:
// 1. 凭据只记录存在性,不记录值
log.info("member login memberId={} tokenPresent={}",
LogSanitizer.maskMemberId(memberId), token != null);
// 2. 用户输入进日志前先压成一行
log.warn("search rejected query={}", LogSanitizer.oneLine(rawQuery));
// 3. 异常作为最后一个参数,消息里不放堆栈
log.error("borrow failed isbn={}", isbn, failure);
maskMemberId 可以这样实现:
public static String maskMemberId(String memberId) {
if (memberId == null || memberId.length() <= 2) {
return "***";
}
return memberId.charAt(0) + "***" + memberId.charAt(memberId.length() - 1);
}
会员 ID 是否脱敏取决于业务:如果日志平台需要按会员 ID 聚合,脱敏会破坏聚合能力;这时可以记录不可逆哈希,例如 sha256(memberId) 的前 12 位,既能关联又不可还原。不要用 Base64 当脱敏,Base64 是可逆编码。
日志级别仍然由 LIBRARYD_LOG_LEVEL 控制。生产默认 INFO,排查时临时设置到 DEBUG,排查结束改回。不要在代码里写死 log.debug 的开关,也不要把 System.out.println 留在业务路径上;标准输出绕过 Logback 的级别、格式和脱敏。
常见错误与边界
错误一:记录完整请求体。 log.info("borrow request={}", request) 会把会员 ID、ISBN 甚至未来新增的邮箱字段全部写进去。请求体应该只记录必要的业务键,或者记录 DTO 的 toString() 之前先确认它不含敏感字段。record 的默认 toString() 会展开所有组件,风险很高。
错误二:只在消息里脱敏,MDC 里仍是原文。 log.info("member={}", maskEmail(email)) 看起来安全,但如果前面执行了 MDC.put("email", email),JSON encoder 会把原文作为顶层字段输出。脱敏要覆盖消息、MDC、结构化参数三条路径。
错误三:采样了 ERROR。 用统一的 seen % 100 采样所有级别,故障时可能只留下 1% 的堆栈。采样条件必须显式排除 ERROR 和 WARN,或者按级别配置不同的采样率。
错误四:用时间窗口做简单限流。 「每秒最多 10 条」在突发流量下会丢掉关键上下文;「每 100 条放行 1 条」在低流量下可能一条都不留。按请求哈希采样更适合排查,因为它保证同一条请求的日志完整。
错误五:把日志当审计。 日志可以被采样、轮转、删除,也可以被有权限的人修改。借还记录、罚金计算这类审计需求应该写数据库表,日志只作为辅助证据。libraryd 的 loans 表和 fine_cents 列才是权威记录。
边界:脱敏与调试的冲突。 脱敏后无法还原原始值,某些问题只能靠「同一个哈希值」关联。如果业务要求可逆,应该使用加密而不是脱敏,并把密钥放在 KMS,不要放在应用配置里。
边界:日志平台的字段膨胀。 每条日志加十几个结构化字段,单条大小会从几百字节涨到几 KiB。字段设计要克制,查询不用的字段不要加;高基数字段用采样或单独的事件表,不要塞进每条日志。
小结
- 敏感字段分四类:凭据永不记录,PII 脱敏,业务标识按需,内部标识原样。
- 脱敏要覆盖消息、MDC、结构化参数三条路径,不能只处理其中一条。
- 用户输入进日志前压成一行,防止 CRLF 注入;JSON encoder 能转义,但文本模式仍是默认输出。
- 采样按请求哈希比按条数更一致;ERROR 和 WARN 不采样。
- 日志成本按「大小 × 速率 × 保留天数」计算,500 req/s、1 KiB、一天约 41.2 GiB。
- 异步 Appender 省的是请求线程等待,不是体积;关闭阶段不排空会丢日志。
- 审计记录写数据库,日志只做辅助证据。
可观测性不只日志。下一章把 libraryd 的运行状态变成指标:请求计数、耗时分布、借还次数、逾期数量,并通过 /metrics 暴露给 Prometheus。
阅读导航:上一节:12.2 结构化日志:JSON、MDC 与请求上下文 · 下一节:13.1 Micrometer:计数器、计时器与 Prometheus 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。