Halo, Habr! Karma terkuras karena komentar ceroboh di bawah artikel holivar, yang berarti Anda perlu menulis posting yang menarik (saya harap) dan merehabilitasi diri Anda sendiri.
Saya telah menggunakan klien telegram server di php selama beberapa tahun. Dan seperti banyak pengguna - bosan dengan pertumbuhan konstan dalam konsumsi memori. Beberapa sesi dapat memakan waktu 1 hingga 8 gigabyte RAM! Dukungan database telah dijanjikan sejak lama, tetapi belum ada kemajuan ke arah ini. Saya harus menyelesaikan masalahnya sendiri :) Popularitas proyek open source memberlakukan persyaratan menarik pada permintaan tarik:
- Kompatibilitas mundur . Semua sesi yang ada harus terus bekerja di versi baru (sesi adalah contoh serial dari aplikasi dalam sebuah file);
- Kebebasan memilih database . Kemampuan untuk mengubah jenis penyimpanan tanpa kehilangan data dan kapan saja, karena pengguna memiliki konfigurasi lingkungan yang berbeda;
- Ekstensibilitas . Kemudahan menambahkan jenis database baru;
- Simpan antarmuka . Kode aplikasi yang memanipulasi data tidak boleh berubah;
- Asinkron . Proyek ini menggunakan amphp, jadi semua operasi database harus non-pemblokiran;
Untuk detailnya saya mengundang semua orang di bawah kucing.
Apa yang akan kami transfer
Sebagian besar memori MadelineProto ditempati oleh obrolan, pengguna, dan file. Misalnya, di cache peer, saya memiliki lebih dari 20 ribu entri. Ini adalah semua pengguna yang pernah dilihat akun tersebut (termasuk anggota semua grup), serta saluran, bot, dan grup. Semakin tua dan semakin aktif akun tersebut, semakin banyak data yang akan disimpan di memori. Ini adalah puluhan dan ratusan megabyte, dan kebanyakan tidak digunakan. Tetapi Anda tidak dapat menghapus seluruh cache, karena telegram akan segera membatasi akun secara drastis ketika mencoba menerima data yang sama beberapa kali. Misalnya, setelah membuat ulang sesi di server demo publik saya, telegram dalam seminggu menjawab sebagian besar permintaan dengan kesalahan FLOOD_WAIT dan tidak ada yang benar-benar berfungsi. Setelah cache memanas, semuanya kembali normal.
Dari sudut pandang kode, data ini disimpan sebagai array di properti sepasang kelas.
Arsitektur
Berdasarkan persyaratan, lahir skema:
- Semua larik "berat" diganti dengan objek yang mengimplementasikan ArrayAccess;
- Untuk setiap tipe database, kita membuat kelas kita sendiri yang mewarisi basisnya;
- Objek dibuat dan ditulis ke properti selama __consrtuct dan __awake;
- Pabrik abstrak memilih kelas yang diinginkan untuk objek tersebut, bergantung pada database yang dipilih dalam pengaturan aplikasi;
- Jika aplikasi sudah memiliki jenis penyimpanan lain, maka kami membaca semua data dari sana dan menulis larik ke penyimpanan baru.
Masalah dunia yang tidak sinkron
Hal pertama yang saya lakukan adalah membuat antarmuka dan kelas untuk menyimpan array dalam memori. Ini adalah default, identik dalam perilakunya dengan versi program yang lebih lama. Pada malam pertama, saya sangat senang dengan kesuksesan prototipe ini. Kode itu bagus dan sederhana. Sejauh ini belum ditemukan bahwa tidak mungkin menggunakan generator di dalam metode antarmuka Iterator dan di dalam metode yang bertanggung jawab atas unset dan isset.
Harus dijelaskan di sini bahwa amphp menggunakan sintaks generator untuk mengimplementasikan asinkron di php. Hasil menjadi analog dengan async ... menunggu dari js. Jika suatu metode menggunakan asynchrony, maka untuk mendapatkan hasil darinya, Anda harus menunggu hasil ini di kode menggunakan yield. Misalnya:
<?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();
});
Jika dari string
yield $myAsyncFunction();hapus hasil, maka aplikasi akan dihentikan sebelum kode ini dijalankan. Kami tidak akan mendapatkan hasilnya.
Menambahkan hasil sebelum memanggil metode dan fungsi tidak terlalu sulit. Tetapi karena antarmuka ArrayAccess digunakan, metode ini tidak dipanggil secara langsung. Misalnya, unset () memanggil offsetUnset (), dan isset () memanggil offsetIsset (). Situasinya mirip dengan foreach iterator saat menggunakan antarmuka Iterator.
Menambahkan hasil di depan metode bawaan menimbulkan kesalahan karena metode ini tidak dirancang untuk bekerja dengan generator. Sedikit lagi di komentar: di sini dan di sini .
Saya harus berkompromi dan menulis ulang kode untuk menggunakan metode saya sendiri. Untungnya, hanya ada sedikit tempat seperti itu. Dalam kebanyakan kasus, array digunakan untuk membaca atau menulis dengan kunci. Fungsionalitas ini berteman baik dengan generator.
Antarmuka yang dihasilkan adalah:
<?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);
}
Contoh bekerja dengan data
<?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();
//
}
Penyimpanan data
Cara termudah untuk menyimpan data adalah dengan serial. Saya harus meninggalkan penggunaan json untuk mendukung objek. Tabel ini memiliki dua kolom utama: kunci dan nilai.
Contoh query sql untuk membuat tabel:
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'
Setiap kali aplikasi dimulai, kami mencoba membuat tabel untuk setiap properti. Klien Telegram tidak disarankan untuk memulai ulang lebih dari sekali setiap beberapa jam, jadi kami tidak akan memiliki beberapa permintaan untuk membuat tabel per detik :)
Karena kunci utama tidak bertambah otomatis, maka penyisipan dan pembaruan data dapat dilakukan dengan satu kueri, seperti pada array biasa:
INSERT INTO `{$this->table}`
SET `key` = :index, `value` = :value
ON DUPLICATE KEY UPDATE `value` = :value
Tabel dengan nama dalam format% account_id% _% class% _% variable_name% dibuat untuk setiap variabel. Tetapi ketika Anda pertama kali memulai aplikasi, belum ada akun. Dalam kasus ini, Anda harus membuat id sementara acak dengan awalan tmp. Pada setiap peluncuran, kelas dari setiap variabel memeriksa apakah id akun telah muncul. Jika id ada, tabel akan diganti namanya.
Indeks
Struktur basis data sesederhana mungkin sehingga properti baru akan ditambahkan secara otomatis di masa mendatang. Tidak ada koneksi. Hanya indeks kunci PRIMARY yang digunakan. Tetapi ada situasi ketika Anda perlu mencari di bidang lain.
Misalnya, ada larik / tabel obrolan. Kuncinya adalah chat id. Namun seringkali Anda harus mencari berdasarkan nama pengguna. Ketika aplikasi menyimpan data dalam array, pencarian berdasarkan nama pengguna dilakukan seperti biasa dengan melakukan iterasi pada array di foreach. Pencarian ini bekerja pada kecepatan yang dapat diterima di memori, tetapi tidak dalam database. Oleh karena itu, tabel / larik lain telah dibuat dan properti terkait di kelas. Kuncinya adalah nama pengguna, nilainya adalah id obrolan. Satu-satunya kelemahan dari pendekatan ini adalah Anda harus menulis kode tambahan untuk menyinkronkan kedua tabel.
Caching
MySQL lokal cepat, tetapi sedikit caching tidak ada salahnya. Apalagi jika nilai yang sama digunakan beberapa kali berturut-turut. Misalnya, pertama kami memeriksa keberadaan obrolan di database, lalu kami mendapatkan beberapa data darinya. Karakter
sederhana telah ditulis .
<?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
);
}
}
Saya ingin memberi perhatian khusus pada startCacheCleanupLoop. Berkat keajaiban amphp, membatalkan cache menjadi sesederhana mungkin. Callback dimulai pada interval yang ditentukan, mengulang semua nilai dan melihat bidang ts, yang menyimpan stempel waktu dari panggilan terakhir ke elemen ini. Jika panggilan dilakukan lebih dari 5 menit yang lalu (dapat dikonfigurasi dalam pengaturan), maka elemen tersebut akan dihapus. Sangat mudah untuk mengimplementasikan analog ttl dari redis atau memcache menggunakan amphp. Semua ini terjadi di latar belakang dan tidak memblokir utas utama.
Dengan bantuan cache dan asinkron, tidak hanya membaca dipercepat, tetapi juga menulis.
Berikut adalah kode sumber untuk metode yang menulis data ke database.
/**
* 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-> request membuat Promise yang menulis data secara asinkron. Dan operasi dengan cache terjadi secara serempak. Artinya, Anda tidak perlu menunggu menulis ke database dan pada saat yang sama memastikan bahwa operasi baca akan segera mulai mengembalikan data baru.
Metode onResolve dari amphp ternyata sangat berguna. Setelah penyisipan selesai, data akan ditulis kembali ke cache. Jika beberapa operasi tulis terlambat dan cache dan basis mulai berbeda, maka cache akan diperbarui dengan nilai yang ditulis ke basis terakhir. Itu. cache kami akan kembali konsisten dengan basis.
Sumber
→ Tautan untuk menarik permintaan
Dan begitu saja pengguna lain menambahkan dukungan untuk postgre. Hanya butuh 5 menit untuk menulis instruksi untuk itu.
Jumlah kode dapat dikurangi dengan memindahkan metode duplikat ke kelas abstrak umum SqlArray.
Satu hal lagi
Diketahui bahwa saat mengunduh file media dari telegram, pengumpul sampah standar php tidak menangani pekerjaan dan potongan file tetap ada di memori. Biasanya, kebocoran berukuran sama dengan file. Kemungkinan Penyebab: Pengumpul sampah secara otomatis dipicu saat 10.000 link terakumulasi. Dalam kasus kami, tautannya sedikit (lusinan), tetapi masing-masing dapat merujuk ke megabyte data di memori. Sangat malas untuk mempelajari ribuan baris kode dengan implementasi mtproto. Mengapa tidak mencoba kruk elegan dengan \ gc_collect_cycles (); dulu?
Anehnya, itu memecahkan masalah. Ini berarti cukup untuk mengkonfigurasi awal pembersihan berkala. Untungnya, amphp menyediakan alat sederhana untuk eksekusi latar belakang pada interval yang ditentukan.
Menghapus memori setiap detik sepertinya terlalu mudah dan tidak terlalu efektif. Saya memilih algoritma yang memeriksa penguatan memori sejak pembersihan terakhir. Kliring terjadi jika keuntungan lebih besar dari ambang batas.
<?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;
}
}