Oszczędność pamięci przy pracy na dużych zbiorach danych dzięki iteratorom Written on Sierpień 12, 2011, by wookieb.
Za każdym razem kiedy pracujemy z duża ilością rekordów w jednej pętli, liczymy na to, że nie skończy się pamięć. Niektórzy nie zaprzątają sobie tym głowy – do czasu… Kiedy usługa nabiera kształtu i rozmiaru, a w tle uruchamiają się procesy, które co chwilę plują błędami o przekroczeniu limitu pamięci, zaczyna się czuć niezły zapach… problemu w jakim się znaleźli.
Kiedy zwiększenie limitu pamięci nie przyniesie zamierzonego efektu sięgamy po starą dobrą metodę paczkowania danych. Tyle ile razy widziałem realizację owej metody, tyle razy brało mnie na mdłości. Jest znacznie prostsze i o niebo estetyczniejsze rozwiązanie. Iterator. A dokładniej iterator, który sam zadba o paczkowanie danych.
Na początek definicja przykładowego (wersja z PDO, znacznie uproszczona – czytelnik sam powinien zadbać o dostosowanie iteratora pod swój system).
class Iterator_DataPartition implements Iterator {
/**
* @var PDO
*/
protected $_pdo;
/**
* Num of records per partition
* @var integer
*/
protected $_partitionSize;
/**
* @var integer
*/
private $_position = 0;
/**
* Actual partition num
* @var integer
*/
private $_partitionNum = 0;
/**
* Actual partition records
* @var array
*/
protected $_data = array();
/**
* @param PDO $pdo
* @param integer $partitionSize num of records per package
* @param mixed fetchParams...
*/
public function __construct(PDO $pdo, $query, $partitionSize = 50) {
$this->_pdo = $pdo;
$this->_checkQuery($query);
$this->_query = $query;
$this->_partitionSize = (int)$partitionSize;
}
protected function _checkQuery(&$query) {
$query = trim($query);
if (!$query) {
throw InvalidArgumentException('Query is empty');
}
}
public function current() {
return $this->_data[$this->_position];
}
public function key() {
return $this->_partitionNum * $this->_partitionSize + $this->_position;
}
public function next() {
$this->_position++;
}
public function rewind() {
$this->_loadPartition(0);
$this->_position = 0;
}
public function valid() {
if (isset($this->_data[$this->_position])) {
return true;
}
if ($this->_position < $this->_partitionSize) {
return false;
}
if ($this->_loadPartition(++$this->_partitionNum)) {
$this->_position = 0;
return true;
}
return false;
}
/**
* Load records for given partition number
*
* @param type $numOfPartition
* @return boolean whether records found
*/
protected function _loadPartition($numOfPartition) {
$this->_partitionNum = (int)$numOfPartition;
$query = $this->_getPartitionQuery();
$stmt = $this->_pdo->query($query);
if ($stmt) {
$this->_data = $stmt->fetchAll();
}
return (bool)$this->_data;
}
/**
* Create query for fetching partitiokn records
* @return string
*/
protected function _getPartitionQuery() {
$offset = $this->_partitionNum * $this->_partitionSize;
$limit = $this->_partitionSize;
return $this->_query.' LIMIT '.$limit.' OFFSET '.$offset;
}
}
Przykład użycia
$pdo; // nasza przygotowana instancja PDO
$iterator = new Iterator_DataPartition($pdo, 'SELECT * FROM test');
foreach ($iterator as $record) {
// obróbka rekordu
}
Prawda, że wygodne? Zasada działania jest niezwykle prosta. Przy końcu iteracji aktualnej paczki, próbuje pobrać następną. Dzięki temu zużywamy maksymalnie tyle pamięci jak duża jest paczka rekordów.
Sprawdźmy jak wygląda w praktyce zużycie pamięci przy standardowym pobraniu rekordów a przy użyciu naszego iteratora.
Tabela ‘test’ przedstawia się następująco.
CREATE TABLE `test` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=7292 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
Wypełniłem ją 7291 rekordami gdzie jako „name” wstawiłem „Test name”.
Standardowa iteracja po wszystkich rekordach.
$pdo; // nasze PDO
$startTime = microtime(true);
$initMemory = memory_get_usage();
$memory = 0;
foreach ($pdo->query('SELECT * FROM test') as $record) {
$memory = max($memory, memory_get_usage());
}
echo ($memory-$initMemory).PHP_EOL;
echo (microtime(true) - $startTime);
Zużycie pamięci: 562552
Czas wykonywania: 0.23115491867065
A teraz Iterator_DataPartition
// mierzenie pamięci i czasu identycznie jak wyżej
$pdo; // nasze PDO
$iterator = new Iterator_DataPartition($pdo, 'SELECT * FROM test');
foreach ($iterator as $record) {
$memory = max($memory, memory_get_usage());
}
Zużycie pamięci: 25896
Czas wykonywania: 0.93066096305847
Ponad 20 krotnie mniejsze zużycie pamięci! Wynik jest naprawdę dobry ![]()
Niestety za to czas wykonywania wzrósł około czterokrotnie. W przypadku takiego małego zbioru danych różnica jest niewielka lecz przy większych zbiorach problem będzie narastać.
Zoptymalizujmy trochę Iterator. Zanim to zrobimy poznajmy przyczynę problemu. Jest nią nic innego jak wykonywanie zapytania z dużym offsetem. Mysql (jak i zresztą inne bazy danych) potrzebują znacznie więcej czasu na wykonanie takiego zapytania, ponieważ baza musi znaleźć najpierw OFFSET + LIMIT rekordów a następnie odrzucić wszystkie OFFSET pierwszych. Im większy offset, tym dłużej trwa wykonywania zapytania. Ale jest na to rozwiązanie.
Partycjonowanie danych po ID.
Przypominam, że to tylko iterator poglądowy. Właściwa wersja docelowa zależy od waszej implementacji.
class Iterator_DataPartition_IdOffset extends Iterator_DataPartition {
private $_maxId = 0;
const ID_VALUE_TOKEN = '[ID_VALUE]';
protected function _checkQuery(&$query) {
parent::_checkQuery($query);
if (strpos($query, self::ID_VALUE_TOKEN) === false) {
throw new InvalidArgumentException('No token '.self::ID_VALUE_TOKEN.' in given query');
}
}
protected function _loadPartition($numOfPartition) {
$return = parent::_loadPartition($numOfPartition);
if ($return) {
$lastRecord = end($this->_data);
$this->_maxId = $lastRecord['id'];
}
return $return;
}
protected function _getPartitionQuery() {
return str_replace(self::ID_VALUE_TOKEN, $this->_maxId, $this->_query).' LIMIT '.$this->_partitionSize;
}
}
Ta wersja iteratoa wykonuje zapytanie według schematu:
SELECT * FROM tabela WHERE id > [OSTATNIE_ID_REKORDU_Z_OSTATNIEJ_PACZKI] ORDER BY id LIMIT [ROZMIAR_PARTYCJI]
P.s. „ORDER BY id” w wypadku prostego zapytania, ale zwracam na to uwagę abyście wiedzieli, że wyniki MUSZĄ być odpowiednio posortowane.
Mysql ma znacznie mniej roboty. Wyszukuje rekordy gdzie id jest większe od X (które jest największym ID z ostatniej paczki). Z racji tego, że ID to najczęściej nasz index (do tego główny) więc takie zapytanie wykona się znacznie znacznie szybciej. Dodatkowo ograniczamy wyniki do ROZMIAR_PARTYCJI rekordów.
Sprawdźmy jak się spisuje.
// mierzenie pamięci i czasu identycznie jak wyżej
$memory = 0;
$iterator = new Iterator_DataPartition_IdOffset($pdo, 'SELECT * FROM test WHERE id > [ID_VALUE]', 5);
foreach ($iterator as $record) {
$memory = max($memory, memory_get_usage());
}
Zużycie pamięci: 26024
Czas wykonywania: 0.6228621006012
Co prawda czas nadal nie jest „super” aczkolwiek to jedyne opcje optymalizacji jakie znam. Jeżeli zwiększymy rozmiar paczki iteracja będzie szybsza (mniej zapytań do wykonania), ale zużycie pamięci wzrośnie. Dlatego niezwykle ważne jest wybranie odpowiedniego rozmiaru paczki w zależności od naszych potrzeb.
Na koniec tabelka podsumowująca:
| / | Standardowe pobieranie danych | Iterator_DataPartition | Iterator_DataPartition_IdOffset |
|---|---|---|---|
| Zużycie pamięci | 562552 | 25896 | 26024 |
| Czas iteracji: | 0.23115491867065 | 0.93066096305847 | 0.6228621006012 |
Read more from the Bazy Danych, PHP category. If you would like to leave a comment, click here: Comment. or stay up to date with this post via RSS, or you can
Trackback from your site.
Leave a Comment
If you would like to make a comment, please fill out the form below.


