見出し画像

【Jaguar's Blog 11】キャッシュを理解する

この記事は2024年1月10日にNEM/Symgolのコア開発者Jaguar氏によって投稿された記事「Understanding Caches」をChatGPTを用いて翻訳したものです。


Symbolでは、グローバルなブロックチェーンの状態は1つ以上のキャッシュに格納されています。いくつかの中核的なキャッシュがありますが、プラグインシステムを介して他のキャッシュを追加することもできます。キャッシュの実装はテンプレートが多用されており、理解するのが少し複雑かもしれません。それでは、モザイクキャッシュを調査して、それらがどのように機能するか理解してみましょう!


Cache Descriptor

すべてのキャッシュには、キャッシュを記述するディスクリプタが必要です。モザイクキャッシュの場合、ディスクリプタは次のようになります:

Nameはキャッシュの名前です。キャッシュデータベースが有効になると、キャッシュのデータはこの名前のディレクトリに保存されます。また、これはログで頻繁に使用されます。

すべてのデータキャッシュは、キーと値のマップとして構造化されています。KeyTypeはキーの型であり、ValueTypeは値の型です。これらの型にはいくつかの要件があります:

  1. ValueTypeのインスタンスが与えられた場合、対応するKeyTypeを導出する方法がなければなりません。この導出を行う関数はGetKeyFromValueです。このキャッシュでは、MosaicEntryはmosaicIdアクセサを介してモザイクIDを直接公開しています。

  2. ValueTypeは、バイナリバッファへのシリアル化およびバイナリバッファからの逆シリアル化をサポートする必要があります。

各Symbolキャッシュは、次の3つの部分で構成されています:

  • CacheType - 基本データを所有しています

  • CacheViewType - データへの読み取り専用ビュー

  • CacheViewType - データへのメモリ内書き込み可能なビューで、書き込み時コピーのセマンティクスを使用します

Serializerは、ValueTypeをシリアル化および逆シリアル化するための関数を提供します。PatriciaTreeはSerializerを使用してマークルパトリシアツリーを実装しています。

Key Type

KeyTypeは、キャッシュ内のデータを検索するために使用されるキーの型です。MosaicCacheの場合、これはMosaicIdとして定義されており、これは単純な64ビットの符号なし整数値です:

struct MosaicId_tag {};
using MosaicId = utils::BaseValue<uint64_t, MosaicId_tag>;

ℹ️ BaseValueは、異なる概念的な型の混合を防ぐために、プリミティブ型をラップしたものです。たとえば、MosaicIdNamespaceIdはどちらも64ビットの符号なし整数ですが、異なるものを表しています。それらを単純にuint64_tとして定義すれば、片方をもう片方に割り当てる際にエラーが発生しないでしょう。BaseValueを使用すると、この割り当てはコンパイル時エラーになります。

Value Type

ValueTypeは、キャッシュから返される値の型です。これはC++の型であり、単純な構造体ではありません。これにより、キャッシュと対話するコードが簡素化されます。MosaicCacheの場合、これは以下で定義されているMosaicEntryとして定義されています:

/// Tuple composed of a mosaic definition and its current state.
class PLUGIN_API_DEPENDENCY MosaicEntry : public MosaicEntrySupplyMixin {
public:
    static constexpr auto Is_Deactivation_Destructive = false;

public:
    /// Creates a mosaic entry around mosaic \a id and mosaic \a definition.
    MosaicEntry(MosaicId id, const MosaicDefinition& definition);

public:
    /// Gets the mosaic id.
    MosaicId mosaicId() const;

    /// Gets the mosaic definition.
    const MosaicDefinition& definition() const;

    /// Returns \c true if entry is active at \a height.
    bool isActive(Height height) const;

private:
    MosaicId m_id;
    MosaicDefinition m_definition;
};

MosaicEntrySupplyMixinは、モザイクの供給に関連するデータと関数を含むミキシンです。ミキシンの使用は必須ではありません。これらのデータと関数は直接MosaicEntryに定義することもできます。ミキシンを使用する利点は、関連するデータと関数をまとめてグループ化することで、より整ったコードになります。

