一个让我抓狂的问题

上周我在调试一个 bug,花了两个小时才发现——问题在昨晚某个时间点一个配置文件被悄悄修改了。我当时就想:如果有一个工具能告诉我"昨晚 10 点到 11 点,我的系统到底发生了什么",该多好。

我为什么选择用 Go 来做这件事

我之前只会使用Python、Java,Go这个语言其实对我来说是完全陌生的存在,我对Go的了解最初也是从我的同事那里听到的,他说我要是有空闲的时间就试着学学Go,我也不懂Go的魅力在什么地方。 我也是抱着试一试的心态去学习Go,毕竟项目驱动学习是让我觉得学习一个新东西是最快的,并且也是最有成效的一个东西。 我先是用了大模型去进行查询Go的基础语法,看到 =: 这玩意不懂这跟Python、Java、C++这类的语法规则完全不一样,也不知道goroutine 和线程有什么区别,为什么错误处理要写if err != nil 而不是 try/catch。这就让我很抓狂了但是最后也是想着多学一点是一点的心态进行学习。

Event Sourcing:我学到的最重要的概念

在开始写代码之前,我以为 ChronoTrace 的存储逻辑很简单——每隔几秒扫描一次系统状态,把结果存起来就行了。 直到我真正想清楚需求:我需要知道某个文件是什么时候被删的,某个进程是什么时候启动的。定时快照做不到这件事。如果一个进程在两次快照之间启动又退出,它就像从未存在过一样。 这让我意识到,我需要的不是"记录状态",而是"记录发生的事情"。这就是 Event Sourcing 的核心。


先从一个你熟悉的例子说起 想象你的银行 App。 它显示的余额是 500 元——这是"当前状态"。但银行后台实际存储的不只是这个数字,而是一条条的流水记录:

1
2
3
4
5
存入  100 元  ←  事件1
取出  200 元  ←  事件2
存入  600 元  ←  事件3
─────────────
余额  500 元  ←  把所有事件加起来算出来的

余额不是直接存储的,它是从事件计算出来的。 这意味着什么?意味着你可以回答任何时间点的问题:三天前我有多少钱?某笔钱是什么时候进来的?如果只存余额,这些问题永远无法回答。 Event Sourcing 就是把这个思路用在软件系统里:不存储"现在是什么",只存储"发生了什么"。


用传统做法对比一下 如果 ChronoTrace 用传统方式设计,代码大概是这样:

1
2
3
4
5
6
7
8
// 每 5 秒扫描一次,覆盖写入当前状态
func poll() {
    for {
        state := scanSystem()
        db.Save(state)        // 上一次的状态被覆盖了
        time.Sleep(5 * time.Second)
    }
}

这个方案的问题很明显——两次扫描之间发生的所有事情都丢失了。一个进程在第 2 秒启动、第 4 秒退出,系统完全不知道它存在过。 Event Sourcing 的做法是:

1
2
3
4
5
6
7
// 每次发生变化,记录一个事件
wal.Append(Event{Type: "process_start", Payload: ...})
wal.Append(Event{Type: "file_create",   Payload: ...})
wal.Append(Event{Type: "process_exit",  Payload: ...})

// 当前状态 = 把所有事件重放一遍
state = replay(allEvents)

事件是不可变的,只追加不修改。当前状态是从事件"算出来"的,而不是直接存储的。

这个设计让什么变得自然

当我真正用 Event Sourcing 实现 ChronoTrace 之后,发现很多功能几乎是"免费"得到的: 时间回溯变得很自然。想知道昨晚 10 点系统是什么状态?找到 10 点之前最近的快照,然后把快照之后到 10 点的事件重放一遍就行了。不需要专门存储历史状态,历史状态本来就在事件序列里。 用公式表示就是: StateT=SnapshotT0+∑i=T0TEventiState_T = Snapshot_{T_0} + \sum_{i=T_0}^{T} Event_iStateT​=SnapshotT0​​+i=T0​∑T​Eventi​ 完整的操作历史也是免费的。事件日志本身就是系统发生过的所有事情,不需要额外的审计日志。 容灾恢复同样简单。程序崩溃了?WAL 文件还在,从最近的快照重新回放事件就能恢复到崩溃前的状态。

我真正理解它的那一刻 说实话,我第一次看到"Event Sourcing"这个词的时候完全不知道它在说什么。感觉是一个很高深的架构概念,和我这种初学者没什么关系。 真正理解是在写 StateApplier 的时候。这个模块的工作是:接收一个事件,把它"应用"到当前状态上。

1
2
3
4
5
6
7
8
func (a *StateApplier) Apply(e model.Event) {
    switch e.Type {
    case EventFileCreate:
        a.state.Files[info.Path] = info    // 状态变了
    case EventFileDelete:
        delete(a.state.Files, info.Path)   // 状态变了
    }
}

写完这段代码我突然意识到——状态只是事件的投影。真正的数据是事件,状态只是为了方便查询而缓存的计算结果。 这个认知转变对我影响很大。以前我写代码总是先想"这个数据应该存在哪里",现在我会先想"这个系统会发生哪些事件"。

