Eclair背后的架构

  • ACINQ_
  • 发布于 2022-12-22 22:18
  • 阅读 22

本文介绍了闪电网络节点Eclair的架构,Eclair是基于Actor模型的,使其易于构建可靠且可扩展的软件。Eclair使用Scala语言和Akka库,并利用JVM的优势,实现了高性能和稳定性。此外,Eclair还提供了强大的插件系统和集群模式,可以支持大规模的节点。

总结:基于 Actor 模型的 eclair 架构,使其易于构建可扩展的可靠软件。它驱动着网络上最大的闪电节点,该节点已经可靠运行了近 5 年。

一个网络,多种实现

闪电网络是一个围绕共享开源规范构建的去中心化支付网络:闪电 BOLT

多家公司为该规范做出了贡献,其中最活跃的是 ACINQ、Blockstream、Lightning Labs 和 Spiral。规范的更改需要时间,并且需要这些团队达成共识,这保证了被接受的功能都经过了彻底的审查,并使闪电网络更高效、安全和方便使用。

但规范只是一个起点:它需要转化为可用的软件才能真正发挥作用。上面提到的四家公司都有自己实现闪电 BOLT 的软件:eclair (ACINQ)、cln (Blockstream)、lnd (Lightning Labs) 和 ldk (Spiral)。虽然这些实现各不相同,但它们都遵循相同的规范并且可以互操作,这就是我们最终得到单个闪电网络的方式,即使节点运行的是不同的软件。

每个实现都有其优点和缺点,这对整个网络来说是一件好事:用户可以根据他们最能接受的权衡以及他们想要使用闪电网络的方式来选择他们想要运行的软件。在本文中,我们将重点介绍 eclair,它是 ACINQ 节点 的实现,该节点是当今网络上最大的节点。自 2018 年初以来,我们的节点一直运行平稳,开通了数千个通道,转发了大量支付,并提供流动性服务,为非托管移动钱包用户提供良好的用户体验(以前是 eclair-mobile,现在是 Phoenix)。

人们经常问我们:在生产环境中运行这么大的节点一定非常困难,秘诀是什么?他们通常会感到失望,因为我们的答案是它真的没有那么难。Eclair 从一开始就设计为具有并发性、稳定性和水平可扩展性,这使得它在规模上非常易于部署和管理。让我们深入研究我们所做的技术选择来实现这一点。

Actor 模型

Actor 模型是一种并发计算模型,可以嵌入到编程语言中(例如 ErlangElixir),也可以作为现有语言的库来实现(例如适用于 JVM 的 Akka)。它的发明是为了在大型机器集群或多核机器上高效且安全地运行高度并行的任务,方法是消除一整类并发 Bug(没有共享的可变状态)。

Actor 是非常轻量级的进程,不与特定线程绑定,并且通过异步消息传递进行交互,从而无需锁。Actor 具有不暴露给其他 Actor 的可变内部状态。当接收到消息时,Actor 运行一个同步消息处理程序,该处理程序可能会更新其内部状态,向其他 Actor 发送有限数量的消息以及创建有限数量的新 Actor。一个 Actor 一次处理一条消息,这可以防止数据竞争。

Actor 交换消息

上图显示了对等连接逻辑的简化版本,其中单例 Actor(交换机)接收“连接”消息以启动与其他闪电节点的连接,创建子 Actor 以实际处理 TCP 连接逻辑,并在稍后收到“已连接”消息(宣布已创建连接)时更新其内部状态。重要的是要强调,我们在此过程的任何步骤中都不需要任何同步或锁定机制:一切都以异步方式处理。将“连接”消息转发到对等 Actor 后,交换机已准备好处理其他不相关的消息。

这种编程模型迫使开发人员以异步方式处理每个操作,并考虑其他容易被忽略的极端情况,例如:如果我从未收到我发送的消息的响应会怎样?这是实现有限状态机的一个非常好的模型,它非常适合闪电网络。乍一看这似乎很难,但实际上编译器是一个有用的朋友,这使得它成为一种非常愉快的开发体验。生成的代码也很容易测试,并且可以很容易地重现细微的极端情况。

