Финальная часть серии — про самое нервное в любом ecommerce-проекте: как включать новую архитектуру по частям, не устраивать «большой релиз» и не останавливать продажи. 

К этому моменту у нас уже есть SSO, события, наблюдаемость, быстрый каталог, корзина, цены, checkout, интеграции, Gateway и SDK. Теперь начинается самая чувствительная часть — включать все это в продакшен без большого релиза или остановки продаж.

Сложность — в процессе. Один модуль уже готов, второй еще нет, часть трафика ходит по старому пути, часть — по новому… Поэтому я сделала практическую схему постепенного включения: фича-флаги, канареечный трафик, двойное чтение, shadow-режим и критерии готовности.

Часть 1

Часть 2

Часть 3

Часть 4

Часть 5

Часть 6

Фича-флаги и сегменты трафика

Все новые сценарии включаем только через фича-флаги и поэтапно.

Обычно хватает трех уровней управления:

  • глобальный флаг — включен ли модуль вообще;

  • процент трафика — 1%, 10%, 50%, 100%;

  • сегменты и whitelist — QA, сотрудники, тестовые пользователи или отдельные клиенты.

Таблица флагов в Laravel:

Скрытый текст
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('feature_flags', function (Blueprint $t): void {
            $t->string('name', 64)->primary();              // catalog_read, cart_api, pricing_api, orders_fastpath ...
            $t->boolean('enabled')->default(false);
            $t->unsignedTinyInteger('percent')->default(0); // 0..100
            $t->json('whitelist')->nullable();              // [user_id...]
            $t->json('payload')->nullable();                // произвольные параметры
            $t->timestamps();
        });
    }
};

Простой сервис флагов:

<?php

namespace App\Flags;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

final class Flags
{
    public function isOn(string $name, ?int $userId, ?string $stickyKey = null): bool
    {
        $flag = Cache::remember("ff:$name", 5, fn () => DB::table('feature_flags')->find($name));
        if (! $flag || ! $flag->enabled) {
            return false;
        }

        $wl = $flag->whitelist ? \json_decode((string) $flag->whitelist, true) : [];
        if ($userId && \in_array($userId, $wl, true)) {
            return true;
        }

        $percent = (int) $flag->percent;
        if ($percent <= 0) {
            return false;
        }

        // "липкое" распределение: один и тот же ключ всегда попадает одинаково
        $key = $stickyKey ?: (string) $userId ?: request()->ip();
        $hash = \crc32($name . '|' . $key) % 100;

        return $hash < $percent;
    }
}

В Gateway и доменных API вызываем app(Flags::class)->isOn('cart_api', $userId, $cookieCgid).

Расщепление трафика на уровне Nginx

Иногда удобнее отправлять часть трафика на новый сервис. Например, для чтения каталога:

Скрытый текст
map $cookie_ab_catalog $ab_catalog {
    default  "A";
    ~^B$     "B";
}

# Проставляем cookie 10 % пользователям (один раз), остальным - A
map $request_uri $catalog_bucket {
    default 0;
    ~^/catalog   1;
}

server {
    if ($catalog_bucket = 1) {
        add_header Set-Cookie "ab_catalog=$ab_catalog; Path=/; Max-Age=2592000; SameSite=Lax" always;
    }

    location ~ ^/api/v1/catalog/search {
        if ($ab_catalog = B) {
            proxy_pass http://laravel_catalog;  # новый путь
        }
        proxy_pass http://old_bitrix_catalog;   # старый путь
    }
}

Значение cookie ab_catalog=B можно выдавать на бэке через флаг на 10 % пользователей.

Техника двойного чтения и сравнение

Перед переключением чтения на новый сервис включаем shadow-read. Старый путь продолжает отвечать пользователю как раньше, а новый сервис вызывается параллельно в фоне.

Дальше сравниваем ответы: состав данных, цены, остатки, время ответа и ошибки. Так новый путь можно проверить на реальном трафике еще до того, как он начнет обслуживать пользователей напрямую. Пример middleware в Битрикс на PHP:

Скрытый текст
<?php

use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Diag\Debug;

function shadow_compare_catalog(array $params, array $oldResult): void
{
    // включаем по флагу
    if (!get_flag('shadow_catalog')) {
        return;
    }

    $http = new HttpClient(['socketTimeout' => 1, 'streamTimeout' => 1]);
    $http->query('GET', 'https://api.example.ru/api/v1/catalog/search?' . http_build_query($params));
    $new = @\Bitrix\Main\Web\Json::decode((string) $http->getResult());

    // сравниваем топ-5 ids и счетчик
    $oldIds = array_slice(array_column($oldResult['items'] ?? [], 'ID'), 0, 5);
    $newIds = array_slice(array_map(static fn($h) => (int)($h['_source']['id'] ?? 0), (array)($new['list'] ?? [])), 0, 5);

    if ($oldResult['total'] != ($new['total'] ?? null) || $oldIds !== $newIds) {
        Debug::writeToFile([
            'params' => $params,
            'old_total' => $oldResult['total'] ?? null,
            'new_total' => $new['total'] ?? null,
            'old_top' => $oldIds,
            'new_top' => $newIds,
        ], 'catalog_compare', '/local/logs/compat.log');
    }
}

Переключатель в одну строку

У каждого нового модуля должен быть простой и быстрый способ отката. Самый удобный вариант — держать такой переключатель прямо на входе Gateway:

Скрытый текст
<?php

namespace App\Http\Middleware;

use App\Flags\Flags;
use Closure;
use Illuminate\Http\Request;

final class KillSwitch
{
    public function handle(Request $r, Closure $next)
    {
        if (env('KILL_CATALOG', false)) {
            return response()->json(['ok' => false, 'error' => 'temporarily_disabled'], 503);
        }
        return $next($r);
    }
}

В критический момент достаточно переключить Gateway или Nginx обратно на старый путь. Новый сервис при этом может отвечать 503, чтобы фронт сразу получил понятную ошибку, а не висел в бесконечных retry и таймаутах. 

Порядок включения модулей и критерии готовности

Б2. Ускорение админки

Включаем сразу для роли «Контент».

Следим за несколькими вещами:

  • P95 открытия формы товара < 1.5 s;

  • Сохранить < 400 ms;

  • меньше JS-ошибок в админке.

Если что-то пошло не так — просто выключаем модуль в Битрикс. Gateway не трогаем.

Б1. Каталог и OpenSearch

Запускаем поэтапно:

  • сначала включаем двойное чтение и сравниваем выдачу, агрегации и пагинацию;

  • потом переводим 5% каталожного трафика;

  • дальше 50% и 100%.

Проверяем:

  • TTFB листинга на cache hit < 150 ms;

  • cache miss < 400 ms;

  • расхождение total не больше ±1%.

Откат простой:

  • возвращаем старый маршрут в Nginx;

  • инвалидируем кэш.

В1. Адаптер корзины

Сначала:

  • читаем корзину через API;

  • кнопки «Добавить» пока остаются старыми.

Потом:

  • переводим add и patch на API для части пользователей;

  • Битрикс продолжает материализовать корзину для скидок.

Смотрим:

  • конфликты ревизий < 2%;

  • p95 add/patch < 80 ms.

Для отката фронт просто возвращается к старым вызовам. Redis-корзины доживают свой TTL и не мешают.

В2. Цены и остатки

Этапы здесь такие:

  • двойное чтение и сравнение с b_*;

  • логирование расхождений в Sentry;

  • переход карточки товара на /api/pricing и /api/inventory;

  • перевод каталога на batch-запросы.

Критерии:

  • p95 /pricing < 120 ms;

  • cache hit > 80%;

  • лаг price.changed и stock.changed < 30 s.

Откат — возврат на старое чтение.

В3. Быстрый путь заказов

Сначала включаем новый API для 1% заказов с материализацией в Битрикс.

Дальше идем постепенно:

  • 10%;

  • 50%;

  • 100%.