覚えておいてください、モザイクは期限切れになる可能性があるアーティファクトです。これには値タイプに2つの追加の要件が加わります:

  1. Is_Deactivation_Destructive - 期限が切れたときに値が変更され、再ハッシュする必要がある場合にtrueに設定されるフラグ。モザイクの場合、これはfalseです。なぜなら、モザイクは刈り込まれないからです。名前空間とロックハッシュの場合、これはtrueです。なぜなら、これらの両方が刈り込まれるからです。

  2. isActive - 高さを受け取り、その高さで値がアクティブであればtrueを返す関数。

Cache

Symbolでは、各キャッシュは単に基本データへの異なるビューを提供するデータストアです。プラグインや拡張機能と連携している場合、キャッシュに直接アクセスすることはほとんどありませんが、キャッシュの動作を理解することは重要です。

Types

キャッシュは概念的にはキーと値のストアですが、最適化のために、通常は副次的なストアも持っています。重要なのは、キャッシュがプライマリストアのデータで完全に説明されていることです。実際、これがマークルパトリシアツリーを構築するために使用される唯一のデータです。セカンダリストアは通常、データをグループ化するために使用され、新しいデータを含んではいけません。言い換えれば、すべてのセカンダリストアはプライマリストアから再構築できる必要があります。

CacheTypes構造体は、キーと値のストアを記述するために使用されます:

/// Mosaic cache types.
struct MosaicCacheTypes {
public:
    using CacheReadOnlyType = ReadOnlyArtifactCache<BasicMosaicCacheView, BasicMosaicCacheDelta, MosaicId, state::MosaicEntry>;

// region secondary descriptors

public:
    struct HeightGroupingTypesDescriptor {
    public:
        using KeyType = Height;
        using ValueType = utils::IdentifierGroup<MosaicId, Height, utils::BaseValueHasher<MosaicId>>;
        using Serializer = MosaicHeightGroupingSerializer;

    public:
        static auto GetKeyFromValue(const ValueType& heightMosaics) {
            return heightMosaics.key();
        }
    };

// endregion

public:
    using PrimaryTypes = MutableUnorderedMapAdapter<MosaicCacheDescriptor, utils::BaseValueHasher<MosaicId>>;
    using HeightGroupingTypes = MutableUnorderedMapAdapter<HeightGroupingTypesDescriptor, utils::BaseValueHasher<Height>>;

public:
    using BaseSetDeltaPointers = MosaicBaseSetDeltaPointers;
    using BaseSets = MosaicBaseSets;
};

ここでは、独自のディスクリプタを持つセカンダリストアが見られます:HeightGroupingTypesDescriptor。セカンダリストアのディスクリプタはKeyTypeValueType、およびSerializerのみを必要とすることに注意してください。これらは以前に議論されたプライマリタイプディスクリプタと同じ意味を持っています。

PrimaryTypesはプライマリストアです。これは、キータイプがMosaicIdで値タイプがMosaicEntryMosaicCacheDescriptorから)のunordered_mapです。Mutableバリアントを使用する必要がある点に注意してください。なぜなら、MosaicEntryはミュータブルだからです。これにより、コピー時に書き込むセマンティクスが有効になります。utils::BaseValueHasherは、基本値のハッシュオブジェクトであり、STLのunorderedコンテナで使用できるようになっています。

HeightGroupingTypesはセカンダリストアです。これは、キータイプがHeightで値タイプがIdentifierGroup<...>(HeightGroupingTypesDescriptorから)のunordered_mapです。Mutableバリアントを使用する必要がある点に注意してください。なぜなら、IdentifierGroup<...>はミュータブルだからです。

Base Sets

基礎となるキャッシュデータは、1つ以上のストアで構成されています。これらのストア(プライマリおよびセカンダリのキーと値のストアとオプションのマークルパトリシアツリーストア)は、BaseSets構造体に格納されています:

struct MosaicBaseSets : public CacheDatabaseMixin {
public:
    /// Indicates the set is not ordered.
    using IsOrderedSet = std::false_type;

public:
    explicit MosaicBaseSets(const CacheConfiguration& config)
            : CacheDatabaseMixin(config, { "default", "height_grouping" })
            , Primary(GetContainerMode(config), database(), 0)
            , HeightGrouping(GetContainerMode(config), database(), 1)
            , PatriciaTree(hasPatriciaTreeSupport(), database(), 2)
    {}

public:
    MosaicCacheTypes::PrimaryTypes::BaseSetType Primary;
    MosaicCacheTypes::HeightGroupingTypes::BaseSetType HeightGrouping;
    CachePatriciaTree<MosaicPatriciaTree> PatriciaTree;
...

CacheDatabaseMixinは、基本となるキャッシュストレージの初期化を担当しています。通常、これはRocksDBになります(enableCacheDatabaseStorageがtrueの場合)。RocksDBのカラムファミリーの名前は、第二引数として提供されます。enableVerifiableStateがtrueの場合、追加のpatricia_treeカラムファミリーが自動的に追加されます。

IsOrderedSetは、データが順序付けられているかどうかを示すために提供する必要があります(std::true_typeまたはstd::false_type)。ここでは提供されていません。これは順序付きセットの刈り込みを許可するために必要です。

ℹ️ この記事では、最も一般的なタイプのキャッシュであるデータキャッシュを見ています。ただし、ハッシュキャッシュなど、いくつかのランタイムキャッシュも存在します。これらのキャッシュは時間的な性質を持っているため、刈り込みが必要です。また、それらはブロックチェーンの状態ハッシュに入力されていないので、比較的小さな有限のブロックを処理することで完全に再構築できます。

Cache Container

これで、すべてのディスクリプタが設定されたので、実際にキャッシュを定義できます。キャッシュは、BaseSetsに含まれるデータを包む単なるラッパーです。これにより、そのデータに異なるタイプのビューを作成できるようになります:

  1. View - データの読み取り専用ビュー。

  2. Delta - データの書き込み可能なビューで、変更をコミットして基本データを変更できるもの。重要なのは、すべての変更はコピー時に書き込むセマンティクスを使用して行われる点です。コミットされない場合、変更は拒否されます。たとえば、あるブロックがいくつかの有効なトランザクションと無効なトランザクションを含む場合、無効なトランザクションが拒否された後、すべてのメモリ内の変更が元に戻されます。

  3. DetachedDelta - データの書き込み可能なビューで、コミットできずに基本データを変更できないもの。このビューは最も重要なのはブロック生成中に使用されます。ハーベスタは、作成するブロックが有効であり、競合するトランザクションを含まないようにする必要があります。これを実現するために、ブロックを構築する際にブロックチェーンの状態に対してトランザクションを検証および実行します。

ビューを理解したので、MosaicCacheを見てみましょう:

/// Cache composed of mosaic information.
using BasicMosaicCache = BasicCache<MosaicCacheDescriptor, MosaicCacheTypes::BaseSets>;

/// Synchronized cache composed of mosaic information.
class MosaicCache : public SynchronizedCache<BasicMosaicCache> {
public:
    DEFINE_CACHE_CONSTANTS(Mosaic)

public:
    /// Creates a cache around \a config.
    explicit MosaicCache(const CacheConfiguration& config) : SynchronizedCache<BasicMosaicCache>(BasicMosaicCache(config))
    {}
};

これは非常にシンプルです。ほぼすべてのロジックはすべてのキャッシュで共通しており、リファクタリングされています。BasicCacheは4つの重要な関数を定義しています:

  1. createView - データの読み取り専用ビューを作成します。

  2. createDelta - データの書き込み可能なビューを作成し、変更をコミットして基本データを変更できます。

  3. createDetachedDelta - コミットできず、基本データを変更できないデータの書き込み可能なビューを作成します。