Actor 模型最重要的特性之一是位置透明性:由于 Actor 通过消息相互通信,因此这些 Actor 位于何处并不重要。它们可能位于同一进程内、同一机器上的不同进程中,甚至位于远程机器上。这由 actor 模型实现以原生方式处理:应用程序逻辑在发送消息时使用 actor “引用”,并且不需要关心消息如何到达其目的地(保证至多一次交付)。稍后我们将看到 eclair 如何利用这一点来轻松地进行水平扩展。

Scala,一种函数式编程语言

Eclair 是用 Scala 实现的,Scala 是一种在 JVM(Java 虚拟机)上运行的函数式编程语言。我们使用 Akka 库将 actor 模型与函数式编程相结合。这两种编程模型可以很好地协同工作,并且可以轻松地从简单、可组合的组件创建复杂的系统,同时保证正确性和高效的并发性。

函数式编程可以帮助开发人员专注于小型、范围窄的组件:数据不变性和对副作用的严格控制保证了这些组件不会因为竞争条件或被更高级别的组件滥用而最终处于意外状态。它还提供了非常丰富且富有表现力的类型系统。实际上,这意味着开发人员可以专注于找到正确的架构和数据模型来解决问题,然后在编译器的帮助下,实现就会变得非常轻松。它可以轻松地简洁地解决复杂的问题,这就是为什么我们的代码库比其他实现小得多的原因。

以下代码段显示了如何创建洋葱加密支付:请注意模式匹配和函数组合如何帮助创建简洁的实现,该实现易于检查其正确性。

/**
 * @param cmd             command to send the HTLC for this payment.
 * @param outgoingChannel channel to send the HTLC to.
 * @param sharedSecrets   shared secrets (used to decrypt the error in case of payment failure).
 */
case class OutgoingPaymentPacket(cmd: CMD_ADD_HTLC, outgoingChannel: ShortChannelId, sharedSecrets: Seq[(ByteVector32, PublicKey)])

/** Helpers to create outgoing payment packets. */
object OutgoingPaymentPacket {

  case class NodePayload(nodeId: PublicKey, payload: PerHopPayload)
  case class PaymentPayloads(amount: MilliSatoshi, expiry: CltvExpiry, payloads: Seq[NodePayload])

  sealed trait OutgoingPaymentError extends Throwable
  case class CannotCreateOnion(message: String) extends OutgoingPaymentError { override def getMessage: String = message }
  case class CannotDecryptBlindedRoute(message: String) extends OutgoingPaymentError { override def getMessage: String = s"expected route to $expected, got route to $actual" }
  case class InvalidRouteRecipient(expected: PublicKey, actual: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"expected route to $expected, got route to $actual" }
  case class MissingTrampolineHop(trampolineNodeId: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"expected route to trampoline node $trampolineNodeId" }
  case class MissingBlindedHop(introductionNodeIds: Set[PublicKey]) extends OutgoingPaymentError { override def getMessage: String = s"expected blinded route using one of the following introduction nodes: ${introductionNodeIds.mkString(", ")}" }
  case object EmptyRoute extends OutgoingPaymentError { override def getMessage: String = "route cannot be empty" }

  sealed trait Upstream
  object Upstream {
    case class Local(id: UUID) extends Upstream
    case class Trampoline(adds: Seq[UpdateAddHtlc]) extends Upstream {
      val amountIn: MilliSatoshi = adds.map(_.amountMsat).sum
      val expiryIn: CltvExpiry = adds.map(_.cltvExpiry).min
    }
  }

