Збереження стану онлайну користувача в Redis

Привіт! У цій статті ми протестуємо різні типи даних (Hash, Set та Sorted set), які доступні в Redis, для задачі збереження онлайну користувачів.

Якщо ви одразу хочете глянути результати, то ось репозиторій.

Передісторія. Перше найпростіше рішення

Років п’ять тому я займався розробкою інтернет-магазину на PHP. З відділу маркетингу прийшла задача покращити рекламні розсилки, враховуючи онлайн-користувачів.

Спершу потрібно було додати збереження останньої активності користувача. Це збереження додав до API, яке повертає поточну корзину, бо поточна корзина потрібна майже на кожній сторінці інтернет-магазину.

Для збереження онлайну додав таблицю user_online:

CREATE TABLE user_online
(
    user_id INT NOT NULL PRIMARY KEY,
    online  INT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users (id)
) ENGINE = InnoDB;

А саме збереження викликалось простою вставкою:

INSERT INTO user_online (user_id, online)
VALUES (?, ?)
ON DUPLICATE KEY
    UPDATE online = VALUES(online);

Хоча, зараз би заповнив всю таблицю user_online даними (час останнього замовлення або ж нулем) й потім оновлював через UPDATE:

UPDATE user_online
SET online = ?
WHERE user_id = ?;

Рішення пройшло code review, протестували на staging, протестували на rc (release candidate), задеплоїли в production, перше найпростіше рішення працює.

Передісторія. Поверніть як було раніше

Вже за п’ятнадцять хвилин Артем (DevOps) піднявся з-за свого стола й зайшов запитати PHP-розробників, чому навантаження на MySQL відчутно зросло, що ми такого задеплоїли. На думку спадала лише задача онлайну користувачів.

Задача повернулась в роботу, а Артем зменшив навантаження на MySQL, переправивши вставки в чорну діру:

CREATE TABLE user_online_blackhole
(
    user_id INT NOT NULL,
    online  INT NOT NULL
) ENGINE = BLACKHOLE;

RENAME TABLE user_online TO user_online_innodb, user_online_blackhole TO user_online;

Принаймні, я це саме так запам’ятав. А стаття могла мати назву «Збереження онлайну користувачів в чорну діру».

Передісторія. Краще рішення

Redis в інтернет-магазині використовується в багатьох місцях, а тому очевидне рішення — використовувати Redis як буфер для збереження онлайну.

Тепер, після виклику API, отримання корзини, активність користувача має зберігатись в Redis, й кожні 10 хвилин має запускатись консольна команда, яка переносить онлайн десятки тисяч користувачів з Redis-а в MySQL та очищати буфер в Redis.

В Redis будуть зберігатись пари ключ-значення, user_id та timestamp відповідно.

Типи даних для збереження онлайну

Отже, потрібно вибрати типи даних для збереження пар user_id та timestamp. Найочевиднішим рішенням є словник, який представлений в Redis типом Hash.

Тип Sorted set також можна використовувати як словник. Тип Set буде мати меншу точність, але достатню для наших потреб.

Методика вибору оптимального типу даних для збереження онлайну

Для кожного типу даних Redis (Hash, Sorted set та Set) я напишу сервіс-обгортку на Go, який буде вміти працювати з цим типом даних, зберігати пару user_id та timestamp в Redis та читати список пар.

Кожен сервіс на Go покрию тестом, а також напишу бенчмарки. Порівняю, скільки пам’яті використовує Redis для збереження онлайну мільйона користувачів, 10 та 25 мільйонів.

У Redis є альтернативи: KeyDB та DragonflyDB, їх також протестую.

Налаштування проєкту

Всі три бази Redis, KeyDB, DragonflyDB, а також тести на Go будуть запускатись в Docker-контейнерах, щоб ви могли перевірити.

Код доступний в репозиторії на GitHub.

go mod init github.com/doutivity/research-online-redis-go
cat docker-compose.yml
version: "3.7"

services:
  app:
    container_name: "research-online-redis-go-app"
    image: golang:1.20.0-alpine
    working_dir: /go/src/github.com/doutivity/research-online-redis-go
    volumes:
      - .:/go/src/github.com/doutivity/research-online-redis-go
    command: "sleep 1d"
    depends_on:
      - redis1
      - keydb1
      - dragonflydb1

  redis1:
    container_name: "research-online-redis-1"
    image: "redis:latest"

  keydb1:
    container_name: "research-online-keydb-1"
    image: "eqalpha/keydb:latest"

  dragonflydb1:
    container_name: "research-online-dragonflydb-1"
    image: "docker.dragonflydb.io/dragonflydb/dragonfly"

Для роботи з Redis в Go потрібно підключити сторонню бібліотеку, в попередній статті про Redis, я використовував «github.com/go-redis/redis», тепер ця бібліотека стала офіційною:

// old, v8
import "github.com/go-redis/redis/v8"

// new, v9
import "github.com/redis/go-redis/v9"

А також підключу бібліотеку, яка спрощує мені тестування.

go get github.com/redis/go-redis/v9
go get github.com/stretchr/testify/require

Для перевірки, що налаштував успішно, напишу й запущу простий тест PING:

package research_online_redis_go

import (
	"context"
	"testing"

	"github.com/redis/go-redis/v9"
	"github.com/stretchr/testify/require"
)