  4. commit - デルタビューの保留中の変更を基本ストレージにコミットします。

SynchronizedCacheは、すべてのデータビューにわたってリーダーライターロックセマンティクスを追加します。ビューの作成では、すべての場合でREADロックが取得されます。WRITEロックは、変更がすべて検証されてメモリ内で保留中の場合にのみ、commitの後に取得されます。コミットは効果的に変更をメモリからストレージにフラッシュします。

DEFINE_CACHE_CONSTANTSは、キャッシュフレンドリーな名前(Mosaic)からキャッシュIDとキャッシュ名を導出し、それらをキャッシュからアクセス可能にします。

Cache View

キャッシュビュー全体での類似点から、一般的なキャッシュ機能の大部分はミキシンにリファクタリングされています。たとえば、キャッシュがアイテムの存在を確認する機能を公開したい場合、containsミキシンを追加できます。

/// Mixin for adding contains support to a cache.
template<typename TSet, typename TCacheDescriptor>
class ContainsMixin {
private:
    using KeyType = typename TCacheDescriptor::KeyType;

public:
    /// Creates a mixin around \a set.
    explicit ContainsMixin(const TSet& set) : m_set(set)
    {}

public:
    /// Gets a value indicating whether or not the cache contains an element with \a key.
    bool contains(const KeyType& key) const {
        return m_set.contains(key);
    }

private:
    const TSet& m_set;
};

このミキシンは、セットへのconst参照をシンプルにラップして、それをセット上のcontains関数に委任するだけです。

モザイクキャッシュビューを見ると、それは単に多くのミキシンから構成されていることがわかります:

/// Mixins used by the mosaic cache view.
struct MosaicCacheViewMixins : public PatriciaTreeCacheMixins<MosaicCacheTypes::PrimaryTypes::BaseSetType, MosaicCacheDescriptor> {};

/// Basic view on top of the mosaic cache.
class BasicMosaicCacheView
        : public utils::MoveOnly
        , public MosaicCacheViewMixins::Size
        , public MosaicCacheViewMixins::Contains
        , public MosaicCacheViewMixins::Iteration
        , public MosaicCacheViewMixins::ConstAccessor
        , public MosaicCacheDeltaMixins::PatriciaTreeView
        , public MosaicCacheViewMixins::ActivePredicate {
public:
    using ReadOnlyView = MosaicCacheTypes::CacheReadOnlyType;

public:
    /// Creates a view around \a mosaicSets.
    explicit BasicMosaicCacheView(const MosaicCacheTypes::BaseSets& mosaicSets)
            : MosaicCacheViewMixins::Size(mosaicSets.Primary)
            , MosaicCacheViewMixins::Contains(mosaicSets.Primary)
            , MosaicCacheViewMixins::Iteration(mosaicSets.Primary)
            , MosaicCacheViewMixins::ConstAccessor(mosaicSets.Primary)
            , MosaicCacheViewMixins::PatriciaTreeView(mosaicSets.PatriciaTree.get())
            , MosaicCacheViewMixins::ActivePredicate(mosaicSets.Primary)
    {}
};

/// View on top of the mosaic cache.
class MosaicCacheView : public ReadOnlyViewSupplier<BasicMosaicCacheView> {
public:
    /// Creates a view around \a mosaicSets.
    explicit MosaicCacheView(const MosaicCacheTypes::BaseSets& mosaicSets) : ReadOnlyViewSupplier(mosaicSets)
    {}
};

PatriciaTreeCacheMixinsは、ミキシンのエイリアスを含むシンプルな構造体です。ミキシンは通常複数のテンプレート引数を取るため、これにより使用が簡略化されます。MosaicCacheViewMixinsはこれをさらに簡略化し、PatriciaTreeCacheMixinsに適切なテンプレート引数を提供します。

BasicMosaicCacheViewの定義では、選択されたミキシンが継承されていることに注意してください。MoveOnlyのインスタンスは移動できますが、意図せずにコピーすることはできません。コンストラクタは以前に説明したBaseSetsを受け入れ、継承されたミキシンを構築します。

最後に、MosaicCacheViewReadOnlyViewSupplierを使用して、asReadOnlyと呼ばれる関数を追加します。これにより、読み取り専用ビュー(ReadOnlyViewの型)が返されます。デルタビューは同様の関数を公開し、読み取り専用および書き込み可能なキャッシュビューの両方を問い合わせるコードを簡単に記述できます。

Cache Delta

キャッシュデルタビューの定義は、キャッシュ読み取り専用ビューと非常に似ているはずです:

/// Mixins used by the mosaic cache delta.
struct MosaicCacheDeltaMixins
        : public PatriciaTreeCacheMixins<MosaicCacheTypes::PrimaryTypes::BaseSetDeltaType, MosaicCacheDescriptor> {
    using Touch = HeightBasedTouchMixin<
        typename MosaicCacheTypes::PrimaryTypes::BaseSetDeltaType,
        typename MosaicCacheTypes::HeightGroupingTypes::BaseSetDeltaType>;
};

/// Basic delta on top of the mosaic cache.
class BasicMosaicCacheDelta
        : public utils::MoveOnly
        , public MosaicCacheDeltaMixins::Size
        , public MosaicCacheDeltaMixins::Contains
        , public MosaicCacheDeltaMixins::ConstAccessor
        , public MosaicCacheDeltaMixins::MutableAccessor
        , public MosaicCacheDeltaMixins::PatriciaTreeDelta
        , public MosaicCacheDeltaMixins::ActivePredicate
        , public MosaicCacheDeltaMixins::BasicInsertRemove
        , public MosaicCacheDeltaMixins::Touch
        , public MosaicCacheDeltaMixins::DeltaElements {
public:
    using ReadOnlyView = MosaicCacheTypes::CacheReadOnlyType;

public:
    /// Creates a delta around \a mosaicSets.
    explicit BasicMosaicCacheDelta(const MosaicCacheTypes::BaseSetDeltaPointers& mosaicSets);

public:
    using MosaicCacheDeltaMixins::ConstAccessor::find;
    using MosaicCacheDeltaMixins::MutableAccessor::find;

public:
    /// Inserts the mosaic \a entry into the cache.
    void insert(const state::MosaicEntry& entry);

    /// Removes the value identified by \a mosaicId from the cache.
    void remove(MosaicId mosaicId);

private:
    MosaicCacheTypes::PrimaryTypes::BaseSetDeltaPointerType m_pEntryById;
    MosaicCacheTypes::HeightGroupingTypes::BaseSetDeltaPointerType m_pMosaicIdsByExpiryHeight;
};

/// Delta on top of the mosaic cache.
class MosaicCacheDelta : public ReadOnlyViewSupplier<BasicMosaicCacheDelta> {
public:
    /// Creates a delta around \a mosaicSets.
    explicit MosaicCacheDelta(const MosaicCacheTypes::BaseSetDeltaPointers& mosaicSets) : ReadOnlyViewSupplier(mosaicSets)
    {}
};

PatriciaTreeCacheMixinsが再び使用されていますが、この時点では異なるテンプレート引数が提供されています。BaseSetTypeの代わりにBaseSetDeltaTypeが渡されています。さらに、カスタムのHeightBasedTouchMixinのエイリアスが追加されています。

BasicMosaicCacheDeltaBasicMosaicCacheViewに似ています。insertremoveをオーバーライドしていることに注意してください。これらの関数のカスタム実装は、モザイクIDを有効期限の高さでグループ化するセカンダリストアの更新を処理します。m_pEntryByIdおよびm_pMosaicIdsByExpiryHeightは、プライマリおよびセカンダリキー値ストアへのポインタであり、これらはコンストラクタで割り当てられます。

MosaicCacheDeltaReadOnlyViewSupplierを使用して、asReadOnlyと呼ばれる関数を追加します。これにより、読み取り専用ビュー(ReadOnlyViewの型)が返されます。重要なのは、この関数の戻り値の型がMosaicCacheViewと同じであることです。

ℹ️ 同じMosaicCacheDelta型がDeltaビューとDetachedDeltaビューの両方で使用されていることに注意してください。これにより、オブザーバはどちらかを選んでシームレスに作業できます。

Serializers

RocksDBをサポートするためには、各プライマリおよびセカンダリストアに関連するシリアライザが必要です。モザイクキャッシュの場合、以下のシリアライザが定義されています:

/// Primary serializer for mosaic cache.
struct MosaicEntryPrimarySerializer : public CacheSerializerAdapter<state::MosaicEntrySerializer, MosaicCacheDescriptor> {};

/// Serializer for mosaic cache height grouped elements.
struct MosaicHeightGroupingSerializer : public IdentifierGroupSerializer<MosaicCacheTypes::HeightGroupingTypesDescriptor> {};

プライマリおよびセカンダリストアのシリアライザは、値をストリームにシリアル化およびデシリアル化するためのSaveおよびLoad関数を定義する必要があります。Saveは値とストリームを受け取り、その値をストリームにシリアル化します。Loadはストリームを受け取り、最初の値をデシリアル化します。

プライマリおよびセカンダリストアのシリアライザにはわずかな違いがあります。プライマリシリアライザは常にバージョン付きデータを書き込みますが、セカンダリストアはそうではありません。プライマリシリアライザは常にブロックチェーンの任意の時点のデータを処理できるようにする必要があります。そうでなければ、ステートハッシュが壊れます。対照的に、セカンダリストアは常にプライマリストアのデータから完全に再構築できます。これをサポートするために、プライマリシリアライザはState_Versionも定義する必要があり、これはステートのバージョンを示します。

MosaicEntrySerializerを見直すと、Save、Load、およびState_Versionがすべて定義されていることがわかります:

/// Policy for saving and loading mosaic entry data.
struct MosaicEntrySerializer {
    /// Serialized state version.
    static constexpr uint16_t State_Version = 1;

