交易池分析
core\txpool.go
前言
- 这篇文章是博主的朋友写的。
- 在写这篇文章的时候,笔者已经完完全全看完该代码文件的每一行代码,但是由于代码量过于庞大,所以自然存在边看边忘的情况,所以在此写下这篇文章记录自己的理解以及收获,笔者能力有限,错误之处在所难免,还望包容
- 由于该文件代码量过大,而且笔者还同时在看该仓库其他的代码文件,所以笔者决定分期写,一边阅读其他的源码文件,一边回顾
txPool.go
同时写下阅读笔记- 而且大部分的理解已经记录在源码文件中了,所以阅读源码中的笔记更能帮助理解
一些重要概念
可执行交易和非可执行交易
。可执行交易
是指从交易池中择优选出的一部分交易可以被执行,打包到区块中。非可执行交易
则相反,任何刚进入交易池的交易均属于非可执行状态,在某一个时刻才会提升为可执行状态。
本地交易
在交易池中将交易标记为 local 的有多种用途:- 在本地磁盘存储已发送的交易。这样,本地交易不会丢失,重启节点时可以重新加载到交易池,实时广播出去。
- 可以作为外部程序和以太坊沟通的一个渠道。外部程序只需要监听文件内容变化,则可以获得交易清单。
local交易
可优先于remote 交易
。对交易量的限制等操作,不影响local
下的账户和交易。
nonce
总结:- 以太坊中有两种
nonce
,一种是在区块中的nonce
,主要是调整挖矿难度;一种是每笔交易中nonce
。 - 每个外部账户(私钥控制的账户)都有一个
nonce
值,从 0 开始连续累加,每累加一次,代表一笔交易。 - 某一地址的某一交易的
nonce
值如果大于当前的nonce
,该交易会被放到交易池的queue
列表中,直到缺失的nonce
被提交到交易池中。 - 地址的
nonce
值是一个连续的整数,起设计的主要目的是防止双花。 - 在发生一笔交易时,如果不指定
nonce
值时,节点会根据当前交易池的交易自动计算该笔交易的nonce
。有可能会出现节点 A 和节点 B 计算的nonce
值不一样的情况。
- 以太坊中有两种
-
时隙 (
slots
) 和时段 (epochs
)-
信标链是以太坊 2.0 的心脏,它令以太坊系统在和谐与共识中有序运行。每个 slot 为 12 秒,每个 epoch 由 32 个 slots 组成,即 6.4 分钟。
-
Epoch 0 中的前 32 个 slots,创世区块在 Slot 0 中产生
-
在每个 slot 中,在信标链和分片中都可能新增一个区块。我们可以想象,信标链和分片链有序且紧密地排列在一起,当系统在理想情况下运转时,每 12 秒就有一个信标(链)区块和 64 个分片区块产生。验证者大致按照这个时间同步。
-
我们可以将一个 slot 看作是区块生成时间,不同的是 slots 内可以没有区块。信标链和分片的创世区块都在 Slot 0 中产生。分片将在信标链 epoch 0 的下一个 epoch 中开始运作,但无论是分片链还是信标链,都有自己的 epoch 0,且包含其创世区块。
-
一些 go 语言的奇妙用法
-
...
其实是 go 的一种语法糖。第一个用法主要是用于函数有多个不定参数的情况,可以接受多个不确定数量的参数。第二个用法是 slice 可以被打散进行传递。这个是一个关于该语法的一篇博客可以参考: Go 中…的用法
Go语言等待组(sync.WaitGroup)
对于我来说是一个几乎不曾见过的 go 语言的语法知识,所以在此进行查找记录一下,个人理解:该等待组在本 go 语言程序中的作用是调用wg.Wait()
时阻塞使得等在组里面的所有的 go 协程都运行完毕,然后才恢复,也是一种同步携程的方法。具体请看这篇文章:sync 包——WaitGroup
关于交易中的nonce
的深入剖析:
参考文章: 1. 一文讲清楚以太坊的 nonce 2. 以太坊交易中的 Nonce 详解
交易池源码解析 (core/txpool.go
)
前提参数
我们可以通过源码看到前面定义了一大堆参数,初看时毫无头绪,但是等你将>这两千行代码完整的看完之时,你基本上就可以理解大部分参数的含义了
- 以下的这些参数我还不理解,等会补充:
1 | const ( |
- 一些错误处理的
error
变量
1 | var ( |
- 度量参数(或者说是计数器)
1 | var ( |
交易池配置
交易池配置不多,但每项配置均直接影响交易池对交易的处理行为。配置信息由 TxPoolConfig 所定义,相关的解释已经在源码中
1 | // TxPoolConfig are the configuration parameters of the transaction pool. |
以上有解释过的默认配置(如: PriceLimit=1
PriceBump=10
)在下面已经被定义成常量:
1 | var DefaultTxPoolConfig = TxPoolConfig{ |
该结构体被用于Txpool
中:
1 | // TxPool contains all currently known transactions. Transactions |
就像上面的交易配置,再结合下面的图像:
我们可以发现,以太坊将交易按状态分为两部分:可执行交易和非可执行交易。分别记录在pending
容器中和 queue
容器,交易池先采用一个 txLookup
(内部为 map)跟踪所有交易。同时将交易根据本地优先,价格优先原则将交易划分为两部分 queue
和 pending
。而这两部交易则按账户分别跟踪
Txpool
初始化
**func NewTxPool(config TxPoolConfig, chainconfig *params.ChainConfig, chain blockChain) *TxPool **
1. 检查配置,配置有问题的话就用默认配置初始化
config = (&config).sanitize()
2. 初始化本地账户:
pool.locals = newAccountSet(pool.signer)
相关函数为详情为:
1 | func newAccountSet(signer types.Signer, addrs ...common.Address) *accountSet { |
3. 将配置的本地账户地址加入进去
pool.locals.add(addr)
我们在安装以太坊客户端可以指定一个数据存储目录,此目录便会存储着所有我们导入的或者通过本地客户端创建的帐户 keystore 文件。而这个加载过程便是从该目录加载帐户数据
4. 更新交易池:
1 | //reset检索区块链的当前状态,并确保交易池的内容相对于链状态有效。 |
更新交易池使用的reset
函数非常关键,我们要进行讲解:
首先是reset
函数的目的是:
-
对应
oldHead=nil
的情况时:一般发生在刚创建交易池的时候,我们会用
chain.CurrentBlock().Header()
(就是当前的区块头)来进行替换,说是复制也可以,达到reset
函数的目的; -
对应
oldHead!=nil
的情况时:
发生原因: 由于以太坊是分布式系统,当本地节点已经挑选出最优的交易,准备广播给整个网络,这个时候矿工已经打包了一个区块,本地节点的区块头就是旧的了,本地筛选的交易有可能已经被打包,如果已经被打包生成了新区块,再将这个交易广播已经没有任何的意义,甚至我们费尽心思准备好的 pending 缓冲区里的交易都是无效的。
解决方法: 为了避免以上的情况发生我们就需要监听链是否有新区块产生,也就是ChainHeadEvent
事件(相关调用的函数为runReorg
函数),监听到之后就要回退,现在这里不是我们这讨论的范畴;
具体代码就是这样完成的:1
2
3// Subscribe events from blockchain and start the main event loop.
//在交易池启动后,将订阅链的区块头事件
pool.chainHeadSub = pool.chain.SubscribeChainHeadEvent(pool.chainHeadCh) -
pool.wg.Add(1)
关于这行代码的解释请看 go 语言语法知识go语言等待组
那一块,这里不再讲解; -
go pool.scheduleReorgLoop()
文件给出的解释是这样的,与后面加载本地日志的操作相呼应Start the reorg loop early so it can handle requests generated during journal loading.
-
如果本地交易开启 那么从本地磁盘加载本地交易:
1 | // If local transactions and journaling is enabled, load from disk |
- 开启主循环
1 | //启动事件循环并返回 |
到此初始化结束
添加交易到交易池
交易池中交易的来源一方面是其他节点广播过来的,一方面是本地提交的,追根到源代码一个是
AddLocal()
,一个是AddRemote()
,不管哪个都会调用addTxs()
。所以我们对添加交易的讨论就会从这个函数开始,然后逐步引出全局
先看下面这一张图简要说明一下操作的流程:
- 首先是遍历整个
交易map(txs)
,将其中已经存在的和无效签名的交易过滤出去,注意同时其中要进行相关数据的记录
1 | for i, tx := range txs { |
- 将交易进行添加的操作:
1 | //上锁防止冲突 |
于是乎我们就必须进入addTxsLocked()
函数中去了解其进行的操作,代码不长,于是全部放在下面:
1 | // addTxsLocked attempts to queue a batch of transactions if they are valid. |
我们发现除了一些初始化和计数器的操作,还有一个至关重要的add()
函数,它是将交易添加到queue
中,等待后面的promote
,到pending
中去。如果在queue
或者pending
中已经存在,并且它的``gas price`更高时,将覆盖之前的交易。我们来了解一下该函数的具体操作步骤:
以下按照源码顺序编写,所以不贴源码了,请对照源码观看
-
过滤交易池中已有的交易,记住是通过
hash值
进行的判断,因为即便两笔交易nonce
值一样,hash
值也断然不会相同; -
判断
local
标记,并进行共识性验证validateTx()
validateTx: 主要做了以下几件事
[ ]交易大小不能超过 32kb- 交易金额不能为负
- 交易 gas 值不能超出当前交易池设定的 gaslimit
- 交易签名必须正确
- 如果交易为远程交易,则需验证其 gasprice 是否小于交易池 gasprice 最小值,如果是本地,优先打包,不管 gasprice
- 判断当前交易 nonce 值是否过低
- 交易所需花费的转帐手续费是否大于帐户余额 cost == V + GP * GL
- 判断交易花费 gas 是否小于其预估花费 gas
-
如果交易池已满,丢弃价格过低的交易,注意这边的
GlobalSlots
和GlobalQueue
,就是我们说的pending
和queue
的最大容量,如果交易池的交易数超过两者之和,就要丢弃价格过低的交易。 -
进入一个重要的
if
语句进行判断:- 判断当前交易在
pending
队列中是否存在nonce值
相同的交易。存在则判断当前交易所设置的gasprice
是否超过设置的PriceBump(为10)
百分比,超过则替换覆盖已存在的交易old==nil
和nil
,否则报错返回false
和错误信息替换交易gasprice
过低;无论如何返回,该函数都已经在此处退出。 - 不存在的话就把它扔到
queue
队列中(通过enqueueTx()
函数)。
- 判断当前交易在
-
对该笔交易进行一些
local
的操作。 -
交易晋升
接着我们还是回到
addTxs()
这个函数中来,我们发现我们又是遇上一个极为重要的函数requestPromoteExecutables()
,下面进行相关的剖析: