Showing posts with label MySQL. Show all posts
Showing posts with label MySQL. Show all posts

PHP caching: shm vs. apc vs. memcache vs. mysql vs. file cache (update: fill apc from cron)

Lessons learned:
  • shm/apc are 32-60 times faster than memcached or mysql
  • shm/apc are 2 times faster than php file cache with apc
  • php file cache with apc is 15-24 times faster than memcached or mysql
  • mysql is 2 times faster than memcached when storing more than 400 bytes
  • memcached is 2 times faster than mysql when storing less than 400 bytes
  • php file cache with apc is 2-3 times faster than normal file cache
  • php file cache without apc is 8 times slower than normal file cache

Tests were made with PHP 5.3.10, MySQL 5.5.29, memcached 1.4.13, 64bit, 3.4GHz (QEMU):


shm
0.031 0.020 0.021 0.021 0.026 0.028 0.032 0.042 0.084 0.155 0.290 0.629 0.110
Total: 1.489, Avg: 0.115

apc
0.025 0.025 0.025 0.026 0.031 0.036 0.043 0.060 0.106 0.171 0.328 0.756 0.097
Total: 1.728, Avg: 0.133

memcache
3.116 3.014 3.005 3.072 3.077 3.910 3.929 4.067 4.308 10.371 15.323 25.013 3.281
Total: 85.488, Avg: 6.576

memcache socket
1.736 1.756 1.981 1.780 1.809 1.907 1.941 1.983 2.225 9.368 14.071 24.897 1.979
Total: 67.435, Avg: 5.187

memcached
2.241 2.540 2.713 2.769 2.897 3.249 4.286 5.298 7.729 10.539 16.021 28.060 2.578
Total: 90.919, Avg: 6.994

mysql myisam
3.267 3.291 3.310 3.295 3.700 3.777 3.888 4.078 4.368 6.272 6.930 9.626 3.726
Total: 59.529, Avg: 4.579

mysql memory
3.238 3.360 3.470 3.502 3.310 3.346 3.681 4.108 4.370 6.286 7.279 7.079 3.397
Total: 56.426, Avg: 4.340

file cache
0.593 0.595 0.593 0.609 0.546 0.563 0.574 0.600 0.648 0.800 1.115 1.956 0.775
Total: 9.966, Avg: 0.767

php file cache
0.177 0.176 0.175 0.180 0.187 0.188 0.195 0.210 0.236 0.318 0.479 0.901 0.228
Total: 3.650, Avg: 0.281

(10b 0.1k 0.3k 0.5k 1k 2k 4k 8k 16k 32k 64k 128k array)
Notes: Numbers in seconds, smaller numbers are better, connection times for memcached and mysql are not counted, file cache was done on tmpfs.

Cold cache, fill apc cache from cron:

All entries in apc cache are kept in shared memory. Shared memory is available to the web server and is not shared with the command line php (php-cli). When the web server is restarted, the cache is empty and refilling the complete cache can create performance problems. To fill the apc cache with a cron job, we use a second script to dump the cache to a binary file and load it inside the web server:


// php cron.php
$data = array("cached"=>1, "hello"=>"world");
apc_store($data);
apc_bin_dumpfile(array(), array_keys($data), "/var/cache/apc_cron.bin");

// bootstrap.php, http://myserver.com/...
if (apc_fetch("cached")===false) apc_bin_loadfile("/var/cache/apc_cron.bin");

echo apc_fetch("hello"); // gives "world"

Related articles:

Here is the test script:


<?php
// memcached -m 64 -s /tmp/m.sock -a 0777 -p 0 -u memcache
// memcached -m 64 -l 127.0.0.1 -p 11211 -u memcache
set_time_limit(3600);
error_reporting(E_ALL);
ini_set("display_errors", 1);
ini_set("apc.enable_cli", 1);
mysqli_report(MYSQLI_REPORT_STRICT | MYSQLI_REPORT_ERROR);

$data = array( // test are range from 10-128,000 bytes
"1111111110",
str_repeat("1111111110", 10),
str_repeat("1111111110", 30),
str_repeat("1111111110", 50),
str_repeat("1111111110", 100),
str_repeat("1111111110", 200),
str_repeat("1111111110", 400),
str_repeat("1111111110", 800),
str_repeat("1111111110", 1600),
str_repeat("1111111110", 3200),
str_repeat("1111111110", 6400),
str_repeat("1111111110", 12800),
array(0=>"1111111110", "id2"=>"hello world", "id3"=>"foo bar", "id4"=>42)
);

echo "shm\n";
$t = array();
foreach ($data as $key=>$val) $t[] = shm(4000+$key, $val);
echo stats($t);

echo "apc\n";
$t = array();
foreach ($data as $key=>$val) $t[] = apc((string)$key, $val);
echo stats($t);

echo "memcache\n";
$t = array();
$m = memcache_connect("127.0.0.1", 11211);
foreach ($data as $key=>$val) $t[] = memcache($m, (string)$key, $val);
echo stats($t);

echo "memcache socket\n";
$t = array();
$m = memcache_connect("unix:///tmp/m.sock", 0);
foreach ($data as $key=>$val) $t[] = memcache($m, (string)$key, $val);
echo stats($t);

echo "memcached\n";
$t = array();
$m = new Memcached();
$m->addServer("127.0.0.1", 11211);
foreach ($data as $key=>$val) $t[] = memcached($m, (string)$key, $val);
echo stats($t);

/* not in memcached 1.x
echo "memcached socket\n";
$t = array();
$m = new Memcached();
$m->addServer("unix:///tmp/m.sock", 0);
foreach ($data as $key=>$val) $t[] = memcached($m, (string)$key, $val);
echo stats($t);
*/

