Skip to content

第三章:让数据审计变容易

ch03

在第二章里,游戏公司Q对它的服务进行了改造,允许用户以数字签名的方式注册和登录系统。这次升级相当成功,大量老玩家纷纷尝试新的系统。不仅如此,原来不太信任Q公司(担心自己密码会泄漏),但是喜欢Q公司游戏的潜在用户也打消了顾虑,于是Q公司的用户数量和DAU(每日活跃用户量)都暴增。

但是Q公司的热度也吸引了监管部门的注意,毕竟其游戏影响力较大。为了获得更好的游戏体验,大量用户花钱充值Q币,然后去游戏里购买装备。这些钱加起来是一笔不小的数目,如果Q公司随意动用这些钱,用户很可能会蒙受损失。另外一个方面,许多玩家也表示了担忧,害怕Q公司会随意增发Q币,导致自己的装备大幅贬值。

基于以上这些原因,监管部门派审计人员入驻Q公司,他们会定期对Q币进行审计。而Q公司为了重新获得用户的信任,也表现出了积极配合的态度。经过仔细的设计,Q公司打算再一次改造自己的服务,让Q币的每一次流动都有迹可循。本章将详细介绍Q公司是如何做到这一点的。

积分系统

为了更好理解这一章的内容,我们可以先做个场景代入:假设自己是少儿编程班的老师,会通过发放积分卡的方式,鼓励学生积极参与课堂互动。当学生积攒到一定数量的积分后,就能用这些积分卡兑换礼物,比如小零食、小玩具、小文具等。为了清晰追踪积分的流转,我们会用一张表格记录每一次积分的发放与兑换情况,这张表格的大致形态如下图所示:

credits

从上面这张表里,我们能获得哪些信息呢?以张三为例,我们能看到他已经来上了两次课,每次课后各获得一张100分的积分卡。之后他用这两张积分卡(共200分)兑换了一份150分的小礼物,兑换后还得到了一张50分的积分卡作为找零。不过,想知道张三现在手里还有多少积分,从表格里很难一眼看出来。但只要顺着表格里每一行关于他的积分发放、兑换、找零记录逐笔追踪计算,就能得出最终的积分余额。

要是你能理解这个积分例子,那就能轻松看懂本章的核心知识点:UTXO记账模型。两者的核心逻辑是相通的,唯一差别在于:老师是在纸上记录积分的发放、兑换与找零操作,而Q公司则是把类似的交易行为(比如Q币的转账、消耗、找零)记录在数据库中,本质都是对“资产流转过程”的追踪记录。

解决方案

应对审计的办法说起来也简单:像上面这个积分系统一样,把每一笔账都记下来。但做起来又没那么简单,至少很难让人信服。比如说,Q公司可以把所有涉及到Q币余额变动的行为都记在一张数据库表里,这的确可以满足审计的需求。但是这个办法明显是有问题的,毕竟这张表也是存在Q公司的数据库里的。例如,谁能保证Q公司不去修改其中的某些记录?谁能保证Q公司不会删除其中的某些记录?或者谁又能保证Q公司不会凭空捏造一些记录?就算Q公司不会去这样做,万一黑客攻破Q公司的数据库干了这些坏事怎么办?

大家有没有觉得这些问题有点似曾相识?没错,这个问题基本和密码泄漏问题如出一辙。既然问题差不多,那么解决方案应该也类似。回忆一下,在第二章中,我们是怎么解决密码问题的?是的,用数字签名。于是Q公司再次拿起数字签名这把大锤子,砸向这些钉子。由于这次的钉子比较棘手,所以我们分两章来解决它。这一章先解决如何记账的问题,下一章再解决如何防止账本被篡改的问题。

在正式介绍解决方案之前,我们还需要再进一步梳理一下Q币的流动行为。经过一番简化以后,Q公司把Q币的流动初步归纳为下面这四种:

