---
title: "101: Cache and Redis"
---
###### tags: `jubo` `tutorials` `cache` `redis`
[TOC]
**雞肋小知識**
| | |
| -------- | -------- |
| ![](https://i.imgur.com/NI2raF2.png)| ![](https://i.imgur.com/RmRatRj.png)
|
# Purposes of cache
- 主資料庫開始**不穩定**了,需要保護主資料庫
- 改善整體系統在資料**讀取路徑**的吞吐量
# General rules
## Pareto principle (aka. 80-20 rule)
- **假設 80% 的流量都來自 20% 的資料所貢獻**。故需要被快取的資料量可粗估就是 20% 的流量大小
- 可能合理假設加上快取的請求的流量 QPS (Query per second)
- 了解被快取的資料的 avg. byte size
**練習題**
- 假設
- 系統的 QPS 為 30
- 每筆資料的 avg. size=1024 bytes
- expire time 設為 1 天 (86400 seconds)
- 則快取系統的記憶體至少需要
- 30 (QPS) * 86400 * 1024 (bytes) * 20% ~= **0.5 GiB**
## TTL must be set
- 被快取的資料一定要給 TTL (expire time)
- 後續才有辦法估計真實用量 (會搭配可觀測性基礎建設)
- 若使用 Redis 時沒有給 TTL,預設使用 [Least Recently Used (LRU) cache](https://redis.io/topics/lru-cache) 來剔除 keys
- 這會導致在尖峰時刻,Redis 得同時忙著處理 client requests 以及 key eviction,**會傷害服務吞吐量**
# Common problems and solutions
## [Cache stampede](https://en.wikipedia.org/wiki/Cache_stampede) (踩踏)
問題來源:
- 商業情境有熱點資料 (hot key),該資料快取過期
- 高並行流量同時存取該過期的資料,但 app tier 未正確實作 **Single Flight**
![](https://i.imgur.com/vTRPTKU.png)
(source: https://www.cyningsun.com/01-11-2021/golang-concurrency-singleflight.html)
### Common implementation mistake
```mermaid
sequenceDiagram
autonumber
participant App
participant Cache
participant DB
App->>Cache: get cached data
Cache-->>App: return cached data
Note left of App: reply client (if cache hit)
Note over App, DB: if cache miss...
App->>DB: query data
DB-->>App: return data
App->>Cache: update cache
Cache-->>App: return ack
Note left of App: reply client
```
:::danger
在高並行請求下,同一瞬間會有大量的 cache miss,進而造成:
- :fire: 大量的 requests 衝進到 DB
- :fire: 大量的 cache recomputation
![](https://i.imgur.com/O8JrFOp.png)
(source: https://www.cyningsun.com/01-11-2021/golang-concurrency-singleflight.html)
:::
### Distributed locks (for single flight)
那我們來調整一下實作邏輯:
1. 首先我們可以利用 cache server 做為 distributed lock manager
2. 在 cache miss 後、query data 前,嘗試取得 global lock
3. 拿到 lock 後,再進去 DB 拿資料
4. 並在 update cache 後歸還 lock
```mermaid
sequenceDiagram
autonumber
participant App
participant Cache
participant DB
Note over App, DB: if cache miss...
rect rgb(191, 223, 255)
loop RETRY if needed*
App->>Cache: acquire lock
Cache-->>App: return lock
end
end
App->>DB: query data
DB-->>App: return data
App->>Cache: update cache**
Cache-->>App: return ack
rect rgb(191, 223, 255)
App->>Cache: release lock**
Cache-->>App: return ack
end
Note left of App: reply client
```
:::info
:information_source: * **重試策略**
- 拿不到 lock 的重試策略,應考慮商業情境的需求
- 若商業情境可以接受不 retry,那就直接 reply (降低 application 複雜度)
- 直到有明確的商業情境需求作為規範再實作
- 若有需求,則應使用 [exponential backoff (with jitter) retry algorithm](https://cloud.google.com/iot/docs/how-tos/exponential-backoff) 來實作
:::
:::info
:information_source: ** **原子操作**
- 若使用 Redis,這兩個動作可考慮透過執行 [Lua script](https://redis.io/commands/eval) 將其包成 atomic operation,App 可以少實作一些錯誤處理情境
:::
:::danger
:fire: 若按照上述的循序圖實作,仍屬常見的錯誤實作。是什麼地方沒考慮到呢?
:::
:::spoiler Answer
多個 requests 遇到 cache miss 時,的確只有一筆 request 能成功拿到 lock,而**其他 requests 皆在嘗試 acquiring lock**。
當第一筆成功歸還 lock 後,**next request 拿到 lock 就又衝進 DB 了!** :fire: 造成無謂的流量繼續打在 DB 上。
:::
---
#### :bulb: 正確的實作
- 拿到 lock 之後,應**再檢查一次 cached data 是否已存在**
- 若有,直接 release lock 後回傳資料
- 若無,才進 DB 拿資料、recomputation
```mermaid
sequenceDiagram
autonumber
participant App
participant Cache
participant DB
Note over App, DB: if cache miss...
rect rgb(191, 223, 255)
loop RETRY if needed
App->>Cache: acquire lock
Cache-->>App: return lock
end
end
rect rgb(51, 204, 51)
App->>Cache: get cached data
Cache-->>App: return cached data
end
alt
Note over App, DB: if cache hit...
rect rgb(191, 223, 255)
App->>Cache: release lock
Cache-->>App: return ack
end
Note left of App: reply client
else
Note over App, DB: if cache miss...
App->>DB: query data
DB-->>App: return data
App->>Cache: update cache
Cache-->>App: return ack
rect rgb(191, 223, 255)
App->>Cache: release lock
Cache-->>App: return ack
end
Note left of App: reply client
end
```
## Cache penetration (穿透)
問題來源:
- 前述的 *cache stampede* 假設當 cache miss 時,database **一定有你要檢索的資料**;但 *cache penetration* 的情境是:**database 就是沒有該資料**,**所以造成大量的 cache miss** :fire:
- 另一種常見的原因是**惡意流量**,攻擊者製造許多隨機的 keys 來攻擊系統 :fire:
### Cache null
- 最簡單的想法就是把該 key 存下來,value 為 null
- :warning: **但 TTL 不能設太長**,以避免影響系統對正常用戶的可用性
### [Bloom filter](https://en.wikipedia.org/wiki/Bloom_filter)
![](https://i.imgur.com/wUXwVIE.png)
(source: https://www.evanlin.com/BloomFilter/)
- Bloom filter 是一種[節省空間的機率型資料結構](https://en.wikipedia.org/wiki/Bloom_filter)
- 透過 bit array 以及多個 hash functions 的設計,此資料結構可提供非常輕巧的檢索功能,以降低 cache/DB 的負擔
#### 讀取路徑流程圖
```mermaid
flowchart LR
S(Start) --request--> A
A[App] --> B{Is key in bloomfilter?}
B --> |No| A
B --> |Yes| C{Is key in cache?}
C --> |Yes, return cache| A
C --> |No, check DB| D[(DB)]
D --return query result--> A
```
- 我們先透過它來檢索一筆資料:
- 當它告訴我們不存在,則**一定不在**資料庫裡
- 當它告訴我們存在,則資料**可能在資料庫裡** (i.e. **有誤判率**)。此時該 request 就可以去 cache/DB 查資料
- 由於查詢結果可能會出現[誤判 (false positives)](https://en.wikipedia.org/wiki/Bloom_filter#Probability_of_false_positives),故也需要透過調整 bit array 長度以及 hash function 數量的方式,來設計允許的誤判率
#### 但仍有問題需要考慮
- 是否有一個 **BloomFilter as a Service** 的應用實體提供給 distributed system 使用呢?
- 如何 delete 存進 Bloom Filter 的 key?
:::info
:information_source: 如何計算 BloomFilter 的 False Positive
每次檢驗某 key 的時候,會找出 k 個 bit position,當全部的 bits 皆為 1 時,該 key 很可能有在 set 裡。
問題在於:執行 k 個 hash function,每次都是 bit=1 的機率為何?
以下為推導思路:
符號定義:
- $k$ hash functions
- $n$ bits
則當我存入 1 個 key 進入 BloomFilter 時,其中一個 bit=0 的機率為:
$(1-\dfrac{1}{n})^k$
故當存入越來越多個 keys 時 (e.g. $m$ 個 keys),可以期待其中一個 bit=0 的機率越來越低,故將上式做 $m$ 次方:
$((1-\dfrac{1}{n})^k)^m=(1-\dfrac{1}{n})^{km}$
我們有了一個 bit=0 的機率,就可以知道在存入了 $m$ 個 keys 之後,其中一個 bit=1 的機率了:
$1-(1-\dfrac{1}{n})^{km}$
故回到原本的問題:因為我有 $k$ 個 hash function,而每次都出現 bit=1 的機率就變成:
$(1-(1-\dfrac{1}{n})^{km})^k$
讓我搞懂怎計算的參考資料:
- [Bloom Filters](https://youtu.be/bEmBh1HtYrw?t=311)
- [布隆过滤器(Bloom Filter)原理及Golang实现](https://zhuanlan.zhihu.com/p/165130282)
:::
## Cache avalanche (雪崩)
問題來源:
- 快取過期時間 (expire time) 皆相同,導致大量快取接近同時過期
- Cache server 當機,導致大量快取失效
### TTL with jitter
- 過期時間設置加上一個偏差值。一個可行的策略是加上 5% 偏誤
#### 練習題
- 原本設計是 TTL=3600 sec.
- 則設置 expire time 時,使用 3600±5% = 3600±180
- 使得實際被設置的 TTL 被均勻分散在 3420~3780 之間
![](https://i.imgur.com/mUx1w10.png =500x)
(source: [TalkGo: #112 go-zero 分布式缓存最佳实践](https://youtu.be/csKogMn4v0Q?t=759))
### Cluster deployment
- 使用叢集架構部署 cache server,避免[單點故障](https://en.wikipedia.org/wiki/Single_point_of_failure)引起的雪崩
:::info
:information_source: 參考資料:
- Simple self-hosting: [Deploying Redis Cluster on Kubernetes](https://www.containiq.com/post/deploy-redis-cluster-on-kubernetes)
- GCP Product: [High availability on Memorystore for Redis](https://cloud.google.com/memorystore/docs/redis/high-availability)
:::
# Redis basics
## Single threaded
- Redis 使用**單執行緒**處理用戶端請求
- 帶給 distributed system 的好處不會有 race condition
## Persistence
- 別預期它提供可靠的永久性儲存 (i.e. [Durability](https://en.wikipedia.org/wiki/Durability_(database_systems)))
- i.e. 若 Redis server 當機是會遺失資料的
- 因為預設儲存機制是一段時間才對 memory 做 snapshot,故有機率 loss data (see [Topic: Redis Persistence](https://redis.io/topics/persistence))
- 如不能接受,需要調整 Redis server configuration :tired_face:
## Database namespacing
- 仍然有 logical database space 的概念的
- 但預設只有 **16 個** ([stackoverflow.com](https://stackoverflow.com/questions/36735236/maximum-number-of-databases-in-redis))
- 平常登入使用的是 index 0 的 DB
- 可透過 [SELECT](https://redis.io/commands/select) 指令切換 DB
### Reminders
- Redis 官方建議
> *In practical terms, Redis databases should be used to separate different keys belonging to the same application (if needed), and not to use a single Redis instance for multiple unrelated applications.* - [SELECT](https://redis.io/commands/select)
- **一個 Redis instance** 應只服務**一個業務實體**
- 其中的 numbered databases 就只是提供該業務實體方便隔離 key space
- 不要讓一個 Redis 服務不同的業務實體
- 但此功能在 open source Redis Cluster 及 Redis Modules 上會**不能用**!另外,Redis 的作者也自省這是一個 Redis 的錯誤設計 (source: https://redis.com/blog/7-redis-worst-practices/#3.Numbered_databases/SELECT)
### Great practice (opinionated)
- 撰寫 **integration testing** 時,顯式地控制讓不同的 test cases 連向不同的 numbered databases,**避免測資污染**
## Data types
![](https://i.imgur.com/8kPXzDE.png)
- 看一遍 [Redis data types intro](https://redis.io/topics/data-types-intro),了解如何善用 Redis 來降低 app tier 複雜度、並做出更好的 system design
- 各種 date types 的 API 可再參考 [Redis 教程 - 菜鸟教程](https://www.runoob.com/redis/redis-tutorial.html),很有用
# Redis practices
## Quick starting on local
- 使用 docker [Redis official image](https://hub.docker.com/_/redis) 快速運行一個免洗 Redis server
- `Makefile` code snippets:
```bash
REDIS_PORT ?= 6379
stop-redis:
@echo "stop redis..."
@docker stop redis-6 | true
restart-redis: stop-redis
@echo "start redis..."
@docker run -d --rm \
--name redis-6 \
-p $(REDIS_PORT):6379 \
redis:6.2-alpine
```
- 使用 `redis-cli` (*installed via [`brew install redis`](https://formulae.brew.sh/formula/redis)*) 連接 Redis server
## [Distributed locks](https://redis.io/topics/distlock)
### How to acquire lock
- 使用 [`SET`](https://redis.io/commands/set) 與 `NX` + `EX` options 來完成
- `NX`: Only set the key if it does not already exist.
- `EX`*`seconds`* : Set the specified expire time, in seconds.
- **加上 TTL 的目的是讓 Redis 自動幫你釋放 lock,避免 [deadlock](https://en.wikipedia.org/wiki/Deadlock)。** 因為:
- App 做太久了、不符原設計了,卡死該資源影響別的服務
- App 可能因故重啟、當機,故無法主動釋放該 lock
```mermaid
sequenceDiagram
participant App1
participant App2
participant Redis
App1->>Redis: acquire lock (SET foo token1 NX EX 30)
Redis->>App1: return OK
Note over App1: do things...
App2->>Redis: acquire lock (SET foo token2 NX EX 30)
Redis->>App2: return nil
Note over App2: failed to SET
```
- command line demo:
```bash=
$ redis-cli
127.0.0.1:6379> SET foo bar NX EX 30
OK
127.0.0.1:6379> SET foo hello NX EX 30
(nil)
127.0.0.1:6379> GET foo
"bar"
```
### How to release lock
:::danger
:fire: 有潛在問題的做法
- 直覺:使用 `DEL` 指令刪除該 key
- 但是你怎知該 key 真的是你 `SET` 的?
- 要是不小心在 EX 的時間後才去 release 該 lock,很有可能 release 的是別人 `SET` 的 lock
:::
:::success
:bulb: 正確做法
- 在 **`SET` lock** 的時候,使用一組 non-guessable large random string (e.g. [UUID v4 string](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))) 作為 value
- **release lock** 的時候使用 Lua script 來提供 [atomic operation](https://en.wikipedia.org/wiki/Linearizability) 的特性、檢查 value 真的是我 `SET` 的才 release lock
:::
- 以下為正確做法的循序圖:
```mermaid
sequenceDiagram
participant App
participant Redis
Note over App: generate TOKEN_FOO
App->>Redis: acquire lock (SET foo TOKEN_FOO NX EX 30)
Redis->>App: return OK
Note over App: do things...
App->>Redis: rety to release lock (via sending a Lua script)
Note over Redis: check the foo's value if is TOKEN_FOO
alt
Note over Redis: matched
Redis->>App: return deleting result (1 or 0)
else
Note over Redis: not matched
Redis->>App: return 0
end
```
- 使用的 [Lua script](https://redis.io/commands/set#patterns) 範例
```lua=
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
```
## Bloom Filter Pattern
- 有遇到再深入了解 [Redis Best Practices - Bloom Filter Pattern](https://redis.com/redis-best-practices/bloom-filter-pattern/)
## Further reading
- [Redis Best Practices - official articles](https://redis.com/redis-best-practices/introduction/)
- [AmazingTalker 微服務化-快取服務](https://medium.com/amazingtalker-tech/amazingtalker-%E5%BE%AE%E6%9C%8D%E5%8B%99%E5%8C%96-%E5%BF%AB%E5%8F%96%E6%9C%8D%E5%8B%99-1f22f94b93ea)