echo "mysql myisam\n";
$t = array();
$m = new mysqli("127.0.0.1", "root", "", "t1");
mysqli_query($m, "drop table if exists t1.cache");
mysqli_query($m, "create table t1.cache (id int primary key, data mediumtext) engine=myisam");
foreach ($data as $key=>$val) $t[] = mysql_cache($m, $key, $val);
echo stats($t);

echo "mysql memory\n";
$t = array();
mysqli_query($m, "drop table if exists t1.cache");
mysqli_query($m, "create table t1.cache (id int primary key, data varchar(65500)) engine=memory");
foreach ($data as $key=>$val) $t[] = mysql_cache($m, $key, $val);
echo stats($t);

echo "file cache\n";
$t = array();
foreach ($data as $key=>$val) $t[] = file_cache((string)$key, $val);
echo stats($t);

echo "php file cache\n";
$t = array();
foreach ($data as $key=>$val) $t[] = php_cache((string)$key, $val);
echo stats($t);

function stats($t) {
return "\nTotal: ".number_format(array_sum($t), 3).", ".
"Avg: ".number_format(array_sum($t) / count($t), 3)."\n\n";
}

function format($num) {
return number_format($num, 3);
}

function shm($id, $data) {
if (is_array($data)) {
$arr = true;
$data = serialize($data);
} else $arr = false;
$len = strlen($data);
$shm_id = shmop_open($id, "c", 0644, $len);
shmop_write($shm_id, $data, 0);
$start = microtime(true);
if ($arr) {
for ($i=0; $i<100000; $i++) $v = unserialize(shmop_read($shm_id, 0, $len));
} else {
for ($i=0; $i<100000; $i++) $v = shmop_read($shm_id, 0, $len);
}
echo format($end = microtime(true)-$start)." ";
shmop_close($shm_id);
assert(substr(is_array($v) ? $v[0] : $v, 0, 10)=="1111111110");
return $end;
}

function apc($id, $data) {
apc_store($id, $data);
$start = microtime(true);
for ($i=0; $i<100000; $i++) $v = apc_fetch($id);
echo format($end = microtime(true)-$start)." ";
assert(substr(is_array($v) ? $v[0] : $v, 0, 10)=="1111111110");
return $end;
}

function memcache($m, $id, $data) {
memcache_set($m, $id, $data);
$start = microtime(true);
for ($i=0; $i<100000; $i++) $v = memcache_get($m, $id);
echo format($end = microtime(true)-$start)." ";
assert(substr(is_array($v) ? $v[0] : $v, 0, 10)=="1111111110");
return $end;
}

function memcached($m, $id, $data) {
$m->set($id, $data);
$start = microtime(true);
for ($i=0; $i<100000; $i++) $v = $m->get($id);
echo format($end = microtime(true)-$start)." ";
assert(substr(is_array($v) ? $v[0] : $v, 0, 10)=="1111111110");
return $end;
}

function mysql_cache($m, $id, $data) {
$d = is_array($data) ? serialize($data) : $data;
mysqli_query($m, "insert into t1.cache values (".$id.", '".$d."')");
$start = microtime(true);
if (is_array($data)) {
for ($i=0; $i<100000; $i++) {
$v = mysqli_query($m, "SELECT data FROM t1.cache WHERE id=".$id)->fetch_row();
$v = unserialize($v[0]);
}
} else {
for ($i=0; $i<100000; $i++) {
$v = mysqli_query($m, "SELECT data FROM t1.cache WHERE id=".$id)->fetch_row();
}
}
echo format($end = microtime(true)-$start)." ";
assert(substr($v[0], 0, 10)=="1111111110");
return $end;
}

function file_cache($id, $data) {
file_put_contents($id, is_array($data) ? serialize($data) : $data);
$start = microtime(true);
if (is_array($data)) {
for ($i=0; $i<100000; $i++) $v = unserialize(file_get_contents($id));
} else {
for ($i=0; $i<100000; $i++) $v = file_get_contents($id);
}
echo format($end = microtime(true)-$start)." ";
assert(substr(is_array($v) ? $v[0] : $v, 0, 10)=="1111111110");
return $end;
}

function php_cache($id, $data) {
$id .= ".php";
$data = is_array($data) ? var_export($data, 1) : "'".$data."'";
file_put_contents($id, "<?php\n\$v=".$data.";");
touch($id, time()-10); // needed for APC's file update protection
$start = microtime(true);
for ($i=0; $i<100000; $i++) include($id);
echo format($end = microtime(true)-$start)." ";
assert(substr(is_array($v) ? $v[0] : $v, 0, 10)=="1111111110");
return $end;
}

How to implement really small and fast ORM with PHP (Part 7: IDE)

Fork me on GitHub

Queries are gaining more and more complexity, data is getting bigger and bigger. Most optimizations in database technology are done in the database server. This is an approach to optimize queries on the client side.

With this ORM, queries ...
  • don't select more data than needed
  • contain less joins when data is expected to be consistent
  • can be written manually in pure SQL
  • are not written in a new query language

We need a good API, so ...

  • it should be easy to learn
  • method names must be short and intuitive
  • the goal is to map datasets and relations to objects
  • the API should offer method chaining
  • special features like auto-increments should be included
  • the code should be small, no getters and setters
  • the database schema is created before writing PHP code
  • relationships should be defined in the database, not in the code
  • we get low latencies combined with low memory usage

