Persistent layer cache

[!TIP] This document is machine-translated by Google. If you find grammatical and semantic errors, and the document description is not clear, please PR

Principles of Cache Design

We only delete the cache without updating it. Once the data in the DB is modified, we will directly delete the corresponding cache instead of updating it.

Let’s see how the order of deleting the cache is correct.

  • Delete the cache first, then update the DB

redis-cache-01

Let’s look at the situation of two concurrent requests. A request needs to update the data. The cache is deleted first, and then B requests to read the data. At this time, there is no data in the cache, and the data is loaded from the DB and written back to the cache, and then A updates the DB , Then the data in the cache will always be dirty data at this time, until the cache expires or there is a new data update request. As shown

redis-cache-02

  • Update the DB first, then delete the cache

    redis-cache-03

A requests to update the DB first, and then B requests to read the data. At this time, the old data is returned. At this time, it can be considered that the A request has not been updated, and the final consistency is acceptable. Then A deletes the cache, and subsequent requests will Get the latest data, as shown in the figure redis-cache-04

Let’s take another look at the normal request flow:

  • The first request to update the DB and delete the cache
  • The second request to read the cache, if there is no data, read the data from the DB and write it back to the cache
  • All subsequent read requests can be read directly from the cache redis-cache-05

Let’s take a look at the DB query, assuming that there are seven columns of ABCDEFG data in the row record:

  • A request to query only part of the column data, such as ABC, CDE or EFG in the request, as shown in the figure redis-cache-06

  • Query a single complete row record, as shown in the figure redis-cache-07

  • Query part or all of the columns of multiple rows, as shown in the figure redis-cache-08

For the above three cases, firstly, we don’t need partial queries, because some queries cannot be cached. Once cached, the data is updated, and it is impossible to locate which data needs to be deleted; secondly, for multi-line queries, according to actual scenarios and If necessary, we will establish the corresponding mapping from the query conditions to the primary key in the business layer; and for the query of a single row of complete records, go-zero has a built-in complete cache management method. So the core principle is: go-zero cache must be a complete line record.

Let’s introduce in detail the cache processing methods of the three built-in scenarios in go-zero:

  • Cache based on primary key
    1. PRIMARY KEY (`id`)

This kind of cache is relatively the easiest to handle, just use the primary key as the key in redis to cache line records.

  • Cache based on unique index redis-cache-09

When doing index-based cache design, I used the design method of database index for reference. In database design, if you use the index to check data, the engine will first find the primary key in the tree of index -> primary key, and then use the primary key. To query row records, an indirect layer is introduced to solve the corresponding problem of index to row records. The same principle applies to the cache design of go-zero.

Index-based cache is divided into single-column unique index and multi-column unique index:

But for go-zero, single-column and multi-column are just different ways of generating cache keys, and the control logic behind them is the same. Then go-zero’s built-in cache management can better control the data consistency problem, and also built-in to prevent the breakdown, penetration, and avalanche problems of the cache (these were discussed carefully when sharing at the gopherchina conference, see follow-up gopherchina Share video).

In addition, go-zero has built-in cache access and access hit rate statistics, as shown below:

  1. dbcache(sqlc) - qpm: 5057, hit_ratio: 99.7%, hit: 5044, miss: 13, db_fails: 0

But for go-zero, single-column and multi-column are just different ways of generating cache keys, and the control logic behind them is the same. Then go-zero’s built-in cache management can better control the data consistency problem, and also built-in to prevent the breakdown, penetration, and avalanche problems of the cache (these were discussed carefully when sharing at the gopherchina conference, see follow-up gopherchina Share video).

  • The single-column unique index is as follows:

    1. UNIQUE KEY `product_idx` (`product`)
  • The multi-column unique index is as follows:

    1. UNIQUE KEY `vendor_product_idx` (`vendor`, `product`)

    Cache code interpretation

1. Cache logic based on the primary key

redis-cache-10

The specific implementation code is as follows:

  1. func (cc CachedConn) QueryRow(v interface{}, key string, query QueryFn) error {
  2. return cc.cache.Take(v, key, func(v interface{}) error {
  3. return query(cc.db, v)
  4. })
  5. }

The Take method here is to first get the data from the cache via the key, if you get it, return it directly, if you can’t get it, then use the query method to go to the DB to read the complete row record and write it back Cache, and then return the data. The whole logic is relatively simple and easy to understand.

Let’s take a look at the implementation of Take in detail:

  1. func (c cacheNode) Take(v interface{}, key string, query func(v interface{}) error) error {
  2. return c.doTake(v, key, query, func(v interface{}) error {
  3. return c.SetCache(key, v)
  4. })
  5. }

The logic of Take is as follows:

  • Use key to find data from cache
  • If found, return the data
  • If you can’t find it, use the query method to read the data
  • After reading it, call c.SetCache(key, v) to set the cache