Платеж можно оставить на старом пути и вынести позже.

Проверяем:

  • p95 POST /api/orders < 300 ms;

  • материализация p95 < 5 s;

  • доля ошибок < 0.1%.

Если нужен откат:

  • выключаем materialize-job;

  • фронт возвращается на старую ручку Битрикс.

Г1. Шлюз интеграций

Подключаем по частям:

  • сначала вебхукии платежек;

  • потом создание платежей;

  • дальше доставки и ERP.

Смотрим на:

  • DLQ < 0.5%;

  • среднее число попыток < 1.5;

  • очереди без лагов > 5 минут.

При откате возвращаем старые прямые вызовы. Задачи в DLQ не теряются.

Г2. Уведомления

Сначала переносим только системные письма:

  • заказ создан;

  • оплата прошла.

Потом подключаем SMS и push.

Критерии:

  • p95 рендера < 50 ms;

  • p95 отправки < 3 s;

  • bounce-rate остается в норме.

При откате Битрикс снова использует свои шаблоны, а сервис уведомлений можно оставить в standby.

Чек-листы выпусков

Для каждого этапа держим отдельный чек-лист. Например, перед переключением каталога на новое чтение проверяем:

  • backfill витрин svc_catalog_*, svc_price_snapshot, svc_inventory_snapshot завершен;

  • индекс OpenSearch собран полностью, поиск без дыр, размер индекса в допустимых границах;

  • события catalog.element.updated, price.changed, stock.changed приходят стабильно, лаг < 10 s;

  • shadow-read показывает ≥ 99% совпадений по total и топам, все расхождения объяснимы;

  • Nginx настроен на расщепление трафика, фича-флаги заведены;

  • алерты на p95 TTFB, долю 5xx, лаг индексации и cache hit работают;

  • runbook отката подготовлен: возврат старого пути и очистка кэшей описаны заранее.

Пример: маршрутизация в коде гейтвея

Для корзины включаем новый путь по флагу:

<?php

use App\Flags\Flags;
use Illuminate\Support\Facades\Route;

Route::get('/page/cart', static function (\Illuminate\Http\Request $r) {
    $uid = $r->user()?->getAuthIdentifier() ? (int) $r->user()->getAuthIdentifier() : null;
    $gid = (string) $r->cookie('cgid', '');

    if (app(Flags::class)->isOn('cart_api', $uid, $gid)) {
        return app(\App\Http\Controllers\Gateway\CartController::class)->show($r);
    }

    // старый путь (до миграции фронта)
    return redirect()->away('https://bitrix.example.ru/personal/cart/');
});

Финальные советы по операционке

После нескольких таких запусков понятно, что основная сложность обычно не в Laravel, Redis или OpenSearch. Самое сложное — научиться менять продакшен постепенно, без героических ночных релизов.

Несколько вещей, которые потом экономят очень много времени и нервов:

  • не пытайтесь переносить все сразу — каждый модуль должен приносить пользу отдельно;

  • заранее договоритесь с бизнесом про SLO и показывайте метрики «до» и «после» каждого этапа;

  • любой новый путь сначала должен уметь нормально откатываться и только потом — принимать production-трафик;

  • решения принимаем по метрикам;

  • для каждого модуля держим короткие runbook: как выключить, как вернуть старый путь, как проверить состояние;

  • в момент инцидента счет обычно идет на минуты, поэтому инструкции должны читаться быстрее, чем открывается IDE.

Что отдаем на проекте: артефакты, структура, инфраструктура

Вот базовый пакет поставки, с которым команда может поднять окружение, проверить контракты и подключать модули постепенно, а не одним большим релизом.

1. Контракты API

AsyncAPI 2.6 для событий: catalog.element.updated, price.changed, stock.changed, order.created|updated, integration.events.

Скрытый текст

Примеры фрагментов:

# openapi/cart.yaml
openapi: 3.1.0
info: { title: Cart API, version: 1.0.0 }
paths:
  /api/v1/cart:
    get:
      responses:
        '200':
          description: Snapshot
          content: { application/json: { schema: { $ref: '#/components/schemas/Cart' } } }
    post:
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AddLine' } } } }
      responses: { '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Cart' } } } } }
components:
  schemas:
    Cart:
      type: object
      properties:
        owner: { type: string }
        revision: { type: integer }
        currency: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              lineId: { type: string }
              sku: { type: string }
              qty: { type: number }
              attrs: { type: object, additionalProperties: { type: string } }
    AddLine:
      type: object
      required: [sku, qty]
      properties:
        sku: { type: string }
        qty: { type: number, minimum: 0.001 }
        attrs: { type: object, additionalProperties: { type: string } }

# asyncapi/events.yaml
asyncapi: 2.6.0
info: { title: Commerce Events, version: 1.0.0 }
channels:
  price.changed:
    publish:
      message:
        name: price.changed
        payload:
          type: object
          required: [product_id, ptype, amount, currency, version, updated_at]
          properties:
            product_id: { type: integer }
            ptype: { type: string }
            segment: { type: string, default: default }
            amount: { type: number }
            currency: { type: string }
            version: { type: integer }
            updated_at: { type: string, format: date-time }

2. Скелеты пакетов и модулей

  • Laravel-пакеты: auth-bridge, outbox-streams, catalog-read, cart, pricing-inventory, orders, integration-hub, notifications, gateway.

  • Bitrix-модули: project.core (outbox, fast-order), project.admin-accelerator.

  • SDK: @acme/commerce-sdk-core, @acme/commerce-sdk-react, @acme/commerce-sdk-vue.

Минимальные composer.json и psr-4 неймспейсы, phpstan.neon, php-cs-fixer.php по PSR.

// php-cs-fixer.php
<?php

$rules = [
    '@PSR12' => true,
    'array_syntax' => ['syntax' => 'short'],
    'ordered_imports' => true,
    'no_unused_imports' => true,
];

return (new PhpCsFixer\Config())
    ->setRules($rules)
    ->setRiskyAllowed(true)
    ->setFinder(
        PhpCsFixer\Finder::create()->in(['src','app'])
    );

3. Monorepo-структура

Скрытый текст
repo/
├─ services/
│  ├─ bitrix/                # шаблон проекта Битрикс + local/modules/project.*
│  ├─ gateway/               # Laravel Gateway
│  ├─ catalog-read/          # OpenSearch
│  ├─ cart/
│  ├─ pricing-inventory/
│  ├─ orders/
│  ├─ integration-hub/
│  └─ notifications/
├─ sdk/
│  ├─ core/
│  ├─ react/
│  └─ vue/
├─ contracts/
│  ├─ openapi/
│  └─ asyncapi/
├─ ops/
│  ├─ docker/
│  ├─ compose/
│  ├─ k8s/
│  └─ terraform/
├─ observability/
│  ├─ grafana-dashboards/
│  └─ prom-rules/
├─ docs/
│  ├─ runbooks/
│  ├─ rollout-checklists/
│  └─ decisions/             # ADR
└─ .github/workflows/

4. Docker Compose для локалки

С профильным набором: Nginx, Bitrix (php-fpm), Laravel-сервисы, MySQL, Redis, OpenSearch, Mailhog, Jaeger, Prometheus, Grafana.

