Skip to content

Кешування для підвищення продуктивності

Проблеми з продуктивністю можуть виникнути разом зі зростанням популярності. Кілька типових прикладів: відсутність індексів бази даних або безліч SQL-запитів на сторінку. У вас не буде жодних проблем із порожньою базою даних, але зі збільшенням кількості трафіку та дедалі більшим обсягом даних, в якийсь момент, вони можуть виникнути.

Додавання заголовків HTTP-кешу

Використання стратегій HTTP-кешування є відмінним способом досягнення максимальної продуктивності для кінцевих користувачів без особливих зусиль. Додайте зворотний проксі-кеш у продакшн, щоб увімкнути кешування і використовуйте CDN для досягнення ще кращих результатів продуктивності.

Закешуймо головну сторінку на одну годину:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,9 +33,12 @@ class ConferenceController extends AbstractController
     #[Route('/', name: 'homepage')]
     public function index(ConferenceRepository $conferenceRepository): Response
     {
-        return new Response($this->twig->render('conference/index.html.twig', [
+        $response = new Response($this->twig->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
+        $response->setSharedMaxAge(3600);
+
+        return $response;
     }

     #[Route('/conference/{slug}', name: 'conference')]

Метод setSharedMaxAge() встановлює термін дії кешу для зворотних проксі. Використовуйте setMaxAge(), щоб контролювати кеш браузера. Час встановлюється у секундах (1 година = 60 хвилин = 3600 секунд).

Кешувати сторінку конференції складніше, оскільки вона більш динамічна. В будь-який момент хтось може додати коментар, і ніхто не захоче чекати одну годину, щоб побачити його на сайті. В таких випадках, використовуйте стратегію HTTP-валідації.

Активація ядра HTTP-кешу Symfony

Щоб перевірити стратегію HTTP-кешу, увімкніть зворотний зворотний HTTP-проксі Symfony, але тільки в середовищі "розробки" (для "продакшн" середовища ми будемо використовувати "надійніше" рішення):

1
2
3
4
5
6
7
8
9
10
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -22,3 +22,7 @@ when@test:
         test: true
         session:
             storage_factory_id: session.storage.factory.mock_file
+
+when@dev:
+    framework:
+        http_cache: true

Крім того, що це повноцінний зворотний HTTP-проксі, HTTP-проксі Symfony (за допомогою класу HttpCache) додає деяку корисну інформацію налагодження у якості HTTP-заголовків. Це дуже допомагає при валідації встановлених нами заголовків кешу.

Перевірте це на головній сторінці:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
1
2
3
4
5
6
7
8
9
10
11
HTTP/2 200
age: 0
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store
content-length: 50978

Для найпершого запиту кеш-сервер повідомляє вам, що відбувся miss (промах) і що він виконав store (збереження), щоб закешувати відповідь. Перевірте заголовок cache-control, щоб побачити налаштовану стратегію кешу.

Для наступних запитів відповідь закешовано (період (age) також оновлено):

1
2
3
4
5
6
7
8
9
10
11
HTTP/2 200
age: 143
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: fresh
content-length: 50978

Уникнення SQL-запитів за допомогою ESI

Слухач TwigEventSubscriber впроваджує глобальну змінну в Twig для всіх об'єктів конференції. Це відбувається для кожної окремої сторінки веб-сайту. Мабуть, це прекрасне місце для оптимізації.

Ви не будете додавати нові конференції щодня, тому код запитує одні й ті ж дані з бази даних знову і знову.

Ми, можливо, захочемо закешувати імена конференцій і "slugs" за допомогою кешу Symfony, але всякий раз, коли це можливо, я вважаю за краще покладатися на інфраструктуру HTTP-кешування.

Коли ви хочете закешувати фрагмент сторінки, перемістіть його за межі поточного HTTP-запиту, створивши підзапит. ESI ідеально підходить для цього. ESI — це спосіб вбудувати результат одного HTTP-запиту в інший.

Створіть контролер, який повертає лише фрагмент HTML, що відображає конференції:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -41,6 +41,14 @@ class ConferenceController extends AbstractController
         return $response;
     }

+    #[Route('/conference_header', name: 'conference_header')]
+    public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
+    {
+        return new Response($this->twig->render('conference/header.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]));
+    }
+
     #[Route('/conference/{slug}', name: 'conference')]
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
     {

Створіть відповідний шаблон:

templates/conference/header.html.twig
1
2
3
4
5
<ul>
    {% for conference in conferences %}
        <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
    {% endfor %}
</ul>

Перейдіть за посиланням /conference_header, щоб перевірити, що все працює правильно.

Час розкрити хитрість! Оновіть макет Twig, щоб викликати щойно створений контролер:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -16,11 +16,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            <ul>
-            {% for conference in conferences %}
-                <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
-            {% endfor %}
-            </ul>
+            {{ render(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

І вуаля! Оновіть сторінку, і веб-сайт все одно відображатиметься так само.

Tip

Використовуйте панель профілювальника Symfony "Request / Response", щоб дізнатися більше про основний запит і його підзапити.

Тепер, щоразу, коли ви переходите на сторінку в браузері, виконуються два HTTP-запити: один для заголовка й один для головної сторінки. Ви погіршили продуктивність. Вітаю!

HTTP-виклик заголовка конференції на даний момент виконується всередині Symfony, тому HTTP-транзакція не відбувається. Це також означає, що немає можливості скористатися заголовками HTTP-кешу.

Перетворіть виклик на "реальний" HTTP за допомогою ESI.

По-перше, увімкніть підтримку ESI:

1
2
3
4
5
6
7
8
9
10
11
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -12,7 +12,7 @@ framework:
         cookie_samesite: lax
         storage_factory_id: session.storage.factory.native

-    #esi: true
+    esi: true
     #fragments: true
     php_errors:
         log: true

Потім використовуйте render_esi замість render:

1
2
3
4
5
6
7
8
9
10
11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -16,7 +16,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            {{ render(path('conference_header')) }}
+            {{ render_esi(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

Якщо Symfony виявить зворотний проксі, який знає, як поводитися з ESI, він вмикає підтримку автоматично (якщо ні, то він переходить до синхронної обробки підзапиту).

Оскільки зворотний проксі Symfony підтримує ESI, перевірмо його журнали (спочатку видаліть кеш — див. "Очищення" нижче):

1
$ curl -s -I -X GET https://127.0.0.1:8000/
1
2
3
4
5
6
7
8
9
10
11
12
HTTP/2 200
age: 0
cache-control: must-revalidate, no-cache, private
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:20:05 GMT
expires: Mon, 28 Oct 2019 08:20:05 GMT
x-content-digest: en4dd846a34dcd757eb9fd277f43220effd28c00e4117bed41af7f85700eb07f2c
x-debug-token: 719a83
x-debug-token-link: https://127.0.0.1:8000/_profiler/719a83
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store; GET /conference_header: miss
content-length: 50978

Оновіть кілька разів: відповідь за посиланням / закешовано, а за посиланням /conference_header — ні. Ми досягли чогось значного: маючи всю сторінку в кеші, одна частина все ще залишається динамічною.

Але це зовсім не те, чого ми хочемо. Закешуйте хедер сторінки на одну годину, незалежно від усього іншого:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -44,9 +44,12 @@ class ConferenceController extends AbstractController
     #[Route('/conference_header', name: 'conference_header')]
     public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
     {
-        return new Response($this->twig->render('conference/header.html.twig', [
+        $response = new Response($this->twig->render('conference/header.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
+        $response->setSharedMaxAge(3600);
+
+        return $response;
     }

     #[Route('/conference/{slug}', name: 'conference')]

Кеш тепер увімкнено для обох запитів:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
1
2
3
4
5
6
7
8
9
10
11
HTTP/2 200
age: 613
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 07:31:24 GMT
x-content-digest: en15216b0803c7851d3d07071473c9f6a3a3360c6a83ccb0e550b35d5bc484bbd2
x-debug-token: cfb0e9
x-debug-token-link: https://127.0.0.1:8000/_profiler/cfb0e9
x-robots-tag: noindex
x-symfony-cache: GET /: fresh; GET /conference_header: fresh
content-length: 50978

Заголовок x-symfony-cache містить два елементи: основний запит / і підзапит (ESI conference_header). Обидва знаходяться в кеші (fresh).

Стратегія кешу головної сторінки та її ESI можуть бути різними. Якщо у нас є сторінка "about", ми можемо зберегти її в кеш на тиждень, і при цьому заголовок буде оновлюватися щогодини.

Видаліть слухача, оскільки він нам більше не потрібен:

1
$ rm src/EventSubscriber/TwigEventSubscriber.php

Очищення HTTP-кешу для тестування

Тестування веб-сайту в браузері або за допомогою автоматичних тестів стає трохи складнішим з шаром кешування.

Ви можете вручну видалити весь HTTP-кеш, видаливши каталог var/cache/dev/http_cache/:

1
$ rm -rf var/cache/dev/http_cache/

Ця стратегія погано працює, якщо ви хочете інвалідувати лише деякі URL-адреси або якщо ви хочете інтегрувати інвалідацію кешу у свої функціональні тести. Додаймо невелику, доступну лише для адміністратора, кінцеву точку HTTP, щоб інвалідувати деякі URL-адреси:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -36,3 +36,5 @@ services:
         tags:
             - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference'}
             - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference'}
+
+    Symfony\Component\HttpKernel\HttpCache\StoreInterface: '@http_cache.store'
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
+use Symfony\Component\HttpKernel\KernelInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
@@ -52,4 +54,16 @@ class AdminController extends AbstractController
             'comment' => $comment,
         ]));
     }
+
+    #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+    public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
+    {
+        if ('prod' === $kernel->getEnvironment()) {
+            return new Response('KO', 400);
+        }
+
+        $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+        return new Response('Done');
+    }
 }

Новий контролер був обмежений лише HTTP-методом PURGE. Цей метод не входить у стандарт HTTP, але він широко використовується для інвалідації кешів.

За замовчуванням параметри маршруту не можуть містити символ /, оскільки він розділяє сегменти URL-адреси. Ви можете перевизначити це обмеження для останнього параметра маршруту, наприклад uri, встановивши ваш власний шаблон вимог (.*).

Те, як ми отримуємо екземпляр HttpCache, також може виглядати дещо дивним; ми використовуємо анонімний клас, оскільки отримати доступ до "реального" неможливо. Екземпляр HttpCache обертає справжнє ядро, яке не знає про шар кешу, як це і має бути.

Інвалідуйте головну сторінку та хедер конференції за допомогою наступних викликів cURL:

1
2
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/conference_header

Підкоманда symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL повертає поточну URL-адресу локального веб-сервера.

Note

Контролер не має імені маршруту, оскільки він ніколи не буде згадуватися в коді.

Групування подібних маршрутів за префіксом

Два маршрути в адміністративному контролері мають той самий префікс /admin. Замість того щоб повторювати його у всіх маршрутах, виконайте рефакторинг маршрутів, щоб налаштувати префікс у самому класі:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,7 @@ use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
 use Twig\Environment;

+#[Route('/admin')]
 class AdminController extends AbstractController
 {
     private $twig;
@@ -28,7 +29,7 @@ class AdminController extends AbstractController
         $this->bus = $bus;
     }

-    #[Route('/admin/comment/review/{id}', name: 'review_comment')]
+    #[Route('/comment/review/{id}', name: 'review_comment')]
     public function reviewComment(Request $request, Comment $comment, Registry $registry): Response
     {
         $accepted = !$request->query->get('reject');
@@ -55,7 +56,7 @@ class AdminController extends AbstractController
         ]));
     }

-    #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+    #[Route('/http-cache/{uri<.*>}', methods: ['PURGE'])]
     public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
     {
         if ('prod' === $kernel->getEnvironment()) {

Кешування інтенсивних операцій ЦП/пам'яті

У нас на веб-сайті немає алгоритмів, що інтенсивно використовують ЦП або пам'ять. Щоб поговорити про локальні кеші, створімо команду, яка відображає поточний крок, над яким ми працюємо (точніше, ім'я тегу Git, закріпленого за поточною фіксацією Git).

Компонент Symfony Process дозволяє вам виконати команду й отримати результат (стандартний вивід і вивід помилок).

Реалізуйте команду:

src/Command/StepInfoCommand.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

class StepInfoCommand extends Command
{
    protected static $defaultName = 'app:step:info';

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
        $process->mustRun();
        $output->write($process->getOutput());

        return 0;
    }
}

Note

Ви могли б використовувати make:command, щоб створити команду:

1
$ symfony console make:command app:step:info

Що робити, якщо ми хочемо закешувати вивід на декілька хвилин? Використовуйте кеш Symfony.

І оберніть код логікою кешу:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- a/src/Command/StepInfoCommand.php
+++ b/src/Command/StepInfoCommand.php
@@ -6,16 +6,31 @@ use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Process\Process;
+use Symfony\Contracts\Cache\CacheInterface;

 class StepInfoCommand extends Command
 {
     protected static $defaultName = 'app:step:info';

+    private $cache;
+
+    public function __construct(CacheInterface $cache)
+    {
+        $this->cache = $cache;
+
+        parent::__construct();
+    }
+
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
-        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
-        $process->mustRun();
-        $output->write($process->getOutput());
+        $step = $this->cache->get('app.current_step', function ($item) {
+            $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
+            $process->mustRun();
+            $item->expiresAfter(30);
+
+            return $process->getOutput();
+        });
+        $output->writeln($step);

         return 0;
     }

Процес тепер викликається лише в тому випадку, якщо елемент app.current_step відсутній у кеші.

Профілювання та порівняння продуктивності

Ніколи не додавайте кеш наосліп. Майте на увазі, що додавання деякого кешу додає рівень складності. Складно передбачити, що буде працювати швидко, а що повільно, ви можете опинитися в ситуації, коли кеш робить ваш застосунок повільнішим.

Завжди вимірюйте вплив додавання кешу за допомогою інструменту профілювання, такого як Blackfire.

Зверніться до кроку про "Продуктивність", щоб дізнатися більше про те, як ви можете використовувати Blackfire для тестування коду перед розгортанням.

Налаштування зворотного проксі-кешу в продакшн

Замість використання зворотного проксі Symfony у середовищі розробки ми збираємося використовувати "надійніший" зворотний проксі Varnish.

Додайте Varnish у сервіси Platform.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--- a/.platform/services.yaml
+++ b/.platform/services.yaml
@@ -2,3 +2,12 @@
 database:
     type: postgresql:13
     disk: 1024
+
+varnish:
+    type: varnish:6.0
+    relationships:
+        application: 'app:http'
+    configuration:
+        vcl: !include
+            type: string
+            path: config.vcl

Використовуйте Varnish у якості основної точки входу в маршрути:

1
2
3
4
5
6
--- a/.platform/routes.yaml
+++ b/.platform/routes.yaml
@@ -1,2 +1,2 @@
-"https://{all}/": { type: upstream, upstream: "app:http" }
+"https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
 "http://{all}/": { type: redirect, to: "https://{all}/" }

Нарешті, створіть файл config.vcl, щоб налаштувати Varnish:

.platform/config.vcl
1
2
3
sub vcl_recv {
    set req.backend_hint = application.backend();
}

Увімкнення підтримки ESI у Varnish

Підтримка ESI у Varnish має бути увімкнена явно для кожного запиту. Щоб охопити всі запити, Symfony використовує стандартні заголовки Surrogate-Capability та Surrogate-Control для узгодження підтримки ESI:

.platform/config.vcl
1
2
3
4
5
6
7
8
9
10
11
sub vcl_recv {
    set req.backend_hint = application.backend();
    set req.http.Surrogate-Capability = "abc=ESI/1.0";
}

sub vcl_backend_response {
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
        unset beresp.http.Surrogate-Control;
        set beresp.do_esi = true;
    }
}

Очищення кешу Varnish

Інвалідація кешу у продакшн, ймовірно, ніколи не знадобиться, за винятком особливих потреб і, можливо, у не-master гілках. Якщо вам потрібно часто очищати кеш, це, ймовірно, означає, що стратегія кешу має бути змінена (шляхом зниження TTL або використовуючи стратегію валідації замість стратегії закінчення терміну дії).

У будь-якому випадку, подивімося, як налаштувати Varnish для інвалідації кешу:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--- a/.platform/config.vcl
+++ b/.platform/config.vcl
@@ -1,6 +1,13 @@
 sub vcl_recv {
     set req.backend_hint = application.backend();
     set req.http.Surrogate-Capability = "abc=ESI/1.0";
+
+    if (req.method == "PURGE") {
+        if (req.http.x-purge-token != "PURGE_NOW") {
+            return(synth(405));
+        }
+        return (purge);
+    }
 }

 sub vcl_backend_response {

У реальному житті ви, ймовірно, обмежите IP-адреси, як описано в документації по Varnish.

Тепер очистьте кеш для деяких URL-адрес:

1
2
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`conference_header

URL-адреси виглядають дещо дивно через те, що адреси, повернені env:url, вже закінчуються символом /.

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version