func TestRedisPing(t *testing.T) {
	client := redis.NewClient(&redis.Options{
		Addr: "redis1:6379",
	})

	result, err := client.Ping(context.Background()).Result()
	require.NoError(t, err)
	require.Equal(t, "PONG", result)
}

func TestKeydbPing(t *testing.T) {
	client := redis.NewClient(&redis.Options{
		Addr: "keydb1:6379",
	})

	result, err := client.Ping(context.Background()).Result()
	require.NoError(t, err)
	require.Equal(t, "PONG", result)
}

func TestDragonflydbPing(t *testing.T) {
	client := redis.NewClient(&redis.Options{
		Addr: "dragonflydb1:6379",
	})

	result, err := client.Ping(context.Background()).Result()
	require.NoError(t, err)
	require.Equal(t, "PONG", result)
}
docker-compose up -d
[+] Running 5/5
 ⠿ Network research-online-redis-go_default  Cre...                                            0.0s
 ⠿ Container research-online-dragonflydb-1   Star...                                           0.9s
 ⠿ Container research-online-redis-1         Started                                           1.1s
 ⠿ Container research-online-keydb-1         Started                                           1.0s
 ⠿ Container research-online-redis-go-app    Start...                                          1.5s
docker exec research-online-redis-go-app go test ./... -v -run=Ping -count=1
=== RUN   TestRedisPing
--- PASS: TestRedisPing (0.00s)
=== RUN   TestKeydbPing
--- PASS: TestKeydbPing (0.00s)
=== RUN   TestDragonflydbPing
--- PASS: TestDragonflydbPing (0.00s)
PASS
ok  	github.com/doutivity/research-online-redis-go	0.004s

Тести пройшли, проєкт налаштований успішно.

Збереження онлайну за допомогою типу даних Hash

Для збереження онлайну я буду використовувати команду HSET:

HSET key field value [field value ...]
Sets the specified fields to their respective values in the hash stored at key.

This command overwrites the values of specified fields that exist in the hash. If key doesn’t exist, a new key holding a hash is created.

А для отримання всіх збережених пар буду використовувати команду HGETALL:

HGETALL key
Returns all fields and values of the hash stored at key.

Як воно все працює буде зрозуміло на практиці:

docker exec research-online-redis-1 redis-cli HSET "h:online:main" "user_id:100" "timestamp:1680136500"
1 # The number of fields that were added.
docker exec research-online-redis-1 redis-cli HSET "h:online:main" "user_id:100" "timestamp:1680136500"
0 # The number of fields that were added.
docker exec research-online-redis-1 redis-cli HSET "h:online:main" "user_id:101" "timestamp:1680136501"
1 # The number of fields that were added.
docker exec research-online-redis-1 redis-cli HSET "h:online:main" "user_id:102" "timestamp:1680136502"
1 # The number of fields that were added.
docker exec research-online-redis-1 redis-cli HLEN "h:online:main"
3 # number of fields in the hash, or 0 when key does not exist.
docker exec research-online-redis-1 redis-cli HGETALL "h:online:main"
user_id:100
timestamp:1680136500
user_id:101
timestamp:1680136501
user_id:102
timestamp:1680136502

Префікси user_id та timestamp додав для кращого розуміння, але краще зберігати без них:

docker exec -it research-online-redis-1 redis-cli
DEL "h:online:main"
HSET "h:online:main" "100" "1680136500"
HSET "h:online:main" "101" "1680136501"
HSET "h:online:main" "102" "1680136502"
HGETALL "h:online:main"
100
1680136500
101
1680136501
102
1680136502

Згідно попереднього опису задачі, ми маємо зберігати онлайн в Redis буфер (HSET), переносити дані з Redis-у в MySQL (HGETALL) та очищати буфер.

Ми маємо очищати буфер, щоб уникнути повторного перенесення онлайну, а ще без очищення, hash «h:online:main» може розростись й містити онлайн для мільйонів користувачів й відповідно кожні 10 хвилин буде значне навантаження на MySQL.

Для очищення буферу ми можемо просто видаляти ключ «h:online:main» командою DEL, але у такому випадку ми можемо випадково втратити трохи даних, бо команда HSET викликається на сервері, а команди HGETALL та DEL в команді по крону:

HSET "h:online:main" "100" "1680136500"
HSET "h:online:main" "101" "1680136501"
HGETALL "h:online:main"
HSET "h:online:main" "102" "1680136502" # ми втратимо ці дані
DEL "h:online:main"

Правильним рішення буде використовувати команду RENAME:

RENAME key newkey
RENAME "h:online:main" "h:online:tmp"
Відповідно дані будемо читати з ключа «h:online:tmp», а ключ «h:online:main» буде доступний для запису нових даних:
HSET "h:online:main" "100" "1680136500"
HSET "h:online:main" "101" "1680136501"
RENAME "h:online:main" "h:online:tmp" # DEL "h:online:tmp" IF EXISTS BEFORE RENAME
HGETALL "h:online:tmp"
HSET "h:online:main" "102" "1680136502"

Обгортка на Go для тестування збереження онлайну за допомогою типу даних Hash

Тепер, коли ми знаємо, як працює тип даних Hash в Redis, то можемо написати обгортку на Go разом з тестами:

package research_online_redis_go

type UserOnlinePair struct {
	UserID    int64
	Timestamp int64
}
package research_online_redis_go

import (
	"context"
	"strconv"

	"github.com/redis/go-redis/v9"
)

type HashOnlineStorage struct {
	client *redis.Client
}

