🦅

Nostream のパフォーマンス改善に取り組んだ話

2023/12/21に公開

The English version of the article is here.
英語版に翻訳した記事はこちらです。
Story about an effort to improve the performance of REQ in Nostream

Nostr Advent Calendar 2023

Nostr Advent Calendar 2023に参加中です。
20日目はYutaroさんのNostr エクスペリエンスを最大化する方法は?、22日目は電子馬さんのです。

はじめに

Nostr は、私たちのコミュニケーションを豊かにする新世代の分散型ソーシャルネットワーク(SNS)です。
この革新的なシステムは、私たちが自由にメッセージやコンテンツを複数の Nostr リレーを介してやりとりすることで、利用者やクライアントから見た耐検閲性と耐障害性を高めて、個別の Nostr リレーのダウンがサービスとしての停止につながらないように見せています。

しかし、私が運営している Nostr リレーは、ある重要な問題に直面していました。それは、「WebSocketを介した検索リクエスト(REQ)の処理が遅れ、時間がかかってしまう」ということです。

私の運営している Nostr リレーのリスト

この記事では、この問題を解決するために私がどのように取り組んだか、その成果をお伝えします。

問題の詳細

JSONB 演算子 @> によるリレーサーバーのパフォーマンス問題

Nostr のリレーサーバーとクライアント間に NIP と呼ばれる仕様に基づいて、共通の取り決め (Nostrプロトコル)でイベントやリクエストを WebSocket 通信の中でやりとりしています。その中でも、クライアントが送信する「REQ」と呼ばれる検索リクエストは、サーバー側で多数のイベントの中から条件式に従ってフィルタリングして返却するという動きをしており、非常に処理として重たいものです。

残念ながら、使用している Nostream リレーの実装では、この処理を JSONB の @> 演算子を多用していました。この演算子の処理はテーブルのインデックスを活用できず、必ずテーブルの全行・全件を調べなければならず、特にデータが多いときは、サーバーの CPU 使用率が高騰してしまっていました。

スローログに現れる問題の SQL 文

