一次与AI的"设计对话":审计日志降级方案从1000行到350行

24 阅读12分钟

一次与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沉默了。

是的——pendingarchivingarchived 本质上就是同一个文件的不同阶段。文件存在就代表待补偿,文件不存在就代表已完成。

用目录区分状态,有点多余。

我又问:"状态字段是不是也多余了?"

"状态字段确实可以用来标记 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倍间隔后重试
  - ⚠️ 不再继续尝试,避免大量记录被误写入死信

数据库恢复:
  - 下次尝试成功 → 清空失败记录
  - 恢复正常补偿节奏
  - 从熔断位置继续补偿,未写入死信的记录正常保存

双重价值

  1. 保护数据库,避免持续压力
  2. 减少故障期间误写入死信的记录数量

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天以上的文件自动清理
    └── ...

核心设计:

  1. 一个Properties文件,记录 文件名=已补偿行数
  2. 所有 .log 文件一视同仁,不需要区分状态
  3. 滚动时原子迁移索引
  4. 当前文件也能被实时补偿
  5. 失败记录写入死信文件,永不丢失
  6. 熔断机制保护数据库

最终成果:

  • 代码量:~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编程工具,希望我的经历能给你一些参考。


完整仓库地址:

github仓库地址 gitee仓库地址