Doctrine批处理迭代高内存使用

32

我一直在研究在Doctrine中使用迭代器进行批处理(http://docs.doctrine-project.org/en/2.0.x/reference/batch-processing.html)。我有一个包含20,000张图片的数据库,我希望能够迭代处理。

我了解到使用迭代器可以避免Doctrine在内存中加载每一行数据。然而,在这两个示例之间的内存使用几乎完全相同。我是通过使用(memory_get_usage() / 1024)在使用前后计算内存使用量的。

$query = $this->em->createQuery('SELECT i FROM Acme\Entities\Image i');
$iterable = $query->iterate();

while (($image = $iterable->next()) !== false) {
    // Do something here!
}

迭代器的内存使用情况。

Memory usage before: 2823.36328125 KB
Memory usage after: 50965.3125 KB

这个第二个例子使用 findAll 方法将整个结果集加载到内存中。

$images = $this->em->getRepository('Acme\Entities\Image')->findAll();

findAll的内存使用情况。

Memory usage before: 2822.828125 KB
Memory usage after: 51329.03125 KB
6个回答

75
批处理使用Doctrine比看起来要棘手,即使有iterate()IterableResult的帮助。正如您所期望的那样,IterableResult的最大好处是它不会将所有元素加载到内存中,第二个好处是它不会保存对您加载的实体的引用,因此IterableResult不会阻止GC从您的实体中释放内存。但是,还有另一个对象Doctrine的EntityManager(更具体地说是UnitOfWork),它保存了对显式或隐式查询的每个对象的所有引用(EAGER关联)。简而言之,无论您通过findAll()findOneBy()甚至是DQL查询和IterableResult返回任何实体时,都会在Doctrine内部保存对每个实体的引用。该引用仅存储在一个关联数组中,以下是伪代码:$identityMap['Acme\Entities\Image'][0] = $image0;
因为在每次循环迭代时,即使你先前的图像不在循环范围或IterableResult的范围内,它们仍然存在于identityMap中,GC无法清除它们,因此你的内存消耗与调用findAll()时相同。
现在让我们看看代码实际发生了什么。
$query = $this->em->createQuery('SELECT i FROM Acme\Entities\Image i');  
// here doctrine only creates Query object, no db access here

$iterable = $query->iterate();
// unlike findAll(), upon this call no db access happens.  
// Here the Query object is simply wrapped in an Iterator  

while (($image_row = $iterable->next()) !== false) {  
    // now upon the first call to next() the DB WILL BE ACCESSED FOR THE FIRST TIME
    // the first resulting row will be returned
    // row will be hydrated into Image object
    // ----> REFERENCE OF OBJECT WILL BE SAVED INSIDE $identityMap <----
    // the row will be returned to you via next()

    // to access actual Image object, you need to take [0]th element of the array                            


     $image = $image_row[0];
    // Do something here!
     write_image_data_to_file($image,'myimage.data.bin');
    
    //now as the loop ends, the variables $image (and $image_row) will go out of scope 
    // and from what we see should be ready for GC
    // however because reference to this specific image object is still held
    // by the EntityManager (inside of $identityMap), GC will NOT clean it 
}
// and by the end of your loop you will consume as much memory
// as you would have by using `findAll()`.

所以第一种解决方案是实际告诉Doctrine EntityManager将对象从$identityMap中分离。我还将while循环替换为foreach,使其更易读。
foreach($iterable as $image_row){
    $image = $image_row[0]; 

    // do something with the image
    write_image_data_to_file($image);
    
    $entity_manager->detach($image);
    // this line will tell doctrine to remove the _reference_to_the_object_ 
    // from identity map. And thus object will be ready for GC
}

然而,上面的示例存在一些缺陷,即使它被列在Doctrine批处理文档中。如果您的实体Image中有任何一个关联是EAGER加载,那么它就无法正常工作。但是,如果您EAGER地加载了任何一个关联,例如:
/*
  @ORM\Entity
*/
class Image {
  
  /* 
    @ORM\Column(type="integer")
    @ORM\Id 
   */
  private $id;
  
  /*
    @ORM\Column(type="string")
  */
  private $imageName;

  /*
   @ORM\ManyToOne(targetEntity="Acme\Entity\User", fetch="EAGER")
   This association will be automatically (EAGERly) loaded by doctrine
   every time you query from db Image entity. Whether by findXXX(),DQL or iterate()
  */
  private $owner;

  // getters/setters left out for clarity
}

如果我们使用与上面相同的代码段,则在

foreach($iterable as $image_row){
    $image = $image_row[0]; 
    // here becuase of EAGER loading, we already have in memory owner entity
    // which can be accessed via $image->getOwner() 

    // do something with the image
    write_image_data_to_file($image);
    
    $entity_manager->detach($image);
    // here we detach Image entity, but `$owner` `User` entity is still
    // referenced in the doctrine's `$identityMap`. Thus we are leaking memory still.
   
}

可能的解决方案是使用EntityManager::clear()EntityManager::detach(),它们可以完全清除身份映射表。
foreach($iterable as $image_row){
    $image = $image_row[0]; 
    // here becuase of EAGER loading, we already have in memory owner entity
    // which can be accessed via $image->getOwner() 

    // do something with the image
    write_image_data_to_file($image);
    
    $entity_manager->clear();
    // now ``$identityMap` will be cleared of ALL entities it has
    // the `Image` the `User` loaded in this loop iteration and as as
    // SIDE EFFECT all OTHER Entities which may have been loaded by you
    // earlier. Thus you when you start this loop you must NOT rely
    // on any entities you have `persist()`ed or `remove()`ed 
    // all changes since the last `flush()` will be lost.
   
}

@Stphane,使用HYDRATE_SCALAR是个有趣的建议。起初我并没有考虑它作为可行的选项,因为当你使用HYDRATE_SCALAR时,你将无法得到以可遍历对象图形式返回的对象。你将无法像$image->getUser()->getProfession()->getSalary()那样跟随一个对象到另一个对象的关系。它们将被返回为大型联合数组(类似于SELECT ... JOIN)。当你拥有平面对象关系(1或2级)时,HYDRATE_SCALAR可能适用于你。但我不确定当你稍后向对象添加更多关系时它是如何工作的... - Dimitry K
1
@SalmanvonAbbas,我想表达的是,调用->iterate()方法只会返回一个包装对象(实际上还没有向MySQL或MySQL驱动程序发出任何物理请求)。只有在迭代器请求第一个项目时,才会启动查询过程。(因此,这种行为不会与@BlocksByLukas所述的相矛盾,假设他的研究结果是正确的,并且适用于此处)。另外,在我之前的评论中,我错误地提到了两个方法getIterator()/ iterate(),但应该正确地只有一个iterate()(无法再编辑该评论)。 - Dimitry K
“detach” 已被弃用。 - Anton Duzenko
@AntonDuzenko,我想知道新的方法是什么?从(Doctrine Docs about Batch processing)中我们可以读到以下内容:ORM工具不适用于大规模插入、更新或删除。每个RDBMS都有自己最有效的处理这些操作的方式...... 看起来答案是:"不要使用Doctrine进行批处理" :S 你也可以在这里找到Doctrine Issue on Deprecation of EntityRepository#clear() 有帮助。 - Dimitry K
至少我们想要将其用于批量选择(许多嵌套实体)。EntityManager::clear() 似乎可以胜任,但它仍然在这个缓存无聊的事情上浪费了大量的 CPU 时间。我对 Doctrine 的内部松散感到不满。@DimitryK 如果你想要一个工作得到很好地完成,就自己动手吧 ;) - Anton Duzenko
显示剩余5条评论