func NewHashOnlineStorage(client *redis.Client) *HashOnlineStorage {
	return &HashOnlineStorage{client: client}
}

func (s *HashOnlineStorage) Store(ctx context.Context, pair UserOnlinePair) error {
	return s.client.HSet(
		ctx,
		"h:online:main",
		strconv.FormatInt(pair.UserID, 10),
		pair.Timestamp,
	).Err()
}

func (s *HashOnlineStorage) Count(ctx context.Context) (int64, error) {
	return s.client.HLen(ctx, "h:online:main").Result()
}

func (s *HashOnlineStorage) GetAndClear(ctx context.Context) ([]UserOnlinePair, error) {
	var (
		oldKey = "h:online:main"
		newKey = "h:online:tmp"
	)

	err := s.client.Rename(ctx, oldKey, newKey).Err()
	if err != nil {
		return nil, err
	}

	userOnlineMap, err := s.client.HGetAll(ctx, newKey).Result()
	if err != nil {
		return nil, err
	}

	result := make([]UserOnlinePair, 0, len(userOnlineMap))
	for stringUserID, stringTimestamp := range userOnlineMap {
		userID, err := strconv.ParseInt(stringUserID, 10, 64)
		if err != nil {
			// unreachable, ignore for article

			// logging or use https://github.com/hashicorp/go-multierror

			// just in case
			continue
		}

		timestamp, err := strconv.ParseInt(stringTimestamp, 10, 64)
		if err != nil {
			// unreachable, ignore for article

			// logging or use https://github.com/hashicorp/go-multierror

			// just in case
			continue
		}

		result = append(result, UserOnlinePair{
			UserID:    userID,
			Timestamp: timestamp,
		})
	}

	return result, nil
}

Тести будуть враховувати Redis, KeyDB та DragonflyDB:

package research_online_redis_go

import (
	"context"
	"testing"

	"github.com/redis/go-redis/v9"
	"github.com/stretchr/testify/require"
)

func TestRedisHashOnlineStorage(t *testing.T) {
	testHashOnlineStorage(t, "redis1:6379")
}

func TestKeydbHashOnlineStorage(t *testing.T) {
	testHashOnlineStorage(t, "keydb1:6379")
}

func TestDragonflydbHashOnlineStorage(t *testing.T) {
	testHashOnlineStorage(t, "dragonflydb1:6379")
}

func testHashOnlineStorage(t *testing.T, addr string) {
	t.Helper()

	ctx := context.Background()

	client, err := Client(ctx, addr)
	require.NoError(t, err)

	require.NoError(t, client.FlushAll(ctx).Err())

	storage := NewHashOnlineStorage(client)

	expected := []UserOnlinePair{
		{
			UserID:    10000001,
			Timestamp: 1679800725,
		},
		{
			UserID:    10000002,
			Timestamp: 1679800730,
		},
		{
			UserID:    10000003,
			Timestamp: 1679800735,
		},
	}

	for _, pair := range expected {
		err := storage.Store(ctx, pair)

		require.NoError(t, err)
	}

	actualCount, err := storage.Count(ctx)
	require.NoError(t, err)
	require.Equal(t, int64(len(expected)), int64(actualCount))

	actual, err := storage.GetAndClear(ctx)
	require.NoError(t, err)

	requireUserOnlinePairsEqual(t, expected, actual)
}

В терміналі 1 я запускаю команду MONITOR, щоб бачити команди, які виконуються в Redis:

docker exec research-online-redis-1 redis-cli monitor
OK

Переходжу в термінал 2 та запускаю тести:

docker exec research-online-redis-go-app go test ./... -v -run='Test(Redis|Keydb|Dragonflydb)HashOnlineStorage' -count=1
=== RUN   TestRedisHashOnlineStorage
--- PASS: TestRedisHashOnlineStorage (0.08s)
=== RUN   TestKeydbHashOnlineStorage
--- PASS: TestKeydbHashOnlineStorage (0.01s)
=== RUN   TestDragonflydbHashOnlineStorage
--- PASS: TestDragonflydbHashOnlineStorage (0.01s)
PASS
ok  	github.com/doutivity/research-online-redis-go	0.098s

Повертаюсь в термінал 1 та бачу команди, виконані в Redis:

1680140476.951838 [0 172.30.0.5:38208] "hello" "3"
1680140476.951908 [0 172.30.0.5:38208] "ping"
1680140476.954159 [0 172.30.0.5:38208] "flushall"
1680140476.954233 [0 172.30.0.5:38208] "hset" "h:online:main" "10000001" "1679800725"
1680140476.954303 [0 172.30.0.5:38208] "hset" "h:online:main" "10000002" "1679800730"
1680140476.954371 [0 172.30.0.5:38208] "hset" "h:online:main" "10000003" "1679800735"
1680140476.954429 [0 172.30.0.5:38208] "hlen" "h:online:main"
1680140476.954482 [0 172.30.0.5:38208] "rename" "h:online:main" "h:online:tmp"
1680140476.954542 [0 172.30.0.5:38208] "hgetall" "h:online:tmp"

Тести пройшли, збереження в Hash працює успішно.

Обгортка на Go для тестування збереження онлайну за допомогою типу даних Sorted set

Ми будемо використовувати команди ZADD та ZRANGE, але вже без такого детального розборку з прикладами, як то було з типом Hash, бо ви все одно побачите приклади виконання Redis-команд в терміналі 1.

ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member
  ...]
ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count]
  [WITHSCORES]

