その堎でデヌタベヌスを倉曎する機胜を備えた単玔なORMを䜜成する

画像



こんにちは、Habrカルマは、ホリバヌの蚘事の䞋での䞍泚意なコメントのために排氎されたした。぀たり、興味深い私は願っおいたす投皿を曞いお、自分自身をリハビリする必芁がありたす。



私は数幎間、phpでテレグラムサヌバヌクラむアントを䜿甚しおいたす。そしお、倚くのナヌザヌのように、メモリ消費量の絶え間ない増加にうんざりしおいたす。䞀郚のセッションでは、1〜8ギガバむトのRAMが必芁になる堎合がありたす。デヌタベヌスのサポヌトは長い間玄束されおきたしたが、この方向ぞの進展はありたせんでした。私は自分で問題を解決しなければなりたせんでした:)オヌプン゜ヌスプロゞェクトの人気はプルリク゚ストに興味深い芁件を課したした



  1. 埌方互換性。既存のすべおのセッションは、新しいバヌゞョンでも匕き続き機胜する必芁がありたすセッションは、ファむル内のアプリケヌションのシリアル化されたむンスタンスです。
  2. デヌタベヌスの遞択の自由。ナヌザヌは環境の構成が異なるため、デヌタを倱うこずなく、い぀でもストレヌゞタむプを倉曎する機胜。
  3. 拡匵性。新しいタむプのデヌタベヌスの远加のしやすさ。
  4. むンタヌフェむスを保存したす。デヌタを操䜜するアプリケヌションコヌドは倉曎しないでください。
  5. 非同期。プロゞェクトはamphpを䜿甚するため、すべおのデヌタベヌス操䜜は非ブロッキングである必芁がありたす。


詳しくは猫の䞋のみんなを招埅したす。



䜕を転送したすか



MadelineProtoのメモリのほずんどは、チャット、ナヌザヌ、およびファむルによっお占められおいたす。たずえば、ピアキャッシュには、2䞇を超える゚ントリがありたす。これらは、アカりントがこれたでに芋たすべおのナヌザヌすべおのグルヌプのメンバヌを含む、およびチャネル、ボット、グルヌプです。アカりントが叀く、アクティブであるほど、より倚くのデヌタがメモリに保存されたす。これらは数十メガバむトず数癟メガバむトであり、それらのほずんどは䜿甚されおいたせん。ただし、同じデヌタを耇数回受信しようずするず、テレグラムによっおアカりントがすぐに厳しく制限されるため、キャッシュ党䜓をクリアするこずはできたせん。たずえば、パブリックデモサヌバヌでセッションを再䜜成した埌、1週間以内の電報は、ほずんどのリク゚ストにFLOOD_WAIT゚ラヌで応答し、実際には䜕も機胜したせんでした。キャッシュがりォヌムアップした埌、すべおが正垞に戻りたした。



コヌドの芳点から、このデヌタはクラスのペアのプロパティに配列ずしお栌玍されたす。



建築



芁件に基づいお、スキヌムが生たれたした。



  • すべおの「重い」配列は、ArrayAccessを実装するオブゞェクトに眮き換えられたす。
  • デヌタベヌスの皮類ごずに、基本クラスを継承する独自のクラスを䜜成したす。
  • オブゞェクトは、__ consrtuctおよび__awake䞭に䜜成され、プロパティに曞き蟌たれたす。
  • 抜象ファクトリは、アプリケヌション蚭定で遞択されたデヌタベヌスに応じお、オブゞェクトに必芁なクラスを遞択したす。
  • アプリケヌションにすでに別のタむプのストレヌゞがある堎合は、そこからすべおのデヌタを読み取り、アレむを新しいストレヌゞに曞き蟌みたす。


非同期の䞖界の問題



私が最初にしたこずは、メモリに配列を栌玍するためのむンタヌフェむスずクラスを䜜成するこずでした。これはデフォルトであり、動䜜は叀いバヌゞョンのプログラムず同じです。最初の倜、私はプロトタむプの成功に非垞に興奮したした。コヌドは玠晎らしく、シンプルでした。これたでのずころ、Iteratorむンタヌフェヌスのメ゜ッド内、およびunsetずissetを担圓するメ゜ッド内でゞェネレヌタヌを䜿甚できないこずは発芋されおいたせん。



ここで、amphpがゞェネレヌタヌ構文を䜿甚しおphpに非同期を実装するこずを明確にする必芁がありたす。歩留たりは非同期に類䌌するようになりたす... jsから埅ちたす。メ゜ッドが非同期を䜿甚する堎合、そのメ゜ッドから結果を取埗するには、yieldを䜿甚しおコヌドでこの結果を埅぀必芁がありたす。䟋えば



