使用Yii框架,最快地将设计在MySQL上的ActiveRecord迁移到MongoDB上

前提 (Qian ti)

现代的Web应用程序由于不知道何时会产生大量访问,所以需要从初期阶段就意识到负载均衡的设计。应用服务器通过扩展规模以实现负载均衡,而数据库则通常通过读取副本模式(使用一个主服务器进行写入,从多个从服务器进行读取)来分担负载。

尽管如此,我们仍然困扰于主数据库写入事务成为瓶颈的问题。因此,对于不需要ACID特性但有大量写入的数据,我们会选择将其与主数据库分开,并使用NoSQL数据库。如果使用MongoDB,可以通过使用分片功能来分散处理写入负载。

然而,在尚未完成全面环境建立的原型阶段,我们会希望能在简单的环境下尽快实现朴素的设计思想。从一开始就有意识地进行性能调优建模并不容易。最好的方法是暂时忘记性能问题,尽快制作出原型。但即使这样,如果没有对这种技术债务(为了快速启动而借入的债务)的后续解决保证,我们无法安心地进行工作。

我们将看一下在Yii框架中是如何解决这个问题的。

创造一个名为“原型”的负债

为了更容易理解,我将举一个稍微夸张的例子。

投稿記事需要一个有点不同的页面浏览计数。它可以动态地从过去任意日期开始计算页面浏览次数。

所以,为了记录所有页面浏览次数,我们决定设置具有has-many关系的AccessLog。每当浏览网页时,我们将创建一条记录。我们考虑用MySQL来实现这个功能。

    public function up()
    {
        $this->createTable('{{%access_log}}', [
            'id' => Schema::TYPE_BIGPK,
            'post_id' => Schema::TYPE_INTEGER . ' NOT NULL',
            'recorded_at' => Schema::TYPE_INTEGER . ' NOT NULL',
        ]);
        $this->addForeignKey('fk_access_log_post_id', '{{%access_log}}', 'post_id', '{{%post}}', 'id', 'CASCADE');
    }

在Gii中生成并添加方法。

<?php
namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%post}}".
 *
 * @property integer $id
 * @property string $title
 * @property string $body
 *
 * @property AccessLog[] $accessLogs
 */
class Post extends \yii\db\ActiveRecord
{
    public static function tableName()
    {
        return '{{%post}}';
    }

// 中略

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getAccessLogs()
    {
        return $this->hasMany(AccessLog::className(), ['post_id' => 'id']);
    }

    /**
     * 投稿記事のページが表示されたときにレコードを作成する
     */
    public function recordPageViewed()
    {
        $log = new AccessLog();
        $log->post_id = $this->id;
        $log->recorded_at = time();
        $log->save(false);
        // $this->refresh();
    }

    /**
     * ある日時以降のページビュー数を得る
     * 
     * @param integer $timestamp
     * @return integer
     */
    public function getPageViewAfter($timestamp=null)
    {
        $query = $this->getrelation('accessLogs');
        if ($timestamp) {
            $query->where(['>=', 'recorded_at', $timestamp]);
        }
        return $query->count();
    }
}
<?php
namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%access_log}}".
 *
 * @property integer $id
 * @property integer $post_id
 * @property integer $recorded_at
 *
 * @property Post $post
 */
class AccessLog extends \yii\db\ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return '{{%access_log}}';
    }

// 中略

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getPost()
    {
        return $this->hasOne(Post::className(), ['id' => 'post_id']);
    }
}

在视图处理结束时调用Post::recordPageViewed()方法将数据插入access_log表,以达到所需的功能。

在开发阶段,它可以正常运行,但是不能直接发布出去。对于开发环境,只需要一个人进行测试,但是在Web上存在着成千上万潜在用户。仅仅通过页面显示就导致主数据库写入操作,从而造成负载过重是一个严重的问题。还有就是如何偿还这种技术债务才是关键。

将MySQL的ActiveRecord转换为MongoDB。

Yii Framework 2.0 在开发过程中同时为关系型数据库和NoSQL设计了 ActiveRecord,并且共享了大部分抽象类和特性。这样,即使是不同类型的 ActiveRecord,也具有相当大的兼容性。

要使用MongoDB,需要在Composer中添加安装库。

$ composer require yiisoft/yii2-mongodb

在应用程序的设置文件中添加mongodb组件。

$config = [
    'components' => [
        // ...
        'mongodb' => [
            'class' => 'yii\mongodb\Connection',
            'dsn' => 'mongodb://localhost:27017/mydb',
        ],
    ],

在这之后,将AccessLog转换为MongoDB的ActiveRecord,那么之前提到的这两个类的实现会变成这样。

<?php
namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%post}}".
 *
 * @property integer $id
 * @property string $title
 * @property string $body
 *
 * @property AccessLog[] $accessLogs
 */
