مرحبا هبر! تم استنزاف الكارما بسبب تعليق مهمل تحت مقالة هوليفار ، مما يعني أنك بحاجة إلى كتابة منشور ممتع (آمل) وإعادة تأهيل نفسك.
لقد كنت أستخدم عميل الخادم telegram في php لعدة سنوات. ومثل العديد من المستخدمين - تعبت من النمو المستمر في استهلاك الذاكرة. يمكن أن تستغرق بعض الجلسات من 1 إلى 8 غيغابايت من ذاكرة الوصول العشوائي! تم الوعد بدعم قاعدة البيانات لفترة طويلة ، ولكن لم يكن هناك تقدم في هذا الاتجاه. اضطررت إلى حل المشكلة بنفسي :) فرضت شعبية مشروع المصدر المفتوح متطلبات مثيرة للاهتمام على طلب السحب:
- التوافق مع الإصدارات السابقة . يجب أن تستمر جميع الجلسات الحالية في العمل في الإصدار الجديد (الجلسة هي نسخة متسلسلة من التطبيق في ملف) ؛
- حرية اختيار قاعدة البيانات . القدرة على تغيير نوع التخزين دون فقد البيانات وفي أي وقت ، نظرًا لأن المستخدمين لديهم تكوينات مختلفة للبيئة ؛
- التمدد . سهولة إضافة أنواع جديدة من قواعد البيانات ؛
- واجهة حفظ . يجب ألا يتغير رمز التطبيق الذي يعالج البيانات ؛
- عدم التزامن . يستخدم المشروع amphp ، لذلك يجب أن تكون جميع عمليات قاعدة البيانات غير محظورة ؛
لمزيد من التفاصيل أدعو الجميع تحت القط.
ماذا سننقل
تشغل الدردشات والمستخدمون والملفات معظم ذاكرة MadelineProto. على سبيل المثال ، في ذاكرة التخزين المؤقت للأقران ، لدي أكثر من 20 ألف إدخال. هؤلاء هم جميع المستخدمين الذين شاهدهم الحساب (بما في ذلك أعضاء جميع المجموعات) ، بالإضافة إلى القنوات وبرامج الروبوت والمجموعات. كلما كان الحساب أقدم وأكثر نشاطًا ، زادت البيانات الموجودة في الذاكرة. هذه عشرات ومئات الميغابايت ، ومعظمها غير مستخدم. لكن لا يمكنك مسح ذاكرة التخزين المؤقت بالكامل ، لأن البرقيات ستقيد الحساب على الفور بشدة عند محاولة تلقي نفس البيانات عدة مرات. على سبيل المثال ، بعد إعادة إنشاء الجلسة على خادم العرض التوضيحي العام الخاص بي ، أجابت البرقيات خلال أسبوع على معظم الطلبات بخطأ FLOOD_WAIT ولم ينجح شيء حقًا. بعد تسخين ذاكرة التخزين المؤقت ، عاد كل شيء إلى طبيعته.
من وجهة نظر الكود ، يتم تخزين هذه البيانات كمصفوفات في خصائص زوج من الفئات.
هندسة معمارية
بناءً على المتطلبات ، وُلد مخطط:
- يتم استبدال كافة المصفوفات "الثقيلة" بالكائنات التي تقوم بتطبيق ArrayAccess ؛
- لكل نوع من أنواع قواعد البيانات نقوم بإنشاء الفئات الخاصة بنا والتي ترث القاعدة الأساسية ؛
- يتم إنشاء الكائنات وكتابتها إلى الخصائص أثناء __قناة_الإصدار و __awake؛
- يختار المصنع المجرد الفئة المطلوبة للكائن ، اعتمادًا على قاعدة البيانات المحددة في إعدادات التطبيق ؛
- إذا كان التطبيق يحتوي بالفعل على نوع آخر من التخزين ، فسنقرأ جميع البيانات من هناك ونكتب المصفوفة في وحدة التخزين الجديدة.
مشاكل العالم غير المتزامن
أول شيء فعلته هو إنشاء واجهات وفئة لتخزين المصفوفات في الذاكرة. كان هذا هو السلوك الافتراضي المطابق للإصدار الأقدم من البرنامج. في الأمسية الأولى ، كنت متحمسًا جدًا لنجاح النموذج الأولي. كان الرمز لطيفًا وبسيطًا. حتى الآن لم يتم اكتشاف أنه من المستحيل استخدام المولدات داخل طرق واجهة Iterator والطرق الداخلية المسؤولة عن unset و isset.
يجب توضيح هنا أن amphp يستخدم صيغة المولد لتنفيذ غير متزامن في php. يصبح العائد مشابهًا للغير متزامن ... في انتظار js. إذا كانت الطريقة تستخدم عدم التزامن ، فمن أجل الحصول على نتيجة منه ، عليك انتظار هذه النتيجة في الكود باستخدام العائد. على سبيل المثال:
<?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();إزالة العائد ، ثم سيتم إنهاء التطبيق قبل تنفيذ هذا الرمز. لن نحصل على النتيجة.
إضافة العائد قبل استدعاء الأساليب والوظائف ليس بالأمر الصعب. ولكن منذ استخدام واجهة ArrayAccess ، لا يتم استدعاء الطرق مباشرة. على سبيل المثال ، تستدعي unset () offsetUnset () ، وتستدعي isset () offsetIsset (). يتشابه الوضع مع مكرري foreach عند استخدام واجهة Iterator.
تؤدي إضافة العائد أمام الأساليب المضمنة إلى حدوث خطأ لأن هذه الطرق غير مصممة للعمل مع المولدات. أكثر من ذلك بقليل في التعليقات: هنا و هنا .
كان علي التنازل عن الكود وإعادة كتابته لاستخدام أسالي الخاصة. لحسن الحظ ، كان هناك عدد قليل جدًا من هذه الأماكن. في معظم الحالات ، تم استخدام المصفوفات للقراءة أو الكتابة بالمفتاح. هذه الوظيفة جعلت صداقات كبيرة مع المولدات.
الواجهة الناتجة هي:
<?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 من أجل دعم الأشياء. يحتوي الجدول على عمودين رئيسيين: مفتاح وقيمة.
مثال على استعلام 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'
في كل مرة يبدأ فيها التطبيق ، نحاول إنشاء جدول لكل خاصية. لا يُنصح عملاء Telegram بإعادة التشغيل أكثر من مرة كل بضع ساعات ، لذلك لن يكون لدينا عدة طلبات لإنشاء جداول في الثانية :)
نظرًا لأن المفتاح الأساسي لا يتزايد تلقائيًا ، فيمكن عندئذٍ إدراج البيانات وتحديثها باستخدام استعلام واحد ، كما هو الحال في المصفوفة العادية:
INSERT INTO `{$this->table}`
SET `key` = :index, `value` = :value
ON DUPLICATE KEY UPDATE `value` = :value
يتم إنشاء جدول بالاسم بالتنسيق٪ account_id٪ _٪ class٪ _٪ variable_name٪ لكل متغير. ولكن عند بدء التطبيق لأول مرة ، لا يوجد حساب حتى الآن. في هذه الحالة ، يجب عليك إنشاء معرف مؤقت عشوائي باستخدام البادئة tmp. في كل عملية إطلاق ، تتحقق فئة كل متغير من ظهور معرف الحساب. إذا كان المعرف موجودًا ، فستتم إعادة تسمية الجداول.
فهارس
هيكل قاعدة البيانات بسيط قدر الإمكان بحيث يتم إضافة خصائص جديدة تلقائيًا في المستقبل. لا توجد اتصالات. يتم استخدام فهارس المفاتيح الأساسية فقط. ولكن هناك حالات تحتاج فيها إلى البحث في مجالات أخرى.
على سبيل المثال ، هناك مجموعة / دردشات جدول. المفتاح فيه هو معرف الدردشة. لكن غالبًا ما يتعين عليك البحث باسم المستخدم. عندما كان التطبيق يخزن البيانات في مصفوفات ، تم إجراء البحث باسم المستخدم كالمعتاد من خلال التكرار عبر المصفوفة في foreach. عمل هذا البحث بسرعة مقبولة في الذاكرة ، لكن ليس في قاعدة البيانات. لذلك ، تم إنشاء جدول / صفيف آخر والخاصية المقابلة في الفئة. المفتاح هو اسم المستخدم ، والقيمة هي معرف الدردشة. العيب الوحيد في هذا الأسلوب هو أنه يجب عليك كتابة رمز إضافي لمزامنة الجدولين.
التخزين المؤقت
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 دقائق (قابلة للتكوين في الإعدادات) ، فسيتم حذف العنصر. من السهل جدًا تنفيذ نظير ttl من redis أو memcache باستخدام amphp. كل هذا يحدث في الخلفية ولا يمنع الخيط الرئيسي.
بمساعدة ذاكرة التخزين المؤقت وعدم التزامن ، لا يتم تسريع عمليات القراءة فحسب ، بل يتم أيضًا الكتابة.
فيما يلي الكود المصدري للطريقة التي تكتب البيانات إلى قاعدة البيانات.
/**
* 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-> يقوم الطلب بإنشاء وعد يكتب البيانات بشكل غير متزامن. وتحدث العمليات مع ذاكرة التخزين المؤقت بشكل متزامن. أي ، ليس عليك انتظار الكتابة إلى قاعدة البيانات وفي نفس الوقت تأكد من أن عمليات القراءة ستبدأ فورًا في إعادة البيانات الجديدة.
تبين أن طريقة onResolve من amphp مفيدة جدًا. بعد اكتمال الإدراج ، ستتم كتابة البيانات في ذاكرة التخزين المؤقت مرة أخرى. إذا تأخرت بعض عمليات الكتابة وبدأت ذاكرة التخزين المؤقت والقاعدة في الاختلاف ، فسيتم تحديث ذاكرة التخزين المؤقت بالقيمة المكتوبة إلى القاعدة أخيرًا. أولئك. سوف تصبح ذاكرة التخزين المؤقت الخاصة بنا متوافقة مرة أخرى مع القاعدة.
مصدر
→ رابط لسحب الطلب
ومثلما أضاف مستخدم آخر دعم postgre. استغرق الأمر 5 دقائق فقط لكتابة التعليمات الخاصة به.
يمكن تقليل مقدار الكود عن طريق نقل الطرق المكررة إلى فئة الملخص العامة SqlArray.
شيء اخر
لوحظ أنه أثناء تنزيل ملفات الوسائط من telegram ، لا يتكيف جامع القمامة القياسي php مع العمل وتبقى أجزاء من الملف في الذاكرة. عادة ، كانت التسريبات بنفس حجم الملف. السبب المحتمل: يتم تشغيل أداة تجميع البيانات المهملة تلقائيًا عند تراكم 10000 رابط. في حالتنا ، كانت الروابط قليلة (عشرات) ، لكن كل منها يمكن أن يشير إلى ميغا بايت من البيانات في الذاكرة. لقد كانت دراسة آلاف الأسطر من التعليمات البرمجية باستخدام تطبيق 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;
}
}