Напишемо обгортку на Go для роботи з типом Sorted set:

package research_online_redis_go

import (
	"context"
	"strconv"

	"github.com/redis/go-redis/v9"
)

type SortedSetOnlineStorage struct {
	client *redis.Client
}

func NewSortedSetOnlineStorage(client *redis.Client) *SortedSetOnlineStorage {
	return &SortedSetOnlineStorage{client: client}
}

func (s *SortedSetOnlineStorage) Store(ctx context.Context, pair UserOnlinePair) error {
	return s.client.ZAdd(ctx, "z:online:main", redis.Z{
		Score:  float64(pair.Timestamp),
		Member: strconv.FormatInt(pair.UserID, 10),
	}).Err()
}

func (s *SortedSetOnlineStorage) Count(ctx context.Context) (int64, error) {
	return s.client.ZCard(ctx, "z:online:main").Result()
}

func (s *SortedSetOnlineStorage) GetAndClear(ctx context.Context) ([]UserOnlinePair, error) {
	var (
		oldKey = "z:online:main"
		newKey = "z:online:tmp"
	)

	err := s.client.Rename(ctx, oldKey, newKey).Err()
	if err != nil {
		return nil, err
	}

	members, err := s.client.ZRangeWithScores(ctx, newKey, 0, -1).Result()
	if err != nil {
		return nil, err
	}

	result := make([]UserOnlinePair, 0, len(members))
	for _, member := range members {
		stringUserID, ok := member.Member.(string)
		if !ok {
			// unreachable, ignore for article

			// just in case
			continue
		}

		userID, err := strconv.ParseInt(stringUserID, 10, 64)
		if err != nil {
			// unreachable, ignore for article

			// logging or use https://github.com/hashicorp/go-multierror

			// just in case
			continue
		}

		result = append(result, UserOnlinePair{
			UserID:    userID,
			Timestamp: int64(member.Score),
		})
	}

	return result, nil
}

Сервіс SortedSetOnlineStorage дуже схожий на HashOnlineStorage.

Я відрефакторю попередні тести Test(Redis|Keydb|Dragonflydb)HashOnlineStorage, щоб перевикористати їх:

package research_online_redis_go

import (
	"context"
)

type OnlineStorage interface {
	Store(ctx context.Context, pair UserOnlinePair) error
	Count(ctx context.Context) (int64, error)
	GetAndClear(ctx context.Context) ([]UserOnlinePair, error)
}
package research_online_redis_go

import (
	"context"
	"testing"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/stretchr/testify/require"
)

type onlineStorageConstructor func(client *redis.Client) OnlineStorage

func testOnlineStorage(t *testing.T, addr string, newStorage onlineStorageConstructor) {
	t.Helper()

	ctx := context.Background()

	client, err := Client(ctx, addr)
	require.NoError(t, err)

	require.NoError(t, client.FlushAll(ctx).Err())

	storage := newStorage(client)

	expected := []UserOnlinePair{
		{
			UserID:    10000001,
			Timestamp: 1679800725,
		},
		{
			UserID:    10000002,
			Timestamp: 1679800730,
		},
		{
			UserID:    10000003,
			Timestamp: 1679800735,
		},
	}

	for _, pair := range expected {
		err := storage.Store(ctx, pair)

		require.NoError(t, err)
	}

	actualCount, err := storage.Count(ctx)
	require.NoError(t, err)
	require.Equal(t, int64(len(expected)), int64(actualCount))

	actual, err := storage.GetAndClear(ctx)
	require.NoError(t, err)

	requireUserOnlinePairsEqual(t, expected, actual)
}
package research_online_redis_go

import (
	"testing"

	"github.com/redis/go-redis/v9"
)

var sortedSetOnlineStorageConstructor onlineStorageConstructor = func(client *redis.Client) OnlineStorage {
	return NewSortedSetOnlineStorage(client)
}

func TestRedisSortedSetOnlineStorage(t *testing.T) {
	testOnlineStorage(t, "redis1:6379", sortedSetOnlineStorageConstructor)
}

func TestKeydbSortedSetOnlineStorage(t *testing.T) {
	testOnlineStorage(t, "keydb1:6379", sortedSetOnlineStorageConstructor)
}

func TestDragonflydbSortedSetOnlineStorage(t *testing.T) {
	testOnlineStorage(t, "dragonflydb1:6379", sortedSetOnlineStorageConstructor)
}

В терміналі 1 я запускаю команду MONITOR, щоб бачити команди, які виконуються в KeyDB:

docker exec research-online-keydb-1 keydb-cli monitor
OK

Переходжу в термінал 2 та запускаю тести:

docker exec research-online-redis-go-app go test ./... -v -run='Test(Redis|Keydb|Dragonflydb)SortedSetOnlineStorage' -count=1
=== RUN   TestRedisSortedSetOnlineStorage
--- PASS: TestRedisSortedSetOnlineStorage (0.01s)
=== RUN   TestKeydbSortedSetOnlineStorage
--- PASS: TestKeydbSortedSetOnlineStorage (0.00s)
=== RUN   TestDragonflydbSortedSetOnlineStorage
--- PASS: TestDragonflydbSortedSetOnlineStorage (0.05s)
PASS
ok  	github.com/doutivity/research-online-redis-go	0.067s

Повертаюсь в термінал 1 та бачу команди, виконані в KeyDB:

1680178291.343241 [0 172.23.0.5:35168] "hello" "3"
1680178291.343337 [0 172.23.0.5:35168] "ping"
1680178291.345644 [0 172.23.0.5:35168] "flushall"
1680178291.345795 [0 172.23.0.5:35168] "zadd" "z:online:main" "1679800725" "10000001"
1680178291.345997 [0 172.23.0.5:35168] "zadd" "z:online:main" "1679800730" "10000002"
1680178291.346151 [0 172.23.0.5:35168] "zadd" "z:online:main" "1679800735" "10000003"
1680178291.346239 [0 172.23.0.5:35168] "zcard" "z:online:main"
1680178291.346316 [0 172.23.0.5:35168] "rename" "z:online:main" "z:online:tmp"
1680178291.346399 [0 172.23.0.5:35168] "zrange" "z:online:tmp" "0" "-1" "withscores"

Тести пройшли, збереження в Sorted set працює успішно.

Порівняння виконаних Redis-команд для типів Hash та Sorted set

Просто виніс порівняльну таблицю для наглядності:

HashSorted set
HSET "h:online:main" "10000001" "1679800725"
HSET "h:online:main" "10000002" "1679800730"
HSET "h:online:main" "10000003" "1679800735"

HLEN "h:online:main"

RENAME "h:online:main" "h:online:tmp"

HGETALL "h:online:tmp"
ZADD "z:online:main" "1679800725" "10000001"
ZADD "z:online:main" "1679800730" "10000002"
ZADD "z:online:main" "1679800735" "10000003"

ZCARD "z:online:main"

RENAME "z:online:main" "z:online:tmp"

ZRANGE "z:online:tmp" "0" "-1" "WITHSCORES"

Збереження онлайну за допомогою типу даних Set

У типу даних Set відсутня можливість зберігати пари user_id та timestamp у форматі ключ та значення, як це вдавалось для типу Hash та Sorted set.

Але timestamp можна засунути в назву Redis-ключа.

Для прикладу, в нас є користувач user_id = 10000001, який був онлайн timestamp = 1679800725, тоді ми можемо зберегти онлайн у Set командою SADD:

SADD "s:online:main:1679800725" "10000001"
SADD "s:online:main:1679800730" "10000002"
SADD "s:online:main:1679800735" "10000003"

У такому рішенні буде забагато Redis-ключів. Щоб це виправити, то потрібно групувати в один ключ (округлювати на початок часу), ми втрачаємо точність, але це прийнятно для нашого рішення:

SADD "s:online:main:1679800500" "10000001"
SADD "s:online:main:1679800500" "10000002"
SADD "s:online:main:1679800500" "10000003"

Обгортка SetOnlineStorage доступна в репозиторії, за бажанням можете поставити зірочку на GitHub.

docker exec research-online-dragonflydb-1 redis-cli monitor
docker exec research-online-redis-go-app go test ./... -v -run='Test(Redis|Keydb|Dragonflydb)SetOnlineStorage' -count=1
=== RUN   TestRedisSetOnlineStorage
--- PASS: TestRedisSetOnlineStorage (0.03s)
=== RUN   TestKeydbSetOnlineStorage
--- PASS: TestKeydbSetOnlineStorage (0.01s)
=== RUN   TestDragonflydbSetOnlineStorage
--- PASS: TestDragonflydbSetOnlineStorage (0.11s)
PASS
ok  	github.com/doutivity/research-online-redis-go	0.157s
1680181635.714046 [0 172.23.0.5:58334]  "HELLO" "3"
1680181635.719216 [0 172.23.0.5:58334]  "PING"
1680181635.738626 [0 172.23.0.5:58334]  "FLUSHALL"
1680181635.770106 [0 172.23.0.5:58334]  "SADD" "s:online:main:1679800725" "10000001"
1680181635.775436 [0 172.23.0.5:58334]  "SADD" "s:online:main:1679800730" "10000002"
1680181635.780776 [0 172.23.0.5:58334]  "SADD" "s:online:main:1679800735" "10000003"
1680181635.785386 [0 172.23.0.5:58334]  "KEYS" "s:online:main:*"
1680181635.1244336 [0 172.23.0.5:58334]  "SCARD" "s:online:main:1679800735"
1680181635.1249376 [0 172.23.0.5:58334]  "SCARD" "s:online:main:1679800730"
1680181635.1256206 [0 172.23.0.5:58334]  "SCARD" "s:online:main:1679800725"
1680181635.1261996 [0 172.23.0.5:58334]  "KEYS" "s:online:main:*"
1680181635.1724486 [0 172.23.0.5:58334]  "RENAME" "s:online:main:1679800735" "s:online:tmp"
1680181635.1736686 [0 172.23.0.5:58334]  "SMEMBERS" "s:online:tmp"
1680181635.1743606 [0 172.23.0.5:58334]  "RENAME" "s:online:main:1679800730" "s:online:tmp"
1680181635.1753966 [0 172.23.0.5:58334]  "SMEMBERS" "s:online:tmp"
1680181635.1759236 [0 172.23.0.5:58334]  "RENAME" "s:online:main:1679800725" "s:online:tmp"
1680181635.1768146 [0 172.23.0.5:58334]  "SMEMBERS" "s:online:tmp

Вибір найкращого рішення

Потрібно вибрати одну з трьох баз Redis, KeyDB або DragonflyDB.

