一次与AI的"设计对话":审计日志方案从1000行到350行
我最近用AI编程工具做了一次系统设计,在原有审计日志系统之上增加了日志降级逻辑,具体内容可参考:2026最简最强操作日志实现
不是那种"AI帮我写代码我复制粘贴"的使用方式,而是真正把它当成一个设计伙伴,反复对话、质疑、追问。
结果挺有意思的:方案从1000行简化到350行,可靠性反而更高了。
我想把这个过程完整记录下来,既是复盘,也是想聊聊:在AI时代,工程师真正值钱的能力是什么。
01
先说背景。
有个审计日志组件,会丢数据。
你可能觉得,丢几条日志有什么大不了的?但如果我告诉你,这些日志是用来满足合规要求的,是审计追溯的重要依据——丢一条都可能说不清楚,你还会觉得无所谓吗?
反正我觉得有所谓。
所以我的目标很明确:任何情况下,日志都不能丢。
02
拿到需求,我的第一反应是什么?
整一套完整的降级体系。
我把需求描述给了AI编程工具,它很快给出了方案:
应用层
↓
内存队列(Disruptor/BlockingQueue)
↓ 满/异常
本地文件降级(WAL模式)
├─ pending/ # 待补偿文件
├─ archiving/ # 补偿中文件
└─ archived/ # 已归档文件
↓
补偿线程(定时扫描 + 批量重放)
↓
数据库
配套还有一个复杂的索引文件:
{
"version": "1.0",
"files": {
"audit_pending_20260416_001.log": {
"totalLines": 1000,
"compensatedLines": 800,
"status": "COMPENSATING",
"lastRetryTime": 1713254500000,
"retryCount": 2
}
}
}
看起来挺完善的,对吧?
但我没有直接接受。
03
我盯着这个方案看了一会儿,心里有个声音:这套东西是不是太复杂了?
于是我开始问AI:
"你真的需要三个目录吗?"
AI想了想,说:三目录是为了区分文件的不同阶段。
我又问:"但这三个阶段,真的需要用目录来区分吗?"
AI沉默了。
是的——pending、archiving、archived 本质上就是同一个文件的不同阶段。文件存在就代表待补偿,文件不存在就代表已完成。
用目录区分状态,有点多余。
我又问:"状态字段是不是也多余了?"
"状态字段确实可以用来标记 PENDING、COMPENSATING、COMPLETED 三种状态..."
但问题是:如果文件存在就代表待补偿或补偿中,文件不存在就代表已完成,那显式的 status 字段是不是就多余了?
AI承认了。
我又问:"索引需要这么复杂吗?每个文件记录5+个字段,JSON解析和维护成本都不低。"
"确实...可以简化为键值对格式。"
第一轮对话结束:方案从"三目录+状态机"简化成了"单目录+无状态"。
代码量直接少了40%。
关键洞察:我们陷入了"完整性陷阱"——试图在设计阶段解决所有可能的问题,导致系统过度复杂。
04
但简化还没停止。
我又问AI:
"需要支持断点续传吗?"
AI给了两个选项:
方案A(极简):不记录补偿进度,每次失败从头重试。
方案B(折中):记录补偿进度,支持断点续传。
我开始算账:
- 数据库保存成功率 > 99.9%
- 就算失败,10MB文件重新读取也就几百毫秒
- 但为了支持断点续传,代码复杂度会增加不少
我的判断:不要断点续传。
理由很简单——省掉的复杂度,远大于浪费的那点IO。这是个划算的买卖。
第二轮对话结束:去掉断点续传。
关键洞察:记录 compensatedLines 是为了避免重复读取,而非为了可靠性。
05
我又问AI:
"你有没有考虑过,当前正在写入的文件不在索引里,会不会导致当前文件一直不触发补偿?"
这个问题AI之前没想到,赶紧分析了一下:
T1: 写入 audit_current.log (0 → 5000 行)
T2: 数据库恢复可用
T3: 补偿线程扫描目录
- 发现 audit_current.log
- 但索引中不存在该文件
- 跳过不处理 ❌
T4: 继续写入,直到达到 10000 行阈值才滚动
T5: 滚动后变成 audit_pending_001.log,才开始补偿
结果:这 5000 条日志延迟了很长时间才被补偿!
我的判断:允许补偿当前文件。
代价是需要用读写锁处理并发,但这个成本是可控的。
第三轮对话结束:当前文件也能被实时补偿。
06
还没完。
我又问了一个问题:
"文件滚动后,新文件名在索引里找不到,补偿时会不会从第0行开始读?"
这是一个容易忽略的边界情况。
如果不做处理:
T1: audit_current.log 写了 10000 行,索引记录 audit_current.log=10000
T2: 达到阈值,滚动文件
- rename: audit_current.log → audit_20260416_001.log
- 新建: audit_current.log
- 重置索引: audit_current.log=0
T3: 补偿线程扫描
- 发现 audit_20260416_001.log
- 索引中不存在该文件
- 从第 0 行开始补偿 ❌ 重复劳动!
T4: 启动时清理孤儿索引
- 发现 audit_current.log=10000 但文件不存在
- 删除该索引条目 ❌ 浪费
根本原因:滚动时没有同步更新索引中的键名。
我的判断:滚动时要同步迁移索引。
private void rotateFile() {
writer.close();
String newFileName = generateFileName();
currentFile.renameTo(new File(directory, newFileName));
// 关键:迁移索引
int compensatedLines = index.getCompensatedLines("audit_current.log");
index.atomicRenameKey("audit_current.log", newFileName);
currentFile = new File(directory, "audit_current.log");
writer = new BufferedWriter(new FileWriter(currentFile, true));
index.updateCompensatedLines("audit_current.log", 0);
index.flush();
}
第四轮对话结束:索引跟着文件走,避免重复劳动。
07
我又问AI:
"补偿过程中,如果某条记录始终保存失败怎么办?"
如果某条记录一直失败——可能是JSON格式错误,可能是数据库约束冲突,可能是业务逻辑拒绝——那补偿线程会怎么处理?
AI分析了一下如果不处理会怎样:
// 当前逻辑:失败后不更新索引
try {
storeService.batchSaveAuditLog(batch);
} catch (Exception e) {
log.error("Failed to save batch", e);
return false; // ❌ 下次扫描还是从同一行开始
}
结果:
- 第1次扫描:第100行失败,索引停留在99
- 第2次扫描:还是第100行失败,索引还是99
- 第N次扫描:无限重试,阻塞后续记录补偿 ❌
三个核心问题:
- 无限重试:同一条记录永远失败,浪费CPU和IO
- 阻塞后续:后面的正常记录无法被补偿
- 资源浪费:补偿线程持续尝试无效操作
我又问AI:"怎么解决?"
AI想了想,说:"可以加个最大重试次数,失败3次就跳过。"
但我追问:"跳过之后,这条记录就丢了?"
AI沉默了。
是的,如果只是跳过,失败的数据就真的丢了。但我们的目标是零丢失,不能让任何一条记录莫名消失。
我的判断:失败记录要写入死信文件,供后续人工处理。
08
AI继续给出了方案:
核心思路:极简失败追踪 + 死信队列
AI解释道:
"补偿线程是顺序向前的,不会回头重试历史行。因此,我们只需要追踪当前卡住的行,而非所有历史失败行。"
// 简化前(过度设计)
Map<String, Map<Integer, Integer>> failedLines
// fileName -> lineNumber -> retryCount
// 问题:内存占用大,补偿线程不会回头
// 简化后(极简设计)
Map<String, int[]> currentFailures
// fileName -> [lineNumber, retryCount]
// 优势:每个文件只占8字节,符合实际使用场景
工作流程:
第1次扫描:
- 从索引位置 startLine=100 开始
- 处理第101行 → 失败
- recordFailure(file, 101) → retryCount=1
- 不更新索引,返回 false
第2次扫描:
- 还是从 startLine=100 开始(索引未变)
- 跳过已成功的100行
- 处理第101行 → 又失败
- recordFailure(file, 101) → retryCount=2
第3次扫描:
- 还是从 startLine=100 开始
- 处理第101行 → 再失败
- recordFailure(file, 101) → retryCount=3
- isPermanentlyFailed = true
- 写入死信文件 ✅
- 更新索引到101(跳过该行)✅
- clearFailure(file) 清除记录
第4次扫描:
- 从 startLine=101 开始
- 继续处理后续行...
死信文件格式(JSON Lines):
{"originalLog":{...},"failureReason":"DataIntegrityViolationException: Duplicate key","failedAt":"2026-04-18T22:00:00","retryCount":3,"sourceFile":"audit_20260418_001.log","lineNumber":1234}
{"originalLog":{...},"failureReason":"ParseException: Invalid JSON","failedAt":"2026-04-18T22:01:00","retryCount":0,"sourceFile":"audit_20260418_001.log","lineNumber":1235}
AI补充道:
"死信文件会自动滚动(单文件≤10MB)和清理(保留10天),避免无限增长。"
第五轮对话结束:失败记录写入死信,永不丢失。
09
我又问AI:
"你再审视一下最终方案,看看有没有什么问题,或者可以改进的地方?"
AI沉默了一会儿,然后说:
"等一下,我意识到一个问题:如果补偿线程持续失败,我们会把大量记录写入死信文件。但这些记录真的是'永久失败'吗?还是只是因为数据库暂时不可用?"
我愣了一下。这是个好问题。
AI继续分析:
"如果数据库故障持续1小时,期间补偿线程一直在重试——每次重试失败都会把记录标记为'永久失败'并写入死信。但等数据库恢复后,这些记录其实都是可以正常保存的,只是被错误地写入了死信文件。"
关键洞察:熔断机制不只是为了保护数据库,还能在数据库故障期间减少误写入死信的记录数量。
AI给出了改进方案:
// 滑动时间窗口
private final ConcurrentLinkedQueue<Long> failureTimestamps = new ConcurrentLinkedQueue<>();
private static final int WINDOW_SIZE_MS = 60 * 1000; // 1分钟窗口
private static final double FAILURE_RATE_THRESHOLD = 0.5; // 50%失败率熔断
// 记录每次失败
public void recordFailure() {
failureTimestamps.add(System.currentTimeMillis());
cleanupOldFailures(); // 清理超时的记录
}
// 检查是否需要熔断
public boolean shouldCircuitBreak() {
long now = System.currentTimeMillis();
long windowStart = now - WINDOW_SIZE_MS;
// 计算窗口内的失败次数和总尝试次数
long failuresInWindow = failureTimestamps.stream()
.filter(t -> t >= windowStart)
.count();
// 如果失败率超过阈值,熔断
return failuresInWindow > totalAttemptsInWindow * FAILURE_RATE_THRESHOLD;
}
// 熔断后的行为
if (shouldCircuitBreak()) {
log.warn("Circuit broken, waiting for recovery...");
Thread.sleep(RETRY_INTERVAL * 10); // 10倍间隔再试
// 熔断期间不再继续补偿
// 等数据库恢复后,从熔断位置继续补偿
// 避免大量误写入死信文件
}
工作流程:
数据库开始出现故障:
- T1: 补偿失败 → 记录 failure timestamp
- T2: 又失败 → 记录 failure timestamp
- T3: 又失败 → 记录 failure timestamp
- ...
熔断触发(失败率>50%):
- 补偿线程暂停补偿
- 等待10倍间隔后重试
- ⚠️ 不再继续尝试,避免大量记录被误写入死信
数据库恢复:
- 下次尝试成功 → 清空失败记录
- 恢复正常补偿节奏
- 从熔断位置继续补偿,未写入死信的记录正常保存
双重价值:
- 保护数据库,避免持续压力
- 减少故障期间误写入死信的记录数量
AI总结道:"熔断不仅保护数据库,还能减少误判。,一举两得。"
我的判断:这个熔断机制有必要,而且实现起来不复杂。
第六轮对话结束:加熔断机制,保护数据库,减少误判。
10
最终方案长这样:
audit-log-fallback/
├── audit_current.log # 当前活跃写入
├── audit_20260416_001.log # 历史文件
├── audit_20260416_002.log
├── .compensation_index # 唯一索引
└── dead-letter/ # 死信目录
├── dead_20260418.log # 当前写入文件(<10MB),磁盘使用率超过50以上时,10天以上的文件自动清理
└── ...
核心设计:
- 一个Properties文件,记录
文件名=已补偿行数 - 所有
.log文件一视同仁,不需要区分状态 - 滚动时原子迁移索引
- 当前文件也能被实时补偿
- 失败记录写入死信文件,永不丢失
- 熔断机制保护数据库
最终成果:
- 代码量:~1000 行 → ~350 行(减少 65%)
- 索引结构:JSON对象 → Properties键值对
- 状态管理:3个目录+状态字段 → 无状态
- 失败处理:无限重试 → 最多3次+死信队列+熔断机制
11
写完这个方案,我复盘了一下,发现一件事:
AI给了很多方案,但每一个关键决策,都是我做的。
- 三目录要不要?→ 我决定不要
- 断点续传要不要?→ 我决定不要
- 当前文件要不要补偿?→ 我决定要
- 滚动时迁不迁移索引?→ 我决定迁移
- 失败记录怎么处理?→ 我决定写死信
- 熔断机制要不要?→ 我让AI审视方案后,AI提出来,我判断要
AI的价值在于快速探索可能性**,而人的价值在于判断哪个可能性是真正值得追求的。
更难得的是:熔断机制是我让AI审视最终方案后,AI主动发现的问题。它发现了"持续失败会被误写入死信"这个隐藏问题,并主动提出了解决方案。让AI审视自己的方案,是提升设计质量的高级技巧。
12
回顾整个设计过程,我经历了三个阶段:
第一阶段:过度设计
- 试图解决所有可能的问题
- 复杂的状态机和索引结构
- 教训:完整性 ≠ 可靠性
第二阶段:逐步简化
- 质疑每个设计决策的必要性
- 去掉冗余的状态和字段
- 洞察:简单性本身就是可靠性
第三阶段:极简确立
- 单一索引、无状态文件
- 滚动时迁移、实时补偿
- 死信队列、永不丢失
- 熔断机制、保数据库
- 成果:350 行代码实现零丢失保障
13
这篇是系列第二篇,讲的是一次真实的AI协作设计过程。
下篇文章《从这次实战聊聊:AI辅助设计,为什么人的判断力比以往更值钱》](juejin.cn/post/762972…) 我想聊聊:在这整个过程中,是什么让我做出了那些判断?AI时代,工程师真正稀缺的能力到底是什么?
这个系列是关于AI辅助设计的实战复盘和方法思考。如果你也用AI编程工具,希望我的经历能给你一些参考。
完整仓库地址: