Наблюдаемость Shared*
Каждый экземпляр OxPHP\Shared\* — это запись в реестре, которую среда выполнения и так отслеживает для подсчёта ссылок и ёмкости. Эти данные доступны операторам в виде JSON-интроспекции по адресу /__ox_shared/* и в виде метрик Prometheus с префиксом oxphp_shared_*. Эта страница — справочник и практическое руководство.
Включение
Наблюдаемость работает поверх внутреннего сервера. Чтобы запустить его, задайте INTERNAL_ADDR:
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
Снимок верхнего уровня: агрегированные счётчики по типам, память, частота операций и насыщение относительно настроенных лимитов.
{
"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). По одной строке на запись:
{
"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
Детали одной записи с учётом её типа:
{
"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.
{ "id": 42, "type": "Counter", "preview": "1420" }Используйте preview во время разработки; отключайте его в продакшене, когда значения могут содержать пользовательские данные.
GET /__ox_shared/types
Перечисляет каталог типов v1; полезно для генерируемых инструментов, которым нужно соответствие tag → класс:
{
"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.
{
"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 |
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 |
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=….
Отслеживается как последующая работа; сегодня наблюдаемость обеспечивается через /__ox_shared/entry.
Диагностические сценарии
Pool насыщен (429, повторные попытки не помогают)
Симптомы: HTTP-клиенты видят таймауты, oxphp_shared_pool_waiting растёт, oxphp_shared_pool_count упирается в maxSize.
Проверьте:
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"}. Если хотя бы одно из них велико:
curl /__ox_shared/entries?limit=500и отсортируйте поmem_bytes, чтобы найти основных потребителей.curl /__ox_shared/entry?id=<N>для каждого, чтобы проверить форму. Для Map смотритеkey_countпротивmax_entries.- Самая частая причина: неограниченный
Shared\Mapс ключами из пользовательского ввода. Решение — ограничениеmaxEntriesи политика хранения.
Обёртка не собирается сборщиком мусора
refcount в /__ox_shared/entries показывает, сколько активных удержаний существует. Если оно остаётся выше 1 после выхода PHP-обёртки из области видимости, значит, её удерживает живой другая запись Shared.
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>, чтобы получить полную форму:
# 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 и потоками-владельцами. Восстановление:
curl /__ox_shared/entry?id=<mutex_id>для каждого и убедитесь, чтоpoisoned=false. Если запись отравлена, детектор уже прервал цикл.- Если цикл — это настоящий баг повторного входа, переработайте код так, чтобы использовать отдельные mutex для каждой области блокировки.
- Поднимите
SHARED_LOCK_DIAGNOSTICS=strictв staging, чтобы будущий повторный вход превращался в быстрый отказ (fast-fail), а не в обнаруженный цикл.
Стенд длительного soak-тестирования
tests/soak/pool_soak.sh — это ручной (не для CI) стенд для проверки стабильности Shared\Pool при непрерывной нагрузке в течение часов или дней. Он:
- Запускает dev-образ с динамическим масштабированием воркеров (
PHP_WORKERS=4:40по умолчанию) и короткимidleTimeoutпула, чтобы планировщик вытеснения срабатывал непрерывно. - Загружает
tests/soak/workload.phpв качестве бутстрапа воркера, который создаёт 10 пулов ×maxSize=8и обслуживает захват/освобождение на каждый запрос. - Генерирует трафик с помощью
wrkв течениеSOAK_DURATION_MINминут (по умолчанию 1440 = 24 ч). - Скрейпит
/metricsи RSS контейнера каждые 60 с вtests/soak/out/<timestamp>/metrics.csv. - В конце записывает
verify.txtс результатом pass/fail по пяти критериям выхода для релиза (дрейф RSS в пределах ±5%, ноль паник из-за устаревших дескрипторов, ноль утёкших записей при завершении, плавный рост вытеснений по idle-timeout, ноль срабатываний детектора взаимоблокировок).
Требования на хосте: docker, wrk, curl, awk.
Типичные способы запуска:
# 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 по пяти критериям выхода.
Не подключайте это к CI. Запуск на 24 часа обходится недёшево, и его цель — уверенность перед релизом, а не непрерывная проверка.
Периодичность скрейпинга
Эндпоинты реестра обходят актуальное состояние под блокировками на чтение, поэтому скрейпинг дёшев, но не бесплатен. Рекомендуемая периодичность:
/metrics— каждые 15 с (типичное значение по умолчанию для Prometheus). Только агрегаты; накладные расходы пренебрежимо малы./__ox_shared/summary— каждые 60 с для дашбордов. Немного тяжелее, чем/metrics./__ox_shared/entries— только по требованию. Проходит по всем шардам; не скрейпите на каждом тике./__ox_shared/entry//preview//graph— по требованию во время расследований.
Связанные материалы
- Разделяемое состояние — ментальная модель и обзор примитивов.
- Метрики Prometheus — основные метрики сервера на том же эндпоинте
/metrics. - Внутренний сервер — как эндпоинты
/__ox_shared/*подключаются кINTERNAL_ADDR. - Миграция во внешнее хранилище — когда насыщение структурное, а не настраиваемое.