DatabaseStarsLanguage
Redis59100+C
KeyDB7100+C++
DragonflyDB18300+C++

А також потрібно вибрати один з типів даних Hash, Sorted set або Set.

Загалом 10 варіантів вибору.

Вибір бази очевидний, Redis має найбільше зірочок на GitHub.

Вибір типу даних також очевидний, Hash простіше пояснити колегам, а обгортка на Go HashOnlineStorage простіша та має менше коду, ніж SortedSetOnlineStorage та SetOnlineStorage.

Спіймав? Ця стаття вже занадто перевантажена, тому буде продовження з бенчмарками, порівнянням використаної пам’яті для кожного типу даних, а також стрес-тестами з падінням баз DragonflyDB та KeyDB.

Епілог

Дякую вам, що дочитали статтю, якщо сподобалась, то підтримайте, будь ласка, фінансово збір для 65 бригади від Go-розробника.

10-й варіант вибору

10-й можливий варіант вибору залишити рішення з MySQL.

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

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному11
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

Ярославе, круто, дякую за статтю!
якщо відкритий до фідбеку — то я б для імеджів не використовував тег ʼ

:latest

ʼ - бо через якийсь час може перестати працювати або змінити бенчмарки

Кого пантеличить слово «онлайн» то маркетологам для рекламної розсилки потрібно було знати останній час активності користувача (користувач був онлайн в yyyy-mm-dd).

Користувачі самі погоджувались отримувати рекламні розсилки бо тоді отримували більшу знижку на наступні покупки.

Теж люблю експеременти з го. А що у вас використовуються як ідентифікація що юзер онлайн. Тобто зрозуміло що після очищення його ід буде знову в редісі, а в 10 ти хвилинний момент він рахується як онлайн чи його запис оновлюється запитом до api.

Точна ідентифікація, що користувач онлайн, потрібна для чатів та соціальних мереж, а для маркетологів інтернет-магазину було достатньо знати останній день онлайну yyyy-mm-dd

Ваш варіант з оновленням «раз на день» зрозумілий, проте все одно краще оновлювати пачками в MySQL.

Тоді вже рішення буде таке:

ZADD "z:daily:online:{current_day}" NX "{current_timestamp}" "{user_id}"
EXPIRE "z:daily:online:{current_day}" 86400
ZADD:
NX: Only add new elements. Don’t update already existing elements.
KEYS "z:daily:online:*"
ZRANGE "{key}" "{previous_timestamp}" "-1" WITHSCORES

Якщо маркетологи відправлять рекламну розсилку мільйону користувачів та уявимо, що весь мільйон користувачів перейде за посиланням то це всього ~ 92 MB в Redis згідно тестів.

Можна оптимізувати та трохи ускладнити рішення зі статті, але враховуючи, що середня тривалість візиту користувача до 10 хвилин то оптимізація зайва.

Я розумію для чого це потрібно, ми теж відстежуємо коли користувач був онлайн останній раз. Я хотів би зрозуміти що у вас служить ідентифікатором по якому ви вважаєте що користувач онлайн. Наприклад зайшов користувач на сайт, створився запис, він апдейтиця якщо через 2 хв користувач знову повернуся на сайт? Хочу зрозуміти логіку.

Я так зрозумів що це не зовсім онлайн, а в який день/дату користувач останній раз заходив на сайт.

API запит на отримання корзини є ідентифікатором бо робиться при завантаженні майже кожної сторінки інтернет-магазину: сторінка каталогу, сторінка продукту.

Так все зрозумів, дякую. А чи не пробували ви, я думаю точно потреба у маркетологів була, записувати переглянуті товари в монгу з датою перегляду товару( від цього й брати відлік) наприклад, і звідти вести аналітичні процеси, тим самим знижуючи і об’єм, і навантаження на основну бд.

У переглядів товарів був схожий лічильник, як у темах на DOU, кожен перегляд рахується новим.

Можливо подібне вже додали в інтернет-магазин, але в мене відсутня про це інформація.

Якщо я вас правильно зрозумів то щоразу, коли користувач отримує доступ до сторінки, установіть/оновіть поле timestamp у його записі таблиці Users. Потім ви виконайте підрахунки для всіх користувачів, які мають timestamp протягом останніх n хвилин. Щось більше, ніж це, і їх можна вважати «офлайн». Але, якщо ви використовуєте серверний час за допомогою функції NOW() у MySQL, ви маєте можливість уникнути необхідность працювати з часовими поясами. Це стандартний спосіб відстеження кількості користувачів, які зараз перебувають у мережі (тобто, активних протягом останніх x хвилин). Але черз сесії буде краще...Крім того врахуйте факт того, що якщо ваша бібліотека redis не конвеєрна(pipelined), кілька сотень операцій GET можуть бути дуже повільними просто через банальну затримку мережі.

Для підрахунку «скільки за сьогодні користувачів було онлайн» у маркетологів є Google Analytics.

Ні сьогодні, а зараз!Питання стояло саме так!

«Скільки зараз користувачів онлайн» також вирішується через Google Analytics.

Ви статус користувача демонструєте на сайті?

Це ж інтернет-магазин, там відсутня потреба показувати статус одного покупця іншим покупцям, як це прикладу є в чатах.