8
如果你将 Doctrine 的 iterate() 和分批策略相结合,就可以遍历大型记录。
例如:

$batchSize = 1000;
$numberOfRecordsPerPage = 5000;

$totalRecords = $queryBuilder->select('count(u.id)')
            ->from('SELECT i FROM Acme\Entities\Image i')
            ->getQuery()
            ->getSingleScalarResult();   //Get total records to iterate on

        $totalProcessed = 0;

        $processing = true;

        while ($processing) {
            $query = $entityManager->createQuery('SELECT i FROM Acme\Entities\Image i')
                ->setMaxResults($numberOfRecordsPerPage) //Maximum records to fetch at a time
                ->setFirstResult($totalProcessed);
          
             $iterableResult = $query->iterate();
          
            while (($row = $iterableResult->next()) !== false) {
                $image = $row[0];
                $image->updateSomethingImportant();
              
                 if (($totalProcessed % $batchSize ) === 0) {
                    $entityManager->flush();
                    $entityManager->clear();
                }
                $totalProcessed++;
            }
            if ($totalProcessed === $totalRecords) {
                break;
            }
        }

    $entityManager->flush();


请查看使用Doctrine 2迭代大量数据


迭代应该足够了。也许他应该禁用Doctrine缓存? - Loenix

5

我坚信使用Doctrine进行批处理或任何与MySQL(PDO或mysqli)迭代相关的操作都只是一种幻觉。

