Привет, Хабр! Меня зовут Агаджанян Давид, хочу поделиться некоторыми инженерами рекомендациями, которые часто на моем опыте помогали держать highload нагрузку не прибегая к хардкору. Примеры будут на Go. Эти подходы довольно хорошо известны, но как мне кажется они недооценены и многие этими подходами пренебрегают. Если вы впервые видите их, то рекомендую хотя бы попробовать реализовать в своих проектах и провести бенчмарки, возможно вы будете приятно удивлены. Этих подходов в 90% случаях мне хватало за глаза, когда требовалось быстро и кратно увеличить перфоманс приложения в короткие сроки. Ну и конечно же делитесь своим опытом к каким подходам для оптимизаций вы прибегаете в первую очередь, буду рад взять себе интересное на заметку
Refresh-ahead caching
Если по бизнес логике вашего приложения допустимо отдавать данные не первой свежести, то кешируйте их в приложении и отдавайте как есть. А сами данные обновляйте в фоне
Пример: у вас есть главная страница со списком популярных фильмов, обновляете вы этот список редко, да и если отдадите устаревший, то в лучшем случае никто не заметит, в худшем никто не пострадает. Так почему бы просто не взять и не закешировать этот список прямо в приложении?
Концепт: закешировать список популярных фильмов в памяти и отдавать как есть, при этом в фоне запустить воркер, который раз в N секунд обновит данные в памяти
Реализация: ниже код, но если вам удобнее смотреть в github, welcome
package main
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
type Movie struct {
Title string `json:"Title"`
}
type CachedPopularItems struct {
lock sync.RWMutex
Movies []Movie
}
func main() {
ctx := context.Background()
// initializing cache and fill
cache := CachedPopularItems{}
cache.Movies = getPopularMoviesFromDB()
go func() {
timer := time.NewTicker(1 * time.Second)
defer timer.Stop()
// initializing background job
for {
select {
// refreshing cache
case <-timer.C:
movies := getPopularMoviesFromDB()
// updating cache struct
cache.lock.Lock()
cache.Movies = movies
cache.lock.Unlock()
// app is terminating
case <-ctx.Done():
break
}
}
}()
http.HandleFunc("/getPopularMovies", func(writer http.ResponseWriter, request *http.Request) {
cache.lock.RLock()
movies := cache.Movies
cache.lock.RUnlock()
bytes, _ := json.Marshal(movies)
writer.Header().Add("Content-Type", "application/json")
writer.Write(bytes)
})
_ = http.ListenAndServe(":8890", nil)
}
// Getting from DB
func getPopularMoviesFromDB() []Movie {
// simulation request to database with latency
time.Sleep(5 * time.Second)
return []Movie{{Title: "Avatar"}, {Title: "I Am Legend"}, {Title: "The Wolf of Wall Street"}}
}
Плюсы
Никакой логики, пришел запрос, сразу отдали ответ
Снимается нагрузка на хранилище, особенно если запрос тяжеловесный
Снимается сетевой поход в хранилище
Узкое горлышко приложения в таком случае - это кол-во открытых соединений и сетевой канал
В случае если хранилище будет недоступно, пользователи все равно будут получать данные
Минусы
Подходит только для тех данных, которые можно отдавать в устаревшем состоянии
В простой реализации подходит только для простых справочных данных, если запросы имеют вариативность, то внедрить этот механизм та еще задача
Этот и другие подходы к кешированию можно прочитать в известном справочнике system-design-primer
Do once, give it to everyone
Если много пользователей приходят одновременно в сервис за одной и той же информацией, зачем ее выполнять в лучшем случае дважды, а в худшем тысячи раз?
Пример: у вас есть приложение с книгами, какие-то книги смотрят чаще, какие-то реже, и бывает такое, что на страницу определенных книг приходится высокая нагрузка, отследить причину пиков не удается, а ресурсы сэкономить хочется
Концепт: научиться считать хеш-код задачи, которую требуется сделать с учетом входных данных, выполнять ее один раз и отдавать ее всем запросившим. На примере ниже видно что одно и та же книга запрошена дважды, можно пойти в хранилище один раз и отдать ее обоим запросившим клиентам
Реализация: реализация на Go в github
package main
import (
"encoding/json"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/sync/singleflight"
)
type Book struct {
ID int
Title string `json:"Title"`
}
func main() {
// Struct for syncing work
s := singleflight.Group{}
http.HandleFunc("/getBook/", func(writer http.ResponseWriter, request *http.Request) {
bookID, _ := strconv.Atoi(strings.TrimLeft(request.RequestURI, "/getBook/"))
workHash := fmt.Sprintf("book:%d", bookID)
// Doing work with same hash once
result, _, _ := s.Do(workHash, func() (interface{}, error) {
return getBookFromDB(bookID), nil
})
book := result.(Book)
bytes, _ := json.Marshal(book)
writer.Header().Add("Content-Type", "application/json")
writer.Write(bytes)
})
_ = http.ListenAndServe(":8890", nil)
}
// Getting from DB
func getBookFromDB(id int) Book {
// simulation request to database with latency
time.Sleep(1 * time.Second)
return Book{ID: id, Title: randSeq(rand.Intn(30))}
}
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
// Random string generator
func randSeq(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
Плюсы
Предотвращение дублирования повторяющихся параллельных задач
Экономия ресурсов CPU/Сети
Минусы
Подойдет только при небольшой вариативности, иначе никакого профита этот подход не принесет
Worker pool
Вам хорошо известна пропускная способность вашего приложения и нагрузка, которую вам необходимо обрабатывать или саму задачу можно разбить на подзадачи и запараллелить
Пример 1: у вас есть сервис, который выполняет сложные вычислительные операции и инициализация объектов для выполнения - дорогая операция, поэтому необходимо подготовиться заранее, при этом нужно ограничить количество одновременно выполняемых вычислений
Пример 2: у вас есть сервис, который на один запрос выполняет множество операций (батч запрос) и их можно выполнить параллельно, собрав результаты в единый отчет
Концепт: на старте приложения инициализировать N воркеров, которые будут выполнять полезную работу, и сбрасывать состояние объектов воркера после завершения задачи
Реализация: реализация на Go в github
package main
import (
"context"
"encoding/json"
"math/rand"
"net/http"
"time"
)
type WorkerPool struct {
jobs chan WorkJob
}
func (w *WorkerPool) StartWorker() {
go func() {
for {
work := <-w.jobs
// simulating work
time.Sleep(1 * time.Second)
status := false
if work.ID%10 > 5 {
status = true
}
work.Result <- WorkJobResult{Status: status}
}
}()
}
// Adding work job to queue
func (w *WorkerPool) AddJob(ctx context.Context, id int) <-chan WorkJobResult {
resultChan := make(chan WorkJobResult, 1)
select {
// trying to add wor job
case w.jobs <- WorkJob{ID: id, Result: resultChan}:
// in case if request is aborted
case <-ctx.Done():
return nil
}
// return chan where consumer can read result
return resultChan
}
type WorkJob struct {
ID int
Result chan WorkJobResult
}
type WorkJobResult struct {
Status bool
}
func main() {
// worker pool with three workers
wp := WorkerPool{
jobs: make(chan WorkJob, 3),
}
wp.StartWorker()
wp.StartWorker()
wp.StartWorker()
http.HandleFunc("/handle", func(writer http.ResponseWriter, request *http.Request) {
resultsChan := make([]<-chan WorkJobResult, 0)
for i := 0; i < 10; i++ {
resultChan := wp.AddJob(context.Background(), rand.Intn(100))
resultsChan = append(resultsChan, resultChan)
}
status := false
for _, res := range resultsChan {
resStatus := <-res
status = status && resStatus.Status
}
bytes, _ := json.Marshal(status)
writer.Write(bytes)
})
_ = http.ListenAndServe(":8890", nil)
}
Плюсы
Ограничение пропускной способности приложения
Параллельное выполнение подзадач
Экономия ресурсов, так как вы можете переиспользовать в worker pool объекты между задачами и не генерировать лишнего мусора
Минусы
При работе с параллельностью легко допустить ошибку и сломать приложение
Итог
Делитесь своими любимыми практиками, буду рад открыть что-то новое. Буду признателен любым конструктивным замечаниям. Спасибо!
Arteandr
Отличная статья!