Щоб дізнатися, чи перебуває певний користувач у режимі он-лайн — немає нічого швидшого, ніж читання одного ключа.Застосування Redis sets більше підходять для речей, які не можливо зробити за допомогою ключів, особливо там, де отримати всі елементи в наборі важливіше, ніж перевірити, чи є певний елемент у цьому наборі.

KeyDB також можна як заміну Redis використовувати, зворотня сумісність. Трохи швидше, якщо велике навантаження і багато ядер на сервері. Працює, питань до keydb нема. А ось про DragonflyDB було цікаво, чекаю на другу частину :)

Hash для такої задачі виглядає самим логічним, але якщо не хочеться чистити дані кожен раз, а тримати все в кеші, витягуючі лише нові, то вже sorted set + окремий ключ з датою останнього сінку і робити ZRANGE key last_sync_time +inf WITHSCORES

А MySql грузити то вже коли в нього є запас по потужності, хоча він на нормальному залазі та ssd/nvme також буде тисячі і більше апдейтів робити (вже не кажу про innodb_flush_log_at_trx_commit=0/2, але в проді то вже ніхто не робить, тому що iops’ів диску вистачає і вимикати durability таке, хоче тре перевірити інші IO settings — innodb_log_file_size, innodb_write_io_threads).
Як і казав, писати напряму в mysql при навантаженні для такої задачі не дуже бажано, але можливо, от в Postgres це точно погана ідея з його механізмом апдейтів (фізичне дублювання записів та vacuum), постійно покращують в останніх версіях, але-але.

Потім з redis’а оновлювати в базі дані також не окремими запитами, а batch’ем чи хоча б в одній транзакції, дискова підсистема буде вдячна, та за дед-локи можна не хвилюватись, більше ніхто ж там не робить апдейти в цю таблицю.

Якщо варіант з MySQL оптимізувати (оновлювати дані пачками), то це рішення буде простіше.

Так, в Go (stateful server) є можливість зберігати цей буфер онлайну користувачів в пам’яті, а потім пачкою вставляти в MySQL.

Але тоді, п’ять років тому, я працював з PHP (stateless server), а у PHP відсутня можливість зберігати стан між різними HTTP-реквестами у своїй пам’яті.

Я щось подібне колись робив на PHP з використанням черг повідомлень. Воркер який розгрібає цю чергу набирає необхідну кількість задач і оновлює все одним запитом.

Зараз це ще можна спробувати зробити за допомогою shared memory в swoole.

На мою думку то простіше зберігати в Redis й кожні N хвилин запускати команду по крону, яка буде переносити з Redis в MySQL.

А MQ вже використовувати для складніших задач.

Redis надає різні persistence option. Якщо ви взагалі не використовуєте persistence option, може статися така ситуація, що після перезапуску сервера Redis усі дані буде втрачено.Краще за все буде використовувати Redis з RabbitMQ. Redis для показу користувачам результатів у реальному часі.RabbitMQ для додавання даних у чергу та збереження даних у MySQL.RabbitMQ може впоратися з навантаженням у години пік. Таким чином, усі виклики збереження будуть у черзі.

На якомусь форумі MySQL я знайшов, що хлопець опублікував формулу, і після її розрахунку для мого сервера та моєї системи я виявив, що я намагався використати більш 6 ГБ оперативної пам’яті, якої не було в системі.USEDRAM = innodb_pool_buffer_size + key_buffer_size +     ( (read_buffer_size + sort_buffer_size + read_rnd_buffer_size + join_buffer_size)          * max_connection )

Якщо будуть змістовні коментарі українською мовою то буду на такі відповідати, всі інші коментарі в /dev/null.

Ясно шё без Redis не заюзается(хотя можно в принципе подумать), потому что выборочное значение будет увеличиваться каждый раз, когда один и тот же пользователь используя защищенное соединение будет вызывать определенную метрику. Это приведет к неточному результату в конце дня. Для решения этой задачи просто получим день, месяц и год и передаем их функции. Эта информация, особенно «день», нам понадобится для группировки наших метрик с Prometheus. При реализации Prometheus я обычно создаю файл «myMetrics.js», в котором определяю свои метрики. Обычно юзается какое-то промежуточное программное обеспечение, которое проверяет запрос «токен jwt || bearer token». Для этого я бы создал промежуточный файл «myProtect.js». Он будет содержать мою логику проверки jwt. Если проверка пройдена, я получу идентификатор пользователя (который будет использоваться в качестве идентификатора, необходимого при выдаче токена jwt) из декодированного токена. Затем я нахожу пользователя, чей идентификатор соответствует декодированному идентификатору токена, и прикреплял сведения о пользователе к новому свойству объекта запроса с именем «пользователь» (т. е. req.user).Это создаст новую метрику с именем «my_au» и некоторыми метками (год, месяц и день) с совокупным значением выборки, равным 1(т.к. юзер активный!).
В итоге именно так мы и собираемся в итоге получить общий обобщенный дневной показатель. Используя идентификатор пользователя в качестве ключа
Проверьте, есть ли кешированные данные на основе ключа. Далее проверяем если данных нет, это должен быть первый раз, когда пользователь открывает веб-сайт за день. Таким образом, сохраняем идентификатор пользователя вместе с результатом...
Расчет того, сколько времени осталось до конца дня с момента открытия сайта в секундах как конечное время. Затем увеличиваем ежедневный показатель активных пользователей на 1.
Приведенная выше реализация гарантирует, что у меня будет самое точное ежедневное количество пользователей, поскольку счетчик будет увеличиваться только в том случае, если благодаря Redis появится новый пользователь или новый ключ. Зачем было это так разводить, десять строчек кода и все!