<?php

include 'vendor/autoload.php';

$MadelineProto = new \danog\MadelineProto\API('session.madeline');
$MadelineProto->async(true);

$MadelineProto->loop(function() use($MadelineProto) {
    $myAsyncFunction = function() use($MadelineProto): \Generator {
        $me = yield $MadelineProto->start();
        yield $MadelineProto->echo(json_encode($me, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
    };

    yield $myAsyncFunction();
});


文字列からの堎合
yield $myAsyncFunction();
むヌルドを削陀するず、このコヌドが実行される前にアプリケヌションが終了したす。結果は埗られたせん。



メ゜ッドや関数を呌び出す前にyieldを远加するこずはそれほど難しくありたせん。ただし、ArrayAccessむンタヌフェむスが䜿甚されるため、メ゜ッドは盎接呌び出されたせん。たずえば、unsetはoffsetUnsetを呌び出し、issetはoffsetIssetを呌び出したす。 Iteratorむンタヌフェヌスを䜿甚する堎合、foreachむテレヌタヌの堎合も状況は䌌おいたす。



組み蟌みメ゜ッドの前にyieldを远加するず、これらのメ゜ッドはゞェネレヌタヌで動䜜するように蚭蚈されおいないため、゚ラヌが発生したす。コメントでもう少しここずここ。



自分のメ゜ッドを䜿甚するには、コヌドを劥協しお曞き盎す必芁がありたした。幞いなこずに、そのような堎所はほずんどありたせんでした。ほずんどの堎合、キヌによる読み取りたたは曞き蟌みには配列が䜿甚されおいたした。この機胜は、ゞェネレヌタヌず玠晎らしい友達になりたした。



結果のむンタヌフェむスは次のずおりです。



<?php

use Amp\Producer;
use Amp\Promise;

interface DbArray extends DbType, \ArrayAccess, \Countable
{
    public function getArrayCopy(): Promise;
    public function isset($key): Promise;
    public function offsetGet($offset): Promise;
    public function offsetSet($offset, $value);
    public function offsetUnset($offset): Promise;
    public function count(): Promise;
    public function getIterator(): Producer;

    /**
     * @deprecated
     * @internal
     * @see DbArray::isset();
     *
     * @param mixed $offset
     *
     * @return bool
     */
    public function offsetExists($offset);
}


デヌタの操䜜䟋



<?php
...
//
$existingChat = yield $this->chats[$user['id']];

//. 
yield $this->chats[$user['id']] = $user;
//   yield,           .
$this->chats[$user['id']] = $user;


//unset
yield $this->chats->offsetUnset($id);

//foreach
$iterator = $this->chats->getIterator();
while (yield $iterator->advance()) {
    [$key, $value] = $iterator->getCurrent();
    //  
}


デヌタストレヌゞ



デヌタを保存する最も簡単な方法はシリアル化されおいたす。オブゞェクトをサポヌトするために、jsonの䜿甚を断念しなければなりたせんでした。このテヌブルには、キヌず倀の2぀の䞻芁な列がありたす。



テヌブルを䜜成するためのsqlク゚リの䟋



            CREATE TABLE IF NOT EXISTS `{$this->table}`
            (
                `key` VARCHAR(255) NOT NULL,
                `value` MEDIUMBLOB NULL,
                `ts` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (`key`)
            )
            ENGINE = InnoDB
            CHARACTER SET 'utf8mb4' 
            COLLATE 'utf8mb4_general_ci'


アプリケヌションが起動するたびに、プロパティごずにテヌブルを䜜成しようずしたす。テレグラムクラむアントは、数時間に1回以䞊再起動するこずはお勧めしたせん。そのため、1秒あたりにテヌブルを䜜成するための耇数のリク゚ストはありたせん:)



プラむマリキヌは自動むンクリメントされないため、デヌタの挿入ず曎新は、通垞の配列のように1぀のク゚リで実行できたす。



INSERT INTO `{$this->table}` 
            SET `key` = :index, `value` = :value 
            ON DUPLICATE KEY UPDATE `value` = :value


account_id_class_variable_nameの圢匏の名前を持぀テヌブルが倉数ごずに䜜成されたす。ただし、最初にアプリケヌションを起動したずきは、ただアカりントがありたせん。この堎合、tmpプレフィックスを䜿甚しおランダムな䞀時IDを生成する必芁がありたす。起動するたびに、各倉数のクラスはアカりントIDが衚瀺されおいるかどうかを確認したす。idが存圚する堎合、テヌブルの名前が倉曎されたす。



むンデックス



デヌタベヌスの構造は可胜な限り単玔であるため、将来、新しいプロパティが自動的に远加されたす。接続はありたせん。PRIMARYキヌむンデックスのみが䜿甚されたす。ただし、他のフィヌルドで怜玢する必芁がある堎合もありたす。



たずえば、配列/テヌブルチャットがありたす。その䞭の鍵はチャットIDです。しかし、倚くの堎合、ナヌザヌ名で怜玢する必芁がありたす。アプリケヌションがデヌタを配列に栌玍しおいるずき、ナヌザヌ名による怜玢は、foreachで配列を反埩するこずによっお通垞どおり実行されたした。この怜玢は、メモリでは蚱容可胜な速床で機胜したしたが、デヌタベヌスでは機胜したせんでした。したがっお、別のテヌブル/配列が䜜成され、クラス内の察応するプロパティが䜜成されたした。キヌはナヌザヌ名、倀はチャットIDです。このアプロヌチの唯䞀の欠点は、2぀のテヌブルを同期するために远加のコヌドを䜜成する必芁があるこずです。



キャッシング



ロヌカルmysqlは高速ですが、少しのキャッシュで問題が発生するこずはありたせん。特に、同じ倀が連続しお耇数回䜿甚される堎合。たずえば、最初にデヌタベヌス内のチャットの存圚を確認しおから、そこからデヌタを取埗したす。



簡単な自転車の特性が曞かれたした。



<?php

namespace danog\MadelineProto\Db;

use Amp\Loop;
use danog\MadelineProto\Logger;

trait ArrayCacheTrait
{
    /**
     * Values stored in this format:
     * [
     *      [
     *          'value' => mixed,
     *          'ttl' => int
     *      ],
     *      ...
     * ].
     * @var array
     */
    protected array $cache = [];
    protected string $ttl = '+5 minutes';
    private string $ttlCheckInterval = '+1 minute';

    protected function getCache(string $key, $default = null)
    {
        $cacheItem = $this->cache[$key] ?? null;
        $result = $default;

        if (\is_array($cacheItem)) {
            $result = $cacheItem['value'];
            $this->cache[$key]['ttl'] = \strtotime($this->ttl);
        }

        return $result;
    }

    /**
     * Save item in cache.
     *
     * @param string $key
     * @param $value
     */
    protected function setCache(string $key, $value): void
    {
        $this->cache[$key] = [
            'value' => $value,
            'ttl' => \strtotime($this->ttl),
        ];
    }

    /**
     * Remove key from cache.
     *
     * @param string $key
     */
    protected function unsetCache(string $key): void
    {
        unset($this->cache[$key]);
    }

    protected function startCacheCleanupLoop(): void
    {
        Loop::repeat(\strtotime($this->ttlCheckInterval, 0) * 1000, fn () => $this->cleanupCache());
    }

    /**
     * Remove all keys from cache.
     */
    protected function cleanupCache(): void
    {
        $now = \time();
        $oldKeys = [];
        foreach ($this->cache as $cacheKey => $cacheValue) {
            if ($cacheValue['ttl'] < $now) {
                $oldKeys[] = $cacheKey;
            }
        }
        foreach ($oldKeys as $oldKey) {
            $this->unsetCache($oldKey);
        }

        Logger::log(
            \sprintf(
                "cache for table:%s; keys left: %s; keys removed: %s",
                $this->table,
                \count($this->cache),
                \count($oldKeys)
            ),
            Logger::VERBOSE
        );
    }
}


startCacheCleanupLoopに特に泚意を払いたいず思いたす。 amphpの魔法のおかげで、キャッシュの無効化は可胜な限り簡単です。コヌルバックは指定された間隔で開始し、すべおの倀をルヌプしお、この芁玠ぞの最埌の呌び出しのタむムスタンプを栌玍するtsフィヌルドを調べたす。呌び出しが5分以䞊前蚭定で構成可胜であった堎合、芁玠は削陀されたす。 amphpを䜿甚しおredisたたはmemcacheからttlアナログを実装するのは非垞に簡単です。これはすべおバックグラりンドで発生し、メむンスレッドをブロックしたせん。



キャッシュず非同期性の助けを借りお、読み取りだけでなく曞き蟌みも高速化されたす。



デヌタベヌスにデヌタを曞き蟌むメ゜ッドの゜ヌスコヌドは次のずおりです。



/**
     * Set value for an offset.
     *
     * @link https://php.net/manual/en/arrayiterator.offsetset.php
     *
     * @param string $index <p>
     * The index to set for.
     * </p>
     * @param $value
     *
     * @throws \Throwable
     */

    public function offsetSet($index, $value): Promise
    {
        if ($this->getCache($index) === $value) {
            return call(fn () =>null);
        }

        $this->setCache($index, $value);

        $request = $this->request(
            "
            INSERT INTO `{$this->table}` 
            SET `key` = :index, `value` = :value 
            ON DUPLICATE KEY UPDATE `value` = :value
        ",
            [
                'index' => $index,
                'value' => \serialize($value),
            ]
        );

        //Ensure that cache is synced with latest insert in case of concurrent requests.
        $request->onResolve(fn () => $this->setCache($index, $value));

        return $request;
    }


$ this->リク゚ストは、デヌタを非同期に曞き蟌むPromiseを䜜成したす。たた、キャッシュを䜿甚した操䜜は同期しお行われたす。぀たり、デヌタベヌスぞの曞き蟌みを埅぀こずはできたせん。同時に、読み取り操䜜がすぐに新しいデヌタを返し始めるこずを確認しおください。



amphpのonResolveメ゜ッドは非垞に䟿利であるこずがわかりたした。挿入が完了するず、デヌタは再びキャッシュに曞き蟌たれたす。䞀郚の曞き蟌み操䜜が遅れ、キャッシュずベヌスが異なり始めた堎合、キャッシュは最埌にベヌスに曞き蟌たれた倀で曎新されたす。それら。キャッシュは再びベヌスず䞀臎するようになりたす。



゜ヌス



→プルリク゚ストぞのリンク



そしお、そのように別のナヌザヌがpostgreのサポヌトを远加したした。そのための指瀺を曞くのにたった5分しかかかりたせんでした。



重耇するメ゜ッドを䞀般的な抜象クラスSqlArrayに移動するこずで、コヌドの量を枛らすこずができたす。



もう䞀぀



テレグラムからメディアファむルをダりンロヌドしおいる間、暙準のガベヌゞコレクタヌphpは䜜業に察応せず、ファむルの䞀郚がメモリに残っおいるこずに気づきたした。通垞、リヌクはファむルず同じサむズでした。考えられる原因10,000個のリンクが蓄積されるず、ガベヌゞコレクタヌが自動的にトリガヌされたす。私たちの堎合、リンクは数数十でしたが、それぞれがメモリ内のメガバむトのデヌタを参照する可胜性がありたす。 mtprotoの実装で数千行のコヌドを研究するのは非垞に怠惰でした。最初に\ gc_collect_cycles;で゚レガントなクラッチを詊しおみたせんか



驚いたこずに、それは問題を解決したした。これは、定期的なクリヌニングの開始を構成するだけで十分であるこずを意味したす。幞い、amphpは、指定された間隔でバックグラりンドで実行するためのシンプルなツヌルを提䟛したす。



毎秒メモリをクリアするのは簡単すぎおあたり効果的ではないように芋えたした。前回のクリヌンアップ以降のメモリゲむンをチェックするアルゎリズムに萜ち着きたした。ゲむンがしきい倀よりも倧きい堎合、クリアが発生したす。



<?php

namespace danog\MadelineProto\MTProtoTools;

use Amp\Loop;
use danog\MadelineProto\Logger;

class GarbageCollector
{
    /**
     * Ensure only one instance of GarbageCollector
     * 		when multiple instances of MadelineProto running.
     * @var bool
     */
    public static bool $lock = false;

    /**
     * How often will check memory.
     * @var int
     */
    public static int $checkIntervalMs = 1000;

    /**
     * Next cleanup will be triggered when memory consumption will increase by this amount.
     * @var int
     */
    public static int $memoryDiffMb = 1;

    /**
     * Memory consumption after last cleanup.
     * @var int
     */
    private static int $memoryConsumption = 0;

    public static function start(): void
    {
        if (static::$lock) {
            return;
        }
        static::$lock = true;

        Loop::repeat(static::$checkIntervalMs, static function () {
            $currentMemory = static::getMemoryConsumption();
            if ($currentMemory > static::$memoryConsumption + static::$memoryDiffMb) {
                \gc_collect_cycles();
                static::$memoryConsumption = static::getMemoryConsumption();
                $cleanedMemory = $currentMemory - static::$memoryConsumption;
                Logger::log("gc_collect_cycles done. Cleaned memory: $cleanedMemory Mb", Logger::VERBOSE);
            }
        });
    }

    private static function getMemoryConsumption(): int
    {
        $memory = \round(\memory_get_usage()/1024/1024, 1);
        Logger::log("Memory consumption: $memory Mb", Logger::ULTRA_VERBOSE);
        return (int) $memory;
    }
}



All Articles