To make things easier, we make some restrictions:

  • only UTF-8
  • only MySQL/MariaDB (mysqli)
  • only PHP 5.4.0+
  • only buffered queries

Our ORM should have the same efficiency as handwritten SQL. So the following statements should produce only 1 query:

  • DBo::Guestbook(42)->Comments()->delete(); // "Guestbook" and "Comments" are tables
  • DBo::Guestbook(42)->Comments()->update('hidden', 1);
  • DBo::Guestbook(42)->Comments()->count();
  • DBo::Student()->Attend()->Lecture()->Uses()->Book(); // "Uses", "Book", etc. are tables

The following statements should only update 1 column:

  • DBo::Guestbook(42)->Comments()->update('hidden', 1);
  • DBo::Guestbook(42)->update('title', 'hello');

The following statements should only select 1 column (that's the hard one):

  • foreach (DBo::Guestbook() as $gb) echo $gb->title;

This gets implemented with a two-pass method: We store the columns used in the first run in DBo::usage_col[code position] and reuse it in the second run. The "code position" is the line where the constructor was called (returned from debug_backtrace()).

Meta data like columns or indexes should be fetched statically into the code (not during runtime). To create a join out of "Guestbook()->Comments()", the relevant columns are chosen during runtime. The syntax is:
  • table.primary_key_field = other_table.table_primary_key_field
  • e.g. sale.id = salepos.sale_id

To export the schema, we use:


// export schema to schema.php
DBo::conn(new mysqli('127.0.0.1', 'root', 'some_pw'));
DBo::exportSchema();

To keep control over the queries, we allow normal queries and debugging:


$entries = DBo::query('SELECT * FROM guestbook WHERE id=?', [42]); // Iterator
foreach ($entries as $obj) {...}

$id = DBo::query('INSERT INTO guestbook VALUES (...)'); // LastInsert ID
// 42

$affected = DBo::query('UPDATE guestbook SET active=0'); // Affected rows
// 10

$subject = DBo::value('SELECT subject FROM guestbook WHERE id=42'); // String
// 'Hello World'

$categories = DBo::values('SELECT DISTINCT categories FROM guestbook'); // Array
// ['Sports', 'Movies', 'Music']

$row = DBo::one('SELECT * FROM guestbook WHERE id=42'); // Array
// [id=>42, title=>'hello']

$row = DBo::keyValue('SELECT id,title FROM guestbook'); // Array
// [42=>'hello', 43=>'world']

$row = DBo::keyValues('SELECT id,title,subject FROM guestbook'); // Array
// [42=>['title'=>'hello', 'subject'=>'world'], 43=>['title'=>...]

echo DBo::Guestbook(42)->Comments(); // SQL String
// SELECT a.* FROM Comments a, Guestbook b WHERE ...

echo DBo::Guestbook(42)->Comments()->explain(); // explain SQL string
// EXPLAIN SELECT ... id | select_type | table | type ...

DBo::Guestbook(42)->Comments()->print_r(); // print_r related comments
// Array( id=... )

We also allow transactions:


DBo::begin();
$dbo = DBo::Guestbook(42);
$dbo->Comments()->delete(); // DELETE FROM Comments ...
$dbo->update('comments_count', 0); // UPDATE Guestbook SET ...
DBo::commit();

Here are some examples how the ORM should work:


// create a new entry in table "Guestbook"
// set attribute values to "hello" and "world"
// finally print out the primary key (auto-generated by auto-increment)
// - gives INSERT INTO Guestbook SET subject='hello', details='world'
$obj = DBo::Guestbook();
$obj->subject = 'hello';
$obj->details = 'world';
echo $obj->insert(); // 43 (from auto_increment)
echo $obj->id; // 43
// or $_POST = ['subject'=>'hello', ...]
echo DBo::Guestbook()->insert($_POST); // 43

// map entry in table "Guestbook" with primary key "42" to "$obj"
// - gives SELECT * FROM Guestbook WHERE id=42
$obj = DBo::Guestbook(42);
if (!$obj->exists()) {...}
echo $obj->subject;
// or
echo DBo::Guestbook(42)->subject;

// update antry in table "Guestbook" with primary key "42", set "hidden" to "1"
// - gives UPDATE Guestbook SET hidden=1 WHERE id=42
$obj = DBo::Guestbook(42);
$obj->hidden = 1;
$obj->update();
// or
DBo::Guestbook(42)->update('hidden', 1);
// or $_POST = ['hidden'=>'1', ...]
DBo::Guestbook(42)->update($_POST);

// delete entry in table "Guestbook" with primary key "42"
// - gives DELETE FROM Guestbook WHERE id=42
DBo::Guestbook(42)->delete();

// increment a field in table "Guestbook" with primary key "42"
// - gives UPDATE Guestbook SET likes=likes+1 WHERE id=42
DBo::Guestbook(42)->update('likes=likes+1');

Doing 1:n and n:m relations should be also very easy:


// get all Comments for Guestbook entry with primary key 42
// join a 1:n relationship (table.id = table2.table_id)
// - gives SELECT * FROM Comments WHERE guestbook_id=42
$comments = DBo::Guestbook(42)->Comments();
foreach ($comments as $comment) {...}

// update comments
// - gives UPDATE Comments SET hidden=1 WHERE guestbook_id=24
// - note that traditional ORMs do one update statement for each dataset
DBo::Guestbook(42)->Comments()->update('hidden', 1);

// update comments with where predicate
// - gives UPDATE Comments SET active=0 WHERE active=1 AND guestbook_id=24
DBo::Guestbook(42)->Comments('active=1')->update('active', 0);

// deleting comments works in the same way
// - gives DELETE FROM Comments WHERE guestbook_id=24
DBo::Guestbook(42)->Comments()->delete();

// n:m Students attend Lectures
// - gives SELECT a.* FROM Lecture a, Attend b WHERE b.student_id=21 AND
// b.lecture_id = a.id
$lectures = DBo::Student(21)->Attend()->Lecture();
foreach ($lectures as $lecture) {...}

// n:m Students attend Lectures, Lecture uses Books
// - gives SELECT a.* FROM Book a, Uses b, Lecture c, Attend d
// WHERE d.student_id = 21 AND d.lecture_id = c.id
// AND c.id = b.lecture_id AND b.book_id = a.id
$books = DBo::Student(21)->Attend()->Lecture()->Uses()->Book();
foreach ($books as $book) {...}

Sometimes it is better to avoid normalization and store multiple values inside a string. This reduces the number of tables, relations and costly joins, e.g. using a string value like "100,101,102" instead of a join. The data can be also encoded as a JSON string with '[100,101,102]' or '["100","101","102"]'. To do the encoding and decoding automatically, the names of the members can be amended with "_arr" and "_json". Here is an example:


// automatic encoding and decoding of values
// - gives UPDATE Guestbook SET tags_arr='sport,music,tv' WHERE id=42
// UPDATE Guestbook SET tags_json='{"a":"b","c":"d"}' WHERE id=42
$obj = DBo::Guestbook(42);
$obj->update('tags_arr', ['sport','music','tv']); // field tags (Varchar)
// or
$obj->update('tags_json', ['a'=>'b', 'c'=>'d']); // field tags2 (Varchar)

$obj = DBo::Guestbook(42);
print_r($obj->tags_arr); // Array([0] => sport\n [1] => music\n [2] => tv)
// or
print_r($obj->tags_json); // Array([a] => b\n [c] => d)

Predicates can be defined in several ways:


// select one dataset
// - gives SELECT * FROM Guestbook WHERE id=10
DBo::Guestbook('id=10');
DBo::Guestbook('id=?', 10);
DBo::Guestbook(['id'=>10]);
DBo::Guestbook(10); // id is a numeric primary key

// select multiple datasets
// - gives SELECT * FROM Guestbook WHERE id IN (10,11,12)
DBo::Guestbook('id in (10,11,12)');
DBo::Guestbook('id in ?', [10,11,12]);
DBo::Guestbook(['id'=>[10,11,12]]);
DBo::Guestbook([10,11,12]); // id is a primary key

// select multiple primary keys
// - gives SELECT * FROM Guestbook WHERE (id,id2) IN ((10,11))
DBo::Guestbook('id=10 and id2=11');
DBo::Guestbook('(id,id2) in ?', [10,11]);
DBo::Guestbook('id=? and id2=?', 10, 11);
DBo::Guestbook(['id'=>10, 'id2'=>11]);
DBo::Guestbook([[10,11]]); // id and id2 are a primary key

// select multiple datasets with multiple primary keys
// - gives SELECT * FROM Guestbook WHERE (id,id2) IN ((10,1),(11,2))
DBo::Guestbook('(id,id2) in ((10,1), (11,2))');
DBo::Guestbook('(id,id2) in ?', [[10,1], [11,2]]);
DBo::Guestbook([[10,1], [11,2]]); // id and id2 are a primary key

// additional predicates
DBo::Guestbook(10, "active=1");
DBo::Guestbook("active=1 and foo=?", "bar");

// limit
foreach (DBo::Order("status=open")->limit(100) as $obj) {...

Joins are skipped when data is expected to be consistent:


foreach (DBo::guestbook(42)->comments() as $comment) {...}
// gives SELECT * FROM comments WHERE guestbook_id=42

Custom SQL can be used:


$comments = DBo::object("SELECT * FROM Comments WHERE guestbook_id=?", [42]);
foreach ($comments as $comment) {...}

Aggregate functions can be used:


// SELECT count(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->count(); // 40

// SELECT sum(price) FROM order where customer_id=42
DBo::Customer(42)->Order()->sum('price'); // 600

// SELECT avg(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->avg('price'); // 23

// SELECT stddev(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->stddev('price'); // 8

Data can be archived:


// archiving is not yet implemented

// create archive table
// CREATE TABLE Customer_archive LIKE Customer;
// # remove primary & auto_increment, add index and timestamp
// ALTER TABLE Customer_archive DROP primary key, MODIFY id int,
// ADD index(id), ADD ts timestamp;

// archive record before updating
// Customer.id = primary key, Customer_archive.id = index
// INSERT INTO test.Customer_archive
// SELECT a.*, now() FROM test.Customer a WHERE a.id=42
// UPDATE test.Customer a SET a.hidden=1 WHERE a.id=42
DBo::Customer(42)->archive()->update('hidden', 1);

// copy record to table
// INSERT INTO somedb.sometable
// SELECT a.* FROM test.Customer a WHERE a.id=42
DBo::Customer(42)->copyTo('somedb.sometable');

Custom classes can also be used:


class DBo_Sales extends DBo {
public function buildData($insert=false) {
// custom validation
$data = parent::buildData();
if (empty($data["some_val"])) throw new Exception(...);
return $data;
}

public function insert($arr=null) {
// execute some pre trigger
parent::insert($arr);
// execute some post trigger
}

public function delete() {
if ($this->status != 'draft') throw new Exception(...);
parent::delete();
}

public function completed() {
return DBo::query('SELECT * FROM sales WHERE completed=1');
}

public function get_age() {
return date_diff(new DateTime($this->birthdate), new DateTime())->y;
}
}

// DBo_{table} is automatically used as class
print_r(DBo::Sales());
=> DBo_Sales Object (...

print_r(DBo::Sales()->completed());
=> returns completed() from Dbo_Sales

print_r(DBo::object("SELECT * FROM Sales")->completed());
=> returns completed() from Dbo_Sales

// get_{field}() is automatically used when the member not exists
print_r(DBo::Sales()->age);
=> returns get_age()

The database connection should be opened when the first query is being executed. We use:


class mysqli_lazy extends mysqli {
public function query($query) {
if (!@$this->host_info) parent::connect('127.0.0.1', 'root', '', 'db');
// or persistent connection: 'p:127.0.0.1'
return parent::query($query);
}
}
DBo::conn(new mysqli_lazy, 'db');

// instead of
DBo::conn(new mysqli('127.0.0.1', 'root', '', 'db'), 'db');
// or persistent connection: 'p:127.0.0.1'

To get all queries on stdout, we use:


class mysqli_log extends mysqli {
public function query($query) {
echo $query."\n";
return parent::query($query);
}
}
DBo::conn(new mysqli_log('127.0.0.1', 'root', '', 'db'), 'db');

The fastest way to get data is reading it from a hash table in the main memory. With the APC extension, we can persist data between many requests. The syntax for caching looks like this:


// caching is not yet implemented

// cache categories for 60 seconds
$payments = DBo::Categories()->cache(60);
// [{id=>0, name=>Sports}, {id=>1, name=>Movies}, ...]

$payments = DBo::Categories()->oarray(60);
// [[id=>0, name=>Sports], [id=>1, name=>Movies], ...]

$payments = DBo::Categories()->ovalues('col_name', 60);
// [Sports, Movies, ...]

$payments = DBo::Categories()->okeyValue('col_id', 'col_name', 60);
// [0=>Sports, 1=>Movies, ...]

$payments = DBo::Categories()->count(60); // 42

Broken queries or connection errors can be handled with try-catch:


try {
DBo::conn(new mysqli('127.0.0.1', 'root', '', 'db'), 'db');
DBo::query('select * from invalid');
}
catch (mysqli_sql_exception $e) {
echo $e->getMessage();
exit(1);
}

If column names are ambiguous, we need to prefix them with "@":


// SELECT a.* FROM app a, os b WHERE a.os_id=b.id AND id=42 AND id=13
DBo::os('id=42')->app('id=13');

// SELECT a.* FROM app a, os b WHERE a.os_id=b.id AND b.id=42 AND a.id=13
DBo::os('@id=42')->app('@id=13');

Security:

Scalar parameters are escaped automatically if the previous parameter is a string. When using scalar parameters without predicates, you need to cast them manually:


DBo::Guestbook('id=?', $_GET['id']); // automatic
DBo::Guestbook((int)$_GET['id']); // manual!
DBo::Guestbook((array)$_GET['ids']); // manual!

Other parameters and field names are automatically escaped:


DBo::Guestbook([ $_GET['id'], $_GET['val'] ]); // automatic
DBo::Guestbook([ 'id' => $_GET['id'] ]); // automatic

DBo::Guestbook()->setFrom($_POST)->insert(); // automatic
DBo::Guestbook()->limit($_POST['limit']); // automatic

IDE integration: (e.g. PhpStorm)


// auto-generation of PHPdoc hints is not yet implemented

// type hint
$sales = DBo::Sales(); /* @var $sales DBo_Sales */

// or class hint
/**
* @method static DBo_Sales Sales some description
*/
class DBo implements IteratorAggregate {...}

// or extended class hint
/**
* @method static DBo_Sales Sales some description
*/
class HDBo extends DBo {}

$sales = HDBo::Sales();

/**
* @property int id
* @property decimal_6_2 price
* @property varchar_40 desc
*/
class DBo_Sales extends DBo {}





Keyboard shortcuts:
- auto-complete: Ctrl+space
- documentation: Ctrl+q
- variable info: Ctrl+mouseover


The implementation in detail:

Running DBo::Student()->Attend()->Lecture()->array() as a single query can be implemented by using a stack. The query itself presents a chain of tables being joined by some predicates. Attend() and others are handled by PHP's magic __call(). Every table in the chain pushes a new element on the stack containing the name of the table, the primary key and the required predicate(s).

Even with the best ORM, you can still write bad code:


foreach (DBo::os(10)->app() as $app) {
if (!$app->active) continue; // bad
...
}
foreach (DBo::os(10)->app('active=1') as $app) {...} // good, less data

foreach (DBo::os(10)->app() as $app) {
foreach ($app->compontent() as $compontent) {...} // bad, many queries
}
foreach (DBo::os(10)->app()->compontent() as $compontent) {...} // good

foreach (DBo::os(10)->app() as $app) $app->update('active', 1); // bad
DBo::os(10)->app()->update('active', 1); // good, 1 query

foreach (DBo::sale(10)->salepos() as $salepos) {
if (DBo::logistics($salepos->id, 'shipped=1')->exists()) {
$salepos->update('complete', 1); // bad
}
}
DBo::sale(10)->salepos()->logistics('shipped=1')
->salepos()->update('complete', 1); // good

In general, it is better to select as little data as possible from the database. Joins are often expensive, but if the database handles them correctly, it is much more efficient than performing joins directly in PHP.

Changelog:


The code is available at: https://github.com/thomasbley/DBo (~500 loc, test coverage 100%)


Mass inserts, updates: SQLite vs MySQL (update: delayed inserts)

Lessons learned:
  • SQLite performs inserts 8-14 times faster then InnoDB / MyISAM
  • SQLite performs updates 4-8 times faster then InnoDB and as fast as MyISAM
  • SQLite performs selects 2 times faster than InnoDB and 3 times slower than MyISAM
  • SQLite requires 2.6 times less disk space than InnoDB and 1.7 times more than MyISAM
  • Allowing null values or using synchronous=NORMAL makes inserts 5-10 percent faster in SQLite

Using SQLite instead of MySQL can be a great alternative on certain architectures. Especially if you can partition the data into several SQLite databases (e.g. one database per user) and limit parallel transactions on one database. Replication, backup and restore can be done easily over the file system.

The results:
(MySQL 5.6.5 default config without binlog, SQLite 3.7.7, PHP 5.4.5, 2 x 1.4 GHz, disk 5400rpm)

insert [s] sum [s] update [s] size [MB]
JSON 1.84 1.30 2.92 2.96
CSV 1.97 2.25 3.7 2.57
SQLite (memory)2.740.120.520.00
SQLite (memory, not null)3.000.120.520.00
SQLite (normal sync)3.060.171.212.88
SQLite3.170.160.962.88
SQLite (not null)3.400.171.152.88
MySQL (memory)21.250.040.063.17
MySQL (memory, not null)21.280.040.073.17
MySQL (CSV, not null)23.800.171.562.51
MySQL (MyISAM)25.600.051.021.72
MySQL (MyISAM, not null)25.650.051.011.72
MySQL (InnoDB)27.720.315.657.52
MySQL (InnoDB, not null)27.740.314.917.52
insert / update 200k (1 process)

Using delayed inserts makes MySQL even slower:
delayed insert [s]
MySQL (memory not null)28.55
MySQL (memory)28.73
MySQL (CSV, not null)60.09
MySQL (MyISAM, not null)33.44
MySQL (MyISAM)32.94
MySQL (InnoDB)60.04
MySQL (InnoDB, not null)61.62
insert 200k (1 process)

insert [s] sum [s] update [s] insert [s] sum [s] update [s]
SQLite (memory)2.850.130.55 6.070.231.10
SQLite (memory, not null)3.110.140.54 6.440.241.09
SQLite (normal sync)3.210.181.56 5.600.171.82
SQLite3.390.181.51 5.940.171.74
SQLite (not null)3.690.181.61 6.340.191.36
MySQL (memory)32.910.060.06 61.090.040.14
MySQL (memory, not null)32.400.040.07 60.590.050.08
MySQL (CSV, not null)36.000.161.70 66.840.204.48
MySQL (MyISAM)37.070.051.09 73.660.111.45
MySQL (MyISAM, not null)36.240.051.08 72.100.071.53
MySQL (InnoDB)40.650.305.62 83.550.3714.41
MySQL (InnoDB, not null)41.730.325.84 88.950.6014.69
insert / update 2 x 200k (2 processes), 4 x 200k (4 processes)

Here is the test script:

// CSV
$csv = tempnam('/tmp', 'csv');
$start = microtime(true);
$fp = fopen($csv, 'w');
for ($i=0; $i<200000; $i++) fputcsv($fp, array($i, $i*2));
fclose($fp);
echo 'csv '.number_format(microtime(true)-$start, 2);

$start = microtime(true);
$fp = fopen($csv, 'r');
$sum = 0;
while (!feof($fp)) $sum += @array_pop(fgetcsv($fp));
fclose($fp);
echo ' '.number_format(microtime(true)-$start, 2);

$start = microtime(true);
$fp = fopen($csv, 'r');
$fp2 = fopen($csv.'2', 'w');
while (!feof($fp)) {
$data = fgetcsv($fp);
fputcsv($fp2, array($data[0], $data[1]+2));
}
fclose($fp);
fclose($fp2);
echo ' '.number_format(microtime(true)-$start, 2)."\n";

// JSON
$json = tempnam('/tmp', 'json');
$start = microtime(true);
$fp = fopen($json, 'w');
for ($i=0; $i<200000; $i++) fwrite($fp, '['.$i.','.($i*2).']'."\n");
fclose($fp);
echo 'json '.number_format(microtime(true)-$start, 2);

$start = microtime(true);
$fp = fopen($json, 'r');
$sum = 0;
while (!feof($fp)) $sum += @array_pop(json_decode(fgets($fp)));
fclose($fp);
echo ' '.number_format(microtime(true)-$start, 2);

$start = microtime(true);
$fp = fopen($json, 'r');
$fp2 = fopen($json.'2', 'w');
while (!feof($fp)) {
$data = json_decode(fgets($fp));
fputs($fp2, '['.$data[0].','.($data[1]+2).']'."\n");
}
fclose($fp);
fclose($fp2);
echo ' '.number_format(microtime(true)-$start, 2)."\n";

// SQLite
// 100.000 was the best transaction size in several runs
sqlite_test(':memory:');
sqlite_test(':memory:', 'not null');
sqlite_test('/tmp/test1a.db', '', 'sync_norm'); // use test2..n for n processes
sqlite_test('/tmp/test1b.db');
sqlite_test('/tmp/test1c.db', 'not null');

function sqlite_test($file, $null='', $opt=false) {
$db = new SQLite3($file);
if ($opt) $db->exec('PRAGMA synchronous=NORMAL');
$db->exec('CREATE TABLE foo (i INT '.$null.', i2 INT '.$null.')');
$start = microtime(true);
$db->exec('begin');
for ($i=0; $i<200000; $i++) {
$db->exec('INSERT INTO foo VALUES ('.$i.', '.($i*2).')');
if ($i%100000==0) {
$db->exec('commit');
$db->exec('begin');
}
}
$db->exec('commit');
echo "sqlite $file $null $opt ".number_format(microtime(true)-$start, 2);

$start = microtime(true);
$db->query('SELECT sum(i2) FROM foo')->fetchArray();
echo ' '.number_format(microtime(true)-$start, 2);

$start = microtime(true);
$db->exec('UPDATE foo SET i2=i2+2');
echo ' '.number_format(microtime(true)-$start, 2);
echo ' '.number_format(@filesize($file)/1048576, 2)."\n";
}

// MySQL
// 30.000 was the best transaction size in several runs
mysql_test('memory', 'not null');
mysql_test('memory');
mysql_test('csv', 'not null');
mysql_test('myisam', 'not null');
mysql_test('myisam');
mysql_test('innodb', 'not null');
mysql_test('innodb');

function mysql_test($e, $null='') {
$db = new mysqli('127.0.0.1', 'root', '', 'test'); // use test2..n
$db->query('DROP TABLE foo');
$db->query('CREATE TABLE foo (i INT '.$null.',i2 INT '.$null.') ENGINE='.$e);

$start = microtime(true);
$db->query('begin');
for ($i=0; $i<200000; $i++) {
$db->query('INSERT INTO foo VALUES ('.$i.', '.($i*2).')');
if ($i%30000==0) {
$db->query('commit');
$db->query('begin');
}
}
$db->query('commit');
echo "mysql $e $null ".number_format(microtime(true)-$start, 2);

$start = microtime(true);
$db->query('SELECT sum(i2) FROM foo')->fetch_row();
echo ' '.number_format(microtime(true)-$start, 2);

$start = microtime(true);
$db->query('UPDATE foo SET i2=i2+2');
echo ' '.number_format(microtime(true)-$start, 2);

$row = $db->query('SHOW TABLE STATUS')->fetch_array();
if (!$row['Data_length']) $row['Data_length'] = filesize('.../test/foo.CSV');
echo ' '.number_format($row['Data_length']/1048576, 2)."\n";
}

MySQLi prepared statements

Lessons learned:
  • Prepared statements are 13 percent faster than normal statements with escaping
  • Prepared statements are 8 percent faster than normal statements without escaping
  • To get improvements, you need at least 10000 inserts for 1 statement
  • Using insert...set is 0.5-1 percent faster than insert...values

Here is the code:

$db = new mysqli('127.0.0.1', 'root', '', 'test');
$db->query('create table if not exists prep (i1 int, i2 int, s1 varchar(255)) engine=myisam');
$db->query('truncate table prep');

$start = microtime(true);
$stmt = $db->prepare('insert into prep (i1,i2,s1) values (?,?,?)');
$i=0; $j=0; $s=null;
$stmt->bind_param('iis', $i, $j, $s);
for ($i=0; $i<100000; $i++) {
$j = $i*2;
$s = 'hello world'.$i;
$stmt->execute();
}
echo 'prep values '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
$stmt = $db->prepare('insert into prep set i1=?, i2=?, s1=?');
$i=0; $j=0; $s=null;
$stmt->bind_param('iis', $i, $j, $s);
for ($i=0; $i<100000; $i++) {
$j = $i*2;
$s = 'hello world'.$i;
$stmt->execute();
}
echo 'prep set '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$db->query("insert into prep (i1,i2,s1) values (".$i.",".($i*2).",'hello world".$i."')");
}
echo 'no escape values '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$db->query("insert into prep set i1=".$i.", i2=".($i*2).", s1='hello world".$i."'");
}
echo 'no escape set '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$arr = [$i, $i*2, 'hello world'.$i];
foreach ($arr as &$item) if (!is_numeric($item)) $item = $db->real_escape_string($item);
$db->query("insert into prep (i1,i2,s1) values ('".$arr[0]."','".$arr[1]."','".$arr[2]."')");
}
echo 'escape string values '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$arr = [$i, $i*2, 'hello world'.$i];
foreach ($arr as &$item) if (!is_numeric($item)) $item = $db->real_escape_string($item);
$db->query("insert into prep set i1='".$arr[0]."', i2='".$arr[1]."', s1='".$arr[2]."'");
}
echo 'escape string set '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$arr = [$i, $i*2, 'hello world'.$i];
foreach ($arr as &$item) $item = $db->real_escape_string($item);
$db->query("insert into prep (i1,i2,s1) values ('".$arr[0]."','".$arr[1]."','".$arr[2]."')");
}
echo 'escape values '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$arr = [$i, $i*2, 'hello world'.$i];
foreach ($arr as &$item) $item = $db->real_escape_string($item);
$db->query("insert into prep set i1='".$arr[0]."', i2='".$arr[1]."', s1='".$arr[2]."'");
}
echo 'escape set '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$arr = array_map([$db, 'real_escape_string'], [$i, $i*2, 'hello world'.$i]);
$db->query("insert into prep (i1,i2,s1) values ('".$arr[0]."','".$arr[1]."','".$arr[2]."')");
}
echo 'escape map values '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);
$db->query('truncate table prep');

