Використання структур як ключів для мапи в Golang

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Вже пару років працюю з 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

Репозиторій з прикладами

👍НравитсяПонравилось0
В избранноеВ избранном4
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

1. Оскільки ключем мапи в Го можуть бути лише comparable типи, варто вам додати до однієї з ваших вкладених структур слайс — отримаєте помилку компілювання goplay.space/#5qczpSEKPeC думаю, важливо мати це на увазі.

2. Мап — референсний тип, тому, щоб мати хоча б якийсь сенс, блокування в цьому коді

namespaceSetMutex.RLock()
var data = namespaceSetExistsMap
namespaceSetMutex.RUnlock()

return data[namespaceSetString(namespace, set)]

має виглядати якось так:

namespaceSetMutex.RLock()
defer namespaceSetMutex.RUnlock()

return namespaceSetExistsMap[namespaceSetString(namespace, set)]

3. Ідіоматично в Go якості сета використовувати map[Type]struct{} замість map[Type]bool, оскільки останній варіант хоча й додає на перший погляд зручності у використанні, має кілька значних недоліків:
* bool займає місце в пам’яті в той час як struct{} - ні
* bool-у в map можна помилково надати значення false, в той час як bool-значення у другій формі отримання значення з мапи (v, ok := map["key"]) однозначно вказує нам на те, чи ключ в ній присутній.

1. Оскільки ключем мапи в Го можуть бути лише comparable типи, варто вам додати до однієї з ваших вкладених структур слайс — отримаєте помилку компілювання goplay.space/#5qczpSEKPeC думаю, важливо мати це на увазі.

Так, про це навмисно пропустив, щоб стаття була простою

2. Мап — референсний тип

У випадку повної заміни мапи і використання мапи для читання обидва варіанти правильні

3. Ідіоматично в Go якості сета використовувати map[Type]struct{}

Якщо в одній команді розробників пишуть 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{} тогда под бул не резервируется память

Это меняется на 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 вигравав в задачах які вирішував

Очень толковый материал, спасибо большое !

Подписаться на комментарии