它的代价

Event Sourcing 不是没有缺点,我在实现过程中也感受到了: 查询变慢了。想知道当前有哪些文件,不能直接读一个表,要把所有事件重放一遍。ChronoTrace 用快照(Snapshot)来解决这个问题——每隔 30 秒把当前状态存一份,查询时从最近的快照开始回放,而不是从头开始。 存储变多了。每一个事件都要持久化,运行时间长了 WAL 文件会很大。这也是为什么需要定期清理快照之前的旧事件。 但对于 ChronoTrace 这个场景来说,这些代价是值得的。我需要的正是完整的历史,而不只是当前状态。

一句话总结

做完这个项目我才真正理解——普通数据库里存的"当前状态"其实是一种信息损失,你用新状态覆盖旧状态的那一刻,历史就消失了。 Event Sourcing 是在说:把发生的每一件事都记下来,未来你会感谢自己的。

三个让我印象深刻的技术细节

写代码之前我以为技术细节是最枯燥的部分,写完之后发现恰恰相反——每一个"为什么要这样写"的背后,都藏着一个值得记录的认知转变。

一、json.RawMessage vs interface{}:一个让序列化变得混乱的选择

设计 Event 结构体的时候,我需要一个字段来存储不同类型事件的载荷数据——文件事件存文件信息,进程事件存进程信息。

最直觉的做法就是用interface()

1
2
3
4
type Event struct {
    Type    EventType   `json:"type"`
    Payload interface{} `json:"payload"`  // 什么都能放
}

看起来很灵活,我一开始也是这样写的。直到我运行测试程序发现输出的JSON长这样:

1
2
3
4
{
  "type": "process_start",
  "payload": "eyJwaWQiOjEyMzQsIm5hbWUiOiJiYXNoIn0="
}

payload 变成了一串 Base64 字符串。 原因是:Go 里 interface{} 在序列化时如果里面装的是 []byte,会自动做 Base64 编码。而我构造 Payload 时用的是 json.Marshal 返回的 []byte,所以被编码了两次——第一次序列化成 JSON,第二次又被当成二进制数据 Base64 了。 修复方式是换成 json.RawMessage :

1
2
3
4
type Event struct {
    Type    EventType       `json:"type"`
    Payload json.RawMessage `json:"payload"`  // 原始 JSON,不二次处理
}

json.RawMessage 本质也是 []byte,但它告诉 Go 的 JSON 库:这里面已经是合法的 JSON 了,直接嵌入,不要再处理。

1
2
3
4
{
  "type": "process_start",
  "payload": {"pid": 1234, "name": "bash"}
}

这件事让我意识到,Go 的类型系统其实在很多地方都有这种"语义类型"的设计——表面上是同一种底层类型,但不同的包装告诉编译器和库"请用不同的方式处理我"。


二、defer 放错位置:一个让 goroutine 悄悄失效的 Bug

写 FileCollector 的时候,我需要在函数退出时关闭文件监听器。很自然地写了 defer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (fc *FileCollector) Start(ctx context.Context, out chan<- model.Event) error {
    watcher, _ := fsnotify.NewWatcher()
    defer watcher.Close()  // 我以为这里是对的

    go func() {
        for {
            select {
            case event := <-watcher.Events:
                // 处理事件
            }
        }
    }()

    return nil  // Start 函数在这里返回
}

代码编译通过,运行也没有报错,但文件事件一条都收不到。 问题在于:defer 是在函数返回时执行,而不是在 goroutine 退出时执行。 Start 函数启动 goroutine 之后立刻 return nil,defer watcher.Close() 随即执行,watcher 被关闭了。goroutine还在后台跑,但它监听的 watcher 已经关门了,自然收不到任何事件。 修复方式是把 defer 移到 goroutine 内部:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
go func() {
    defer watcher.Close()  // goroutine 退出时才关闭
    for {
        select {
        case <-ctx.Done():
            return  // 这里退出,watcher 才关闭
        case event := <-watcher.Events:
            // 处理事件
        }
    }
}()

这个 bug 让我重新理解了 defer 的本质——它绑定的是函数的生命周期,不是代码块的生命周期。当函数和 goroutine 的生命周期不一致时,defer 放在哪里就变得至关重要。

三、阻塞写入让状态机悄悄失效:一个并发 bug 的排查过程

ProcessCollector 的逻辑是:每隔 3 秒轮询一次进程列表,和上一次的结果对比,找出新增和消失的进程。 逻辑很清晰,代码写完也能运行,但测试的时候发现一个奇怪的现象:process_exit 事件永远捕获不到。 加了调试日志之后发现,每次轮询 lastPIDs 里都是空的——更新 lastPIDs 的那行代码从来没有执行过。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 找新增进程
for _, pid := range currentPIDs {
    if !pc.lastPIDs[pid] {
        // 构造事件...
        out <- event  // ← 问题在这里
    }
}

// 找消失进程
for pid := range pc.lastPIDs { ... }

pc.lastPIDs = currentMap  // ← 永远跑不到这里