一,充值。用户花钱充值,系统给用户增加一定数量的Q币。 二,提现。系统扣除用户一定数量的Q币,然后把相应的钱退还给用户。 三,消费。用户在游戏内购买装备等物品,系统扣除用户一定数量的Q币。 四,转账。用户A给用户B转Q币,系统扣除A账户一定数量的Q币,然后转给B账户。

是不是已经很清晰了?然而Q公司却觉得还不够好,所以引入了系统账户的概念。这个特殊的账户会在上线时持有一个很大的虚拟Q币发行量(例如十亿枚)。于是又经过一番简化以后,Q公司把Q币的所有流动行为都统一当作转账来处理:

一,转账。用户A给用户B转Q币,系统扣除A账户一定数量的Q币,然后转给B账户。 二,充值。同上,只是用户A恰好是Q公司的系统账号,用户B则是普通账户。 三,提现。同上,只是用户B恰好是Q公司的系统账号,用户A则是普通账户。 四,消费。同上,只是用户B恰好是某个游戏内商店的账户,用户A则是普通账户。

不难看出,不管Q币如何流转,总的发行量是不变的,而且所有账户的余额加起来应该正好等于总发行量。那怎么改变总发行量呢?我们后面再讨论这个问题。下面我们来看一下Q公司是如何记录以上这些行为的。

添加新表

首先,前面的思路肯定是对的,我们需要一张新的数据库表,把所有的转账交易都记录下来。但是具体怎么设计这张表,以及怎么表示转账交易,可是有讲究的。这张表存放的是全部的转账数据,所以我们可以把它叫做转账表。在没有歧义的情况下,我们也可以称这张表为交易表,里面的每一行数据叫做一个交易记录,简称记录(Item)。需要注意的是,记录和交易未必是一一对应的关系,很可能多条连续的记录对应一笔交易。我们来看一下Q公司设计的交易表的结构,如下所示:

ID输入列表输出连续标志签名是否已花掉
..................
12745公钥X,100010x####...
1288,21,79公钥A,500010x####...
129128公钥B,200000x####...
130128公钥C,300010x####...
..................

上面这张表一共有六列,中间三行展示了其中的一小部分数据。笼统点说这张表就是一个Q币的账本,但是要想看懂它还是有点难度的。下面我来逐列解释一下,我会尽量举一些例子帮助你理解它们:

一,ID。这个列没啥好说的,表示记录ID。它从1开始递增,永远不会重复或者后退。比如上面表里给出的四行数据,它们分别表示第127号、第128号、第129号,和第130号记录。

二,输入列表。这一列可以存储一个(不重复的)列表,列表里的每一项都是一个(没有被花掉的)记录ID。比如第128号记录,它有三个输入,分别是第8号、第21号和第79号记录。

三,输出。这一列里存放两项数据:公钥和Q币数量。比如第129号记录,它里面存储了公钥B(的数据,例子里写文字是为了方便描述)以及2000

四,连续标志。这一列只能是0或者1,用来把属于同一笔交易的记录(必须连续存储)联系起来。比如第128号记录自己就是一笔交易,但是第129和第130号记录一起构成另外一笔交易。

五,签名。把ID、输入列表、输出、连续标志都拼在一起(1),然后生成一个数字签名(2),存到这一列里。那么由谁来生成这个签名呢?后面会讲。

六,是否已花掉。这一列只能是0或者1,表示该记录是否已经被花掉了。新写入的记录状态为0。比如上面的表格里展示的四条记录,只有第128号记录已经被花掉了,其余的都还没有。

解释完了,但好像又没解释。反正你听完以后,脑袋里蹦出来的很有可能是某个著名的问号脸!什么?输入?输出?记录还能被花掉?这都是些什么玩意!好吧,我承认这并不是你的问题。下一节我会结合具体的例子,再来解释一遍为什么要这样设计交易表,以及这些列起到的作用。整个交易记录的数据如下图所示:

txtable

转账交易