Скрытый текст
# ops/compose/docker-compose.yml
version: "3.9"
services:
  nginx:
    image: nginx:1.27
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ../services/bitrix:/var/www/bitrix
      - ../services/gateway:/var/www/gateway
    ports: ["8080:80"]
    depends_on: [bitrix, gateway]
  bitrix:
    build: ../services/bitrix/.docker/php-fpm
    environment:
      - DB_HOST=db
    volumes: [ "../services/bitrix:/var/www/bitrix" ]
    depends_on: [db, redis]
  gateway:
    build: ../services/gateway
    environment:
      - DB_HOST=db
      - REDIS_HOST=redis
      - OPENSEARCH_HOST=opensearch
    volumes: [ "../services/gateway:/var/www/gateway" ]
    depends_on: [db, redis, opensearch]
  db:
    image: mysql:8.4
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=app
    ports: ["3306:3306"]
  redis:
    image: redis:7.2
    ports: ["6379:6379"]
  opensearch:
    image: opensearchproject/opensearch:2.15.0
    environment: { discovery.type: single-node, plugins.security.disabled: "true" }
    ports: ["9200:9200"]
  mailhog:
    image: mailhog/mailhog
    ports: ["8025:8025"]
  jaeger:
    image: jaegertracing/all-in-one:1.57
    ports: ["16686:16686"]
  prometheus:
    image: prom/prometheus
  grafana:
    image: grafana/grafana-oss
    ports: ["3000:3000"]

Makefile для удобства:

up: ; docker compose -f ops/compose/docker-compose.yml up -d
down: ; docker compose -f ops/compose/docker-compose.yml down -v
logs: ; docker compose -f ops/compose/docker-compose.yml logs -f --tail=200
migrate: ; docker compose exec gateway php artisan migrate
seed: ; docker compose exec gateway php artisan db:seed

5. Наблюдаемость

Для всех сервисов подготовлены экспортеры метрик и готовые Grafana-дашборды:

  • latency P50/P95;

  • доля 5xx и 429;

  • лаг Redis Streams;

  • cache hit;

  • длины очередей;

  • DLQ.

Для PHP-сервисов есть готовый OpenTelemetry-конфиг:

  • Laravel;

  • Битрикс;

  • входящие и исходящие HTTP-запросы;

  • Redis;

  • MySQL.

Трейсы можно сразу смотреть в Jaeger через готовый дашборд.

Отдельно подготовлены Prometheus-алерты с порогами из KPI и SLO.

6. CI/CD

GitHub Actions (или GitLab CI) с этапами: линтеры, статика, тесты, сборка образов, деплой.

Скрытый текст
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  php:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3', coverage: none, tools: composer, extensions: redis }
      - run: composer install -q --no-ansi --no-interaction --no-progress --prefer-dist
        working-directory: services/gateway
      - run: vendor/bin/php-cs-fixer fix --dry-run --diff
        working-directory: services/gateway
      - run: vendor/bin/phpstan analyse --no-progress
        working-directory: services/gateway
      - run: vendor/bin/pest
        working-directory: services/gateway

  docker:
    needs: php
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { username: ${{ secrets.DOCKER_USER }}, password: ${{ secrets.DOCKER_PASS }} }
      - name: build gateway
        run: docker build -t ${{ secrets.REGISTRY }}/gateway:${{ github.sha }} services/gateway

Pre-commit в репозитории:
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer
    rev: v3.64.0
    hooks: [{ id: php-cs-fixer, args: ["--config=php-cs-fixer.php","--dry-run","--diff"] }]
  - repo: https://github.com/phpstan/phpstan
    rev: 1.11.8
    hooks: [{ id: phpstan, args: ["analyse","--no-progress"] }]

7. Сид данных и фича-флаги по умолчанию

Сидер, который создает типы цен, тестовые товары, индексы OpenSearch, и включает минимальные флаги в 0%:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

final class FeatureFlagsSeeder extends Seeder
{
    public function run(): void
    {
        foreach (['catalog_read','cart_api','pricing_api','orders_fastpath'] as $name) {
            DB::table('feature_flags')->updateOrInsert(
                ['name' => $name],
                ['enabled' => false, 'percent' => 0, 'updated_at' => now(), 'created_at' => now()]
            );
        }
    }
}

8. Документация и runbook

В поставку входят базовые инструкции для команды и эксплуатации:

  • «Как поднять локальное окружение» — одна страница с make up, migrate, seed и проверкой сервисов;

  • «Как выкатывать по частям» — чек-листы по rollout, shadow-read, канареечному трафику и переключению модулей;

  • «Откат» — пошаговые инструкции по kill-switch, возврату старых маршрутов и очистке кэшей;

  • ADR-документы по ключевым архитектурным решениям:

    • Outbox;

    • Redis Streams;

    • OpenSearch;

    • Gateway и SDK;

    • быстрый checkout и materialize-flow.