原因是:out <- event 是阻塞写入。当 channel 满了,这行代码会一直等待,整个 goroutine 卡在这里,后面的代码全部无法执行。 Mac 上系统进程有几百个,第一次轮询就把容量为 100 的 channel 塞满了,pc.lastPIDs = currentMap 永远跑不到,所以每次轮询都拿着空的 lastPIDs 和当前进程对比,所有进程每次都是"新进程",永远不会有"消失进程"。 修复方式是改成非阻塞写入:

1
2
3
4
5
6
7
8
9
select {
case out <- event:
    // 投递成功
default:
    // channel 满了,丢弃这个事件,但不阻塞
}

// 无论 channel 是否满,这行都能执行
pc.lastPIDs = currentMap

这个 bug 让我理解了 Go 并发里一个重要的原则:channel 写入阻塞不只是"慢",它会改变程序的控制流。 一个看起来无害的 out <- event,可能悄悄让后面所有代码都失效。


回头看这三个细节 这三个问题有一个共同点:代码编译通过,运行也没有崩溃,但行为完全不符合预期。 这种 bug 是最难排查的,因为没有报错信息,只有"结果不对"。排查的过程让我养成了一个习惯:当行为不符合预期的时候,先不急着改代码,而是先搞清楚"为什么会这样"——通常真正的问题藏在一个你以为自己理解、但其实没有理解的地方。

做完这个项目我得到了什么

四周前我对 Go 的理解是零。我知道它存在,知道它"性能好、并发强",但这些标签对我来说没有任何意义——就像你从没骑过自行车,别人告诉你"这辆车很好骑",你也无法真正理解这句话。 现在我能用它做出一个真正能跑的系统。这个变化比我预期的要大。

我学会的不只是语法 学一门新语言,最容易的部分是语法,最难的部分是思维方式。 Go 和 Python 最大的区别不是语法,而是它逼着你去思考一些 Python 帮你藏起来的问题: 这块内存谁负责释放?这个函数会不会阻塞?这两个 goroutine 同时写同一个变量会怎样? Python 的答案是"别担心,我来处理"。Go 的答案是"你来决定,但我给你工具"。 一开始这让我很不适应。后来我发现这种不适应是有价值的——它让我开始真正理解程序在运行时发生了什么,而不只是"代码能跑就行"。

channel 改变了我对并发的理解 做这个项目之前,我对并发的理解是:多个线程,共享变量,加锁保护。 Go 的 channel 给了我另一种视角:不要通过共享内存来通信,而要通过通信来共享内存。 这句话第一次看的时候我完全不懂。直到我写完 Pipeline Bus——采集器把事件丢进 channel,WAL 引擎从 channel 里取出来写盘——我才理解这句话的意思: 两个 goroutine 之间不需要共享任何变量,它们只需要约定好用一个 channel 传递数据。没有锁,没有竞争,数据自然地从一个地方流向另一个地方。 这个思维方式的转变,比学会任何语法都值钱。

我对"把项目做完"有了新的理解 做这个项目之前,我以为"做完"的标志是代码能跑。 做完之后我发现,能跑只是开始。 让我印象最深的是 ProcessCollector 那个 bug——代码编译通过,程序运行正常,但 process_exit 事件永远捕获不到。排查了很久才发现是 channel阻塞导致 lastPIDs 从未更新。 这种 bug 不会有报错,不会有崩溃,只有"结果不对"。解决它需要的不是更多的 Go 语法知识,而是真正理解程序在运行时发生了什么。 我意识到,写代码最难的部分不是让它跑起来,而是让它在各种情况下都按你预期的方式跑起来。 这个认知让我对测试的态度从"能不写就不写"变成了"这是必要的工作"。

一个意外的收获 我本来只是想学 Go。 但做这个项目的过程中,我顺带理解了很多以前只知道名字、不知道含义的概念:Event Sourcing、WAL、快照、管道架构、背压处理…… 这些概念不是 Go 独有的,它们是软件工程里通用的思想。以前我在面试题里见过它们,背过定义,但不理解为什么需要它们。 现在我理解了——因为我自己遇到了它们要解决的问题,然后用它们解决了问题。 这种理解和背定义完全不同,它会留下来。

最后 如果你也在考虑用做项目的方式学一门新语言,我的建议是: 选一个你自己真正想要的工具来做。 不是教程里的 Todo App,不是 GitHub 上随便 fork 的项目,而是一个你自己会用、自己想用的东西。 因为当你遇到困难想放弃的时候——你一定会遇到——“我在做一个我自己需要的东西"是唯一能让你继续下去的理由。 ChronoTrace 对我来说就是这样的项目。它不完美,还有很多可以改进的地方。但它是我做出来的,我知道它每一行代码为什么这样写,我踩过它每一个坑。 这就够了。


这个项目目前其实还有很多功能有待开发,比如 网络收集器(NetCollector)、一个简单的webUi用来体现时间轴、对应时间的文件哈希判断是否进行过修改等。如果有感兴趣的话可以fork该仓库来进行开发,我觉得这个项目用来初学go是个很好的项目。

项目地址:https://github.com/sunshine-aura/ChronoTrace.git