2. Heavy hitters
«Большинство явлений в распределенных системах подчиняются закону Парето: ничтожная часть ключей порождает львиную долю нагрузки».
— Брайан Керниган
Поиск частых элементов (Heavy Hitters): от алгоритма Мисры-Гриса до Count-Min Sketch
Задача поиска «тяжеловесов» (Heavy Hitters) — фундаментальная проблема потоковой телеметрии и сетевой безопасности.
Дана последовательность из $N$ событий. Требуется найти все элементы, частота появления которых превышает заданную долю от общего объема потока:
$$\text{freq}(X) > \varphi \times N \quad (0 < \varphi < 1)$$
Примеры в реальной индустрии:
- Детекция DDoS и брутфорс-атак: выявление IP-адресов, генерирующих более $1\%$ входящего сетевого трафика.
- Мониторинг микросервисов: нахождение эндпоинтов, на которые приходится $80\%$ серверных ошибок HTTP 500.
- Кэширование: определение «горячих» ключей для помещения в локальный In-Memory кэш (Hot Keys Detection).
Поскольку $N$ может достигать миллиардов, хранить точную карту map[string]int невозможно из-за исчерпания RAM. Мы разберем два краеугольных подхода: детерминированный алгоритм Мисры-Гриса и вероятностный скетч Count-Min Sketch.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
A["Входящий элемент X"]:::entry --> B{"X уже есть в мониторе?"}:::warning
B -->|"Да"| C["Увеличить счетчик: count[X]++"]:::process
B -->|"Нет"| D{"Число отслеживаемых ключей < K - 1?"}:::warning
D -->|"Да"| E["Добавить X в монитор: count[X] = 1"]:::process
D -->|"Нет"| F["Декремент всех счетчиков на 1<br/>Удаление обнулившихся ключей"]:::error
C --> G["Следующий элемент потока"]:::success
E --> G
F --> G
1. Детерминированный подход: алгоритм Мисры-Гриса (Misra-Gries)
Алгоритм Мисры-Гриса — это элегантное обобщение классического алгоритма большинства Бойера-Мура (Boyer-Moore Majority Vote).
Чтобы гарантированно найти все элементы, встречающиеся с частотой более $\frac{N}{K}$, алгоритм поддерживает карту максимум из $K - 1$ кандидатов.
Принцип работы
- Если входящий элемент $X$ уже присутствует среди кандидатов, его счетчик увеличивается на $1$.
- Если в карте менее $K - 1$ элементов, $X$ добавляется со счетчиком $1$.
- Если места нет, счетчики всех кандидатов уменьшаются на $1$. Элементы со счетчиком $0$ удаляются из карты.
Математическая гарантия
Каждый раз, когда выполняется массовый декремент, мы отбрасываем $K$ различных элементов (новый входящий плюс $K - 1$ кандидат из карты).
Поскольку всего в потоке $N$ элементов, операция массового декремента может произойти максимум $\frac{N}{K}$ раз.
Следовательно, счетчик любого элемента может быть занижен максимум на $\frac{N}{K}$.
Если истинная частота элемента $X$ строго больше $\frac{N}{K}$, его финальный счетчик гарантированно останется $> 0$. Ни один тяжеловес не будет пропущен!
package heavyhitters
import (
"sync"
)
// MisraGries отслеживает элементы с частотой > N/k
type MisraGries struct {
mu sync.Mutex
k int
counters map[string]int
}
func NewMisraGries(k int) *MisraGries {
if k < 2 {
k = 2
}
return &MisraGries{
k: k,
counters: make(map[string]int, k-1),
}
}
// Process обрабатывает очередной элемент потока
func (mg *MisraGries) Process(item string) {
mg.mu.Lock()
defer mg.mu.Unlock()
// 1. Элемент уже отслеживается
if count, exists := mg.counters[item]; exists {
mg.counters[item] = count + 1
return
}
// 2. Есть свободный слот среди k-1 кандидатов
if len(mg.counters) < mg.k-1 {
mg.counters[item] = 1
return
}
// 3. Слоты заняты: уменьшаем счетчики всех кандидатов
for key, count := range mg.counters {
if count <= 1 {
delete(mg.counters, key)
} else {
mg.counters[key] = count - 1
}
}
}
// Candidates возвращает текущих претендентов на статус Heavy Hitters
func (mg *MisraGries) Candidates() map[string]int {
mg.mu.Lock()
defer mg.mu.Unlock()
result := make(map[string]int, len(mg.counters))
for k, v := range mg.counters {
result[k] = v
}
return result
}
2. Вероятностный подход: Count-Min Sketch
Если требуется оценивать частоту любого произвольного ключа в реальном времени с фиксированным объемом памяти, стандартом индустрии является Count-Min Sketch (CMS).
CMS представляет собой двумерную матрицу счетчиков размера $d \times w$:
- $w = \lceil \frac{e}{\varepsilon} \rceil$ — ширина таблицы (определяет предельную погрешность $\varepsilon$).
- $d = \lceil \ln(\frac{1}{\delta}) \rceil$ — глубина таблицы (определяет вероятность ошибки $\delta$).
- $d$ независимых хеш-функций: $h_1, h_2, \dots, h_d$.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Item["Элемент X"]:::accent --> H1["h1(X) = 3"]:::process
Item --> H2["h2(X) = 1"]:::process
Item --> H3["h3(X) = 7"]:::process
H1 --> R1["Row 0: [ . . . +1 . . . ]"]:::success
H2 --> R2["Row 1: [ . +1 . . . . . ]"]:::success
H3 --> R3["Row 2: [ . . . . . . . +1 ]"]:::success
Свойства Count-Min Sketch:
- Никогда не занижает частоту: из-за коллизий хеш-функций счетчики в ячейках могут только накапливаться. Поэтому:
$$\text{Estimate}(X) \ge \text{TrueCount}(X)$$ - Оценка через минимум: при запросе частоты элемента $X$ мы вычисляем его ячейки во всех $d$ строках и берем минимальное значение:
$$\text{Estimate}(X) = \min_{1 \le i \le d} \text{Table}[i][h_i(X)]$$
Взятие минимума отсекает коллизии в остальных строках!
3. Аппаратная механика и предотвращение False Sharing
В многопоточных бэкенд-серверах десятки горутин параллельно вызывают инкремент счетчиков CMS.
Если хранить матрицу в виде среза срезов [][]uint32:
- Возникает разрыв кэш-локальности (двойной переход по указателям).
- Несколько смежных 32-битных счетчиков попадают в одну и ту же 64-байтную кэш-линию CPU. Когда ядро 1 обновляет счетчик
Table[0][1], а ядро 2 обновляетTable[0][2], протокол когерентности кэшей (MESI) принудительно инвалидирует кэш-линию, приводя к эффекту False Sharing и падению пропускной способности в 10–15 раз.
Решение:
- Использование единого плоского одномерного среза
table []uint32размера $w \times d$. - Вычисление индекса ячейки: $\text{idx} = \text{row} \times w + \text{col}$.
- Атомарный инкремент через
atomic.AddUint32(&table[idx], 1).
4. Вопросы для собеседования
Резюме
- Алгоритм Мисры-Гриса гарантированно находит всех тяжеловесов с частотой $> \frac{N}{K}$ при расходе памяти $O(K)$.
- Count-Min Sketch обеспечивает оценку частоты любого произвольного ключа за $O(d)$ времени без хранения самих ключей.
- Оценка частоты в CMS никогда не бывает ниже истинной, а взятие минимума по строкам минимизирует влияние коллизий.
- В конкурентном коде матрицу скетча следует упаковывать в плоский одномерный срез памяти.
В следующей статье мы объединим кучи и скетчи для отслеживания Top-K частых элементов в режиме реального времени: 3. Top K Frequent Elements stream.