class Post extends \yii\db\ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return '{{%post}}';
    }

// 中略

    /**
     * @return \yii\mongodb\ActiveQuery
     */
    public function getAccessLogs()
    {
        return $this->hasMany(AccessLog::className(), ['post_id' => 'id']);
    }

    /**
     * 投稿記事のページが表示されたときにレコードを作成する
     */
    public function recordPageViewed()
    {
        $log = new AccessLog();
        $log->post_id = $this->id;
        $log->recorded_at = time();
        $log->save(false);
        // $this->refresh();
    }

    /**
     * ある日時以降のページビュー数を得る
     * 
     * @param integer $timestamp
     * @return integer
     */
    public function getPageViewAfter($timestamp=null)
    {
        $query = $this->getrelation('accessLogs');
        if ($timestamp) {
            $query->where(['gte' => ['recorded_at' => $timestamp]]);
        }
        return $query->count();
    }
}
<?php
namespace app\models;

/**
 * This is the model class for collection "access_log".
 *
 * @property integer $_id
 * @property integer $post_id
 * @property integer $recorded_at
 *
 * @property Post $post
 */
class AccessLog extends \yii\mongodb\ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function collectionName()
    {
        return 'access_log';
    }

    /**
     * @inheritdoc
     */
    public function attributes()
    {
        return ['_id', 'post_id', 'recorded_at'];
    }

// 中略、RDBのときと全く同じ

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getPost()
    {
        return $this->hasOne(Post::className(), ['id' => 'post_id']);
    }
}

我写下了没有改变的部分,没有省略。您能够理解,独特功能的部分和较为复杂的部分变化较少吗?

以下是具体的更改点:

    • 名前空間の違う yii\mongodb\ActiveRecord と yii\mongodb\ActiveQuery が登場

tableName() は collectionName() になった
MongoDB にはスキーマがないので attributes() でフィールドを明示
MongoDB の習慣に合わせて id を _id に変更

>= を使った条件式は MongoDB の大小比較 gte を使った構文に

这些都是只需要机械操作就能完成的任务。

     /**
-     * @return \yii\db\ActiveQuery
+     * @return \yii\mongodb\ActiveQuery
      */
     public function getAccessLogs()

     public function getPageViewAfter($timestamp=null)
     {
         $query = $this->getrelation('accessLogs');
         if ($timestamp) {
-            $query->where(['>=', 'recorded_at', $timestamp]);
+            $query->where(['gte' => ['recorded_at' => $timestamp]]);
         }
- * @property integer $id
+ * @property integer $_id

  */
-class AccessLog extends \yii\db\ActiveRecord
+class AccessLog extends \yii\mongodb\ActiveRecord
 {
     /**
      * @inheritdoc
      */
-    public static function tableName()
+    public static function collectionName()
     {
-        return '{{%access_log}}';
+        return 'access_log';
     }

+    /**
+     * @inheritdoc
+     */
+    public function attributes()
+    {
+        return ['_id', 'post_id', 'recorded_at'];
+    }

为什么更改点如此之少呢?当使用Yii的查询构建器时,如果想要指定某个字段的值为如此,且另一个字段的值为如此来进行搜索时,可以按照以下方式编写。

$query->where([
    'field_a' => $value1,
    'field_b' => $value2,
]);

当事情变得有点复杂时,我们可以使用基于键值对的关联数组进行嵌套。这种风格非常类似于MongoDB的查询语法(也与ElasticSearch相似)。虽然不能完全以同样的方式编写任何查询,但我们仍然可以看出它具有相当一致性意识的NoSQL查询构建器。

如果使用更简单或者没有 KVS 的话,可以说它在伪代码中等价于 ActiveRecord::find()->where([‘id’ => $key])->one();。可以用 ActiveRecord::findOne($key); 替换这种模式。

Yii框架在改变ActiveRecord类型时的影响非常小,它成功地进行了良好的抽象化,使得无论使用哪种类型都能保持一致的思维方式。

值得一提的是,MongoDB和RDB之间的关联关系得以保持。即使经过更改,仍然可以使用像 $post->accessLogs 或 $accessLog->post 这样的代码。

当Yii加载相关记录时,不使用JOIN,而是使用使用关联键对应的查询来实现。因此,即使在不同的存储之间的关系中,也能正确地建立关联。(只要能够正确编写没有冲突的关系键的程序)

在Yii中,我们可以创建不受存储实体影响的接口不变的模型。因此,当应用程序的其他部分发生突然的重大变化并导致崩溃时,这种情况将不会发生。我们可以同时保持原型的运行状态,并逐步进行优化。

广告
将在 10 秒后关闭
bannerAds