Використання структур як ключів для мапи в Golang
Вже пару років працюю з Go і хочу розповісти про помилку яку робив сам а також зустрічав у колег, які також перейшли з PHP чи Python на Golang, а саме серіалізація ключів.
В цій темі наведу приклади помилок, виправлення і мікробенчмарки.
Приклад помилки з серіалізацією складного ключа
Є stateful мікросервіс який перед обробкою запита від користувача перевіряє його на правильність, а дані для перевірки оновлює по крону.Приблизно код виглядає так:
package example
import (
"strconv"
"sync"
)
type Request interface {
Namespace() uint64
Set() uint64
}
type FullPathValue struct {
Namespace uint64
Set uint64
}
var (
namespaceSetExistsMap map[string]bool
namespaceSetMutex sync.RWMutex
)
// handle user request
func SomeHandler(request Request) {
if namespaceSetExists(request.Namespace(), request.Set()) {
return
}
// some action
}
// update by cron
func updateNamespaceSet(values []FullPathValue) {
var replace = make(map[string]bool, len(values))
for _, value := range values {
replace[namespaceSetString(value.Namespace, value.Set)] = true
}
namespaceSetMutex.Lock()
namespaceSetExistsMap = replace
namespaceSetMutex.Unlock()
}
func namespaceSetExists(namespace, set uint64) bool {
namespaceSetMutex.RLock()
var data = namespaceSetExistsMap
namespaceSetMutex.RUnlock()
return data[namespaceSetString(namespace, set)]
}
func namespaceSetString(namespace, set uint64) string {
const (
namespaceSetSize = 21*2 + 1 // 21 is uint64 size, 1 delimiters '_'
)
result := make([]byte, 0, namespaceSetSize)
result = strconv.AppendUint(result, uint64(namespace), 10)
result = append(result, '_')
result = strconv.AppendUint(result, uint64(set), 10)
return string(result)
}
схожу на namespaceSetString функцію зустрічав в реальному проекті
одна з причин таких серіалізацій це обмеження PHP де ключами можуть бути строкові та числові значення, таким чином роками напрацьований патерн серіалізації мігрує в іншу мову (думаю така ж ситуація з ключами в Python, Ruby та JavaScript)
а ось в Golang ключами можуть бути структури і відповідно використовувати без серіалізації
package example
import (
"sync"
)
type FullPathValue struct {
Namespace uint64
Set uint64
}
var (
namespaceSetExistsMap map[FullPathValue]bool
namespaceSetMutex sync.RWMutex
)
// update by cron
func updateNamespaceSet(values []FullPathValue) {
var replace = make(map[FullPathValue]bool, len(values))
for _, value := range values {
replace[value] = true
}
namespaceSetMutex.Lock()
namespaceSetExistsMap = replace
namespaceSetMutex.Unlock()
}
func namespaceSetExists(namespace, set uint64) bool {
namespaceSetMutex.RLock()
var data = namespaceSetExistsMap
namespaceSetMutex.RUnlock()
return data[FullPathValue{namespace, set}]
}
Перевірка швидкодії серіалізованого ключа та ключа-структури
Ще до тестів зрозуміло переможця, а тест приведений тільки для відображення числових характеристик:package main
import (
"strconv"
"testing"
)
const (
namespaceSetKeyBinSize = 10*4 + 3 // 10 is uint32 size, 3 delimiters '_'
)
func BenchmarkNamespaceSetKeyBinStringWrite(b *testing.B) {
var data = make(map[string]bool, b.N)
for i := 0; i < b.N; i++ {
data[namespaceSetKeyBinString(uint32(i), uint32(i), uint32(i), uint32(i))] = true
}
}
func BenchmarkNamespaceSetKeyBinStringRead(b *testing.B) {
var data = make(map[string]bool, b.N)
for i := 0; i < b.N; i++ {
data[namespaceSetKeyBinString(uint32(i), uint32(i), uint32(i), uint32(i))] = true
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = data[namespaceSetKeyBinString(uint32(i), uint32(i), uint32(i), uint32(i))]
}
}
func BenchmarkNamespaceSetKeyBinStructWrite(b *testing.B) {
type namespaceSetKeyBin struct {
namespace, set, key, bin uint32
}
var data = make(map[namespaceSetKeyBin]bool, b.N)
for i := 0; i < b.N; i++ {
data[namespaceSetKeyBin{uint32(i), uint32(i), uint32(i), uint32(i)}] = true
}
}
func BenchmarkNamespaceSetKeyBinStructRead(b *testing.B) {
type namespaceSetKeyBin struct {
namespace, set, key, bin uint32
}
var data = make(map[namespaceSetKeyBin]bool, b.N)
for i := 0; i < b.N; i++ {
data[namespaceSetKeyBin{uint32(i), uint32(i), uint32(i), uint32(i)}] = true
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = data[namespaceSetKeyBin{uint32(i), uint32(i), uint32(i), uint32(i)}]
}
}
func namespaceSetKeyBinString(namespace, set, key, bin uint32) string {
result := make([]byte, 0, namespaceSetKeyBinSize)
result = strconv.AppendUint(result, uint64(namespace), 10)
result = append(result, '_')
result = strconv.AppendUint(result, uint64(set), 10)
result = append(result, '_')
result = strconv.AppendUint(result, uint64(key), 10)
result = append(result, '_')
result = strconv.AppendUint(result, uint64(bin), 10)
return string(result)
}
go test ./... -v -bench=. -benchmem
StringWrite 213 ns/op 61 B/op 1 allocs/op StringRead 209 ns/op 31 B/op 1 allocs/op StructWrite 110 ns/op 30 B/op 0 allocs/op StructRead 99.7 ns/op 0 B/op 0 allocs/opяк бачимо використовувати структуру в два рази ефективніше в даному прикладі
Приклад помилки на вкладених мапах
func BenchmarkNestedIntMapKey(b *testing.B) {
var data = make(map[uint32]map[uint32]int, 1024)
for i := 0; i < b.N; i++ {
var nestedKey = uint32(i & 1023)
if nestedData, ok := data[nestedKey]; ok {
nestedData[uint32(i)] = i
} else {
data[nestedKey] = map[uint32]int{
uint32(i): i,
}
}
}
}схожі помилки зустрічав у PHP розробників в реальному проекті, звісно і сам робив, і їх також треба виправляти використанням ключа-структури
Приклад з вкладеними структурами
В попередніх прикладах ключі-структури були дуже простими, то ось приклад з вкладеними структурами:package example
import (
"sync"
)
type AppCountryKey struct {
App uint32
Country string
}
type AppCountrySystemKey struct {
AppCountryKey
System string
}
type Value struct {
RequestTotal uint32
RequestOk uint32
RequestEmpty uint32
}
func (sum Value) Merge(value Value) Value {
return Value{
RequestTotal: sum.RequestTotal + value.RequestTotal,
RequestOk: sum.RequestOk + value.RequestOk,
RequestEmpty: sum.RequestEmpty + value.RequestEmpty,
}
}
var (
aggregationMap = make(map[AppCountrySystemKey]Value)
aggregationMutex sync.Mutex
)
func AddRequest(appCountry AppCountryKey, system string, value Value) {
var key = AppCountrySystemKey{
AppCountryKey: appCountry,
System: system,
}
aggregationMutex.Lock()
if sum, ok := aggregationMap[key]; ok {
aggregationMap[key] = sum.Merge(value)
} else {
aggregationMap[key] = value
}
aggregationMutex.Unlock()
}
Епілог
Стаття разом з кодом писалась за один вечір, бо вже скільки разів зустрічав і робив цю помилку в різних проектах, можливо після статті перестануТакож хочу подякувати Олексію, який допомагав з іншою статтею, а також шукає до себе в команду розробників зі знанням Golang та Rust
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів1. Оскільки ключем мапи в Го можуть бути лише comparable типи, варто вам додати до однієї з ваших вкладених структур слайс — отримаєте помилку компілювання goplay.space/#5qczpSEKPeC думаю, важливо мати це на увазі.
2. Мап — референсний тип, тому, щоб мати хоча б якийсь сенс, блокування в цьому коді
має виглядати якось так:
3. Ідіоматично в Go якості сета використовувати map[Type]struct{} замість map[Type]bool, оскільки останній варіант хоча й додає на перший погляд зручності у використанні, має кілька значних недоліків:
* bool займає місце в пам’яті в той час як struct{} - ні
* bool-у в map можна помилково надати значення false, в той час як bool-значення у другій формі отримання значення з мапи (v, ok := map["key"]) однозначно вказує нам на те, чи ключ в ній присутній.
Так, про це навмисно пропустив, щоб стаття була простою
У випадку повної заміни мапи і використання мапи для читання обидва варіанти правильні
Якщо в одній команді розробників пишуть bool то буду писати також bool, якщо struct{} то struct{}
1. Мені просто здається, що оскільки ви вже пишете в статті, що можна використовувати більш складні структури, варто зауважити, що все ж таки це вірно не у всіх випадках.
2. Так, ви праві, а я був неуважним.
3. Ну таке, я б все ж посперечався.
Коли тільки читав статті то теж бачив помилки і думав що автор багато чого пропустив
Приклад з defer та мапою описував в статті використання Defer у Go (на початку статті)
Можливо в майбутньому в Golang додадуть Set як це зроблено в Rust-і, або ж навчать компілятор оптимізовувати такий варіант
Часом не плануєш зробити тематичний телеграм-канал?
Подібний контент зібраний в одному місці — зекономлений час та нерви))
Якщо про україномовну групу для обговорення Go то думав створити, будеш адміністратором?
Якщо про помилки в Go які роблять PHP розробники, то хто ж зізнається?
То який з «якщо»?
Є люди, які створюють контент... яким є що сказати...
А я з тих хуліганів, що тільки споживають)))
«Якщо» що, то можу благословити)))
Зараз спеціалістів які готові робити контент з Go дуже мало, якщо дивитись по форуму та стрічці то ~10, і думаю десь ~30 спеціалістів які роблять російський та англомовний контент поза DOU
На тижні придумаю назву чату і створю нову тему на DOU, є одна стаття яка застаріла і хочу переопублікувати от і дізнаюсь в чаті «а чи треба це», а також можна обсудити які теми хочуть читати
Підписуйтесь також на t.me/dou_tech
де збираються технічні статті з DOU.
1. Почему не использовать sync.Map?
2. namespaceSetExistsMap map[string]bool
Это меняется на map[string]struct{} тогда под бул не резервируется память
А ти уважно читав статтю? Тоді вже на namespaceSetExistsMap map[FullPathValue]struct{}
з map[string]bool трохи простіше код ніж з struct{}
func namespaceSetExists(namespace, set uint64) bool { namespaceSetMutex.RLock() var data = namespaceSetExistsMap namespaceSetMutex.RUnlock() _, exists := data[FullPathValue{namespace, set}] return exists }sync.Map є сенс використовувати на багатоядерних процесорах, а в проектах яких працював орендували багато слабких серверів на 2-4-8 ядер ~ $20-80 для мікросервісів
в sync.Map ще треба приводити типи, а це ще одне з джерел помилок
ще й тестував рік тому sync.Map та map + mutex, map + mutex вигравав в задачах які вирішував
Очень толковый материал, спасибо большое !