@dimitri-k提供了一个很好的解释,特别是关于工作单元。问题在于有误导的"$query->iterate()",它实际上并没有在数据源上进行迭代。它只是一个\Traversable包装器,包装着已经完全获取的数据源。

下面是一个例子,即使完全从图片中删除Doctrine抽象层,我们仍然会遇到内存问题:

echo 'Starting with memory usage: ' . memory_get_usage(true) / 1024 / 1024 . " MB \n";

$pdo  = new \PDO("mysql:dbname=DBNAME;host=HOST", "USER", "PW");
$stmt = $pdo->prepare('SELECT * FROM my_big_table LIMIT 100000');
$stmt->execute();

while ($rawCampaign = $stmt->fetch()) {
    // echo $rawCampaign['id'] . "\n";
}

echo 'Ending with memory usage: ' . memory_get_usage(true) / 1024 / 1024 . " MB \n";

输出:

Starting with memory usage: 6 MB 
Ending with memory usage: 109.46875 MB

在这里,让人失望的getIterator()方法:

namespace Doctrine\DBAL\Driver\Mysqli\MysqliStatement

/**
 * {@inheritdoc}
 */
public function getIterator()
{
    $data = $this->fetchAll();

    return new \ArrayIterator($data);
}

您可以使用我的小型库来使用PHP Doctrine或DQL或纯SQL 实际上 流式传输大型表。您可以选择任何适当的方式:https://github.com/EnchanterIO/remote-collection-stream


4
你没有解释为什么你的图书馆更好。 - user4962466
@EnchanterIO 你的库如何改善这个问题?https://dev59.com/u4Pba4cB1Zd3GeqPnhYB#36201665 - Enrico Stahn
感谢@blocksbylukas提出这个有趣的想法,但是我现在没有时间去透彻地理解它,因为我不再经常使用Doctrine。 - Dimitry K
我认为你对批处理和Doctrine的看法是错误的,至少现在是这样。它不仅仅是一个已经获取数据的数组迭代器。我刚刚通过实施其他人和文档中概述的迭代器模式,成功地大大减少了内存消耗。 - Richard Kiefer
但是在幕后发生了什么样的查询?你在内存中获取了多少数据?毫无疑问,Doctrine正在执行一些奇妙的技巧,我不记得3年前出了什么问题,但我认为我写下这个答案是因为我认为迭代器会以某种方式从表中“流式传输”行,而我相信它实际上获取了所有行,然后对实体进行了PHP优化。现在可能已经不同了,三年后。那个小库(老实说自那以后就没有用过)非常明确地说明了它所做的事情:SQL偏移量+迭代器循环。 - Lukas Lukac
哦,我想念 PHP!现在感觉很怀旧,这是我在 PHP 中做的最后一件事情之一。 - Lukas Lukac

2

简述:

运行命令时使用--no-debug或将Sql日志记录器设置为null,以防止其保存所有运行的查询。

偶尔使用EntityManager::clear(),内存泄漏将降至几乎为零。


1

对于批处理,我喜欢使用yield这种方式:

ImageRepository.php

<?php

class ImageRepository extends ServiceEntityRepository
{
    // The rest of your code...

    public function findAllImages(): \Iterator
    {
        // Find the total amount of images.
        $total_images = $this->createQueryBuilder('i')
           ->select('COUNT(*)')
           ->getQuery()
           ->getSingleScalarResult();

        $processed_records = 0;

        while (true) {
            $query = $this->createQueryBuilder('i')
                // Process batch of 100 results, this can
                // be whatever amount you can fit in memory,
                ->setMaxResults(100)
                ->setFirstResult($processed_records)
                ->getQuery()
                ->getResult();

            $processed_records += count($query);

            // With yield you are not storing the full
            // amount of images in memory.    
            yield from $query;

            if ($processed_records >= $total_images) {
                break;
            }
        }
    }
}

然后你可以在其他地方使用它

<?php

class ImageCommand extends Command
{
    // The rest of your code...

    public function processAllImages(ImageRepository $repository): void
    {
        $images = $repository->findAllImages();

        foreach ($images as $image) {
            $this->processImage($image);
        }
    }
}

-5

结果可能相似,因为数据库客户端可能会分配额外的您看不到的内存。此外,您的代码使用了从'$query->iterate()'返回的'IterableResult';这允许处理大量结果而不会有内存问题。这只是一些快速的想法,希望它能对您有所帮助。


2
这个答案完全没有帮助。 - stollr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接