  /** Build an encrypted onion packet from onion payloads and node public keys. */
  def buildOnion(packetPayloadLength: Int, payloads: Seq[NodePayload], associatedData: ByteVector32): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
    val sessionKey = randomKey()
    val nodeIds = payloads.map(_.nodeId)
    val payloadsBin = payloads
      .map(p => PaymentOnionCodecs.perHopPayloadCodec.encode(p.payload.records))
      .map {
        case Attempt.Successful(bits) => bits.bytes
        case Attempt.Failure(cause) => return Left(CannotCreateOnion(cause.message))
      }
    Sphinx.create(sessionKey, packetPayloadLength, nodeIds, payloadsBin, Some(associatedData)) match {
      case Failure(f) => Left(CannotCreateOnion(f.getMessage))
      case Success(packet) => Right(packet)
    }
  }

  private case class OutgoingPaymentWithChannel(shortChannelId: ShortChannelId, nextBlinding_opt: Option[PublicKey], payment: PaymentPayloads)

  private def getOutgoingChannel(privateKey: PrivateKey, payment: PaymentPayloads, route: Route): Either[OutgoingPaymentError, OutgoingPaymentWithChannel] = {
    route.hops.headOption match {
      case Some(hop) => Right(OutgoingPaymentWithChannel(hop.shortChannelId, None, payment))
      case None => route.finalHop_opt match {
        case Some(hop: BlindedHop) =>
          // We are the introduction node of the blinded route: we need to decrypt the first payload.
          // 我们是盲路径的介绍节点:我们需要解密第一个payload。
          val firstBlinding = hop.route.introductionNode.blindingEphemeralKey
          val firstEncryptedPayload = hop.route.introductionNode.encryptedPayload
          RouteBlindingEncryptedDataCodecs.decode(privateKey, firstBlinding, firstEncryptedPayload) match {
            case Left(e) => Left(CannotDecryptBlindedRoute(e.message))
            case Right(decoded) =>
              val tlvs = TlvStream(OnionPaymentPayloadTlv.EncryptedRecipientData(firstEncryptedPayload), OnionPaymentPayloadTlv.BlindingPoint(firstBlinding))
              IntermediatePayload.ChannelRelay.Blinded.validate(tlvs, decoded.tlvs, decoded.nextBlinding) match {
                case Left(e) => Left(CannotDecryptBlindedRoute(e.failureMessage.message))
                case Right(payload) =>
                  val payment1 = PaymentPayloads(payload.amountToForward(payment.amount), payload.outgoingCltv(payment.expiry), payment.payloads.tail)
                  Right(OutgoingPaymentWithChannel(payload.outgoingChannelId, Some(decoded.nextBlinding), payment1))
              }
          }
        case _ => Left(EmptyRoute)
      }
    }
  }

  /** Build the command to add an HTLC for the given recipient using the provided route. */
  // 构建命令以使用提供的路由为给定的收件人添加 HTLC。
  def buildOutgoingPayment(replyTo: ActorRef, privateKey: PrivateKey, upstream: Upstream, paymentHash: ByteVector32, route: Route, recipient: Recipient): Either[OutgoingPaymentError, OutgoingPaymentPacket] = {
    for {
      paymentTmp <- recipient.buildPayloads(paymentHash, route)
      outgoing <- getOutgoingChannel(privateKey, paymentTmp, route)
      onion <- buildOnion(PaymentOnionCodecs.paymentOnionPayloadLength, outgoing.payment.payloads, paymentHash) // BOLT 2 requires that associatedData == paymentHash
      // BOLT 2 要求 associatedData == paymentHash
    } yield {
      val cmd = CMD_ADD_HTLC(replyTo, outgoing.payment.amount, paymentHash, outgoing.payment.expiry, onion.packet, outgoing.nextBlinding_opt, Origin.Hot(replyTo, upstream), commit = true)
      OutgoingPaymentPacket(cmd, outgoing.shortChannelId, onion.sharedSecrets)
    }
  }

}

JVM,一个非常被低估的运行时

Eclair 也非常快:上次已知的独立性能基准(尽管现在已经过时)将 eclair 列为处理支付速度最快的闪电实现。对于那些认为 JVM 速度慢的人来说,这会让他们感到惊讶,但现在是时候澄清一些关于这个被低估 run time 的误解了。

