Model Caching


    Every application is different. In most applications though, there is data that changes infrequently. One of the most common bottlenecks in terms of performance, is accessing a database. This is due to the complex connection/communication processes that PHP perform with each request to obtain data from the database. Therefore, if we want to achieve good performance, we need to add some layers of caching where the application requires it.

    This chapter explains the potential areas where it is possible to implement caching to improve performance. Phalcon gives developers the tools they need to implement cashing where their application needs it.

    Caching Resultsets

    A well established technique to avoid continuously accessing the database, is to cache resultsets that don’t change frequently, using a system with faster access (usually memory).

    When requires a service to cache resultsets, it will request it from the Dependency Injection Container. The service name is called . Phalcon offers a cache component that can store any kind of data. We will now see how we can integrate it with our Models.

    First, we will need to register the cache component as a service in the DI container.

    Phalcon offers complete control in creating and customizing the cache component before registering it as a service in the DI container. Once the cache component is properly set up, resultsets can be cached as follows:

    1. <?php
    2. // Get products without caching
    3. $products = Products::find();
    4. // Just cache the resultset. The cache will expire in 1 hour (3600 seconds)
    5. $products = Products::find(
    6. [
    7. 'cache' => [
    8. 'key' => 'my-cache',
    9. ],
    10. ]
    11. );
    12. // Cache the resultset for only for 5 minutes
    13. $products = Products::find(
    14. [
    15. 'cache' => [
    16. 'key' => 'my-cache',
    17. 'lifetime' => 300,
    18. ],
    19. ]
    20. );
    21. // Use the 'cache' service from the DI instead of 'modelsCache'
    22. $products = Products::find(
    23. [
    24. 'cache' => [
    25. 'key' => 'my-cache',
    26. 'service' => 'cache',
    27. ],
    28. ]
    29. );

    Caching could also be applied to resultsets generated using relationships:

    1. <?php
    2. // Query some post
    3. $post = Post::findFirst();
    4. // Get comments related to a post, also cache it
    5. $comments = $post->getComments(
    6. [
    7. 'cache' => [
    8. 'key' => 'my-key',
    9. ],
    10. ]
    11. );
    12. // Get comments related to a post, setting lifetime
    13. $comments = $post->getComments(
    14. [
    15. 'cache' => [
    16. 'key' => 'my-key',
    17. 'lifetime' => 3600,
    18. ],
    19. ]
    20. );

    When a cached resultset needs to be invalidated, you can simply delete it from the cache using the key specified as seen above.

    Which resultset to cache and for how long is up to the developer, after having evaluated the needs of the application. Resultsets that change frequently should not be cached, since the cache results will be invalidated quickly. Additionally caching resultsets consumes processing cycles, therefore the cache that was intended to speed up the application actually slows it down. Resultsets that do not change frequently should be cached to minimize the database interactions. The decision on where to use caching and for how long is dictated by the application needs.

    Forcing Cache

    Earlier we saw how Phalcon\Mvc\Model integrates with the caching component provided by the framework. To make a record/resultset cacheable we pass the key cache in the array of parameters:

    1. <?php
    2. // Cache the resultset for only for 5 minutes
    3. $products = Products::find(
    4. [
    5. 'cache' => [
    6. 'key' => 'my-cache',
    7. 'lifetime' => 300,
    8. ],
    9. ]
    10. );

    This gives us the freedom to cache specific queries, however if we want to cache globally every query performed over the model, we can override the find()/findFirst() methods to force every query to be cached:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Robots extends Model
    4. {
    5. /**
    6. * Implement a method that returns a string key based
    7. * on the query parameters
    8. */
    9. protected static function _createKey(array $parameters)
    10. {
    11. $uniqueKey = [];
    12. foreach ($parameters as $key => $value) {
    13. if (is_scalar($value)) {
    14. $uniqueKey[] = $key . ':' . $value;
    15. } elseif (is_array($value)) {
    16. $uniqueKey[] = $key . ':[' . self::_createKey($value) . ']';
    17. }
    18. }
    19. return join(',', $uniqueKey);
    20. }
    21. public static function find($parameters = null)
    22. {
    23. // Convert the parameters to an array
    24. if (!is_array($parameters)) {
    25. $parameters = [$parameters];
    26. }
    27. // Check if a cache key wasn't passed
    28. // and create the cache parameters
    29. if (!isset($parameters['cache'])) {
    30. $parameters['cache'] = [
    31. 'key' => self::_createKey($parameters),
    32. 'lifetime' => 300,
    33. ];
    34. }
    35. return parent::find($parameters);
    36. }
    37. public static function findFirst($parameters = null)
    38. {
    39. // ...
    40. }
    41. }

    This gives you full control on how the cache should be implemented for each model. If this strategy is common to several models you can create a base class for all of them:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class CacheableModel extends Model
    4. {
    5. protected static function _createKey(array $parameters)
    6. {
    7. // ... Create a cache key based on the parameters
    8. }
    9. public static function find($parameters = null)
    10. {
    11. // ... Custom caching strategy
    12. }
    13. public static function findFirst($parameters = null)
    14. {
    15. // ... Custom caching strategy
    16. }
    17. }

    Then use this class as base class for each Cacheable model:

    1. <?php
    2. class Robots extends CacheableModel
    3. {
    4. }

    Regardless of the syntax we used to create them, all queries in the ORM are handled internally using PHQL. This language gives you much more freedom to create all kinds of queries. Of course these queries can be cached:

    1. <?php
    2. $phql = 'SELECT * FROM Cars WHERE name = :name:';
    3. $query = $this->modelsManager->createQuery($phql);
    4. $query->cache(
    5. [
    6. 'key' => 'cars-by-name',
    7. 'lifetime' => 300,
    8. ]
    9. );
    10. $cars = $query->execute(
    11. [
    12. 'name' => 'Audi',
    13. ]
    14. );

    Some models may have relationships with other models. This allows us to easily check the records that relate to instances in memory:

    This example is very simple, a customer is queried and can be used as required, for example, to show its name. This also applies if we retrieve a set of invoices to show customers that correspond to these invoices:

    1. <?php
    2. // Get a set of invoices
    3. // SELECT * FROM invoices;
    4. $invoices = Invoices::find();
    5. foreach ($invoices as $invoice) {
    6. // Get the customer related to the invoice
    7. // SELECT * FROM customers WHERE id = ?;
    8. $customer = $invoice->customer;
    9. // Print his/her name
    10. echo $customer->name, "\n";
    11. }

    A customer may have one or more bills so, in this example, the same customer record may be unnecessarily queried several times. To avoid this, we could mark the relationship as reusable; by doing so, we tell the ORM to automatically reuse the records from memory instead of re-querying them again and again:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Invoices extends Model
    4. {
    5. {
    6. $this->belongsTo(
    7. 'customers_id',
    8. 'Customer',
    9. 'id',
    10. [
    11. ]
    12. );
    13. }
    14. }

    Note that this type of cache works in memory only, this means that cached data are released when the request is terminated.

    When a related record is queried, the ORM internally builds the appropriate condition and gets the required records using find()/findFirst() in the target model according to the following table:

    This means that when you get a related record you could intercept how the data is obtained by implementing the corresponding method:

    1. <?php
    2. // Get some invoice
    3. $invoice = Invoices::findFirst();
    4. // Get the customer related to the invoice
    5. $customer = $invoice->customer; // Invoices::findFirst('...');
    6. // Same as above
    7. $customer = $invoice->getCustomer(); // Invoices::findFirst('...');

    Accordingly, we could replace the findFirst() method in the Invoices model and implement the cache we consider most appropriate:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Invoices extends Model
    4. {
    5. public static function findFirst($parameters = null)
    6. {
    7. // ... Custom caching strategy
    8. }
    9. }

    In this scenario, we assume that every time we query a result we also retrieve their associated records. If we store the records found together with their related entities perhaps we could reduce a bit the overhead required to obtain all entities:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Invoices extends Model
    4. {
    5. protected static function _createKey($parameters)
    6. {
    7. // ... Create a cache key based on the parameters
    8. }
    9. protected static function _getCache($key)
    10. {
    11. // Returns data from a cache
    12. }
    13. protected static function _setCache($key, $results)
    14. {
    15. // Stores data in the cache
    16. }
    17. public static function find($parameters = null)
    18. {
    19. // Create a unique key
    20. $key = self::_createKey($parameters);
    21. // Check if there are data in the cache
    22. $results = self::_getCache($key);
    23. // Valid data is an object
    24. if (is_object($results)) {
    25. return $results;
    26. }
    27. $results = [];
    28. $invoices = parent::find($parameters);
    29. foreach ($invoices as $invoice) {
    30. // Query the related customer
    31. $customer = $invoice->customer;
    32. // Assign it to the record
    33. $invoice->customer = $customer;
    34. $results[] = $invoice;
    35. }
    36. // Store the invoices in the cache + their customers
    37. self::_setCache($key, $results);
    38. return $results;
    39. }
    40. public function initialize()
    41. {
    42. // Add relations and initialize other stuff
    43. }
    44. }
    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Invoices extends Model
    4. {
    5. public function initialize()
    6. {
    7. // Add relations and initialize other stuff
    8. }
    9. protected static function _createKey($conditions, $params)
    10. {
    11. // ... Create a cache key based on the parameters
    12. }
    13. public function getInvoicesCustomers($conditions, $params = null)
    14. {
    15. $phql = 'SELECT Invoices.*, Customers.* FROM Invoices JOIN Customers WHERE ' . $conditions;
    16. $query = $this->getModelsManager()->executeQuery($phql);
    17. $query->cache(
    18. [
    19. 'key' => self::_createKey($conditions, $params),
    20. 'lifetime' => 300,
    21. ]
    22. );
    23. return $query->execute($params);
    24. }
    25. }

    Caching based on Conditions

    In this scenario, the cache is implemented differently depending on the conditions received. We might decide that the cache backend should be determined by the primary key:

    TypeCache Backend
    1 - 10000mongo1
    10000 - 20000mongo2
    > 20000mongo3

    The easiest way to achieve this is by adding a static method to the model that chooses the right cache to be used:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Robots extends Model
    4. {
    5. public static function queryCache($initial, $final)
    6. {
    7. if ($initial >= 1 && $final < 10000) {
    8. $service = 'mongo1';
    9. } elseif ($initial >= 10000 && $final <= 20000) {
    10. $service = 'mongo2';
    11. } elseif ($initial > 20000) {
    12. $service = 'mongo3';
    13. }
    14. return self::find(
    15. [
    16. 'id >= ' . $initial . ' AND id <= ' . $final,
    17. 'cache' => [
    18. 'service' => $service,
    19. ],
    20. ]
    21. );
    22. }
    23. }

    This approach solves the problem, however, if we want to add other parameters such orders or conditions we would have to create a more complicated method. Additionally, this method does not work if the data is obtained using related records or a find()/findFirst():

    To achieve this we need to intercept the intermediate representation (IR) generated by the PHQL parser and thus customize the cache everything possible:

    The first is create a custom builder, so we can generate a totally customized query:

    1. <?php
    2. use Phalcon\Mvc\Model\Query\Builder as QueryBuilder;
    3. class CustomQueryBuilder extends QueryBuilder
    4. {
    5. public function getQuery()
    6. {
    7. $query = new CustomQuery(
    8. $this->getPhql()
    9. );
    10. $query->setDI(
    11. $this->getDI()
    12. );
    13. if (is_array($this->_bindParams)) {
    14. $query->setBindParams(
    15. $this->_bindParams
    16. );
    17. }
    18. if (is_array($this->_bindTypes)) {
    19. $query->setBindTypes(
    20. $this->_bindTypes
    21. );
    22. }
    23. if (is_array($this->_sharedLock)) {
    24. $query->setSharedLock(
    25. $this->_sharedLock
    26. );
    27. }
    28. return $query;
    29. }
    30. }

    Instead of directly returning a Phalcon\Mvc\Model\Query, our custom builder returns a CustomQuery instance, this class looks like:

    1. <?php
    2. use Phalcon\Mvc\Model\Query as ModelQuery;
    3. {
    4. /**
    5. * The execute method is overridden
    6. */
    7. public function execute($params = null, $types = null)
    8. {
    9. // Parse the intermediate representation for the SELECT
    10. $ir = $this->parse();
    11. if (is_array($this->_bindParams)) {
    12. $params = array_merge(
    13. $this->_bindParams,
    14. (array) $params
    15. );
    16. }
    17. if (is_array($this->_bindTypes)) {
    18. $types = array_merge(
    19. $this->_bindTypes,
    20. (array) $types
    21. );
    22. }
    23. // Check if the query has conditions
    24. if (isset($ir['where'])) {
    25. // The fields in the conditions can have any order
    26. // We need to recursively check the conditions tree
    27. // to find the info we're looking for
    28. $visitor = new CustomNodeVisitor();
    29. // Recursively visits the nodes
    30. $visitor->visit(
    31. $ir['where']
    32. );
    33. $initial = $visitor->getInitial();
    34. $final = $visitor->getFinal();
    35. // Select the cache according to the range
    36. // ...
    37. // Check if the cache has data
    38. // ...
    39. }
    40. // Execute the query
    41. $result = $this->_executeSelect($ir, $params, $types);
    42. $result = $this->_uniqueRow ? $result->getFirst() : $result;
    43. // Cache the result
    44. // ...
    45. return $result;
    46. }
    47. }

    Implementing a helper (CustomNodeVisitor) that recursively checks the conditions looking for fields that tell us the possible range to be used in the cache:

    1. <?php
    2. class CustomNodeVisitor
    3. {
    4. protected $_initial = 0;
    5. protected $_final = 25000;
    6. public function visit($node)
    7. {
    8. switch ($node['type']) {
    9. case 'binary-op':
    10. $left = $this->visit($node['left']);
    11. $right = $this->visit($node['right']);
    12. if (!$left || !$right) {
    13. return false;
    14. }
    15. if ($left === 'id') {
    16. if ($node['op'] === '>') {
    17. $this->_initial = $right;
    18. }
    19. if ($node['op'] === '=') {
    20. $this->_initial = $right;
    21. }
    22. if ($node['op'] === '>=') {
    23. $this->_initial = $right;
    24. }
    25. if ($node['op'] === '<') {
    26. $this->_final = $right;
    27. }
    28. if ($node['op'] === '<=') {
    29. $this->_final = $right;
    30. }
    31. }
    32. break;
    33. case 'qualified':
    34. if ($node['name'] === 'id') {
    35. return 'id';
    36. }
    37. break;
    38. case 'literal':
    39. return $node['value'];
    40. default:
    41. return false;
    42. }
    43. }
    44. public function getInitial()
    45. {
    46. return $this->_initial;
    47. }
    48. public function getFinal()
    49. {
    50. return $this->_final;
    51. }
    52. }

    Finally, we can replace the find method in the Robots model to use the custom classes we’ve created:

    1. <?php
    2. use Phalcon\Mvc\Model;
    3. class Robots extends Model
    4. {
    5. public static function find($parameters = null)
    6. {
    7. if (!is_array($parameters)) {
    8. $parameters = [$parameters];
    9. }
    10. $builder = new CustomQueryBuilder($parameters);
    11. $builder->from(
    12. get_called_class()
    13. );
    14. $query = $builder->getQuery();
    15. if (isset($parameters['bind'])) {
    16. return $query->execute(
    17. $parameters['bind']
    18. );
    19. } else {
    20. return $query->execute();
    21. }
    22. }
    23. }

    Caching PHQL execution plan

    As well as most moderns database systems PHQL internally caches the execution plan, if the same statement is executed several times PHQL reuses the previously generated plan improving performance, for a developer to take better advantage of this is highly recommended build all your SQL statements passing variable parameters as bound parameters:

    1. <?php
    2. for ($i = 1; $i <= 10; $i++) {
    3. $phql = 'SELECT * FROM Store\Robots WHERE id = ' . $i;
    4. $robots = $this->modelsManager->executeQuery($phql);
    5. // ...
    6. }

    In the above example, ten plans were generated increasing the memory usage and processing in the application. Rewriting the code to take advantage of bound parameters reduces the processing by both ORM and database system:

    1. <?php
    2. $phql = 'SELECT * FROM Store\Robots WHERE id = ?0';
    3. for ($i = 1; $i <= 10; $i++) {
    4. $robots = $this->modelsManager->executeQuery(
    5. $phql,
    6. [
    7. $i,
    8. ]
    9. );
    10. // ...
    11. }

    Performance can be also improved reusing the PHQL query:

    1. <?php
    2. $phql = 'SELECT * FROM Store\Robots WHERE id = ?0';
    3. $query = $this->modelsManager->createQuery($phql);
    4. for ($i = 1; $i <= 10; $i++) {
    5. $robots = $query->execute(
    6. $phql,
    7. [
    8. $i,
    9. ]
    10. );
    11. }