The code and explanation of doTake are as follows:

  1. // v - The data object that needs to be read
  2. // key - Cache key
  3. // query - Method used to read complete data from DB
  4. // cacheVal - Method used to write cache
  5. func (c cacheNode) doTake(v interface{}, key string, query func(v interface{}) error,
  6. cacheVal func(v interface{}) error) error {
  7. // Use barriers to prevent cache breakdown and ensure that there is only one request in a process to load the data corresponding to the key
  8. val, fresh, err := c.barrier.DoEx(key, func() (interface{}, error) {
  9. // Read data from the cache
  10. if err := c.doGetCache(key, v); err != nil {
  11. // If it is a placeholder that was put in beforehand (to prevent cache penetration), then the default errNotFound is returned
  12. // If it is an unknown error, then return directly, because we can't give up the cache error and directly send all requests to the DB,
  13. // This will kill the DB in a high concurrency scenario
  14. if err == errPlaceholder {
  15. return nil, c.errNotFound
  16. } else if err != c.errNotFound {
  17. // why we just return the error instead of query from db,
  18. // because we don't allow the disaster pass to the DBs.
  19. // fail fast, in case we bring down the dbs.
  20. return nil, err
  21. }
  22. // request DB
  23. // If the returned error is errNotFound, then we need to set a placeholder in the cache to prevent the cache from penetrating
  24. if err = query(v); err == c.errNotFound {
  25. if err = c.setCacheWithNotFound(key); err != nil {
  26. logx.Error(err)
  27. }
  28. return nil, c.errNotFound
  29. } else if err != nil {
  30. // Statistics DB failed
  31. c.stat.IncrementDbFails()
  32. return nil, err
  33. }
  34. // Write data to cache
  35. if err = cacheVal(v); err != nil {
  36. logx.Error(err)
  37. }
  38. }
  39. // Return json serialized data
  40. return jsonx.Marshal(v)
  41. })
  42. if err != nil {
  43. return err
  44. }
  45. if fresh {
  46. return nil
  47. }
  48. // got the result from previous ongoing query
  49. c.stat.IncrementTotal()
  50. c.stat.IncrementHit()
  51. // Write data to the incoming v object
  52. return jsonx.Unmarshal(val.([]byte), v)
  53. }

2. Cache logic based on unique index

Because this block is more complicated, I used different colors to mark out the code block and logic of the response. block 2 is actually the same as the cache based on the primary key. Here, I mainly talk about the logic of block 1. redis-cache-11

The block 1 part of the code block is divided into two cases:

  • The primary key can be found from the cache through the index. At this time, the primary key is used directly to walk the logic of block 2, and the follow-up is the same as the above-based primary key-based caching logic.

  • The primary key cannot be found in the cache through the index

    • Query the complete row record from the DB through the index, if there is an error, return
    • After the complete row record is found, the cache of the primary key to the complete row record and the cache of the index to the primary key will be written to redis at the same time
    • Return the required row data
  1. // v-the data object that needs to be read
  2. // key-cache key generated by index
  3. // keyer-Use the primary key to generate a key based on the primary key cache
  4. // indexQuery-method to read complete data from DB using index, need to return the primary key
  5. // primaryQuery-method to get complete data from DB with primary key
  6. func (cc CachedConn) QueryRowIndex(v interface{}, key string, keyer func(primary interface{}) string,
  7. indexQuery IndexQueryFn, primaryQuery PrimaryQueryFn) error {
  8. var primaryKey interface{}
  9. var found bool
  10. // First query the cache through the index to see if there is a cache from the index to the primary key
  11. if err := cc.cache.TakeWithExpire(&primaryKey, key, func(val interface{}, expire time.Duration) (err error) {
  12. // If there is no cache of the index to the primary key, then the complete data is queried through the index
  13. primaryKey, err = indexQuery(cc.db, v)
  14. if err != nil {
  15. return
  16. }
  17. // The complete data is queried through the index, set to “found” and used directly later, no need to read data from the cache anymore
  18. found = true
  19. // Save the mapping from the primary key to the complete data in the cache. The TakeWithExpire method has saved the mapping from the index to the primary key in the cache.
  20. return cc.cache.SetCacheWithExpire(keyer(primaryKey), v, expire+cacheSafeGapBetweenIndexAndPrimary)
  21. }); err != nil {
  22. return err
  23. }
  24. // The data has been found through the index, just return directly
  25. if found {
  26. return nil
  27. }
  28. // Read data from the cache through the primary key, if the cache is not available, read from the DB through the primaryQuery method and write back to the cache and then return the data
  29. return cc.cache.Take(v, keyer(primaryKey), func(v interface{}) error {
  30. return primaryQuery(cc.db, v, primaryKey)
  31. })
  32. }

Let’s look at a practical example

  1. func (m *defaultUserModel) FindOneByUser(user string) (*User, error) {
  2. var resp User
  3. // Generate index-based keys
  4. indexKey := fmt.Sprintf("%s%v", cacheUserPrefix, user)
  5. err := m.QueryRowIndex(&resp, indexKey,
  6. // Generate a complete data cache key based on the primary key
  7. func(primary interface{}) string {
  8. return fmt.Sprintf("user#%v", primary)
  9. },
  10. // Index-based DB query method
  11. func(conn sqlx.SqlConn, v interface{}) (i interface{}, e error) {
  12. query := fmt.Sprintf("select %s from %s where user = ? limit 1", userRows, m.table)
  13. if err := conn.QueryRow(&resp, query, user); err != nil {
  14. return nil, err
  15. }
  16. return resp.Id, nil
  17. },
  18. // DB query method based on primary key
  19. func(conn sqlx.SqlConn, v, primary interface{}) error {
  20. query := fmt.Sprintf("select %s from %s where id = ?", userRows, m.table)
  21. return conn.QueryRow(&resp, query, primary)
  22. })
  23. // Error handling, you need to determine whether the returned sqlc.ErrNotFound is, if it is, we use the ErrNotFound defined in this package to return
  24. // Prevent users from perceiving whether or not the cache is used, and at the same time isolate the underlying dependencies
  25. switch err {
  26. case nil:
  27. return &resp, nil
  28. case sqlc.ErrNotFound:
  29. return nil, ErrNotFound
  30. default:
  31. return nil, err
  32. }
  33. }

All the above cache automatic management codes can be automatically generated through goctl, and the internal CRUD and cache of our team are basically automatically generated through goctl, which can save A lot of development time, and the cache code itself is also very error-prone. Even with good code experience, it is difficult to write it correctly every time. Therefore, we recommend using automatic cache code generation tools as much as possible to avoid errors.

Guess you wants