虽然基于 JVM 的程序通常比其他编程语言消耗更多的内存,但运行时性能通常与竞争对手(甚至是非垃圾收集的)相当或更好。这是 JVM 及其垃圾收集器几十年优化的结果。JVM 还使得在必要时可以轻松地与本机代码进行交互,这要归功于 JNI(Java 本机接口):eclair 使用它来调用 libsecp256k1 来执行所有加密操作。

JVM 被大量成熟的企业项目使用,并提供了一个非常丰富且经过实战考验的库和工具生态系统(IDE、调试器、代码分析器、分析器、监控等)。流行的 Java 库拥有数百万或更多的用户,并且已经使用了数十年,这确保了我们不必重新发明轮子,并且可以在有意义的时候使用可靠的现成组件。

函数式程序员从简单组件构建复杂系统的罕见镜头

插件

Eclair 提供了一个非常强大的插件系统:插件接收系统中主要单例 Actor 的 Actor 引用,并且可以随时向这些 Actor 发送他们想要的任何消息。他们还可以注册到事件流上的特定事件,允许他们对重要事件(通道创建、对等连接、支付中继等)做出反应。这为插件作者创造了无限的可能性!

但是这种广泛可能性的主要缺点是缺乏易于使用的文档:我们无法以一种简单、易于理解的方式轻松地记录每个参与者的交互。开发人员必须查看 Actor 接受的消息以及可能发回的消息,并通过浏览数据模型来发现什么是可能的。这为新开发人员设置了一个不可忽略的进入门槛,我们希望通过提供可以作为示例代码的官方插件来帮助插件编写者入门,从而改进这一点。

集群模式

默认情况下,eclair 在 JVM 的同一实例上创建所有参与者:对于不需要扩展到数万个通道和 TCP 连接的节点来说,这更有效。但我们也支持 集群模式,该模式在单独的“前端”机器上运行一些参与者,可用于扩展更大的节点。

这些前端机器处理连接管理(使用 BOLT 8 传输加密)和 Gossip 管理(BOLT 7),而后端机器处理通道和支付。这确保了使用最多带宽并且最容易受到 DoS(拒绝服务)攻击的组件可以放置在负载平衡器后面,甚至可以根据实时使用情况自动扩展(如果需要)。

我们的节点目前使用三台前端机器。这些机器没有满负荷使用,但是使用几台机器是验证集群模式架构并确定下一步可以改进什么的好方法。

ACINQ 节点的架构

当然,我们可以更进一步:eclair 创建的许多其他参与者可以分布在多台机器上,这使得单个逻辑节点可以扩展到数十万甚至数百万个通道(如果需要的话),而无需复杂的运营设置。

结论

由于多种原因,Eclair 是最不为人所知的实现:我们使用一种有点小众的编程语言(但它是一种很棒的语言!),有些人只是不喜欢 JVM,我们没有花足够的时间来解释我们做什么以及我们如何做,而且人们常常认为我们只专注于移动钱包开发(Phoenix)。但是我们节点的统计数据说明了一切:eclair 的可靠性使其成为一个出色的闪电 peer,这解释了我们在网络中的重要地位。从一开始,我们一直在构建闪电网络,并且始终专注于创建干净的、可用于生产的软件,以使点对点比特币支付变得快速、安全、可靠且任何人都可以访问。

我们希望本文有助于阐明 eclair 的工作原理、为什么我们的节点如此可靠,以及 eclair 如何设计为可扩展以适应大型企业。如果你正在运行一个大型节点,或者你正准备运行一个大型节点,我们希望你对我们如何能够帮助你更轻松地完成工作提出反馈。为了保持闪电网络的增长,我们需要不断扩展。试用 eclair,并继续构建!

  • 原文链接: medium.com/@ACINQ/the-ar...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
ACINQ_
ACINQ_
江湖只有他的大名,没有他的介绍。