- PVSM.RU - https://www.pvsm.ru -
Я использую Go для написания рекламной сети вот уже почти год. Разработку веду на сервере Intel i7-7700, 16Gb RAM, 256Gb SSD. И в скрипте который выполняется раз в сутки появилась задача выбрать все показы за прошедшие сутки и пересчитать на этой основе статистику за день сразу по нескольким объектам (сайт, кампания, баннер).
По идиомам Go делается всё достаточно тривиально:
type Hit struct {
siteID, zoneID, poolID, mediaID, campaignID uint32
}
rows, err := db.Query("SELECT siteID, zoneID, poolID, mediaID, campaignID FROM "+where)
if err != nil {
log.Fatal("Query fail", err)
}
defer rows.Close()
var (
c uint32
h Hit
)
for rows.Next() {
rows.Scan(&h.siteID, &h.zoneID, &h.poolID, &h.mediaID, &h.campaignID)
campCounter.Inc(h.campaignID)
siteCounter.Inc(h.siteID)
zoneCounter.Inc(h.zoneID)
poolCounter.Inc(h.poolID)
mediaCounter.Inc(h.mediaID)
c++
}
if err := rows.Err(); err != nil {
log.Fatal("Scan Rows err", err)
}
log.Println(name, " ", c, " ", where, "in", time.Since(now))
Всё работает. И скорость выборки 36 секунд для почти 56 миллионов записей.
hit_20180507 55928930 time BETWEEN 1525640400 AND 1525726799 in 36.331342451s
Под капотом анализатора производительности go tool pprof видим примерно следующее
flat flat% sum% cum cum%
7130ms 18.32% 18.32% 10800ms 27.75% runtime.mallocgc
2380ms 6.12% 24.43% 5710ms 14.67% fmt.(*pp).doPrintf
2140ms 5.50% 29.93% 13300ms 34.17% github.com/go-sql-driver/mysql.(*textRows).readRow
1800ms 4.62% 34.56% 2170ms 5.58% runtime.mapassign_fast32
1700ms 4.37% 38.93% 1700ms 4.37% runtime.heapBitsSetType
1170ms 3.01% 41.93% 36350ms 93.40% main.loadHits
1110ms 2.85% 44.78% 8500ms 21.84% runtime.convT2Eslice
1070ms 2.75% 47.53% 1970ms 5.06% fmt.(*fmt).fmt_integer
950ms 2.44% 49.97% 1380ms 3.55% github.com/go-sql-driver/mysql.readLengthEncodedString
930ms 2.39% 52.36% 1060ms 2.72% runtime.freedefer
930ms 2.39% 54.75% 930ms 2.39% runtime.mapaccess1_fast32
910ms 2.34% 57.09% 2070ms 5.32% runtime.deferreturn
860ms 2.21% 59.30% 1220ms 3.13% runtime.scanobject
Можно заметить что мы работаем в текстовом протоколе MySQL по mysql.(*textRows).readRow, соответственно пришедшие строки Scan конвертирует в uin32 типы. Но на первом месте по времени у нас функция выделения памяти.
Что тут можно ускорить?
Случайно на глаза мне попался тип RawBytes [1] который гарантирует что байты из драйвера базы данных будут переданы пользователю без копирования. Что же. Попытаемся извлечь Scan в промежуточную структуру с полями sql.RawBytes и переконвертируем сами потом []bytes в uint32 с помощью наскоро написанной функции bu2, выбросив проверки на ошибки (ведь вы не станете их искать в пришедшем от БД тексте, да?)
func b2u(b []byte) uint32 {
n := uint32(0)
for _, c := range b {
n = n*uint32(10) + uint32(c-'0')
}
return n
}
type HitRaw struct {
siteID, zoneID, poolID, mediaID, campaignID sql.RawBytes
}
В итоге время обработки сократилось до 28 секунд, что дает уже чтение 2 миллионов строк в секунду!
И профайлер даёт уже такую картину
4690ms 15.68% 15.68% 7630ms 25.51% runtime.mallocgc
2400ms 8.02% 23.70% 2700ms 9.03% runtime.mapaccess1_fast32
1660ms 5.55% 29.25% 1660ms 5.55% runtime.heapBitsSetType
1640ms 5.48% 34.74% 28110ms 93.98% main.loadHits
1590ms 5.32% 40.05% 1860ms 6.22% runtime.mapassign_fast32
1300ms 4.35% 44.40% 12450ms 41.62% github.com/go-sql-driver/mysql.(*textRows).readRow
1140ms 3.81% 48.21% 2090ms 6.99% runtime.deferreturn
1060ms 3.54% 51.76% 1470ms 4.91% github.com/go-sql-driver/mysql.readLengthEncodedString
1050ms 3.51% 55.27% 1050ms 3.51% main.b2u
1040ms 3.48% 58.74% 1130ms 3.78% database/sql.convertAssign
910ms 3.04% 61.79% 8640ms 28.89% runtime.convT2Eslice
730ms 2.44% 64.23% 2540ms 8.49% database/sql.(*Rows).Scan
Что же, неплохо как для начала. Далее я полез изучать драйвер MySQL, который как оказалось написан специально для Go и реализует низкоуровневые протоколы сам, с помощью сокетов. И вот второй протокол MySQL оказался бинарным. Что в теории дает более быструю генерацию ответа MySQL-сервера. Соответственно и драйвер, меньше вызывает функций конвертаций текст-целое число. Чтобы задействовать бинарный протокол надо перейти от db.Query до db.Prepare — stsm.Query — минимум изменений исходного кода и вуаля — 26.70 секунд выполнения.
stmtOut, err := db.Prepare(sqlQ)
defer stmtOut.Close()
if err != nil {
log.Fatal("prepare", err, sqlQ)
}
rows, err := stmtOut.Query()
if err != nil {
log.Fatal("query", err, sqlQ)
}
defer rows.Close()
Профилировщик показывает, что протокол уже действительно бинарный по (*binaryRows).readRow, но при чтении в RawBytes всё равно проходит конвертация в текст, а потом обратно.
flat flat% sum% cum cum%
2910ms 10.79% 10.79% 3310ms 12.27% runtime.mallocgc
2280ms 8.45% 19.24% 2600ms 9.64% runtime.mapaccess1_fast32
1960ms 7.27% 26.51% 7070ms 26.21% database/sql.convertAssign
1530ms 5.67% 32.18% 1810ms 6.71% runtime.mapassign_fast32
1460ms 5.41% 37.60% 6660ms 24.69% github.com/go-sql-driver/mysql.(*binaryRows).readRow
1420ms 5.27% 42.86% 26680ms 98.92% main.loadHits
1210ms 4.49% 47.35% 3010ms 11.16% strconv.AppendInt
1100ms 4.08% 51.43% 1320ms 4.89% strconv.formatBits
950ms 3.52% 54.95% 1650ms 6.12% runtime.deferreturn
820ms 3.04% 57.99% 820ms 3.04% reflect.ValueOf
810ms 3.00% 60.99% 4120ms 15.28% runtime.convT2E64
750ms 2.78% 63.77% 4240ms 15.72% database/sql.asBytes
Давайте же Scan делать сразу в uint32 структуры! Уже ничего не должно конвертироваться — только преобразование целое-целое.
Итог оказался печальным — 49.827306314s То есть замедление вообще ужасающее. Самый тупящий вариант из всех возможных, несмотря на хорошую теоретическую основу для самого быстрого результата. В чем же дело?
Смотрим:
4620ms 9.22% 9.22% 29230ms 58.32% database/sql.convertAssign
3610ms 7.20% 16.42% 4010ms 8.00% runtime.mallocgc
3010ms 6.01% 22.43% 8610ms 17.18% reflect.(*rtype).Name
2980ms 5.95% 28.37% 5600ms 11.17% reflect.(*rtype).String
2770ms 5.53% 33.90% 3330ms 6.64% runtime.mapaccess1_fast32
2570ms 5.13% 39.03% 2570ms 5.13% reflect.ValueOf
1760ms 3.51% 42.54% 1980ms 3.95% runtime.mapassign_fast32
1640ms 3.27% 45.81% 6630ms 13.23% github.com/go-sql-driver/mysql.(*binaryRows).readRow
1540ms 3.07% 48.88% 3870ms 7.72% strconv.FormatInt
1240ms 2.47% 51.36% 49600ms 98.96% main.loadHits
1150ms 2.29% 53.65% 1150ms 2.29% reflect.Value.Type
1120ms 2.23% 55.89% 1120ms 2.23% reflect.Value.Elem
1070ms 2.13% 58.02% 30950ms 61.75% database/sql.(*Rows).Scan
1070ms 2.13% 60.16% 1070ms 2.13% strconv.ParseUint
Судя по наличию strconv.ParseUint — преобразование 2 типов выполняется через строку! Серьезно? reflect-преобразования вышли на первые строчки по времени выполнения. Не зря Роб Пайк говорит об осторожном использовании рефлексии. Можно натворить дел.
Изучив драйвер MySQL я наткнулся на то, что с бинарного протокола все данные преобразуются в int64 — попробуем извлечь из этого пользу. Scan делаем в структуру
type HitRaw struct {
siteID, zoneID, poolID, mediaID, campaignID int64
}
...
h.siteID = uint32(raw.siteID)
h.zoneID = uint32(raw.zoneID)
h.poolID = uint32(raw.poolID)
h.mediaID = uint32(raw.mediaID)
h.campaignID = uint32(raw.campaignID)
Результат получился 33.98 сек. С таким раскладом по функциям
3600ms 10.48% 10.48% 14360ms 41.79% database/sql.convertAssign
2860ms 8.32% 18.80% 3340ms 9.72% runtime.mallocgc
2560ms 7.45% 26.25% 2920ms 8.50% runtime.mapaccess1_fast32
1660ms 4.83% 31.08% 6730ms 19.59% github.com/go-sql-driver/mysql.(*binaryRows).readRow
1540ms 4.48% 35.56% 33970ms 98.86% main.loadHits
1410ms 4.10% 39.67% 1690ms 4.92% runtime.mapassign_fast32
1340ms 3.90% 43.57% 1340ms 3.90% reflect.ValueOf
1290ms 3.75% 47.32% 4010ms 11.67% reflect.Value.Set
940ms 2.74% 50.06% 15960ms 46.45% database/sql.(*Rows).Scan
900ms 2.62% 52.68% 900ms 2.62% reflect.Value.Elem
840ms 2.44% 55.12% 840ms 2.44% reflect.Value.Type
840ms 2.44% 57.57% 1500ms 4.37% runtime.deferreturn
810ms 2.36% 59.92% 810ms 2.36% reflect.directlyAssignable
760ms 2.21% 62.14% 760ms 2.21% runtime.getitab
730ms 2.12% 64.26% 900ms 2.62% reflect.Value.assignTo
720ms 2.10% 66.36% 4060ms 11.82% runtime.convT2E64
Видно, что sql.convertAssign уменьшает всю выгоду от использования бинарного протокола. И теперь данные не копируются через текст, но внутри reflect определить что int64 можно копировать в переменную int64 пользователя — ещё довольно сложно. И копирование числа в текст и обратно идет быстрее, чем reflect.directlyAssignable — reflect.Value.assignTo.
В качестве разминки я попробовал перевести функцию b2u на Go-ассемблер. Ассемблер был моим одним из первых выученных в школе языков программирования на БК-0011 без дисковода и кассетного магнитофона) Так что это было забавно. Хотя Go генерирует практически оптимальный код и если вы не придумаете алгоритмические трики или использование нестандартных команд языка ASM — то смысла особого в написании этих функций нет.
// func b2u(data []byte) uint32
//
// memory layout of the stack relative to FP
// +0 data slice ptr
// +8 data slice len
// +16 data slice cap
#include "textflag.h"
TEXT ·B2u(SB),NOSPLIT,$0-24
// data ptr
MOVQ data+0(FP), SI
// data len
MOVQ data+8(FP), CX
// result in AX
MOVBLZX (SI), AX
// - '0'
SUBL $48, AX
// check end of loop
DECQ CX
JZ AX2RET
LOOPBYTE:
//move to one byte upper
INCQ SI
MOVBLZX (SI), BX
//prev result *= 10
IMULL $10, AX
// bx -= '0'
SUBL $48, BX
ADDL BX, AX
// check end of loop while (cx--)
DECQ CX
JNZ LOOPBYTE
AX2RET:
MOVL AX, ret+24(FP)
RET
По тестам она даёт ускорение 2-20%, от Go-версии. Зависит от кол-ва цифр в числе.
В итоге рабочий пример ускорился до 26.94 секунды.
Вывод из статьи, для тех, кто просматривал текст — самый быстрый способ прочитать большой объем целочисленных данных из MySQL в память — использовать db.Prepare — stmt.Query — Scan в sql.RawBytes и преобразование байт-слайса в целое число самописной функцией. То есть, показанные в стандартных примерах способы работы не всегда оптимальны. Ждем когда в Go введут generic — может это ускорит работу стандартных драйверов баз данных. Или возможно, разработчики обратят внимание на поведение драйвера. Ведь Go в тестах, в которых появляются SELECT-выборки из БД, не блещет производительностью [2].
UPD: В комментариях привели пример чудодейственного драйвера github.com/lazada/sqle [3] якобы рассчитанного на быстрое чтение. Итог оказался при чтении через
rows.Scan(&h.siteID, &h.zoneID, &h.poolID, &h.mediaID, &h.campaignID) в uint32 переменные очень печальный
55928930 time BETWEEN 1525640400 AND 1525726799 in 1m0.307942824s
И если посмотреть на то, чем занималась минуту программа, становится понятно, что об оптимизации этого случая там просто не задумывались. Стандартный драйвер выигрывает в 2 раза.
flat flat% sum% cum cum%
4.63s 7.62% 7.62% 29.25s 48.11% database/sql.convertAssign
4.22s 6.94% 14.56% 4.68s 7.70% runtime.mallocgc
2.97s 4.88% 19.44% 8.48s 13.95% reflect.(*rtype).Name
2.96s 4.87% 24.31% 5.51s 9.06% reflect.(*rtype).String
2.90s 4.77% 29.08% 41.28s 67.89% github.com/lazada/sqle.(*Rows).Scan
2.51s 4.13% 33.21% 2.95s 4.85% runtime.mapaccess1_fast32
2.38s 3.91% 37.12% 2.38s 3.91% reflect.ValueOf
1.92s 3.16% 40.28% 3.69s 6.07% runtime.assertE2I2
1.77s 2.91% 43.19% 1.77s 2.91% runtime.getitab
1.65s 2.71% 45.90% 7.04s 11.58% github.com/go-sql-driver/mysql.(*binaryRows).readRow
1.49s 2.45% 48.36% 1.86s 3.06% runtime.mapassign_fast32
1.35s 2.22% 50.58% 60.27s 99.13% main.loadHits
1.31s 2.15% 52.73% 4.01s 6.60% github.com/lazada/sqle.typeCheck
1.31s 2.15% 54.88% 4.19s 6.89% strconv.FormatInt
1.28s 2.11% 56.99% 2.88s 4.74% strconv.formatBits
1.25s 2.06% 59.05% 1.25s 2.06% reflect.(*rtype).Kind
1.12s 1.84% 60.89% 31.05s 51.07% database/sql.(*Rows).Scan
Если читать Scan в []byte переменные и конвертировать через b2u() в uint32 получается 44 секунды. По-моему, дальше можно не тестировать, какой замечательный заменитель стандартного database/sql отписали ребята. Очередной миф про ускорение развенчался об практические тесты.
Автор: sania
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/mysql/280123
Ссылки в тексте:
[1] RawBytes: https://golang.org/pkg/database/sql/#RawBytes
[2] не блещет производительностью: https://www.techempower.com/benchmarks/#section=data-r15&hw=ph&test=fortune
[3] github.com/lazada/sqle: https://github.com/lazada/sqle
[4] Источник: https://habr.com/post/358522/?utm_source=habrahabr&utm_medium=rss&utm_campaign=358522
Нажмите здесь для печати.