Эта статья отредактирована и организована на основе содержания третьей прямой трансляции "Go Open Source". Видеоконтент длинный и разделен на две части. Содержание этой статьи было удалено и реконструировано.
Привет всем, я очень рад прийти на "GO Open Source Talk", чтобы поделиться с вами некоторыми историями, дизайнерскими идеями и методами использования, лежащими в основе проектов с открытым кодом. инженерные практики. Я Кевин, автор go-zero, мой github idkevwan.
с нуля обзор
Несмотря на то, что исходный код go-zero был открыт только 7 августа 2020 г., он был широко протестирован в Интернете. Это также является накоплением моего почти 20-летнего инженерного опыта. После открытия исходного кода я получил положительные отзывы от сообщества. За более чем 5 месяцев набрал 6к звезд. Он много раз возглавлял ежедневный список, еженедельный и ежемесячный список языка github Go, а также выиграл самый ценный проект (GVP) gitee, самого популярного проекта года в Китае. В то же время сообщество WeChat очень активно, с группой сообщества из 3000+ человек, энтузиасты go-zero обмениваются опытом go-zero и обсуждают проблемы во время использования.
Как go-zero автоматически управляет кешем?
Принципы проектирования кэша
Мы только удаляем, а не обновляем кеш.Как только данные в БД будут изменены, мы удалим соответствующий кеш напрямую, а не обновим.
Посмотрим, насколько правильный порядок удаления кеша.
- Сначала удалите кеш, затем обновите БД
Рассмотрим ситуацию с двумя одновременными запросами: А запрашивает обновление данных, сначала удаляет кеш, а затем Б запрашивает чтение данных, в это время данных в кеше нет, данные загружаются из БД и записывается обратно в кеш, а затем A обновляет БД, тогда данные в кеше всегда будут грязными данными в это время, пока не истечет срок действия кеша или не появится новый запрос на обновление данных. Как показано
- Сначала обновите БД, затем удалите кеш
Сначала A запрашивает обновление БД, а затем B запрашивает чтение данных.В это время возвращаются старые данные.В это время можно считать, что запрос A не был обновлен, и окончательная согласованность приемлема. Затем А удаляет кеш, а последующие запросы будут получать последние данные, как показано на рисунке.
Давайте еще раз взглянем на обычный поток запросов:
- Первый запрос обновляет БД и удаляет кеш
- Второй запрос читает кеш, если данных нет, читает данные из БД и записывает обратно в кеш
- Последующие запросы на чтение могут быть прочитаны непосредственно из кеша.
Давайте посмотрим на ситуацию с запросом к БД, предполагая, что в записи строки есть семь столбцов данных ABCDEFG:
- Только часть запросов запросов данных столбцов, таких как запросы в нем ABC, EFG CDE или тому подобное, как показано в
- Запросите одну полную запись строки, как показано на рисунке.
- Запросите некоторые или все столбцы записей из нескольких строк, как показано на рисунке.
Для трех вышеперечисленных случаев, во-первых, нам не нужны частичные запросы, так как некоторые запросы не могут кэшироваться, после кэширования данные обновляются, и невозможно определить, какие данные нужно удалить, во-вторых, для многострочных запросов , согласно фактическому сценарию и При необходимости установим соответствующее сопоставление из условий запроса в первичные ключи на бизнес-уровне; а для запроса одной строки полных записей go-zero имеет встроенное полное управление кешем метод. Итак, основной принцип:Кэши go-zero должны быть полными записями строк.
Давайте подробно представим методы обработки кеша трех встроенных сценариев перехода на ноль:
-
Кэш на основе первичного ключа
PRIMARY KEY (`id`)
Это относительно самый простой кеш для работы, и его нужно только
redis
используется вprimary key
в видеkey
Для кэширования записей строк. -
Кэш на основе уникального индекса
Я извлек уроки из этого, когда занимался проектированием кеша на основе индексов.
database
метод проектирования индексов, вdatabase
В дизайне, если данные ищутся по индексу, движок сначала索引->主键
изtree
В нем находится первичный ключ, а затем запись строки опрашивается через первичный ключ, который должен ввести слой косвенности для решения соответствующей проблемы индекса к записи строки. Тот же принцип применим к дизайну кеша go-zero.Кэш на основе индексов далее делится на уникальный индекс с одним столбцом и уникальный индекс с несколькими столбцами:
-
Одностолбцовый уникальный индекс выглядит следующим образом:
UNIQUE KEY `product_idx` (`product`)
-
Многостолбцовый уникальный индекс выглядит следующим образом:
UNIQUE KEY `vendor_product_idx` (`vendor`, `product`)
Но для нулевого, одностолбцового и многостолбцового просто сгенерируйте кеш.
key
Способ другой, логика управления та же. Тогда встроенное управление кешем go-zero сможет лучше контролировать проблему непротиворечивости данных, а также предотвратить поломку, проникновение и лавинные проблемы кеша (это подробно обсуждалось на конференции gopherchina, см. продолжение gopherchina поделитесь видео).Кроме того, go-zero имеет встроенный доступ к кешу и статистику обращений к частоте обращений, как показано ниже:
dbcache(sqlc) - qpm: 5057, hit_ratio: 99.7%, hit: 5044, miss: 13, db_fails: 0
Вы можете увидеть более подробную статистику, которая нам удобна для анализа использования кеша.Для случая, когда частота попаданий в кеш крайне низкая или объем запросов крайне мал, мы можем удалить кеш, что также может уменьшить цена.
-
Интерпретация кода кэша
1. Логика кэширования на основе первичного ключа
Конкретный код реализации выглядит следующим образом:
func (cc CachedConn) QueryRow(v interface{}, key string, query QueryFn) error {
return cc.cache.Take(v, key, func(v interface{}) error {
return query(cc.db, v)
})
}
здесьTake
Метод заключается в том, чтобы сначала пройти кешkey
Получите данные, если вы их получили, верните их напрямую, если вы не можете их получить, то передайте ихquery
путьDB
Прочитайте всю строку и запишите обратно в кеш, прежде чем возвращать данные. Вся логика относительно проста и понятна.
Давайте посмотрим подробноTake
Реализация:
func (c cacheNode) Take(v interface{}, key string, query func(v interface{}) error) error {
return c.doTake(v, key, query, func(v interface{}) error {
return c.SetCache(key, v)
})
}
Take
Логика следующая:
- использовать
key
Найти данные из кеша - Если найдено, вернуть данные
- Если не найдено, используйте
query
способ чтения данных - позвоните после прочтения
c.SetCache(key, v)
установить кеш
один из нихdoTake
Код и объяснение следующие:
// v - 需要读取的数据对象
// key - 缓存key
// query - 用来从DB读取完整数据的方法
// cacheVal - 用来写缓存的方法
func (c cacheNode) doTake(v interface{}, key string, query func(v interface{}) error,
cacheVal func(v interface{}) error) error {
// 用barrier来防止缓存击穿,确保一个进程内只有一个请求去加载key对应的数据
val, fresh, err := c.barrier.DoEx(key, func() (interface{}, error) {
// 从cache里读取数据
if err := c.doGetCache(key, v); err != nil {
// 如果是预先放进来的placeholder(用来防止缓存穿透)的,那么就返回预设的errNotFound
// 如果是未知错误,那么就直接返回,因为我们不能放弃缓存出错而直接把所有请求去请求DB,
// 这样在高并发的场景下会把DB打挂掉的
if err == errPlaceholder {
return nil, c.errNotFound
} else if err != c.errNotFound {
// why we just return the error instead of query from db,
// because we don't allow the disaster pass to the DBs.
// fail fast, in case we bring down the dbs.
return nil, err
}
// 请求DB
// 如果返回的error是errNotFound,那么我们就需要在缓存里设置placeholder,防止缓存穿透
if err = query(v); err == c.errNotFound {
if err = c.setCacheWithNotFound(key); err != nil {
logx.Error(err)
}
return nil, c.errNotFound
} else if err != nil {
// 统计DB失败
c.stat.IncrementDbFails()
return nil, err
}
// 把数据写入缓存
if err = cacheVal(v); err != nil {
logx.Error(err)
}
}
// 返回json序列化的数据
return jsonx.Marshal(v)
})
if err != nil {
return err
}
if fresh {
return nil
}
// got the result from previous ongoing query
c.stat.IncrementTotal()
c.stat.IncrementHit()
// 把数据写入到传入的v对象里
return jsonx.Unmarshal(val.([]byte), v)
}
2. Логика кэширования на основе уникального индекса
Поскольку этот блок более сложный, я пометил блок кода и логику ответа разными цветами,block 2
По сути, это то же самое, что и кеш на основе первичного ключа, здесь в основном речь идет оblock 1
логика.
блок кодаblock 1
Части делятся на два корпуса:
-
Первичный ключ можно найти из кеша через индекс
На этом этапе используйте первичный ключ напрямую
block 2
Логика последующего действия такая же, как и в приведенной выше логике кэширования на основе первичного ключа. -
Первичный ключ не может быть найден из кеша через индекс
- Запросите полные записи строк из БД через индекс, если таковые имеются
error
,возвращение - После того, как полная запись строки будет найдена, кеш от первичного ключа до полной записи строки и кеш от индекса до первичного ключа будут записаны в кеш одновременно.
redis
внутри - Возвращает нужные данные записи строки
- Запросите полные записи строк из БД через индекс, если таковые имеются
// v - 需要读取的数据对象
// key - 通过索引生成的缓存key
// keyer - 用主键生成基于主键缓存的key的方法
// indexQuery - 用索引从DB读取完整数据的方法,需要返回主键
// primaryQuery - 用主键从DB获取完整数据的方法
func (cc CachedConn) QueryRowIndex(v interface{}, key string, keyer func(primary interface{}) string,
indexQuery IndexQueryFn, primaryQuery PrimaryQueryFn) error {
var primaryKey interface{}
var found bool
// 先通过索引查询缓存,看是否有索引到主键的缓存
if err := cc.cache.TakeWithExpire(&primaryKey, key, func(val interface{}, expire time.Duration) (err error) {
// 如果没有索引到主键的缓存,那么就通过索引查询完整数据
primaryKey, err = indexQuery(cc.db, v)
if err != nil {
return
}
// 通过索引查询到了完整数据,设置found,后面直接使用,不需要再从缓存读取数据了
found = true
// 将主键到完整数据的映射保存到缓存里,TakeWithExpire方法已经将索引到主键的映射保存到缓存了
return cc.cache.SetCacheWithExpire(keyer(primaryKey), v, expire+cacheSafeGapBetweenIndexAndPrimary)
}); err != nil {
return err
}
// 已经通过索引找到了数据,直接返回即可
if found {
return nil
}
// 通过主键从缓存读取数据,如果缓存没有,通过primaryQuery方法从DB读取并回写缓存再返回数据
return cc.cache.Take(v, keyer(primaryKey), func(v interface{}) error {
return primaryQuery(cc.db, v, primaryKey)
})
}
Рассмотрим практический пример
func (m *defaultUserModel) FindOneByUser(user string) (*User, error) {
var resp User
// 生成基于索引的key
indexKey := fmt.Sprintf("%s%v", cacheUserPrefix, user)
err := m.QueryRowIndex(&resp, indexKey,
// 基于主键生成完整数据缓存的key
func(primary interface{}) string {
return fmt.Sprintf("user#%v", primary)
},
// 基于索引的DB查询方法
func(conn sqlx.SqlConn, v interface{}) (i interface{}, e error) {
query := fmt.Sprintf("select %s from %s where user = ? limit 1", userRows, m.table)
if err := conn.QueryRow(&resp, query, user); err != nil {
return nil, err
}
return resp.Id, nil
},
// 基于主键的DB查询方法
func(conn sqlx.SqlConn, v, primary interface{}) error {
query := fmt.Sprintf("select %s from %s where id = ?", userRows, m.table)
return conn.QueryRow(&resp, query, primary)
})
// 错误处理,需要判断是否返回的是sqlc.ErrNotFound,如果是,我们用本package定义的ErrNotFound返回
// 避免使用者感知到有没有使用缓存,同时也是对底层依赖的隔离
switch err {
case nil:
return &resp, nil
case sqlc.ErrNotFound:
return nil, ErrNotFound
default:
return nil, err
}
}
Весь приведенный выше код автоматического управления кешем может быть автоматически сгенерирован нашей внутренней командой goctl.CRUD
А кеш в основном автоматически генерируется goctl, что может сэкономить много времени на разработку, да и сам код кеша очень подвержен ошибкам.Даже при хорошем опыте программирования сложно каждый раз писать правильно, поэтому рекомендуем использовать как можно больше Инструмент автоматической генерации кода кеша, чтобы избежать ошибок.
Need more?
Если вы хотите лучше понять проект go-zero, перейдите на официальный сайт и изучите конкретные примеры.
Адрес воспроизведения видео
уууууууууууууууууууууууууууу.ScaleProportion.com/video/BV1JY…
адрес проекта
Добро пожаловать в Go-Zero иstarПоддерживать нас!