問題の深刻さを示す一例として、スローログに頻繁に登場していた SQL 文の一部を以下に示します。このクエリは、特定のタグ(例えば #e#p )を含むイベントを検索する際に使用されていました。

(select * from "events" where "event_kind" in ($1) and ("event_tags" @> $2 or "event_tags" @> $3 or "event_tags" @> $4 or "event_tags" @> $5 or "event_tags" @> $6 or "event_tags" @> $7 or "event_tags" @> $8 or "event_tags" @> $9 or "event_tags" @> $10 or "event_tags" @> $11 or "event_tags" @> $12 or "event_tags" @> $13 or "event_tags" @> $14 or "event_tags" @> $15 or "event_tags" @> $16 or "event_tags" @> $17 or "event_tags" @> $18 or "event_tags" @> $19 or "event_tags" @> $20 or "event_tags" @> $21 or "event_tags" @> $22 or "event_tags" @> $23 or "event_tags" @> $24 or "event_tags" @> $25 or "event_tags" @> $26 or "event_tags" @> $27 or "event_tags" @> $28 or "event_tags" @> $29 or "event_tags" @> $30 or "event_tags" @> $31 or "event_tags" @> $32 or "event_tags" @> $33 or "event_tags" @> $34 or "event_tags" @> $35 or "event_tags" @> $36 or "event_tags" @> $37 or "event_tags" @> $38 or "event_tags" @> $39 or "event_tags" @> $40 or "event_tags" @> $41 or "event_tags" @> $42 or "event_tags" @> $43 or "event_tags" @> $44 or "event_tags" @> $45 or "event_tags" @> $46 or "event_tags" @> $47 or "event_tags" @> $48 or "event_tags" @> $49 or "event_tags" @> $50 or "event_tags" @> $51 or "event_tags" @> $52 or "event_tags" @> $53 or "event_tags" @> $54 or "event_tags" @> $55 or "event_tags" @> $56 or "event_tags" @> $57 or "event_tags" @> $58 or "event_tags" @> $59 or "event_tags" @> $60 or "event_tags" @> $61 or "event_tags" @> $62 or "event_tags" @> $63 or "event_tags" @> $64 or "event_tags" @> $65 or "event_tags" @> $66 or "event_tags" @> $67 or "event_tags" @> $68 or "event_tags" @> $69 or "event_tags" @> $70 or "event_tags" @> $71 or "event_tags" @> $72 or "event_tags" @> $73 or "event_tags" @> $74 or "event_tags" @> $75 or "event_tags" @> $76)) union (select * from "events" where "event_kind" in ($77) and ("event_tags" @> $78 or "event_tags" @> $79 or "event_tags" @> $80 or "event_tags" @> $81 or "event_tags" @> $82 or "event_tags" @> $83 or "event_tags" @> $84 or "event_tags" @> $85 or "event_tags" @> $86 or "event_tags" @> $87 or "event_tags" @> $88 or "event_tags" @> $89 or "event_tags" @> $90 or "event_tags" @> $91 or "event_tags" @> $92 or "event_tags" @> $93 or "event_tags" @> $94 or "event_tags" @> $95 or "event_tags" @> $96 or "event_tags" @> $97 or "event_tags" @> $98 or "event_tags" @> $99 or "event_tags" @> $100 or "event_tags" @> $101 or "event_tags" @> $102 or "event_tags" @> $103 or "event_tags" @> $104 or "event_tags" @> $105 or "event_tags" @> $106 or "event_tags" @> $107 or "event_tags" @> $108 or "event_tags" @> $109 or "event_tags" @> $110 or "event_tags" @> $111 or "event_tags" @> $112 or "event_tags" @> $113 or "event_tags" @> $114 or "event_tags" @> $115 or "event_tags" @> $116 or "event_tags" @> $117 or "event_tags" @> $118 or "event_tags" @> $119 or "event_tags" @> $120 or "event_tags" @> $121 or "event_tags" @> $122 or "event_tags" @> $123 or "event_tags" @> $124 or "event_tags" @> $125 or "event_tags" @> $126 or "event_tags" @> $127 or "event_tags" @> $128 or "event_tags" @> $129 or "event_tags" @> $130 or "event_tags" @> $131 or "event_tags" @> $132 or "event_tags" @> $133 or "event_tags" @> $134 or "event_tags" @> $135 or "event_tags" @> $136 or "event_tags" @> $137 or "event_tags" @> $138 or "event_tags" @> $139 or "event_tags" @> $140 or "event_tags" @> $141 or "event_tags" @> $142 or "event_tags" @> $143 or "event_tags" @> $144 or "event_tags" @> $145 or "event_tags" @> $146 or "event_tags" @> $147 or "event_tags" @> $148 or "event_tags" @> $149 or "event_tags" @> $150 or "event_tags" @> $151 or "event_tags" @> $152) order by "event_created_at" asc limit $153) order by "event_created_at" asc limit $154

このクエリは、複数のタグに対して OR 条件を使用しており、それぞれのタグで JSONB 演算子 @> を使っています。このような構造は、データベースのパフォーマンスにとって非常に負担が大きくなっていました。

問題の状況

このようなクエリは非常に時間がかかり 30 秒を超えるケースが頻繁に発生していました。また、サーバー全体の CPU 使用率が 90-100% に達することも珍しくありませんでした。これは、リレーサーバーの効率性とスケーラビリティにとって大きな障害となっていました。

改善策のアイデア

Nostream 内部の PostgreSQL の SQL クエリ処理に CPU が高騰していることをつぶやいていると、 @koteitan さんがアイデアを一つくれました。

e tag が来たらハッシュテーブルに入れて REQ が来た時のアクセスを index scan 化するリレーとかどうだろう

これです。

改善策の導入

改善前のテーブル構造

Nostream が標準で持っていたテーブルは実質的に一つの events テーブルだけでした。

eventsテーブル

新しい event_tags テーブルの設計

Nostream のパフォーマンスを向上させるために、まず event_tags という新しいテーブルを導入します。このテーブルは、イベントのタグをより効率的に検索可能にするために設計されています。

CREATE TABLE IF NOT EXISTS public.event_tags
(
    id uuid NOT NULL DEFAULT uuid_generate_v4(),
    event_id bytea NOT NULL,
    tag_name text COLLATE pg_catalog."default" NOT NULL,
    tag_value text COLLATE pg_catalog."default" NOT NULL,
    CONSTRAINT event_tags_pkey PRIMARY KEY (id)
)
TABLESPACE pg_default;

ALTER TABLE IF EXISTS public.event_tags
    OWNER to nostr_ts_relay;

CREATE INDEX IF NOT EXISTS event_tags_tag_name_tag_value_index
    ON public.event_tags USING btree
    (tag_name COLLATE pg_catalog."default" ASC NULLS LAST, tag_value COLLATE pg_catalog."default" ASC NULLS LAST)
    TABLESPACE pg_default;

CREATE INDEX IF NOT EXISTS event_tags_event_id_index
    ON public.event_tags USING btree
    (event_id ASC NULLS LAST)
    WITH (deduplicate_items=True)
    TABLESPACE pg_default;

このテーブルには、イベントのID、タグの名前、そしてタグの値を格納します。さらに、これらのフィールドにインデックスを張ることで、検索の効率を大幅に向上させました。

event_tagsテーブル

Knex マイグレーションスクリプト

データベースの変更を適用するために、Knex マイグレーションスクリプトを作成しました。このスクリプトは、新しいテーブルの作成とインデックスの設定を行います。

exports.up = async function (knex) {
  await knex.schema.createTable('event_tags', function (table) {
    table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
    table.binary('event_id').notNullable();
    table.text('tag_name').notNullable();
    table.text('tag_value').notNullable();
  });

  await knex.schema.table('event_tags', function (table) {
    table.index(['tag_name', 'tag_value']);
  });

  await knex.schema.table('event_tags', function (table) {
    table.index(['event_id']);
  });
};

exports.down = function (knex) {
  return knex.schema.dropTable('event_tags');
};

イベント処理のクエリ変更

以前は JSONB の @> 演算子を用いていたため、インデックスを活用できずにパフォーマンスが低下していました。改善策として、 event_tags テーブルを利用するようにイベント処理のクエリを変更しました。

src/repositories/event-repository.ts
--- a/src/repositories/event-repository.ts
+++ b/src/repositories/event-repository.ts
@@ -66,6 +66,8 @@ export class EventRepository implements IEventRepository {
     }
     const queries = filters.map((currentFilter) => {
       const builder = this.readReplicaDbClient<DBEvent>('events')
+        .leftJoin('event_tags', 'events.event_id', 'event_tags.event_id')
+        .select('events.*', 'event_tags.event_id as event_tags_event_id')
 
       forEachObjIndexed((tableFields: string[], filterName: string | number) => {
         builder.andWhere((bd) => {
@@ -107,7 +109,7 @@ export class EventRepository implements IEventRepository {
         })
       })({
         authors: ['event_pubkey', 'event_delegator'],
-        ids: ['event_id'],
+        ids: ['events.event_id'],
       })
 
       if (Array.isArray(currentFilter.kinds)) {
@@ -140,10 +142,8 @@ export class EventRepository implements IEventRepository {
               isEmpty,
               () => andWhereRaw('1 = 0', bd),
               forEach((criterion: string) => void orWhereRaw(
-                '"event_tags" @> ?',
-                [
-                  JSON.stringify([[filterName[1], criterion]]) as any,
-                ],
+                'event_tags.tag_name = ? AND event_tags.tag_value = ?',
+                [filterName[1], criterion],
                 bd,
               )),
             )(criteria)

events テーブルから event_tags テーブルへのタグデータの展開

本来であれば Knex マイグレーションスクリプト の中に下記のようなコードを記載して events テーブルの中の event_tags フィールドのデータを event_tags テーブルに移行する処理を記載するのがプログラムとしては望ましいです。

しかし Nostr リレー上にすでに保存しているイベント件数が多い場合には、Knex マイグレーションスクリプトの実行に時間がかかってしまうため、マイグレーションスクリプトの中には記載せずに、別途オンラインでサービス中に手動でバックグラウンドで実行しました。

当然その間は未処理のイベントに対する REQ はヒットしないことになりますが、クライアントは複数のリレーサーバに同一の REQ を依頼して並行して処理されているように見えます。
Nostr リレーの耐障害性の高さを活かして、このような手法を取ることができました。

  const events = await knex.select('event_id', 'event_tags').from('events');
  const totalEvents = events.length;
  let processedEvents = 0;
  let lastPercentage = 0;

  for (const event of events) {
    for (const tag of event.event_tags) {
      const [tag_name, tag_value] = tag;
      if (tag_name.length === 1 && tag_value) {
        await knex('event_tags').insert({
          events_event_id: event.event_id,
          tag_name: tag_name,
          tag_value: tag_value
        });
      }
    }
    processedEvents++;
    const currentPercentage = Math.floor(processedEvents / totalEvents * 100);
    if (currentPercentage > lastPercentage) {
      console.log(`${new Date().toLocaleString()} Migration progress: ${currentPercentage}%`);
      lastPercentage = currentPercentage;
    }
  }
CREATE OR REPLACE FUNCTION process_event_tags_direct(event_row events) RETURNS VOID AS $$
DECLARE
  tag_element jsonb;
  tag_name text;
  tag_value text;
  exists_flag boolean;
BEGIN
  -- 既に処理されたevent_idがあればスキップ
  SELECT EXISTS(SELECT 1 FROM event_tags WHERE event_id = event_row.event_id) INTO exists_flag;
  IF exists_flag THEN
    RETURN;
  END IF;

  FOR tag_element IN SELECT jsonb_array_elements(event_row.event_tags)
  LOOP
    tag_name := trim((tag_element->0)::text, '"');
    tag_value := trim((tag_element->1)::text, '"');
    IF length(tag_name) = 1 AND tag_value IS NOT NULL AND tag_value <> '' THEN
      INSERT INTO event_tags (event_id, tag_name, tag_value) VALUES (event_row.event_id, tag_name, tag_value);
    END IF;
  END LOOP;
END;
$$ LANGUAGE plpgsql;

DO $$
DECLARE
  cur CURSOR FOR SELECT * FROM events ORDER BY event_id;
  row events%ROWTYPE;
  total_rows int;
  processed_rows int := 0;
BEGIN
  -- 全行数を取得
  SELECT count(*) INTO total_rows FROM events;

  OPEN cur;

  WHILE processed_rows < total_rows LOOP
    FOR i IN 1..100 LOOP
      FETCH NEXT FROM cur INTO row;
      EXIT WHEN NOT FOUND;

      -- process_event_tagsを直接呼び出す
      PERFORM process_event_tags_direct(row);

      processed_rows := processed_rows + 1;
    END LOOP;

    -- 進捗%を出力
    RAISE NOTICE 'Processed: %, Total: %, Remaining: %, Percentage: %', processed_rows, total_rows, total_rows - processed_rows, (processed_rows::float / total_rows::float * 100);
    -- 1秒待機
    PERFORM pg_sleep(0.1);
  END LOOP;

  CLOSE cur;
END $$;

私の手元の環境では、4 コアマシンで 約1300万件 のイベントを処理するのに 約6時間 かかりました。

イベント挿入や更新時のトリガー

この記事では省略していますが、実際には新しいイベントが来たときや更新されたときに event_tags テーブルの該当するデータを更新するトリガーを設定しています。

src/repositories/event-repository.ts

CREATE OR REPLACE FUNCTION process_event_tags() RETURNS TRIGGER AS $$
DECLARE
  tag_element jsonb;
  tag_name text;
  tag_value text;
BEGIN
  DELETE FROM event_tags WHERE event_id = OLD.event_id;

  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
    FOR tag_element IN SELECT jsonb_array_elements(NEW.event_tags)
    LOOP
      tag_name := trim((tag_element->0)::text, '"');
      tag_value := trim((tag_element->1)::text, '"');
      IF length(tag_name) = 1 AND tag_value IS NOT NULL AND tag_value <> '' THEN
        INSERT INTO event_tags (event_id, tag_name, tag_value) VALUES (NEW.event_id, tag_name, tag_value);
      END IF;
    END LOOP;
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER insert_event_tags
AFTER INSERT OR UPDATE OR DELETE ON events
FOR EACH ROW
EXECUTE FUNCTION process_event_tags();

改善後の結果

スロークエリの記録の減少

改善策を導入する前は、 Nostr リレーサーバーのスローログには、30秒を超えるSQL文が頻繁に記録されていました。

しかし、event_tags テーブルの導入とインデックスの適用により、これらのスロークエリは事実上0になりました。これは、従来のような全件走査がなくなり、インデックスを活用した効率的な検索に置き換わったためです。

CPU 使用率の改善

この改善の最も顕著な効果は CPU 使用率の大幅な低下に表れています。改善前は、リレーサーバーのCPU使用率が 90-100% 前後で推移していたのは先ほど述べたとおりです。しかし、改善後は 20-50% 程度に低下しました。これは、データベースの負荷が大幅に軽減されたことを意味しています。

12/14 14:00に改善策を実装した結果

このCPU使用率の低下は、リレーサーバー全体のパフォーマンス向上に直結しています。特に、リアルタイムに近いレスポンスが求められる WebSocket 通信においては、このようなパフォーマンスの向上がユーザーエクスペリエンスに大きな影響を与えます。

総合的なパフォーマンスの向上

この改善によって、リレーサーバーは以前に比べてより多くのリクエストを効率的に処理できるようになりました。具体的には、以下の点が改善されています:

  • REQ リクエストの応答時間の短縮: スロークエリの削減により、ユーザーに対する応答時間が短縮されました。
  • スケーラビリティの向上: CPU 使用率の低下により、同じハードウェアリソースでより多くのリクエストを処理できるようになり、Nostr リレーとしてより増えるユーザーを収容することが可能となりました。

まとめ

この記事を通じて、Nostr リレーサーバーのパフォーマンス問題に対する具体的な解決策を探求しました。効率的なリクエスト処理と CPU 使用率の低下は、今後のサービス提供においても大きな後押しとなっています。

しかし、これは始まりに過ぎません。今後も、Nostr のような多数のユーザーが求めている自由なSNSという世界は、ユーザーコミュニティ自身の協力と創意工夫によってさらに成長し続けると期待しています。一緒に、より良い技術の未来を築きましょう。

Discussion