- PVSM.RU - https://www.pvsm.ru -
Использование монги в production — достаточно спорная тема.
С одной стороный все просто и удобно: положили данные, настроили репликацию, понимаем как шардировать базу при росте объема данных. С другой стороны существует достаточно много страшилок [1], Aphyr в своем последнем jepsen тесте [2] сделал не очень позитивные выводы.
По факту оказывается, что есть достаточно много проектов, где mongo является основным хранилищем данных, и нас часто спрашивали про поддержку mongodb в окметр. Мы долго тянули с этой задачей, потому что сделать "осмысленный" мониторинг на порядок сложнее, чем просто собрать какие-то метрики и настроить какие-нибудь алерты. Нужно сначала разобраться в особенностях поведения софта, чтобы понять, какие именно показатели отслеживать.
Как раз про сложности и проблемы я и хочу рассказать на примере реализации мониторинга запросов к mongodb.
На любую базу данных нужно смотреть с трех сторон:
Мы пока ограничились только мониторингом запросов.
Так как мы говорим о мониторинге, нас не интересует каждый конкретный запрос, мы скорее хотим все запросы сгруппировать по некоторому одинаковому плану выполнения (например postgresql в pg_stat_statements [3] группирует запросы по реальному плану).
Для mongodb идентификатором запроса является тип запроса (find, insert, update, findAndModify, aggregate и другие), база данных, коллекция и bson документ с самим запросом.
Для простоты мы решили, что запросы можно сгруппировать, заменив все значения полей из запроса на "?" и отсортировав по полям.
Например запрос:
{"country": "RU", "city": "Moscow", "$orderby": {"age": -1}}
превращаем в
{country: ?, city: ?, $orderby: {age: ?}}
а потом сортируем по ключам
{$orderby: {age: ?}, city: ?, country: ?}
Скорее всего подобные запросы будут использовать одни и те же индексы вне зависимости от конкретных условий.
Следующий большой вопрос: как получать в реальном времени весь поток запросов.
Единственный штатный способ в mongodb — это profiler [4]. Он записывает статистику по каждому запросу в ограниченную по размеру коллекцию (capped collection). Профайлер может записывать или только медленные запросы (если время исполнения больше заданного в slowOpThresholdMs [5] или записывать абсолютно все запросы. Во втором случае может просесть производительность самой mongodb.
К преимуществам данного подхода стоит отнести очень подробную статистику о выполнении каждого запроса.
Но для нас очень критично не оказать негативного влияния на производительность серверов наших клиентов, поэтому использовать профайлер в режиме записи всех запросов мы не можем. Только "медленных" запросов нам недостаточно, так как мы не увидим полной картины:
По нашему опыту проблемы чаще создают высокочастотные запросы, которые раньше выполнялись 1ms, а потом по какой-то причине стали выполняться к примеру 5ms. А запросы >100ms (дефолтный slowOpThresholdMs) обычно служебные (админка/статистика) и очень редкие.
Так как стандартный профайлер не подошел, мы стали копать в сторону сниффинга трафика. На первом этапе было необходимо выяснить ряд вопросов:
Прототип нашего плагина mongodb был написан за несколько дней с использование библиотеки gopacket [7]. Мы перехватывали пакеты через libpcap, разбирали протокол, bson документы десериализовались с использованием mgo [8].
Так как у нас нет инсталяции mongodb под нагрузкой, мы сделали стенд и запустили готовый benchmark [9]. В нашем случае mongodb и грузилка жили на одной виртуальной машине с 2 ядрами и 2Gb памяти. По нагрузкой мы видели около 10 тысяч пакетов в секунду при трафике ~60Mbit/s.
Наш прототип под такой нагрузкой утилизировал около 70% одного процессорного ядра. Стало понятно, что необходимо профилировать и оптимизировать код. Тут стоит отдать должное стандартному профайлеру [10] golang, нам не нужно было ничего изобретать, а просто тюнить самые прожорливые по CPU участки кода и стараться как можно меньше аллоцировать память для снижения нагрузки на GC.
В точности процесс оптимизации я уже воспроизвести не смогу, но приведу примеры самых значительных изменений:
Bson документ запроса в mongo — это грубо говоря словарь, значения которого могут быть в том числе и такими же словарями.
Так как с самого начала мы решили, что будем нормализовывать запросы, можем вообще не читать значения элементов исходного словаря, если они не являются словарями.
Берем спецификацию [11] и пишем свой примитивный десериализатор. В итоге получилась функция ~100 строк
elementValueType, err = reader.ReadByte()
if err != nil {
break
}
payload, err = reader.ReadBytes(nullByte)
if err != nil {
break
}
elementName = string(payload)
switch elementValueType {
case bsonDouble, bsonDatetime, bsonTimestamp, bsonInt64:
if _, err = reader.ReadN(8); err != nil {
break
}
case bsonString:
l, err = reader.ReadInt()
if err != nil {
break
}
payload, err = reader.ReadN(l)
if err != nil {
break
}
elementValue = string(payload[:len(payload)-1])
case bsonJsCode, bsonDeprecated, bsonBinary, bsonJsWithScope, bsonArray:
l, err = reader.ReadInt()
if err != nil {
break
}
if _, err = reader.ReadN(l - 4); err != nil {
break
}
case bsonDoc:
elementValue, _, _, err = readDocument(reader)
if err != nil {
break
}
case bsonObjId:
if _, err = reader.ReadN(12); err != nil {
break
}
case bsonBool:
if _, err = reader.ReadByte(); err != nil {
break
}
case bsonRegexp:
if _, err = reader.ReadBytes(nullByte); err != nil {
break
}
if _, err = reader.ReadBytes(nullByte); err != nil {
break
}
case bsonDbPointer:
l, err = reader.ReadInt()
if err != nil {
break
}
if _, err = reader.ReadN(l - 4 + 12); err != nil {
break
}
case bsonInt32:
if _, err = reader.ReadN(4); err != nil {
break
}
}
Из всех вариантов полей мы читаем значения только для bsonDocument (рекурсивно вызывая себя же) и bsonString (у нас есть дополнительная логика по определению коллекции и типа запроса), остальные поля мы просто пропускаем.
На наших тестах использование raw sockets [12] напрямую оказалось быстре, чем через pcap.
Возможно это было из-за старой версии libpcap, но мы планировали делать сниффер только под linux, поэтому решили не разбираться, а использовать gopacket.af_packet [13] (тем более не нужно линковать агента с libpcap).
Raw sockets — это специльные сокеты в linux, через которые можно отправить полностью сформированный в userspace (а не ядре) пакет или получить пакеты с определенного сетевого интерфейса. Если говорить про сниффинг, пакеты от ядра попадают в userspace через циклический буфер, что позволяет не делать syscall на перехват каждого пакета. На эту тему есть подробный хардкор [14] в документации ядра.
Так как мы обрабатываем пакеты в один поток, то можем использовать "ZeroCopy [15]" интерфейс сниффера. Но при этом нужно помнить, что ссылок на данный участок памяти дальше в коде оставлять нельзя.
Интерфейс разбора пакетов в gopacket устроен довольно гибко, поддерживает из коробки много разных протоколов, пользователю не нужно думать о том, как инкапсулированы данные верхнего уровня. Но вместе с этим этот интерфейс навязывает необходимость большого числа копирований данных и как следствие большую нагрузку как на CPU так и на GC.
Мы опять решили откинуть все лишнее:)
Наша задача из исходного ethernet фрэйма (а на выходе AF_PACKET мы получаем всегда ethernet) получить:
Для простоты было решено пока не поддерживать IPv6.
func DecodePacket(data []byte, linkType layers.LinkType, packet *TcpIpPacket) (err error) {
var l uint16
switch linkType {
case layers.LinkTypeEthernet:
if len(data) < 14 {
ethernetTooSmall.Inc(1)
err = errors.New("Ethernet packet too small")
return
}
l = binary.BigEndian.Uint16(data[12:14])
switch layers.EthernetType(l) {
case layers.EthernetTypeIPv4:
data = data[14:]
case layers.EthernetTypeLLC:
l = uint16(data[2])
if l&0x1 == 0 || l&0x3 == 0x1 {
data = data[4:]
} else {
data = data[3:]
}
default:
ethernetUnsupportedType.Inc(1)
err = errors.New("Unsupported ethernet type")
return
}
default:
unsupportedLinkProto.Inc(1)
err = errors.New("Unsupported link protocol")
return
}
//IP
var cmp int
if len(data) < 20 {
ipTooSmallLength.Inc(1)
err = errors.New("Too small IP length")
return
}
version := data[0] >> 4
switch version {
case 4:
if binary.BigEndian.Uint16(data[6:8])&0x1FFF != 0 {
ipNonFirstFragment.Inc(1)
err = errors.New("Non first IP fragment")
return
}
if len(data) < 20 {
ipTooSmall.Inc(1)
err = errors.New("Too small IP packet")
return
}
hl := uint8(data[0]) & 0x0F
l = binary.BigEndian.Uint16(data[2:4])
packet.SrcIp[0] = data[12]
packet.SrcIp[1] = data[13]
packet.SrcIp[2] = data[14]
packet.SrcIp[3] = data[15]
packet.DstIp[0] = data[16]
packet.DstIp[1] = data[17]
packet.DstIp[2] = data[18]
packet.DstIp[3] = data[19]
if l < 20 {
ipTooSmallLength.Inc(1)
err = errors.New("Too small IP length")
return
} else if hl < 5 {
ipTooSmallHeaderLength.Inc(1)
err = errors.New("Too small IP header length")
return
} else if int(hl*4) > int(l) {
ipInvalieHeaderLength.Inc(1)
err = errors.New("Invalid IP header length > IP length")
return
}
if cmp = len(data) - int(l); cmp > 0 {
data = data[:l]
} else if cmp < 0 {
if int(hl)*4 > len(data) {
ipTruncatedHeader.Inc(1)
err = errors.New("Not all IP header bytes available")
return
}
}
data = data[hl*4:]
case 6:
ipV6IsNotSupported.Inc(1)
err = errors.New("IPv6 is not supported")
return
default:
ipInvalidVersion.Inc(1)
err = errors.New("Invalid IP packet version")
return
}
//TCP
if len(data) < 13 {
tcpTooSmall.Inc(1)
err = errors.New("Too small TCP packet")
return
}
packet.SrcPort = binary.BigEndian.Uint16(data[0:2])
packet.DstPort = binary.BigEndian.Uint16(data[2:4])
packet.Seq = binary.BigEndian.Uint32(data[4:8])
dataOffset := data[12] >> 4
if dataOffset < 5 {
tcpInvalidDataOffset.Inc(1)
err = errors.New("Invalid TCP data offset")
return
}
dataStart := int(dataOffset) * 4
if dataStart > len(data) {
tcpOffsetGreaterThanPacket.Inc(1)
err = errors.New("TCP data offset greater than packet length")
return
}
packet.Payload = data[dataStart:]
return
}
Для подобных функций всегда стоит писать бенчмарки [16], на этот раз получилась достаточно приятная картина:
Benchmark_DecodePacket-4 50000000 27.9 ns/op
Benchmark_Gopacket-4 1000000 3351 ns/op
То есть мы получили ускорение больше чем в 100 раз.
Значительнуя часть кода этой функции занимает обработка ошибок, там же видно инкременты разных счетчиков из которых мы потом делаем служебные метрики агента и можем легко понять, почему у нас как-то не так работает сниффер. Например, о необходимости добавить поддержку IPv6 мы планируем узнать именно по такой метрике.
Еще мы не пытаемся склеивать tcp payload из разных пакетов, в случае когда данные не влезают в 1 ethernet фрэйм.
Если такой пакет — ответ mongodb, нас интересует только заголовок, а для больших insert запросов например, мы просто возьмем часть запроса из первого пакета.
Выяснилось, что если клиент и сервер находятся на одном сервере, то мы ловим один и тот же пакет 2 раза.
Пришлось делать простой дедубликатор пакетов на основе src ip+port, dest ip+port и TCP seq.
Автор: okmeter.io
Источник [17]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/180363
Ссылки в тексте:
[1] страшилок: https://habrahabr.ru/post/231213/
[2] jepsen тесте: https://aphyr.com/posts/322-jepsen-mongodb-stale-reads
[3] pg_stat_statements: https://www.postgresql.org/docs/9.4/static/pgstatstatements.html
[4] profiler: https://docs.mongodb.com/manual/administration/analyzing-mongodb-performance/#database-profiling
[5] slowOpThresholdMs: https://docs.mongodb.com/manual/reference/configuration-options/#operationProfiling.slowOpThresholdMs
[6] протокола mongodb: https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/
[7] gopacket: https://github.com/google/gopacket
[8] mgo: https://godoc.org/labix.org/v2/mgo/bson
[9] benchmark: https://github.com/tmcallaghan/sysbench-mongodb
[10] профайлеру: https://blog.golang.org/profiling-go-programs
[11] спецификацию: http://bsonspec.org/spec.html
[12] raw sockets: http://man7.org/linux/man-pages/man7/packet.7.html
[13] gopacket.af_packet: https://github.com/google/gopacket/blob/master/afpacket/afpacket.go
[14] хардкор: http://lxr.free-electrons.com/source/Documentation/networking/packet_mmap.txt
[15] ZeroCopy: https://github.com/google/gopacket/blob/master/afpacket/afpacket.go#L241
[16] бенчмарки: http://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go
[17] Источник: https://habrahabr.ru/post/308328/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.