$start = microtime(true);
for ($i=0; $i<100000; $i++) {
$arr = array_map([$db, 'real_escape_string'], [$i, $i*2, 'hello world'.$i]);
$db->query("insert into prep set i1='".$arr[0]."', i2='".$arr[1]."', s1='".$arr[2]."'");
}
echo 'escape map set '.number_format(microtime(true)-$start, 2)."\n";

assert($db->query('select count(*) from prep')->fetch_row()[0]==100000);

prep values 13.08
prep set 13.02
no escape values 14.31
no escape set 14.18
escape string values 15.06
escape string set 14.99
escape values 15.10
escape set 15.04
escape map values 15.51
escape map set 15.45

MySQL or MySQLi or PDO

Lessons learned:
  • MySQLi is 3-4 times slower than MySQL when fetching less then 500 datasets
  • MySQLi is 2-4 times faster than MySQL when fetching more than 500 datasets
  • PDO is 2-5 times slower than MySQL/MySQLi
  • Unbuffered queries are 15-40 percent faster than buffered queries in MySQLi
  • Unbuffered queries are 10-25 percent faster than buffered queries in MySQL for less than 10000 datasets
  • Unbuffered queries are 3-7 percent slower than buffered queries in MySQL for more than 10000 datasets
  • Unbuffered queries are 0-5 percent faster than buffered queries in PDO
  • Non thread safe versions of PHP on win32 are 50 percent faster than thread safe versions

