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

Каждый экземпляр OxPHP\Shared\* — это запись в реестре, которую среда выполнения и так отслеживает для подсчёта ссылок и ёмкости. Эти данные доступны операторам в виде JSON-интроспекции по адресу /__ox_shared/* и в виде метрик Prometheus с префиксом oxphp_shared_*. Эта страница — справочник и практическое руководство.

Включение

Наблюдаемость работает поверх внутреннего сервера. Чтобы запустить его, задайте INTERNAL_ADDR:

bash
INTERNAL_ADDR=127.0.0.1:9090

После этого и JSON-эндпоинты, и /metrics становятся доступны по этому адресу. Дополнительная настройка не требуется.

Каждую из частей можно отключить независимо:

Переменная окружения По умолчанию Действие
SHARED_INTROSPECTION_ENABLED true Включает/отключает JSON API /__ox_shared/*.
SHARED_INTROSPECTION_PREVIEW_ENABLED true Включает/отключает /preview (превью значений могут раскрывать данные).
SHARED_METRICS_ENABLED true Включает/отключает метрики Prometheus oxphp_shared_*.

Отключайте интроспекцию в развёртываниях с недоверенными арендаторами; метрики агрегированные и их можно безопасно оставлять включёнными.

Эндпоинты интроспекции

Все ответы имеют Content-Type: application/json; charset=utf-8. Параметры запроса кодируются стандартным URL-кодированием.

GET /__ox_shared/summary

Снимок верхнего уровня: агрегированные счётчики по типам, память, частота операций и насыщение относительно настроенных лимитов.

json
{ "total_entries": 127, "total_bytes": 2_481_664, "by_type": { "Counter": { "count": 48, "bytes": 3_072, "ops": 1_402_391 }, "Map": { "count": 12, "bytes": 1_638_400, "ops": 48_201 }, "Pool": { "count": 4, "bytes": 16_384, "ops": 67_014 } }, "limits": { "max_entries": 100_000, "max_bytes": 1073741824, "soft_ratio": 0.7 }, "saturation": { "entries": 0.00127, "bytes": 0.00231 }, "diagnostics": { "lock_diagnostics_level": "warn", "cycle_detect_depth": 16, "poison_strict": false } }

Используйте summary в дашбордах и cron-оповещениях. Один скрейп даёт вам состояние по каждому типу и запас по ёмкости.

GET /__ox_shared/entries?limit=N

Перечисляет актуальные записи (ограничено значением limit, по умолчанию 100, максимум 500). По одной строке на запись:

json
{ "items": [ { "id": 42, "type": "Map", "refcount": 2, "ops": 1820, "mem_bytes": 204_800, "age_sec": 612 }, { "id": 43, "type": "Counter", "refcount": 3, "ops": 48_014, "mem_bytes": 64, "age_sec": 612 } ], "next_cursor": null, "total_matching": 127 }

refcount — это внешний счётчик удержаний: сколько PHP-обёрток и вложенных записей Shared удерживают данную запись. Если вы ожидаете, что запись можно собрать сборщиком мусора, но этого не происходит, проверять нужно именно это поле.

GET /__ox_shared/entry?id=N

Детали одной записи с учётом её типа:

json
{ "id": 42, "type": "Map", "refcount": 2, "ops": 1820, "mem_bytes": 204_800, "age_sec": 612, "type_specific": { "key_count": 1_240, "max_entries": 50_000, "saturation": 0.0248, "sample_keys": ["tenant:acme", "tenant:beta", "..."] } }

type_specific зависит от типа: Pool предоставляет { size, in_use, idle, waiting, idle_by_thread, max_size }, Channel предоставляет { capacity, pending, closed, senders_blocked, receivers_blocked }, Counter предоставляет { value } и так далее.

GET /__ox_shared/preview?id=N

Превью формы значений для скаляров и небольших массивов. Строковые значения усекаются до SHARED_PREVIEW_STRING_LIMIT (по умолчанию 256 байт); массивы показывают первые SHARED_PREVIEW_ARRAY_LIMIT элементов (по умолчанию 20). Управляется флагом SHARED_INTROSPECTION_PREVIEW_ENABLED.

json
{ "id": 42, "type": "Counter", "preview": "1420" }

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

GET /__ox_shared/types

Перечисляет каталог типов v1; полезно для генерируемых инструментов, которым нужно соответствие tag → класс:

json
{ "types": [ { "tag": 10, "name": "Counter", "php_class": "OxPHP\\Shared\\Counter" }, { "tag": 11, "name": "Flag", "php_class": "OxPHP\\Shared\\Flag" }, { "tag": 12, "name": "Once", "php_class": "OxPHP\\Shared\\Once" }, { "tag": 20, "name": "Map", "php_class": "OxPHP\\Shared\\Map" }, { "tag": 30, "name": "Mutex", "php_class": "OxPHP\\Shared\\Mutex" }, { "tag": 31, "name": "Channel", "php_class": "OxPHP\\Shared\\Channel" }, { "tag": 50, "name": "Pool", "php_class": "OxPHP\\Shared\\Pool" } ] }

GET /__ox_shared/graph?id=N[&depth=D][&edges=E]

Обход в ширину (BFS) исходящих ссылок Shareable, начиная с id=N. Возвращает узлы и рёбра достижимого подграфа. Значения по умолчанию: depth=16, edges=500. При исчерпании бюджета обходчика в ответе выставляется truncated: true.

json
{ "root": 42, "nodes": [ { "id": 42, "type": "Map", "refcount": 2, "mem_bytes": 204_800 }, { "id": 51, "type": "Counter", "refcount": 1, "mem_bytes": 64 } ], "edges": [ { "from": 42, "to": 51, "key": "hits" } ], "truncated": false }

Обращайтесь к graph после CycleException, чтобы увидеть достижимый путь, по которому прошёл обходчик, или при диагностике «почему этот Counter не собирается сборщиком мусора»: граф показывает всех родителей, удерживающих на нём ссылку.

Метрики Prometheus

Все метрики доступны по GET /metrics вместе с основными метриками сервера.

По всему реестру

Метрика Тип Метки Описание
oxphp_shared_objects_total gauge type Количество актуальных записей по каждому типу.
oxphp_shared_operations_total counter type Совокупное число операций, направленных каждому типу.
oxphp_shared_bytes gauge type Приблизительное число байт по каждому типу (±30% относительно mallinfo).
oxphp_shared_total_bytes gauge Сумма по всем типам.
oxphp_shared_capacity_saturation gauge kind entries и bytes в виде долей от их лимитов.
oxphp_shared_deadlock_detected_total counter Обнаруженные межпоточные циклы ожидания.

Channel

Метрика Тип Метки
oxphp_shared_channel_count gauge channel_id
oxphp_shared_channel_pending (устарело) gauge channel_id
oxphp_shared_channel_senders_blocked gauge channel_id
oxphp_shared_channel_receivers_blocked gauge channel_id
oxphp_shared_channel_items_sent_total counter channel_id
oxphp_shared_channel_items_dropped_total counter channel_id
Note

oxphp_shared_channel_pending — это устаревшее написание oxphp_shared_channel_count; в течение периода депрекации обе серии несут одно и то же значение и разойдутся, когда псевдоним будет удалён в одном из будущих релизов. Новые дашборды подключайте к _count.

Map

Метрика Тип Метки
oxphp_shared_map_entries gauge map_id
oxphp_shared_map_max_entries gauge map_id
oxphp_shared_map_saturation gauge map_id

Pool

Метрика Тип Метки
oxphp_shared_pool_count gauge pool_id
oxphp_shared_pool_size (устарело) gauge pool_id
oxphp_shared_pool_in_use gauge pool_id
oxphp_shared_pool_idle gauge pool_id
oxphp_shared_pool_waiting gauge pool_id
oxphp_shared_pool_acquire_total counter pool_id
oxphp_shared_pool_evicted_total counter pool_id, reason
oxphp_shared_pool_wait_seconds histogram pool_id
Note

oxphp_shared_pool_size — это устаревшее написание oxphp_shared_pool_count; в течение периода депрекации обе серии несут одно и то же значение и разойдутся, когда псевдоним будет удалён в одном из будущих релизов. Новые дашборды подключайте к _count.

Метки oxphp_shared_pool_evicted_total: reason=idle_timeout | evict | shutdown. idle_timeout — это автоматическое вытеснение простаивающего слота, evict — явный вызов Pool::evict(), а shutdown — освобождение при завершении процесса.

Counter / Flag / Once / Mutex

Для отдельных экземпляров counter, flag, once и mutex не предусмотрены собственные серии метрик: они раздули бы кардинальность меток. Для инспекции отдельных экземпляров используйте общерегистровый счётчик oxphp_shared_operations_total{type=...} и JSON /__ox_shared/entry?id=….

Метрики Mutex — кандидат на v1.x

Отслеживается как последующая работа; сегодня наблюдаемость обеспечивается через /__ox_shared/entry.

Диагностические сценарии

Pool насыщен (429, повторные попытки не помогают)

Симптомы: HTTP-клиенты видят таймауты, oxphp_shared_pool_waiting растёт, oxphp_shared_pool_count упирается в maxSize.

Проверьте:

bash
curl -s http://localhost:9090/__ox_shared/entry?id=<pool_id> | jq .type_specific

Посмотрите на idle_by_thread. Если оно равно {} или сильно несбалансировано (у воркера 0 — 8 простаивающих, у воркера 3 — 0), захват конкурирует за потоки, которые оказались заняты чем-то другим. Привязка к потокам в v1 не выполняет ребалансировку. Либо увеличьте maxSize, либо уменьшите горячую точку захвата в рамках одного потока.

Если idle_by_thread сбалансировано, но всё находится в состоянии in_use, увеличьте maxSize.

Насыщение по памяти

Проверьте oxphp_shared_total_bytes и oxphp_shared_capacity_saturation{kind="bytes"}. Если хотя бы одно из них велико:

  1. curl /__ox_shared/entries?limit=500 и отсортируйте по mem_bytes, чтобы найти основных потребителей.
  2. curl /__ox_shared/entry?id=<N> для каждого, чтобы проверить форму. Для Map смотрите key_count против max_entries.
  3. Самая частая причина: неограниченный Shared\Map с ключами из пользовательского ввода. Решение — ограничение maxEntries и политика хранения.

Обёртка не собирается сборщиком мусора

refcount в /__ox_shared/entries показывает, сколько активных удержаний существует. Если оно остаётся выше 1 после выхода PHP-обёртки из области видимости, значит, её удерживает живой другая запись Shared.

bash
curl -s http://localhost:9090/__ox_shared/graph?id=<N> | jq .nodes

Пройдите по графу в обратном направлении. Любой узел, достигающий застрявшей записи, удерживает ссылку. Уберите ссылку ($map->remove($key), закройте канал, удалите запись Mutex) — и refcount уменьшится.

CycleException сработало в продакшене

Сообщение исключения содержит достижимый путь, который исследовал детектор циклов. Сопоставьте эти идентификаторы с типами через /__ox_shared/entries и запросите /__ox_shared/graph?id=<root>, чтобы получить полную форму:

bash
# Exception message: "cycle would form: #42 → #51 → #42" curl -s http://localhost:9090/__ox_shared/graph?id=42 | jq

Результат визуализирует цепочку, чтобы вы могли увидеть, где была случайно введена обратная ссылка.

Сработал детектор взаимоблокировок

oxphp_shared_deadlock_detected_total растёт. Проверьте логи сервера. Детектор пишет запись в лог на каждый цикл с идентификаторами задействованных mutex и потоками-владельцами. Восстановление:

  1. curl /__ox_shared/entry?id=<mutex_id> для каждого и убедитесь, что poisoned=false. Если запись отравлена, детектор уже прервал цикл.
  2. Если цикл — это настоящий баг повторного входа, переработайте код так, чтобы использовать отдельные mutex для каждой области блокировки.
  3. Поднимите SHARED_LOCK_DIAGNOSTICS=strict в staging, чтобы будущий повторный вход превращался в быстрый отказ (fast-fail), а не в обнаруженный цикл.

Стенд длительного soak-тестирования

tests/soak/pool_soak.sh — это ручной (не для CI) стенд для проверки стабильности Shared\Pool при непрерывной нагрузке в течение часов или дней. Он:

  1. Запускает dev-образ с динамическим масштабированием воркеров (PHP_WORKERS=4:40 по умолчанию) и коротким idleTimeout пула, чтобы планировщик вытеснения срабатывал непрерывно.
  2. Загружает tests/soak/workload.php в качестве бутстрапа воркера, который создаёт 10 пулов × maxSize=8 и обслуживает захват/освобождение на каждый запрос.
  3. Генерирует трафик с помощью wrk в течение SOAK_DURATION_MIN минут (по умолчанию 1440 = 24 ч).
  4. Скрейпит /metrics и RSS контейнера каждые 60 с в tests/soak/out/<timestamp>/metrics.csv.
  5. В конце записывает verify.txt с результатом pass/fail по пяти критериям выхода для релиза (дрейф RSS в пределах ±5%, ноль паник из-за устаревших дескрипторов, ноль утёкших записей при завершении, плавный рост вытеснений по idle-timeout, ноль срабатываний детектора взаимоблокировок).

Требования на хосте: docker, wrk, curl, awk.

Типичные способы запуска:

bash
# 24h full soak before a release tests/soak/pool_soak.sh # 1h smoke for validating the harness itself SOAK_DURATION_MIN=60 tests/soak/pool_soak.sh # Heavier concurrency SOAK_CONCURRENCY=400 SOAK_THREADS=8 tests/soak/pool_soak.sh

Артефакты сохраняются в tests/soak/out/<timestamp>/:

  • metrics.csv — одна строка в минуту (unix ts, RSS, число записей по типам, счётчики вытеснений по пулам, число взаимоблокировок, операции).
  • server.log — stdout/stderr контейнера, включая любые следы устаревших дескрипторов или паник.
  • wrk.out / wrk.err — необработанный вывод генератора нагрузки.
  • metrics.final — последний скрейп /metrics, снятый непосредственно перед остановкой контейнера. Используется, чтобы подтвердить, что число записей и байт вернулось к базовому уровню (нет утёкших записей Shared) после прекращения нагрузки.
  • verify.txt — отчёт pass/fail по пяти критериям выхода.
Warning

Не подключайте это к CI. Запуск на 24 часа обходится недёшево, и его цель — уверенность перед релизом, а не непрерывная проверка.

Периодичность скрейпинга

Эндпоинты реестра обходят актуальное состояние под блокировками на чтение, поэтому скрейпинг дёшев, но не бесплатен. Рекомендуемая периодичность:

  • /metricsкаждые 15 с (типичное значение по умолчанию для Prometheus). Только агрегаты; накладные расходы пренебрежимо малы.
  • /__ox_shared/summaryкаждые 60 с для дашбордов. Немного тяжелее, чем /metrics.
  • /__ox_shared/entriesтолько по требованию. Проходит по всем шардам; не скрейпите на каждом тике.
  • /__ox_shared/entry / /preview / /graphпо требованию во время расследований.

Связанные материалы