程序员的时间机器


时间机器可能是在科幻小说中出现次数最多的终极武器,但同时它也是最不可能被实现的机器。

现在我给万能的时间机器加上两个限制条件

  1. 它只能回溯过去,但不能展望末来。
  2. 它只能观测,而不能修改历史。

这台阉割版的时间机器依然强大,有了它,历史上的任何疑难悬案都可以迎刃而解。这就好像在世界上的每一个角落都安装了监视摄像头,仔细想想,它似乎并不是那么遥不可及。我不知道哪天人类会发明它,也不知道这是福是祸,但我知道它对程序员一定很有用。

其实,今天大部分程序员都已经在使用一台时间机器,它的名字叫“Git”。软件代码的版本控制可以说是至今为止软件工程上最伟大的发明之一,有了它我们可以放心大胆的修改代码。因为我们知道过去的代码不会消失,如果有需要,我们总能在代码的提交历史中把它们找回来。

不过代码只是程序的一个部分,在我看来,程序有三个主要的组成部分,除了程序代码外,还有程序运行时的状态,以及它的数据存储(例如程序所用到的数据库)。虽然在代码这一层,我们已经可以做时间旅行了。但后面两个部分,我们是否也能打造相应的时间机器呢?这就是我们今天要分享的话题。

不可变的数据

要实现时间机器,我们需要保存历史上发生过的所有状态。这里就必须提到一个重要的概念,那就是不可变数据(Immutable Data)。它的核心概念是数据就像历史一样,不能被改变或是销毁,它描述了在过去的某个时间点上对象的状态。但我们可以添加新的数据,代表了在一个新的时间点上对象的状态,所以对象的状态是可以改变的,但数据一旦生成就不能再更改。一个对象从创建开始,每次状态改变都会生成一个新的数据,而原有的数据虽然不再使用,但也不会被销毁。这样一来,要查看一个对象在某个特定时间点上的状态,就变得非常简单了,所以不可变数据是实现时间机器的基础。

可惜在传统的编程语言中,大部分数据结构都是可变的,比如当你向一个数组添加一个元素时,数组的内容就发生了变化。此后,我们就再也访问不到变化之前的那个数组了。

不过近年来,不可变数据的优点被越来越多的人发现。这个曾经只在函数式编程语言中使用的概念,开始被主流编程语言所接受,并开始被广泛地使用。比如,Facebook就开源了著名的 Immutable.js,从此不可变数据进入了 Javascript 的生态圈。在它的主页上有一段对于不可变数据的概述,说得非常到位。如果我之前那段你读得有些懵,可以再看看以下这段描述

Immutable collections should be treated as values rather than objects. While objects represent some thing which could change over time, a value represents the state of that thing at a particular instance of time.

程序运行状态的时间机器

“最难调试的 Bug 就是那些无法每次都重现的 Bug”,这句话我想大部分程序员都深有体会。如果你有一个时间机器,可以把时间精确地拔回错误出现的那一刻,去观察当时的程序状态,以及后续发生的所有事件,那世界该有多美好。

在前端开发的世界里,这个问题已经被 Dan Abramov 大神解决了,他的方案就是大名鼎鼎的 Redux 框架。Dan 大神不但做了框架,就连时间机器本身都在他的 redux-devtools 里替你实现了。如下图所示,你可以拖动时间轴来观察程序的历史状态,不但包括程序的内部数据状态,就连界面的每一次更改都可以看得清清楚楚

Redux Devtools Time Machine

对于 Redux 还不熟悉的同学,这里简单介绍一下它的工作原理。

  • 整个程序的状态,统一地由一个 Application State 对象管理,它的值是一个不可变数据。
  • 程序状态的改变必须通过 Action。这里的 Action 可能是用户的输入事件,可能是网络请求事件,或是其他自定义事件。一个 Action 在要更改程序状态时,会生成一个新的 Application State,而不是更改现有的。
  • 程序的界面完全由当前的 Application State 来决定。

Redux 的开发工具会记录下程序执行历史中所有的 Action 以及与其对应的 Application State。所以 Redux 不但能还原程序每一刻的状态,还能还原与每个状态相关联的 Action,这相当于开启了调试者的上帝视角,如下图所示。真正的好东西往往就是这样,如此简单,却又如此强大。

Redux action and state

最近在 JS 的生态圈里又看到了另一个有趣的项目 Automerge,它主要解决的问题是程序状态在多个设备间的同步。它可以用来管理应用程序状态,本身也是基于不可变数据的,每次更改都会生成新的数据实例。有趣的是,它在提交更改时还可以附一个 Commit Message,并支持用 getHistory 方法来查看一个 Automerge 对象历史上所有的更改记录。是不是很有 Git 的味道?

无论是 Redux 还是 Automerge,它们都可以被用来打造查看程序运行状态的时间机器。但这些技术更适用于前端,而不是后端,因为在后端的世界,服务程序本身往往只有很少量的状态,绝大部分的状态在数据存储层,也就是数据库或是文件之中。这里要用到不同的技术,也是我们下节要讨论的重点。

数据存储的时间机器