Here is the test script:

$table = 'test1.test2';
benchmark($table, 100);
benchmark($table, 500);
benchmark($table, 1000);
benchmark($table, 5000);
benchmark($table, 10000);
benchmark($table, 50000);
benchmark($table, 100000);

function benchmark($table, $size) {
mysql_connect('127.0.0.1', 'root', '');
mysql_query('drop table if exists '.$table);
mysql_query("CREATE TABLE $table (id int(11) AUTO_INCREMENT,
str1 varchar(255), str2 varchar(255), PRIMARY KEY (id)) ENGINE=INNODB");
mysql_query("begin");
for ($i=0; $i<$size; $i++) {
mysql_query("insert into $table values(null, 'hello$i', 'world$i')");
}
mysql_query("commit");
// warm up mysql cache
$db = new PDO('mysql:host=127.0.0.1', 'root', '');
foreach ($db->query('select * from '.$table) as $vals) $test = $vals;


$start = microtime(true);
mysql_connect('127.0.0.1', 'root', '');
$result = mysql_query('select * from '.$table);
while ($row = mysql_fetch_assoc($result)) $test = $row;
echo $size.' mysql-buffered '.number_format(microtime(true)-$start, 5)."\n";

$start = microtime(true);
mysql_connect('127.0.0.1', 'root', '');
$result = mysql_unbuffered_query('select * from '.$table);
while ($row = mysql_fetch_assoc($result)) $test = $row;
echo $size.' mysql-unbuffered '.number_format(microtime(true)-$start, 5)."\n";

$start = microtime(true);
$db = mysqli_connect('127.0.0.1', 'root', '');
foreach (mysqli_query($db, 'select * from '.$table) as $row) $test = $row;
echo $size.' mysqli-buffered '.number_format(microtime(true)-$start, 5)."\n";

$start = microtime(true);
$db = mysqli_connect('127.0.0.1', 'root', '');
foreach (mysqli_query($db, 'select * from '.$table, MYSQLI_USE_RESULT)
as $row) $test = $row;
echo $size.' mysqli-unbuffered '.number_format(microtime(true)-$start, 5)."\n";

$start = microtime(true);
$db = new PDO('mysql:host=127.0.0.1', 'root', '');
foreach ($db->query('select * from '.$table) as $vals) $test = $vals;
echo $size.' pdo-buffered '.number_format(microtime(true)-$start, 5)."\n";

$start = microtime(true);
$db = new PDO('mysql:host=127.0.0.1', 'root', '',
array(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false));
foreach ($db->query('select * from '.$table) as $vals) $test = $vals;
echo $size.' pdo-unbuffered '.number_format(microtime(true)-$start, 5)."\n";
}
Tests were made with PHP 5.3.10, MySQL 5.5.29, Kernel 3.2.0, 64bit, 3.4GHz (QEMU), values in seconds:

100 mysql-buffered 0.00011
100 mysql-unbuffered 0.00015
100 mysqli-buffered 0.00041
100 mysqli-unbuffered 0.00032
100 pdo-buffered 0.00043
100 pdo-unbuffered 0.00041
500 mysql-buffered 0.00034
500 mysql-unbuffered 0.00045
500 mysqli-buffered 0.00033
500 mysqli-unbuffered 0.00024
500 pdo-buffered 0.00058
500 pdo-unbuffered 0.00058
1000 mysql-buffered 0.00059
1000 mysql-unbuffered 0.00064
1000 mysqli-buffered 0.00037
1000 mysqli-unbuffered 0.00030
1000 pdo-buffered 0.00090
1000 pdo-unbuffered 0.00096
5000 mysql-buffered 0.00288
5000 mysql-unbuffered 0.00290
5000 mysqli-buffered 0.00077
5000 mysqli-unbuffered 0.00054
5000 pdo-buffered 0.00340
5000 pdo-unbuffered 0.00341
10000 mysql-buffered 0.00564
10000 mysql-unbuffered 0.00580
10000 mysqli-buffered 0.00123
10000 mysqli-unbuffered 0.00079
10000 pdo-buffered 0.00665
10000 pdo-unbuffered 0.00656
50000 mysql-buffered 0.04469
50000 mysql-unbuffered 0.04609
50000 mysqli-buffered 0.01915
50000 mysqli-unbuffered 0.01735
50000 pdo-buffered 0.04679
50000 pdo-unbuffered 0.04587
100000 mysql-buffered 0.08471
100000 mysql-unbuffered 0.09054
100000 mysqli-buffered 0.03967
100000 mysqli-unbuffered 0.03343
100000 pdo-buffered 0.09257
100000 pdo-unbuffered 0.09148