让我们设计一个 Solana 程序。Solana 生态系统提供了大量关于理解Solana 编程模型和创建小型演示程序的文章,这些文章是初学者学习 Solana 和 Anchor 的绝佳方式。然而,真正的程序有更复杂的考虑因素。
在本文中,我们将研究一个我们想要制作的真实程序,并涵盖:
1.将我们的数据定义为 Solana 帐户
2.在我们的数据之间建立关系
3.在程序中存储 token
4.使用索引来加速读取
5.使用分片加速写入
本示例的目的是帮助您提出自己的想法并了解如何将它们建模为 Solana 程序和帐户。
注意:本文假设您熟悉Anchor 的基础知识。
Solana 程序示例——构建预测市场
我们今天要设计的程序是一个类似于 Polymarket、Hedgehog 或Drift BET 的预测市场。如果您不熟悉预测市场,它们允许人们对一个事件的不同结果下注。这可能是哪支球队赢得超级碗,谁赢得“最佳导演”奥斯卡奖,政府是否会在特定时间之前宣布,或者任何其他具有特定结果的现实世界事件。如果人们押注获胜结果,他们将从赌注池中获得奖金。
预测市场架构
以下是预测市场的核心架构以及各个项目之间的关系:
有多个事件。
每项赛事都有多种结果。最终,其中一种结果将被确定为获胜结果。
用户对每个结果下注多个赌注。当用户下注时,他们的资金将被添加到该事件的赢奖池中。
当事件被“解决”时(即当我们知道获胜结果时):
押注获胜结果的用户将可以领取奖金
获胜者将获得奖金池的一部分(扣除赌场佣金)
每位获胜者的奖金池份额将取决于他们对获胜结果的下注份额。
1. 将我们的应用设计为 Solana 账户
如果我们设计这个程序来使用关系数据库,我们会考虑:
相似的数据项作为行存储在表中
用于唯一标识每条数据的主键
表的列定义了每个数据项的预期属性及其类型
在 Solana 中,这些概念大致映射到:
类似的数据项使用相同的帐户类型存储
地址用于唯一地标识每条数据
结构(键和数据类型)定义每个项目的属性
以下是相同的数据,在传统数据库表和 Solana 帐户中显示:
数据库将项目存储在表行中,由其键定义。Solana 程序将项目存储在账户中,由其地址标识。
传统数据库将每条记录存储为表中的一行,而 Solana 将每条记录存储为单独的帐户,类似的记录使用相同的结构。
2. 数据之间的映射关系
在 Solana 中,数据项之间的关系工作方式非常不同。传统数据库使用关系(即表之间的逻辑连接)。Solana 将一对多关系处理为地址向量,每个地址包含一个与该项目相关的帐户。
例如,事件有“结果”,即结果地址的向量。Anchor 将其显示为Vec<Pubkey>,尽管PDA 地址(如我们的结果)实际上并不是公钥。
传统数据库中的一对多关系使用传统的 1 到无限符号表示,而在 Solana 中使用 Vec<Pubkey> 指向帐户地址。
Solana 上的一对多数据记录存储为 Vec<Pubkey>,其中 Pubkeys 是关系中“多”方的地址。
3. 存储代币
与传统数据库不同,Solana 程序还可以在账户内存储资金(而不仅仅是余额数字)。
在我们的示例中,活动需要一个代币账户来存放其奖金池。活动的 PDA 将拥有奖金池账户。当用户下注时,他们会将代币发送到此账户,但更重要的是,当他们领取赌注时,我们的程序将以活动账户的身份签署交易,以将代币移出奖金池。
在我们的程序中,将权限设置为PDA账户的代币账户。
PDA 账户可以作为代币账户的授权方。当我们的 Solana 程序需要从该账户转移代币时,它可以使用 PDA 的种子对交易进行签名。
4.减少阅读量
我们的程序需要尽可能地让用户感到响应迅速,而作为开发人员,我们希望避免为不必要的帐户读取而支付不必要的费用。我们可以通过运行总计和索引使我们的程序响应更快、更高效。
计算累计总数
当用户领取奖金时,我们需要准确知道每个结果的下注金额。预测市场根据以下公式确定获胜用户的赔付金额:奖金池 × 下注金额 ÷ 获胜结果的总下注金额。
目前,我们只将每笔赌注的金额存储在赌注账户中。要获得特定结果的总赌注,我们必须读取每个赌注账户并将金额加起来。
相反,我们为每个结果添加一个字段——total_amount——并在用户下注时递增。这样,当用户获胜时,我们可以轻松确定支付金额,而不必阅读该结果的每一个赌注。
在我们的结果 PDA 中添加一个 total_amount,以存储对此结果的累计投注总额。
每个结果的总投注可以通过将该结果的所有投注相加来确定,但如果我们在添加新投注时保持总投注额的运行,我们的程序将响应更快。
使用索引
我们还需要找到特定用户帐户的所有投注。我们可以使用getProgramAccounts()获取每个投注帐户,并筛选出那些与该用户地址更匹配的帐户。虽然Helius 的快速 getprogramAccounts()比其他 RPC 提供商快得多,但索引是一种常见的替代方案。
因此,让我们创建一个索引来存储每个用户的赌注。当用户进行新的赌注时,如果此项目不存在,我们将创建该项目,并将该赌注添加到该用户的赌注列表中:
一个名为“用户”的新帐户,其种子使用用户的钱包地址和该用户投注的 Vec<Pubkey>
索引使我们能够快速解决常见的用户查询,例如向连接的用户显示其钱包进行的所有投注的列表。
我们还将为每个事件添加标签,这样我们就可以轻松找到所有带有“体育”、“政治”、“欧洲”、“美国”、“政治”等标签的事件。我们将为此创建另一个索引:
添加另一个新帐户,称为 Tags,其中包含标签名称的种子和匹配事件的 Vec<Pubkey>。
另一个常见的用户流程是获取符合特定标签的事件。我们可以通过添加另一个索引来加快该过程。
现在,我们可以通过查看事件标签帐户轻松检索所有感兴趣的事件。
5.减少写入争用
还记得每个事件都有一个单独的代币账户来存储该事件的赌注吗?每次用户添加新赌注时,代币都会被转移到这个账户中。换句话说,账户将被写入。
Solana 速度很快,因为它可以并行操作。但是,更新单个帐户的余额不能并行进行 - 它必须按顺序进行,因为每个帐户在任何给定时间都必须有一个余额。
如果宣布了一项新活动并收到大量投注,则许多写入操作将同时发生在该活动的代币账户中,而我们的程序的交易可能会看起来很慢。这称为写入争用 - 多个交易正在争夺对该账户的访问权限。
在这些情况下,实现并行性的一种方法是通过分片。资源被分成多个部分(称为分片),可以并行访问。
分片的工作原理
使用shard_num() 宏,根据获胜者公钥的最后一个字节的值,将传入付款发送到单独的win_pool分片。这可确保传入付款快速。
稍后,管理指令处理器可以将这些合并到单个赢池账户中,确保我们在同一个账户中拥有流动性来支付获胜者。
根据用户地址的最后一个字节,使用分片将用户发送到特定的代币账户。
分片会为某项创建多个副本或“分片”,并在每个用户和其分片之间建立确定性映射。然后,不同分片上的用户可以并行写入这些帐户。
如果热门活动最终确定,所有获胜者同时领取奖金,我们还需要考虑写入争用问题。我们还需要尽快从奖金池中转移资金。
最好的选择是避免索赔流程。相反,如果我们在活动结束后立即按顺序发送资金,用户就不必处理缓慢的索赔过程——他们的奖金将已存入他们的账户。
但是,您需要先真正拥有用户,然后才可以担心大量用户下注或领取奖金。如果您正在计划 Solana 程序但尚未启动,则无需进行优化来处理您没有的大量用户流量。如果您不需要启动此类大型优化,则应考虑此类优化的额外复杂性,并意识到当您的程序变得更受欢迎时,您可能需要实施这些优化。
结论
在本文中,我们深入探讨了如何在 Solana 上创建真实应用程序。现在,您已经掌握了定义 Solana 帐户、建立数据关系、存储代币以及使用索引和分片优化性能的实用知识。