То напишіть про це окрему статтю з прикладами коду й тестами

Оно этого не стоит и так все понятно. Для публикации статьи нужен какой-то полученный серьезный результат, а тут его нет и быть не может!

Перевірочне питання, чий Крим?

Интересно почему был сделан выбор в пользу Hash? На первый взгляд можно добавлять элементы префиксом и устанавливать для них TTL который будет обновляться по необходимости. После окончания TTL + какая то задержка Redis будет посылать keyspace уведомление которое можно получать и записывать значение в базу.
redis.io/...​l/keyspace-notifications
redis.io/commands/expire

Якщо ви про це Redis keyspace notifications (приклад використання) то все одно потрібно буде робити буфер на стороні Go для збереження подій __keyevent@0__:expired від Redis, щоб потім пачками вставляти в MySQL.

Запропоноване вами рішення складніше та з меншим числом записів в MySQL, що добре, хоча в інтернет-магазині люди проводять значно менше часу, ніж в соціальних мережах, тому можна обійтись простішим рішеннями з Hash, Sorted set та Set.

Так-с, я не зрозумів,

Вибір залишити рішення з MySQL, як є

Так все-таки в результаті використовувалось початкове рішення?

Оновив текст статті.

В результаті, більше п’яти років тому, вибрав Hash.

Ваша реализация очень неудачная(реализация на redis)
Ознакомитесь!!!
redis> TTL key
redis> KEYS pattern

Сформуйте вашу думку у щось зрозуміле

Ваша реализация очень неудачная(реализация на redis)
Ознакомитесь!!!
redis> TTL key

It’s all about trade-offs. Проблема в тому, що TTL в редіс зараз можна встановити тільки на рівні ключа. Тобто якщо ти просто «сереш» в глобальний неймспейс парами типу «user:1000001» => «[timestamp1]», «user:10000002» => «[timestamp2]» і т.п. то так, можна (потрібно) обійтись TTL. Але це доволі компромісне рішення, бо в глобальному просторі ключів потім чорт ногу зламає, а підтримка стає біллю (бо скан мільйонів ключів — це потенційний постріл в ногу в випадку редіса). Тому автор пропонує юзати більш складні структури даних, які підтримує редіс. Це дозволяє не засирати глобальний неймспейс, але нажаль при цьому зникає можливість підчищати мусор силами самого редіса (TTL не підтримується для «внутрощів» складних структур даних) — тому потрібна окрема логіка для видалення застарілих даних. В випадку SortedSet можна було б обійтись без трюку з перейменуванням (просто випилювати опрацьований «кеш» за допомогою ZREMRANGEBYSCORE), але поточне рішення теж не виглядає поганим на перший погляд...

Я, в іншому проєкті, вже на Go, переписував рішення зі скануванням усіх ключів, яке займало півсекунди, на Sorted set з ZREMRANGEBYSCORE.

Так, це можливо (там O(log(n)+m)), тобто якщо оперувати дуже великими діапазонами в ще більшому сеті, то буде боляче

Деталізую, щоб додати ясність.

Є проста гра, в цій грі є рівні складності, чим вище рівень складності тим вища винагорода у грі.
Рівень складності дорівнює числу зіграних ігор за останні 15 хвилин.

Користувач натискає почати нову гру й відбувається розрахунок рівня складності:

KEYS "k:games:complexity:{user_id}:*"

Користувач завершує гру й у Redis зберігається зіграна гра на 15 хвилин:

SET "k:games:complexity:{user_id}:{game_id}" "1" EX 900
game_id — це uuid, який генерується на початку кожної гри.

Коли гру почали рекламувати у соціальних мережах для бета-тестування то проблеми, використання такого рішення, стали очевидні із-за відчутної затримки при старті гри.

Коли я прийшов в цей ігровий проєкт то переписав на Sorted set й стало працювати швидко, як треба:

ZREMRANGEBYSCORE "k:games:complexity:{user_id}" -inf "{now - 900}"
ZCARD "k:games:complexity:{user_id}"
ZADD "k:games:complexity:{user_id}" "{timestamp}" "{game_id}"

Видалення не виконується належним чином. Після повторного сканування ключі все ще будуть існувати?

Уважніше читайте статтю та її цілі

а де тести для

MySQL

? може теж нормас працює ;)
ну і тест для просто GO in memory. Щоб оцінити накладні розходи на комунікацію.

Тести для MySQL між InnoDB та BLACKHOLE готові в окремому репозиторію.

Якщо буде багато бажаючих почитати то оформлю в статтю.

взагалі порівняння от гоу сервіс\чистий(збереження в пам’яті сервісу в умовному статичному хеші
от 2 верусії серевісів робота з MySQL От робота з іншими NoSQL. ну щоб розуміти от виклик сервісу з збереженням в пам’яті без накладних на комунікацію а ось з виділеним сховищем. Ну і InnoDB та BLACKHOLE також дозволить зрозуміти скільки виклик займає, а скільки робота рушія БД.

Ось результати для in memory.

У цієї статті точно буде продовження з бенчмарками Redis, KeyDB та DragonflyDB бо ці бенчмарки вже готові в репозиторії.

Стосовно інших порівнянь то дочекаюсь більше коментарів, бо писати, що потрібно порівняти значно простіше, ніж робити й оформлювати.

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