    /// Saves \a entry to \a output.
    static void Save(const MosaicEntry& entry, io::OutputStream& output);

    /// Loads a single value from \a input.
    static MosaicEntry Load(io::InputStream& input);
};

SaveおよびLoadState_Versionには何もしません。MosaicEntrySerializerCacheSerializerAdapterでラップされていることに注意してください。このアダプタはState_Versionの書き込みと確認を処理します。また、MosaicEntrySerializerに渡されるストリームを自動的に準備します。

対照的に、IdentifierGroupSerializer<...>CacheSerializerAdapterでラップされておらず、State_Versionも定義されていません。

Patricia Tree

最後に、MosaicPatriciaTreeを見てみましょう:

using BasicMosaicPatriciaTree = tree::BasePatriciaTree<
    SerializerHashedKeyEncoder<MosaicCacheDescriptor::Serializer>,
    PatriciaTreeRdbDataSource,
    utils::BaseValueHasher<MosaicId>>;

class MosaicPatriciaTree : public BasicMosaicPatriciaTree {
public:
    using BasicMosaicPatriciaTree::BasicMosaicPatriciaTree;
    using Serializer = MosaicCacheDescriptor::Serializer;
};

BasicMosaicPatriciaTreeは、特定のテンプレート引数が提供されたBasePatriciaTreeのエイリアスです。BasePatriciaTreeは、マークルパトリシアツリーの実装をラップし、それにBaseSetと同様のコピーオンライトのセマンティクスを提供します。SerializerHashedKeyEncoderは、キャッシュキーをークルパトリシアツリーキーに変換するキーエンコーダーを定義します。モザイクキャッシュの場合、それはMosaicIdを受け入れてハッシュしてークルパトリシアツリーキーを生成します。PatriciaTreeRdbDataSourceは、RocksDBを使用してークルパトリシアツリーノードにアクセスする手段を提供します。

ℹ️ これまでに議論したすべてのキー値ストアは実際にはBaseSetsです。概念的には、BaseSetは標準のSTLコンテナをラップし、コピーオンライトのセマンティクスを追加します。実際には、これにはRocksDBへの要求の適切な転送など、少し多くのことが含まれます。非常にテンプレートの多いコードです。

ℹ️ なぜMerkle Patricia TreeキーとしてMosaicIdを直接使用するのではなく、キーエンコーダーが必要なのでしょうか? ハッシュは暗号的にランダムであるため、ハッシュを使用するとよりバランスの取れたツリーが得られ、性能が向上します。また、非常に不均衡なツリーを作成して性能を低下させる可能性のある攻撃を防ぎます。

最後に、MosaicEntryPrimarySerializerがツリーに格納されている値をシリアル化するために使用されていることに注意してください。重要なのは、これがRocksDBに値を書き込むために使用される同じシリアライザであるということです。同じシリアライザが両方の場所で使用されているため、常に一貫していることが確認できます。

Postscript

Symbolでのキャッシュの仕組みについてより理解していただけたことを願っています。また、頭痛がひどくなっていなければ幸いです!

この記事が気に入ったらサポートをしてみませんか?