程序的稳定运行固然重要,但比程序崩溃更严重的是,数据出错或是丢失。这里我指的不是由于数据库系统崩溃造成的数据问题,而是由于程序的逻辑错误导致的。全库的数据备份可以一定程度上解决这个问题,比如你想要了解某条数据在一个月前是什么值,或许你可以调出当时的备份来查看,但对于大型数据库来说,恢得备份的代价是很大的。如果你想知道某条数据在历史上的所有更改,这时备份就帮不上忙了,而是需要为数据库配备一个时间旅行功能。

在如今的大数据时代,我们不但想记录事情的结果,更恨不得将用户的一举一动全部记录下来,比如我们不但想记录用户买单时购物车里有什么,还想知道在购物过程中用户曾经在购物车里放进哪些商品。这时,你会更想要一个数据库的时间机器,来分析用户的历史数据。

在前文中,我们已经了解,时间机器的基础是不可变数据,那有没有基于不可变原则的数据库呢?其实目前如日中天的区块链技术算是一个,但你要真用来它来代替 MySQL,那还是算了吧。

Datomic 数据库是这个领域中的佼佼者。这个名字对于大部分人来说是陌生的,但它的作者就是 Clojure 语言的缔造者 Rich Hickey。在 Clojure 语言中所有的数据结构都是不可变的,所以 Rich 基于同样的理念写了 Datomic 这个不可变的数据库。

Datomic 有多牛呢?可以说它就是数据库版的 Git。Datomic 是分布式的,没有中心服务器集群。它是不可变的,数据库从创建开始的所有历史都会被记录下来,每一个事务都可以写 Commit Message。它有自己的一套声明式的查询语言,与 SQL 不同,但也非常强大。但由于这个数据库是商用闭源的,并且与 Clojure 有着千丝万缕的关系,使得它在 Clojure 社区之外影响力有限。

大家都明白,数据库迁移的代价是很大,很多大型系统根本不可能从现有的 SQL 关系型数据库里迁出来,所以就算 Datomic 很酷,我们也只能看着流流口水。那有没有办法在不换数据库系统的前提下,达到我们的目的呢?答案是肯定,这里提供三种思路

  1. 自己在 SQL 数据库上实现版本系统,给每条记录添加版本号,每次改动产生一条新版本的记录。
  2. 创建单独的日志表,在每次数据变更时,向日志表添加一条更改记录,记录操作类型,以及更改后的值。
  3. 利用数据库自带的变更日志,来保存记录的所有历史版本。

1)的实现代价最大,只可能用于新项目。2)由于在 SQL 中 INSERT 与 UPDATE 语句都不会返回更改后的记录值,所以需要用额外的查询去读取更改后的记录值,这会对数据库产生额外的开销,实现起来也并不容易。3)利用数据库自身的日志系统,我认为是开销最小的方案,但具体实现方式因系统而异,下面简单介绍一下在 MySQL 中如何实现。

首先要打开 MySQL 的 binlog,并将格式设为 row。

log-bin = /path/to/log
binlog_format=row

这样 MySQL 就会将数据库中每一行的插入或是更新都完整记录下来,这就是我们想要的数据库更改历史。但因为 binlog 是二进制格式,所以还要有一个工具来提取 binlog 里记录,并将它们保存到目标存储里。这里的目标存储可以是另一个数据库,或是分布式文件系统(如 HDFS)。

Maxwell 是 Zendesk 公司开源的一个读取 MySQL binlog 内容,并发送到 Kafka/Redis/RabbitMQ 等消息队列中的工具。配置很简单,官网上也写得很清楚,这里就不再赘述。对于每条数据更新,它的输出是一个 JSON 对象,例如

mysql> insert into test.foo set id = 1, name = 'fire';
maxwell: {
  "database": "test",
  "table": "foo",
  "type": "insert",
  "ts": 1449786310,
  "xid": 940752,
  "commit": true,
  "data": { "id":1, "name": "fire" }
}

只需将 Maxwell 产生的这些 JSON 持久化存储,就可以查询原始数据库中任意记录在任意时间点上的值了,这样我们就为现有的 MySQL 数据库打造了一台时间机器。

人生的时间机器

在本文的最后,我来开一个脑洞,能为程序打造时间机器固然有用,但若能创建一部记录人生的时间机器岂不是更棒。其实我觉得这天或许不会太遥远,我们可以带上一副智能眼镜,将自己所见所闻一刻不停的全部录成视频,通过无线网络将这些视频流上传至云端永久保存,这样我们就可以随时调出人生的某一个时刻回顾。

不仅如此,因为每一段录像都带有时间与位置信息,利用 AI 还可以对录像的内容进行分析,例如识别出录像中的人物,场景或是提取出录像的主题。这样我们就可以在时间线上做各式各样的搜索。比如像我这样丢三拉四的人,下次再找不到东西,就可以直接搜索与该物品相关的录像画面,这就解决了我人生的一大痛点。

上面提到的大部分技术都已经实现了,缺少的只是每时每刻做第一视角记录的能力,我期待这一天的到来。但在拥有开挂的人生之前,我还是建议大家平日里多写写日记,记录下自己的生活点滴与突发奇想,这是每个人现在就可以拥有的低配版的人生时间机器。

今天的分享就到这里,欢迎大家留言讨论。