世间的道理都是相通的。老子说,“道生一,一生二,二生三,三生万物”,交易表也有点类似。我们先来看一下整个交易表的第一笔记录,Q公司的Q币铸造(Mint)交易,是什么样子的,如下表所示:

ID输入列表输出连续标志签名是否已花掉
1空空如也公钥Q,10亿1Q的签名
..................

通常来说,输入列表是不能为空的。但是有一种情况例外,那就是这笔交易是Q公司签名的。这种情况下,Q公司实际上是在增加Q币的流通量,通俗点说,就是在“印钱”。我们先假设Q公司不会随意这么做,下一章会彻底解决这个问题。从输出可以看出,Q公司初始发行了十亿枚Q币。由于只有一个账户,所以所有账户的总余额也是十亿。

假设用户A是第一个吃螃蟹的人,最先充值了一万枚Q币,此时交易表看起来是下面这个样子:

ID输入列表输出连续标志签名是否已花掉
1空空如也公钥Q,10亿1Q的签名
21公钥Q,9.9999亿0Q的签名
31公钥A,1万1Q的签名
..................

根据前面的介绍可知:第一,记录#1已经被标记为“已被花掉”,它不能继续被使用了。第二,由记录#2#3的连续标志可知,它俩构成了同一笔转账交易(从Q到A)。我们还可以猜测出:第一,输入列表里的记录必须存在,且此前不能是“已被花掉”状态。第二,输入列表里的记录,以及新的记录,都必须由同一个私钥签名。第三,隶属于同一笔转账交易的记录必须连续存储,且输入列表必须一致。第四,这笔交易所有输入记录的金额之和,必须等于它产生的所有新记录的金额之和。一图胜千言,让我来把这两笔交易画出来,如下图所示:

tx2

如果你把Q币想象成面额不限的纸币,上面的交易就好理解了。Q公司要付给A用户一万枚Q币,可它手上只有一张面额十亿的大钞,又没办法从上面撕下一角,于是只能把整张都花掉,重新开出两张:一张面额一万的给A用户,另一张面额9.9999亿的找零给自己。这和你在便利店用一百元买一瓶水是一个道理,区别只在于现实中是收银员找钱给你,而在这里,找零是付款方在同一笔交易里开给自己的。我们再来看一笔交易。假设用户A马上就又充值了一万枚Q币,此时交易表看起来是下面这个样子:

ID输入列表输出连续标志签名是否已花掉
1空空如也公钥Q,10亿1Q的签名
21公钥Q,9.9999亿0Q的签名
31公钥A,1万1Q的签名
42公钥Q,9.9998亿0Q的签名
52公钥A,1万1Q的签名
..................

我把目前为止全部的交易记录都画出来,如下图所示:

tx3

我们最后再来看一笔交易。假设用户A把自己的两万枚Q币全部转给用户B,此时交易表看起来是下面这个样子:

ID输入列表输出连续标志签名是否已花掉
1空空如也公钥Q,10亿1Q的签名
21公钥Q,9.9999亿0Q的签名
31公钥A,1万1Q的签名
42公钥Q,9.9998亿0Q的签名
52公钥A,1万1Q的签名
63,5公钥B,2万1A的签名
..................

前面的交易都是用掉一个记录,产生两个记录。新的这笔交易是反过来的,用掉两个记录,产生一个记录。到目前为止全部的交易记录包含的所有信息如下图所示:

tx4

如果你还是不太懂,那也没关系,也不影响后面的理解。在下一小节我会继续介绍交易、交易记录和账户表的更多细节。

记账模式

我们重新来思考一下怎么知道某个人有多少钱,以及怎么花钱这两个古老的问题。我们先回到几百年前,那个时候人们主要还在用银子当作一般等价物。比如说张三说自己有十两银子,怎么证明呢?好办,让他拿出来称一下并且验一下成色就可以了。如果他想花八两银子从李四那里买一头牛,他需要把这锭银子拿出来,分成两份。一份二两自己留着,一份八两给到李四,俩人一手交钱一手交货。这种古老的方式被称为银子模型,如下图所示:

silver

后来的纸币模型其实和银子模型也是差不多的,只是纸币只有少数几个固定面额,很难像银子那样随意分割。二十一世纪二十年代的我们显然不用这么麻烦了,我们甚至连纸币都不用了,直接扫码支付。我们大部分的资金都存在银行里,而这些钱在银行里就只是数据库里的一串数字而已。如果张三想花200元从李四那买一件衣服,只要点点手机就行了,银行可以在一瞬间完成这笔交易。像银行这样以账户为单位记录用户余额的方式叫做账户模型,如下图所示:

account

前面Q公司想出来的方案,显然受到了银子模型的启发,因为它看起来就像是电子版的银子模型。在前一节的例子里,我们可以把每一个交易都想象成一个魔法盒子,它会吃掉一些交易记录,然后吐出一些新的交易记录。我们把它吃掉的这些交易记录叫做输入(Input),把它吐出来的交易记录叫做输出(Output)。如果一个输出已经被当成输入给吃掉了,我们就说它已经被花掉了,否则我们就说这个输出是未花费交易输出(Unspent Transaction Output),简称UTXO。这种记账模型被称为UTXO模型,如下图所示:

utxo

索引服务

还记得我们在前一章讨论过的账户表吗?由于所有账户的Q币余额都是实时更新的,所以服务器想查看某个用户的余额是非常简单的,直接查表就行。但是改成UTXO模型后,我们需要扫描整张交易表,统计该账户相关的所有交易记录,这样才能知道他的余额。这也太麻烦了。不难想象,Q公司的服务器每天要处理大量的用户交易,所以需要存储海量的交易记录。如此庞大的交易表,扫描一遍是非常耗时的。

那Q公司怎么解决这个问题呢?答案是继续保留原来用户表中的余额列,但现在这一列存储的只是交易表的最新状态。如果交易表有一条新的交易记录被追加进来,比如用户A给用户B转100个Q币,用户表中A和B的余额也会分别减少和增加100。这么一改,用户表中的余额信息就是冗余的了,即使全部抹掉,也可以通过交易记录表恢复,它的存在只是为了加速查询。现在,我们称用户表对交易表的余额信息进行了索引(Indexing)。

顺便再说一下,其实交易记录表的状态列也是可有可无的,它的存在也只是为了快速查看某条记录是否已经被用过了。如果没有这个列,我们也可以通过扫描整张交易表知道这个状态。像余额一样,其实Q公司也可以把状态列从交易记录表里删掉,另外增加一个索引表来追踪这个状态的变化。这个细节并不影响整体的逻辑,这里就不深入讨论了。

本章小结

为了让用户相信自己的Q币余额不会被随意更改,也为了拥抱监管,Q公司把记账模式从账户模型改成了UTXO模型。现在用户的每一笔Q币转账都是登记在册的,而且这些交易记录都有用户的签名,Q公司既无法更改也无法伪造。虽然UTXO模型导致用户余额的追踪变得低效,但是Q公司也通过增加索引表等解决了这个缺点。

但是新的模型还是存在很多问题的。第一,虽然Q公司无法修改或者伪造交易记录,但是可以随意删除交易记录。比如说某用户充值了一万Q币,但是Q公司悄悄地抹掉了这笔记录,那用户就百口莫辩了。第二,虽然Q公司宣称自己只在初始的时候发一笔铸币交易,让Q币流通量恒定,但是谁又能保证Q公司不会违背诺言,让Q币通胀呢?第三,Q公司可以拒绝记录某些交易。比如说Q公司可以把某个公钥加入黑名单,导致对应的用户再也无法正常游戏,甚至都无法提现。最后,如果审计公司不慎(例如机器出现故障)把整个交易表都丢了怎么办?

以上前两个问题我们会在下一章讨论,后两个问题留到第五章再说。

本书以 CC0 1.0 协议发布,可自由使用