1. 写代码不写日志,等于裸奔:从 System.out 说起
1.1 我为什么突然开始认真对待日志
先说个场景。之前我写过几个 Java 小项目,规模不大,代码里到处都是System.out.println(),那时候觉得"反正能跑,输出到控制台看看不就行了"。直到有一次写一个文件批处理程序,明明本地测试没问题,放到服务端一跑就报错,更尴尬的是——控制台一旦关掉,那点可怜的打印输出就彻底没了。程序跑完到底处理了多少条数据?哪一条出了异常?全凭运气。
从那一刻我开始意识到,日志不是一个"可选项",而是程序运行时的黑匣子。尤其是 Java 这类偏向服务端、长期运行的语言,没有日志等于裸奔。出了问题别说排查,连复现都无从下手。后来我才明白,为什么很多公司的代码规范里第一条就写着:禁止用System.out.println打印业务日志。因为它既没有级别,又没有时间戳,更没有文件输出能力,关闭终端就丢失,仅适合在本地做临时调试。
这一篇日记,我就想把日志和 Git 这两块内容完整记录一遍。它们一个负责"程序运行时的可见性",一个负责"代码演化的可追溯性",看起来是两件事,其实是工程化开发里相辅相成的两条腿。
1.2 日志级别:不是越多越好,而是对应场景
刚开始用日志框架的时候,我犯过一个典型错误:把所有信息都用logger.info()输出,甚至循环里面每处理一条数据就 info 一次。结果日志文件一天涨了几个 GB,真正出了问题想查也查不过来。后来才认真去理解日志级别的意义。
Java 生态里常见级别从低到高大概是:TRACE < DEBUG < INFO < WARN < ERROR < OFF。每个级别对应不同的运行场景:
TRACE:最细粒度的跟踪信息,几乎只在开发环境、定位性能问题时打开。DEBUG:调试信息,用于开发过程中观察变量、流程走向。INFO:关键的运行节点信息,比如服务启动完成、任务开始、任务结束。WARN:有潜在风险但不影响本次运行,比如配置项缺失走默认值、重试了三次才成功。ERROR:出现异常、操作失败,需要人工关注和处理。
关键逻辑在于:日志系统允许你在不同环境设置不同级别。开发环境设DEBUG,测试环境设INFO,生产环境设WARN甚至ERROR。这样既保证问题信息不丢失,又避免日志量爆炸。这也直接回答了热搜词里那个"日志设施"到底是什么意思——它是一整套记录、分流、存储、检索日志的基础能力,而不只是往控制台打几行字。
1.3 从 System.out.println 到日志框架
System.out.println最大的问题不是"能用不能用",而是它背后没有任何工程化设计。想想看,你打了十行输出,怎么区分哪些是调试过程、哪些是关键节点?怎么控制线上环境不打印敏感信息?怎么能把日志写到文件里保留三十天?这些需求System.out一个都满足不了。
Java 自带一个java.util.logging(JUL),虽然够用但功能不算强。实际开发中,Spring Boot 默认使用 Logback,配合 SLF4J 门面接口,是当前最主流的组合。SLF4J 只定义接口,底层可以选择 Logback、Log4j2 等实现。这样做的好处是,业务代码只依赖 SLF4J 的 API,底层日志实现想换就换,不用改业务逻辑。
我当时选 Logback 就一个理由:它是 Spring Boot 的默认实现,资料多、坑少、配置简单,而且和 SLF4J 天然集成。后面我会用一整个章节讲它的实际操作。
2. 第一套日志方案:Logback 上手实录
2.1 引入依赖:Maven 里的三行配置
如果用的是 Spring Boot 项目,其实不需要额外引入 Logback,spring-boot-starter已经自带了。但为了讲清楚原理,我还是从最原始的 Maven 依赖开始说。
<dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.14</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.13</version> </dependency>logback-classic会自动带上logback-core和slf4j-api,所以第二个依赖严格来说可以省略。但写出来有个好处:显式声明了版本,避免依赖冲突。我曾在某个老项目里因为传递依赖版本不一致,出现NoSuchMethodError,排查了半天,最后发现就是 SLF4J 版本被某个第三方库拉低了。
引入依赖之后,写代码的感受和System.out.println完全不同:
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class UserService { private static final Logger log = LoggerFactory.getLogger(UserService.class); public void createUser(String name) { log.info("开始创建用户,参数:{}", name); // 业务逻辑 log.info("用户创建成功:{}", name); } }注意这里用的是占位符{},而不是字符串拼接。我见过很多人一开始不习惯,觉得"开始创建用户,参数:" + name更直观。但日志框架推荐占位符是有原因的:如果当前日志级别不输出这条记录,字符串拼接的代码仍然会执行拼接操作,白白浪费性能;而占位符方式只有在真正需要输出时才会格式化。在循环体或高频方法里,这种差异会被明显放大。
2.2 logback.xml 最小配置拆解
光在代码里调 API 还不够,你得给 Logback 一份配置文件。默认情况下,它会去 classpath 找logback.xml。给你看一份我项目里最小可用的配置:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- 定义控制台输出 --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- 定义文件输出 --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root> </configuration>这份配置做了四件事:定义控制台输出、定义按天滚动的文件输出、设置根日志级别为 INFO、把两个 appender 挂到 root logger 上。这里最值得说的是RollingFileAppender,它解决的是"日志无限增长"的问题。按天滚动,每天生成一个文件,maxHistory=30表示只保留最近三十天,过期自动清理。这样日志存储大小是可控的,不需要手工去删文件。
2.3 输出格式:日志不只是"打一句话"
很多人配置日志只关心"打到哪",不关心"打什么内容",这是个大误区。日志的每一行信息都应该像一条证据记录,能帮你还原当时发生了什么。我个人最推荐的基础 pattern 是这样的:
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n逐个解释:
%d:时间戳,精确到毫秒。排查问题时,时间顺序是关键线索。%thread:线程名。配合并发场景,你能看出哪个线程在执行哪段逻辑。%-5level:日志级别,%-5是左对齐占位,让输出对齐漂亮。%logger{36}:打印日志的类名(缩短到 36 个字符以内),知道是哪段代码输出的。%msg%n:日志消息和换行。
除了基础格式,Logback 还提供了丰富的扩展能力。热搜词里提到的"logback日志堆栈简化"就是一个真实需求:默认的异常堆栈可能打印出几十行,不仅刷屏,还经常包含无用的框架内部调用。Logback 的 pattern 里可以用%ex{短长度}控制堆栈深度,比如%ex{5}只打印前 5 行核心异常信息,配合%xEx还能做去重压缩,省空间又不丢关键线索。
2.4 踩坑:日志文件不生成 / 路径找不到
这个坑我印象很深。第一次配置RollingFileAppender,我写的是:
<file>logs/app.log</file>然后运行程序,控制台有输出,但目录下怎么也找不到logs文件夹。查了半天才发现,Logback 默认情况下不会自动创建不存在的目录。它期望目录已经存在,否则就静默失败或者只在特定情况下创建。解决办法是提前手动建好目录,或者用logback.xml里判断路径再创建。后来我在项目的启动脚本里加上mkdir -p logs才彻底解决。
还有一个更隐蔽的坑:文件路径用相对路径logs/app.log时,它是相对于"进程的工作目录"(也就是你启动 Java 进程时所在的目录)来解析的。如果用 IDE 启动,就是当前模块目录;如果打 jar 包在服务器上用java -jar app.jar启动,那就是你执行命令的那个目录。这就导致"本地能生成日志,服务器上找不到"的诡异现象。后来我习惯把所有日志路径都配置成${LOG_HOME:-logs}这种带环境变量前缀的形式,或者直接写绝对路径,才没有继续踩。
3. 日志是排查问题的第一现场
3.1 一次空指针异常:从日志到定位的完整链路
理论说再多,不如看一个真实排查过程。有一次我写一个订单处理接口,对端回调的数据结构里有个嵌套字段,我在代码里直接取order.getUser().getName(),结果抛了空指针。你看日志:
2024-05-16 14:32:11.087 [http-nio-8080-exec-3] ERROR c.example.OrderService - 处理订单回调失败 java.lang.NullPointerException: null at com.example.OrderService.handleCallback(OrderService.java:147)这行日志就提供了三个关键信息:时间(14:32:11)、线程(http-nio-8080-exec-3,说明是接口请求线程)、代码位置(OrderService.java 第147行)。有了定位,问题基本就解决了一半——是user为 null,还是user.getName()里getName()返回 null?打开代码一看,第 147 行是String name = order.getUser().getName();,再结合回调文档,发现这个接口在某种场景下user字段确实会空缺。
如果当时没有日志,我可能得在代码里加一堆System.out.println重新部署才能定位到这一行。而有了日志系统,线上发生了什么都是留痕的,这个问题从发现到定位不超过五分钟。这也是为什么很多公司排查问题时第一句话就是"先看日志"。
3.2 日志该记录什么,不该记录什么
日志不是越多越好,也不是越少越好。我总结了一套适合自己项目的记录准则:
- 方法入口和出口:记录关键参数和结果,尤其是接口层、批量任务这类容易被调用的地方。
- 异常必须记录:
logger.error("操作失败,orderId={}", orderId, e),把异常对象放最后一个参数,堆栈才会完整打出来。 - 分支决策点:比如"走了 A 逻辑还是走了 B 逻辑",这种信息在排查业务问题时非常有用。
- 高频循环内谨慎记录:比如循环一万次、每次打一条 info,日志量会失控。要么降级到 debug,要么累计到一定数量再打一批。
反过来,不该记录的内容也很明确:密码、token、身份证号、银行卡号这类敏感信息,绝对不能进日志。我见过一个真实事故:某个平台的日志里刷出了用户明文密码,结果日志文件被导出发到了第三方手里,直接变成安全事故。在写日志的时候,敏感字段要么不输出,要么做脱敏,比如密码只显示******,手机号只保留前三位后四位。
3.3 敏感信息别进日志:password 和 token 的教训
说到脱敏,我分享一个最简单的做法。在实体类的toString()里直接跳过或者打码敏感字段:
@Override public String toString() { return "User{id=" + id + ", username='" + username + "', password='******'}"; }但更彻底的做法是在统一出口做过滤,尤其是项目里用了接口出入参全量打印这类便捷手段的。很多框架支持在序列化层做字段脱敏,比如 Jackson 的@JsonProperty(access = Access.WRITE_ONLY)可以让密码只反序列化不序列化,这样即使你把整个对象打出来,密码也出不去。这个习惯越早养成越好,不然等项目大了再回头清理历史日志里的敏感数据,那工作量够你喝一壶的。
4. Git 到底在解决什么问题:版本控制的本质
4.1 没有版本控制的"灾难现场"
日志解决的是"程序运行时的可见性",而 Git 解决的是"代码变更的可追溯性"。我刚开始写代码的时候,是真的用文件副本管理版本的。项目目录里曾经出现过这种文件名:
UserService.java UserService_v2.java UserService_final.java UserService_final_v2.java现在回头看这简直是一场灾难。三天后你自己都分不清final_v2和v3哪个才是真正在用的版本。更糟糕的是,某天不小心删掉了一段功能代码,但你已经想不起是哪个文件里删的、什么时候删的,整个项目直接改崩又退不回去。
Git 要解决的正是这类问题:它可以记录每一次代码的增量变化,形成一个不可篡改的历史链;你可以随时回退到任意一次提交;你可以为实验性改动单独拉分支,不影响主干稳定性;你还能通过对比差异,弄清楚某一行代码是什么时候、因为什么原因改的。
4.2 工作区、暂存区、版本库:三个概念的直观类比
理解 Git 的核心障碍在于"暂存区"这个概念。你可能会想:我明明已经git add了,为什么还要git commit?这两步到底有什么区别?
我用的类比是购物车。工作区是你逛超市时手里的购物篮,你可以随意把东西放进去(新建文件、修改代码),这些变化 Git 知道但不记录。暂存区是收银台的结算台,你把要买的东西放到结算台上(git add),这是"确认要买"的动作,但还没付款。git commit就是付款开小票,交易完成,这一次购买记录被固定下来。如果之后发现买错了,你可以看历史小票(git log)来回退。
所以流程永远是:修改工作区 →git add放到暂存区 →git commit生成版本记录。
4.3 第一个仓库:git init 到第一次 commit
初始化仓库比想象中简单。在你项目的根目录执行:
git init这会在当前目录生成一个隐藏的.git文件夹,里面装着这个仓库的全部历史和配置。注意,一个.git文件夹就是一个独立的仓库,它只管理当前目录及其子目录的文件。初始化之后,你还需要设置身份信息,否则 commit 会失败或者留下错误的作者记录:
git config --global user.name "你的名字" git config --global user.email "你的邮箱"我个人建议--global一次性配置全局身份,因为绝大多数情况下你所有项目都是用同一个身份提交的。如果某些项目需要不同身份,再去那个仓库里单独设置不带--global的配置覆盖即可。
然后就是第一次提交:
git add . git commit -m "初始化项目:用户模块和订单模块"git add .会把当前目录下所有"新增和修改"的文件加入暂存区。但注意,这个操作也会把不该提交的文件加进去,比如 IDE 配置文件、编译产物等。这就引出了.gitignore的重要性,我后面会专门讲。
5. 常用 Git 命令和提交规范:让历史可读
5.1 日常高频命令清单
把 Git 用利索,其实不需要会一大堆命令,高频的就那么几个。我把它们整理成了一张表:
| 命令 | 作用 | 使用频率 |
|---|---|---|
git status | 查看工作区/暂存区状态 | 每天无数遍 |
git add <file> | 把文件加入暂存区 | 每天无数遍 |
git commit -m "message" | 生成一次提交 | 每天多次 |
git pull | 拉取远端更新并合并 | 每天开始工作时 |
git push | 推送本地提交到远端 | 提交后 |
git log --oneline | 查看简洁提交历史 | 回溯时 |
git diff | 查看未暂存的具体改动 | 提交前必看 |
git checkout -- <file> | 丢弃工作区某个文件的改动 | 恢复误删时 |
git reset HEAD <file> | 把已暂存的文件移出暂存区 | 撤销 add 时 |
有几个命令新手容易搞混:git status和git diff。status只告诉你"哪些文件变了",但不会告诉你"具体变了什么";diff才是逐行展示改动的。我现在的习惯是每次 commit 之前,先git diff看一眼改动,再git status看下有没有多加了文件,双保险。
5.2 分支和合并冲突:第一次冲突的完整处理过程
分支是 Git 最核心也最让人困惑的能力。简单理解:main分支是项目的主线,类似定稿的文档;你在dev/feature-login分支上开发登录功能,类似在草稿上写新段落,不会影响定稿。等草稿写好了,再把改动合并回主线。
创建并切换分支:
git checkout -b feature-login我刚开始用的时候,听到"合并分支"就觉得很高大上,其实常用就两种方式:git merge和git rebase。新手建议先从merge开始,语义最直观——把两个分支的历史合并成一条线,保留分叉痕迹。rebase是把当前分支的提交"重放"到目标分支之上,历史更干净,但操作不当容易改写历史,建议等对 Git 有更深理解再碰。
第一次遇到冲突的时候,我当时有点懵:
Auto-merging UserService.java CONFLICT (content): Merge conflict in UserService.java冲突的本质是:两个分支修改了同一个文件的同一行,Git 无法自动决定该听谁的。解决办法不是慌,而是打开那个文件,找到 Git 留下的冲突标记:
<<<<<<< HEAD private String name = "当前分支的版本"; ======= private String name = "另一个分支的版本"; >>>>>>> feature-login你需要手工决定保留哪一段(或者两段都保留),把<<<<<<<、=======、>>>>>>>这些标记行删掉,然后重新git add、git commit。处理冲突的黄金法则是:看不懂的冲突不要乱选,去问改了这段代码的人。你可以用git log查看两个分支在这几行的历史提交,搞清楚各自改动的意图再决定。
5.3 提交信息规范:commit message 为什么值得认真写
热搜词里赫然有一条"git提交规范",说明这是所有 Java 开发者绕不开的话题。我见过太多这种提交记录:
fix bug 修改 提交 ?????这种 commit message 写在当时是明白的,过两周回来看基本是天文数字。规范的 commit message 应该让未来的你(或同事)只看一眼就知道这次改动的含义。目前业界使用最广的规范是 Conventional Commits,格式如下:
<type>(<scope>): <subject> <optional body>type:改动类型,常见有fix(修 bug)、feat(新功能)、refactor(重构)、docs(文档)、test(测试)。scope:影响范围,比如模块名、服务名,可以不写。subject:一句话描述,用祈使句。
举个例子:
fix(order): 修复订单回调时用户为空导致的空指针异常 feat(user): 新增用户分页查询接口 docs(readme): 更新项目部署说明这种提交信息的好处有两个:一是看历史时能快速定位到这次改动是修 bug 还是加功能,二是可以基于 type 做自动化的版本号管理和 changelog 生成。很多团队已经在 CI 流程里强制校验 commit message 格式,不合法直接拒绝提交。
5.4 .gitignore:别把 target 和 idea 目录传上去
刚开始用 Git 的时候,我执行了一次git add .,结果把整个target/目录(Maven 编译产物)和.idea/(IDE 配置)都提交上去了。后果是仓库里多了一堆无用的二进制文件和历史记录,每次构建后git status都会显示一堆文件变更,团队协作时还容易产生冲突。
正确做法是建一个.gitignore文件,把不需要纳入版本控制的内容写进去:
# 编译产物 target/ *.class # IDE 配置 .idea/ *.iml .vscode/ # 日志文件 logs/ *.log # 操作系统文件 .DS_Store # 环境配置(含敏感信息) application-dev.yml.gitignore生效的前提是:这些文件还没有被 Git 跟踪过。如果你之前已经git add过,那么即使写了.gitignore也不生效,需要先执行git rm --cached <file>把它们从暂存区移除,再重新提交一次。
6. 日志与 Git 的联动:形成自己的开发闭环
6.1 改代码前先看日志:先复现再动手
日志和 Git 看起来是两套独立工具,但实际开发中它们可以形成一套完整的闭环。我总结了自己最近半年比较顺手的流程,分享给你。
第一步,遇到线上问题,先去查日志。是空指针、超时、还是业务逻辑异常?日志里的堆栈信息、时间线、参数值就是问题现场。第二步,根据日志定位到出错的代码文件,用git log --oneline <filename>查看这个文件最近的改动历史。这一步特别关键,因为很多 bug 是某次改动引入的,找到那次提交再看 diff,问题原因往往就浮出水面了。
6.2 git log 回顾历史:从提交信息里找回上下文
我举一个前几天刚遇到的例子。有个接口突然变慢了,按经验先查日志,发现某条 SQL 的时间特别长。然后我执行:
git log --oneline -5 -- Mapper.xml结果看到一个提交:
a3f9d21 (refactor): 优化商品列表查询,拆分为多条SQL分批执行我大概就有数了——是前几天的重构把原本的一条复杂 SQL 拆成了多条,但没处理好 N+1 问题,导致每次查询多出几十次数据库往返。顺着这个线索,再看那次提交的git diff,问题就彻底清楚了。这个过程如果没有日志做时间定位、没有 Git 做变更历史回溯,基本只能靠猜。
这也是为什么 commit message 一定要写清楚。它的价值不是给别人看的,而是给未来某个深夜排查问题的自己看的。
6.3 一个完整的日常开发工作流
把所有东西串起来,我现在的工作流长这样:
- 开工前
git pull拉取最新代码,避免基于过期分支开发。 - 新需求拉一个功能分支:
git checkout -b feature/xxx。 - 开发过程中,在关键节点打印日志,用
DEBUG级别观察流程,提交前再决定哪些日志需要保留。 - 写完代码,
git diff检查改动,git status确认没有多余文件。 git add相关文件,用规范格式 commit。- 推送分支:
git push -u origin feature/xxx,然后在远程仓库发起合并请求。 - 代码合并后,如果线上出现问题,先从日志定位,再从
git log回溯变更。
这个流程最大的价值是每一步都有迹可循。日志告诉你"程序发生了什么",Git 告诉你"代码从哪里变成了这样"。两者结合,你就不会再面对一个报错信息手足无措。
7. 学习阶段的体会:这两个工具是"习惯"不是"知识"
最后分享一点我自己的感受。日志和 Git 这类工具,难的地方不在于"命令记不住"或"配置不会写",而在于把它们变成肌肉记忆。命令忘了可以查文档,但"遇到问题先看日志"、"提交前先 diff 一眼"、"commit message 写明白"这些习惯,是需要在日常开发中反复练习才能养成的。
我学 Git 的时候非常抗拒命令行,觉得有图形化工具就够了。后来发现,图形化工具虽然直观,但命令行才能让你真正理解仓库的内部状态。而且大多数服务器环境是没有图形界面的,git status、git log、git diff这几个命令顺手了,到哪里都能干活。
日志也是同理。一开始可以只用默认配置,但至少要养成三个习惯:不直接用System.out.println打业务日志;异常必须记入日志;每次代码改动前先想清楚"这里如果出问题,日志能不能帮我定位"。等这三条成习惯了,再去看各种高级用法:MDC 链路追踪、日志采集到 ELK、慢查询日志分析,自然就不会觉得无从下手。
这一期的内容就到这里。日志和 Git 可以说是 Java 入门阶段最"枯燥"但最值得花时间的两样东西,它们不会让你写出更炫酷的业务代码,但能让你在代码出问题时不至于一脸茫然。下一篇我打算把日志和 Git 继续往深挖一挖,比如多环境日志配置、Git 的 cherry-pick 和 stash 操作,都是实际开发里特别实用的技巧。