9. Демо-сценарии для QA

Для QA и интеграционного тестирования подготовлены:

  • коллекции HTTP-запросов (HTTP YAML, Insomnia) со всеми ручками и примерами payload;

  • фикстуры для песочницы:

    • 1000 товаров;

    • 10 типов цен;

    • 2 склада;

    • 100 тестовых заказов;

  • сценарии нагрузки на k6 или Artillery:

    • листинги;

    • карточка товара;

    • добавление в корзину;

    • расчет цен;

    • создание заказа.

10. Примеры UI

В комплекте идут минимальные примеры фронта из раздела про Gateway и SDK:

  • ProductPage.vue;

  • CartPage.vue.

Отдельно — тестовый SSR-шаблон с интеграцией SDK на серверном рендеринге.

Завершение

Если вкратце, мы не переписываем Битрикс с нуля и не устраиваем многолетнюю миграцию ради модной архитектуры. Мы постепенно выносим из монолита все, что сильнее всего упирается в нагрузку, масштабирование и скорость разработки.

Каталог и фильтры переезжают в OpenSearch. Цены и остатки обслуживаются отдельным API. Корзина становится общей для всех фронтов. Заказ принимается быстро, а тяжелая обработка уходит в очереди и события. Интеграции, уведомления и внешние вызовы работают отдельно и больше не тормозят checkout.

При этом Битрикс остается админкой, контентным слоем и точкой совместимости со старым контуром.

В центре всей схемы — единая авторизация, наблюдаемость, Gateway и понятные контракты между сервисами. За счет этого систему можно развивать по частям и спокойно выкатывать изменения.

Наши итоги:

Блок

Что получили

Архитектура

Битрикс продолжает работать как админка и контентный слой, а тяжелые сценарии постепенно выносятся в отдельные сервисы без переписывания всей системы

Контракты

REST через OpenAPI и события через AsyncAPI позволяют внедрять модули по одному, без большого релиза

Владение данными

Запись остается у владельца схемы, чтение уходит в svc_*, Redis и поисковые витрины

Наблюдаемость

X-Request-Id, трейсы, метрики и логи делают rollout и откат предсказуемыми

Каталог

TTFB листинга: cache hit < 150 ms, miss < 400 ms

Админка

P95 открытия формы < 1.5 s, сохранение < 400 ms

Заказы

POST /api/orders P95 < 300 ms, материализация в b_sale_* P95 < 5 s

Очереди

лаг Redis Streams под нагрузкой < 30 s, DLQ < 0.5%

Риски

Расхождения гасим через shadow-read, события, TTL, ретраи, DLQ и runbook’и

Выкатка

Все включается через фича-флаги: 1% → 10% → 50% → 100%

Откат

У каждого модуля есть kill-switch и короткий сценарий возврата

Инфраструктура

Docker Compose, OpenAPI/AsyncAPI, SDK, Grafana, Prometheus, CI и готовые пакеты

Спасибо, что дочитали. Это была финальная часть нашей серии про Битрикс, Laravel, очереди, OpenSearch, Gateway, checkout и попытку не сойти с ума посреди ecommerce-проекта с историей в десять лет.

Хеппи-энд тут, наверное, в том, что не пришлось ничего переписывать с нуля. Битрикс остался жить своей жизнью: админка, контент, менеджеры, старые интеграции. А все тяжелое, чувствительное к нагрузке и скорости разработки постепенно уехало в отдельные сервисы и нормальные контракты.

В статьях специально много практики. Надеюсь, что-то из этого пригодится вам целиком или хотя бы отдельными кусками. А если у вас был похожий опыт с Битрикс, Laravel, OpenSearch, очередями или постепенным распилом монолита — приходите в комментарии. Всегда интересно сравнить, какие места в проекте оказывались самыми болезненными.

Комментарии (0)