Golang | 本地缓存原理总结与选型对比
引言
在服务端程序当中,缓存作为一种应对高并发的方法,通常是必不可少的, 本文从使用缓存的意义开始
- 介绍了Golang中的本地缓存
freeCache
和bigCache
- 分析了他们的优缺点
- 列举了一些读者可能思考的问题
- 结合他们的设计思想,总结了设计本地缓存时需要注意的地方
- 最后对他们做了性能测试
为什么需要缓存
缓存的优势
- 读写数据存储在内存中,读写速度比磁盘快
- 通常引入缓存能够保护持久化数据库(如MySQL)在高并发场景下被打垮
引入缓存会带来的问题
- 提升系统复杂程度,降低可维护性,增加排查问题的难度
- 需要投入更多的精力去保证缓存数据一致性
- 可能会随之引入缓存穿透、缓存雪崩等问题
缓存的分类
缓存系统按照缓存数据的存储方式可以分为本地缓存和集中式缓存两种。
- 本地缓存: 是指将本地的物理内存划分出一部分空间用来缓冲程序写到服务器的数据
- 集中式缓存: 缓存数据保存在专门的缓存服务器中,应用服务器通过网络请求从缓存服务器获取缓存数据。目前企业中最常用的集中式缓存系统分别是
Redis
和Memcached
本地缓存与集中式缓存的对比
- 本地缓存直接读写本机内存,读写效率较高。读写集中式缓存不仅消耗缓存中间件服务器本身的IO ,同时需要网络IO ,时间消耗相对较高
- 本地缓存与服务本身耦合严重,不利于扩展性。同时使用本机内存,可能对本机服务有影响,存在线程安全的问题
- 本地缓存通常指针对单机,集中式缓存可以支持多服务器
- 集中式缓存的数据是集中管理的,这样就能保证所有应用服务器取到的数据是一致的。而本地缓存的数据是由每个应用服务器独自保存的
为什么有了集中式缓存还要引入本地缓存
- 由于集中式缓存会同时处理多个服务的请求,实际请求量较高,因此引入本地缓存,
降低对集中式缓存服务器的压力
- 本地缓存的读写速度通常快于集中式缓存
什么场景下可以使用本地缓存
- 缓存中间件服务器负载过高
- 数据不太会发生更新
- 对读写性能要求很高
比较几种Golang开源的本地缓存
freecache | bigcache | fastcache | |
---|---|---|---|
适用场景 | 读多写少 | 读多写少 | 读多写少 |
社区活跃度 | |||
存储数据限制 | 初始化时分配内存,不可扩容 | 每个shard数据的queue可以自动扩容 | 初始化时分配内存,不可扩容 |
过期时间支持 | 支持 | 支持 | 支持 |
Key淘汰机制 | 近LRU(LRU+移动次数(Set触发)) | 定时清理 + FIFO(Set) | FIFO(类环形队列) |
Gc | 0GC | 0GC | 0GC |
Gc优化原理 | map非指针优化(通过slice实现map) | map[uint64]uint32 | map[uint64]uint32堆外申请内存 |
锁机制 | 分片+互斥锁 | 分片+读写锁 | 分片+读写锁 |
局限 | bucket空闲分配后无法更改 | 无法对单key设置过期时间 | 无过期时间 |
支持迭代器 |
针对GC优化的方式
- 无GC:分配堆外内存(Mmap)
- 避免GC: map非指针优化(map[uint64]uint32)或者采用slice实现一套无指针的map
- 避免GC:数据存入[]byte slice(环形队列封装循环使用空间)
其中 map非指针优化是Go1.5
版本中引入的一种优化: 如果在map
的键和值中使用没有指针
, 那么GC将忽略它
freecache
介绍
- freecache底层会将数据分成256个
segment
,每个segment持有一把互斥锁, 每次读写的时候仅对对应的segment
加锁, 目的是减少锁粒度 ,类似Java中的ConcurrentHashMap
- 在每个segment中,索引和数据使用两个切片slotData和RingBuf分开存储。
- 数据部分是由RingBuf切片存储的, 它是一个
环形数组
。每个数据块以entry
的形式存放在环形数组中,每个entry
占用大小(header头24字节+key + value ), 其中header
头部存储了entry
的过期时间(由此实现时间entry级的过期设置)、entry对应的value大小以及soltId - 索引部分使用 slotData切片存储,每个segment被逻辑上切分成256个slot,每个solt上的entry会按照hash16(hashVal >> 16)顺序排列,便于
二分查找
定位entry索引
如何实现0GC: 使用map非指针优化
- freecache的指针是固定的,只有512个,每个segment有2个,分别是
ringbuf
和slotData
- 对于map而言,gc时会扫描所有key/value键值对,如果其都是基本类型,那么gc便不会再扫描
架构图
Get
流程图
代码分析
对freecache的get方法做简单的分析
首先看对外暴露的Get
方法
func (cache *Cache) Get(key []byte) (value []byte, err error) {
hashVal := hashFunc(key)
// 1. 定位segmentId
segID := hashVal & segmentAndOpVal
// 2. 加互斥锁
cache.locks[segID].Lock()
// 3. 从对应的segment中,根据key读取数据
value, _, err = cache.segments[segID].get(key, nil, hashVal, false)
cache.locks[segID].Unlock()
return
}
接着跟进get
方法
看到方法内主要调用了 locate
和segment.ringbug.ReadAt
方法,我们依次跟进
func (seg *segment) get(key, buf []byte, hashVal uint64, peek bool) (value []byte, expireAt uint32, err error) {
hdr, ptr, err := seg.locate(key, hashVal, peek)
if err != nil {
return
}
expireAt = hdr.expireAt
if cap(buf) >= int(hdr.valLen) {
value = buf[:hdr.valLen]
} else {
value = make([]byte, hdr.valLen)
}
seg.rb.ReadAt(value, ptr.offset+ENTRY_HDR_SIZE+int64(hdr.keyLen))
if !peek {
atomic.AddInt64(&seg.hitCount, 1)
}
return
}
locate
方法主要做了四件事情
- 对slotsData二分查找 找到entry的索引idx
- 判断entry是否过期
- 更新访问时间
- 返回entry.Header信息
func (seg *segment) locate(key []byte, hashVal uint64, peek bool) (hdr *entryHdr, ptr *entryPtr, err error) {
// 1. 计算slotId 和hash16
slotId := uint8(hashVal >> 8)
hash16 := uint16(hashVal >> 16)
slot := seg.getSlot(slotId)
// 2. 对slotsData二分查找 找到entry的索引idx .虽然此处是二分查找,但slotdData()长度基本为256,因此认为时间复杂度是O(1)
idx, match := seg.lookup(slot, hash16, key)
if !match {
err = ErrNotFound
if !peek {
atomic.AddInt64(&seg.missCount, 1)
}
return
}
// 3. 拿到entry对应的slot
ptr = &slot[idx]
var hdrBuf [ENTRY_HDR_SIZE]byte
// 4. 从ringbuf中 从offset开始,将entry header写入hdrBuf中(24 bytes)
seg.rb.ReadAt(hdrBuf[:], ptr.offset)
// 5. 指针强转成entryHeader结构体
hdr = (*entryHdr)(unsafe.Pointer(&hdrBuf[0]))
if !peek {
now := seg.timer.Now()
// 6. 过期判断
if hdr.expireAt != 0 && hdr.expireAt <= now {
// entry已过期,删除entry
err= notFountd
return
}
// 7. 更新数据(访问时间)
seg.rb.WriteAt(hdrBuf[:], ptr.offset)
}
return hdr, ptr, err
}
lookup
方法会在entry存在时返回entry索引,entry不存在时,返回第一个大于hash16(entry)的索引
这一块有个隐蔽的点我们注意到,locate
方法实际会返回一个error,但在get
方法中,error被忽略了
我们接着看 readAt 方法
readAt
方法主要是 从 offset偏移量 + header头大小
的位置开始,读 entry头部设置的key长度(entry.Header.KeyLen)
个字节的数据到字节数组p
中
func (rb *RingBuf) ReadAt(p []byte, off int64) (n int, err error) {
if off > rb.end || off < rb.begin {
err = ErrOutOfRange
return
}
readOff := rb.getDataOff(off)
readEnd := readOff + int(rb.end-off)
if readEnd <= len(rb.data) {
n = copy(p, rb.data[readOff:readEnd])
} else {
n = copy(p, rb.data[readOff:])
if n < len(p) {
n += copy(p[n:], rb.data[:readEnd-len(rb.data)])
}
}
if n < len(p) {
err = io.EOF
}
return
}
总结
我们来总结一下get方法的流程
- 计算key对应的segment ,并加
互斥锁
- 计算key在segment中的entry的index,拿到entry的的slotId。 并通过指针强转的方式得到entry.Header,优化执行效率
- 根据slot在ringbuf中的偏移量offset,结合entry头信息中的key的长度,拿到返回值value。
Set
流程图
代码分析
func (seg *segment) set(key, value []byte, hashVal uint64, expireSeconds int) (err error) {
// 1. 校验key && value 大小
if len(key) > 65535 {
return ErrLargeKey
}
// 每个freecache有256个segment,因此每个entry大小不能超过 freecache内存大小的1/1024
maxKeyValLen := len(seg.rb.data)/4 - ENTRY_HDR_SIZE
if len(key)+len(value) > maxKeyValLen {
// Do not accept large entry.
return ErrLargeEntry
}
now := seg.timer.Now()
expireAt := uint32(0)
if expireSeconds > 0 {
expireAt = now + uint32(expireSeconds) // 如果set时候设置过期时间为0,永不过期
}
slotId := uint8(hashVal >> 8)
hash16 := uint16(hashVal >> 16)
slot := seg.getSlot(slotId)
// 2. 二分查找 找到entry的索引idx,如果entry不存在,返回的是第一个大于hash16(entry)的索引
idx, match := seg.lookup(slot, hash16, key)
var hdrBuf [ENTRY_HDR_SIZE]byte
// 指针强转得到header头信息
hdr := (*entryHdr)(unsafe.Pointer(&hdrBuf[0]))
// entry是否存在
if match {
// entry存在 ,拿到entry对应的slot ,更新expireAt、KeyLen等信息
// dosomething
// hdr空间是否充足
if hdr.valCap >= hdr.valLen {
// *** 空间充足 覆盖原来的entry头部和value 直接返回 ,时间复杂度O(1)
return
}
// *** 空间不足的情况下,懒删除原entry ,随后在ringbuf尾部append新entry ,时间复杂度O(1)
seg.delEntryPtr(slotId, slot, idx)
match = false
// increase capacity and limit entry len.
for hdr.valCap < hdr.valLen {
hdr.valCap *= 2
}
if hdr.valCap > uint32(maxKeyValLen-len(key)) {
hdr.valCap = uint32(maxKeyValLen - len(key))
}
} else {
// entry不存在,构建新entry
}
entryLen := ENTRY_HDR_SIZE + int64(len(key)) + int64(hdr.valCap)
// *** 数据驱逐 ,作用是腾出足够的内存,时间复杂度O(1)
slotModified := seg.evacuate(entryLen, slotId, now)
if slotModified {
slot = seg.getSlot(slotId)
idx, match = seg.lookup(slot, hash16, key)
// assert(match == false)
}
newOff := seg.rb.End()
// 更新slotData索引切片 。如果超过256个索引,加倍扩容
seg.insertEntryPtr(slotId, hash16, newOff, idx, hdr.keyLen)
// *** entry append到ringbuf尾部 时间复杂度O(1)
return
}
set为什么效率高
- slot切片上的entry索引是根据hash16值有序排列的,采用二分查找算法进行定位,时间复杂度O(log(len(slotData)))
- 对于key不存在的情况下(找不到entry索引)。
- 如果ringbuf容量充足,则直接将entry追加到环尾,时间复杂度为O(1)。
- 如果ringBuf不充足,执行数据驱逐,整体是驱逐
ringbuf
头的entry
,随后将entry追加到ringbuf尾。时间复杂度也是O(1)。
- 对于已经存在的key(找到entry索引)。
- 如果原来给entry的value预留的容量充足的话,则直接更新原来entry的头部和value,时间复杂度为O(1)。
- 如果原来给entry的value预留的容量不足的话,freecache为了避免移动底层数组数据,不直接对原来的entry进行扩容,而是将原来的entry标记为删除(懒删除),然后在环形缓冲区ringBuf的环尾追加新的entry,时间复杂度为O(1)。
evacuate
evacuate
会在 set
方法,向ringbuf中追加数据时被调用。使用一个近似LRU算法进行数据的置换,同时仅在 evacuate 方法中,会将key占用空间真的清理
流程图
源码分析
func (seg *segment) evacuate(entryLen int64, slotId uint8, now uint32) (slotModified bool) {
// RingBuf容量不足的情况
for seg.vacuumLen < entryLen {
oldOff := seg.rb.End() + seg.vacuumLen - seg.rb.Size()
seg.rb.ReadAt(oldHdrBuf[:], oldOff)
oldHdr := (*entryHdr)(unsafe.Pointer(&oldHdrBuf[0]))
oldEntryLen := ENTRY_HDR_SIZE + int64(oldHdr.keyLen) + int64(oldHdr.valCap)
// entry是否被标记删除
if oldHdr.deleted {
// 移动次数记为0
consecutiveEvacuate = 0
atomic.AddInt64(&seg.totalTime, -int64(oldHdr.accessTime))
atomic.AddInt64(&seg.totalCount, -1)
seg.vacuumLen += oldEntryLen
continue
}
// entry是否过期
expired := oldHdr.expireAt != 0 && oldHdr.expireAt < now
// LRU entry最近使用情况
leastRecentUsed := int64(oldHdr.accessTime)*atomic.LoadInt64(&seg.totalCount) <= atomic.LoadInt64(&seg.totalTime)
if expired || leastRecentUsed || consecutiveEvacuate > 5 {
// entry如果已经过期了,或者满足置换条件,则删除掉entry
} else {
// 如果不满足置换条件,则将entry从环头调换到环尾
newOff := seg.rb.Evacuate(oldOff, int(oldEntryLen))
// 更新entry的索引
seg.updateEntryPtr(oldHdr.slotId, oldHdr.hash16, oldOff, newOff)
}
}
return
}
思考
为什么value不存interface{}而存[]byte
通常我们拿到[]byte后还要将其转成我们的 struct{}
,这样更消耗我们的CPU ,为什么不直接将value设置为interface{}
类型呢?
- 在内存中存储很多interface{}会造成很大的 GC 影响, GC 开销与长寿命对象的数量成正比。
- 相关github-issue: [github-issue](github.com/coocood/fre…
- 这篇博客解释了这个回答: go-dev
为什么每个entry大小不能超过freecache的1/1024 而不是每个段segment 即1/256
-
作者认为太大的value不适合存储。github-issue
-
个人认为如果数据太大,会占用较大的ringbuf空间,影响相同segment中其他entry的使用 。至于为什么是 1 / 256 / 4 ,这个 4 我认为是作者经过不同数据集的测试得到的最优的结果。
freecache有考虑内存对齐么
- 观察 segment结构,存在一个
uint32
大小的属性,适用于32位系统下的内存对齐 github-issue
为什么要用互斥锁不用读写锁
- 我们可以发现,freecache在读取的时候也会发生数据的修改,因此必须用互斥锁.github-issue 我们可以发现,在读取key的时候,会修改一次entry的访问时间,那么我们是否可以避免这个操作进而使用读写锁呢?
freecache的github也有一个类似的提案,作者给出了回复
- 在几乎没有竞争的情况下,读写锁可能比互斥锁更慢
- 因为读写锁在操作的时候比互斥锁做了更多的判断
- github-issue
总结
- 更新slotData索引切片的长度时,是直接加倍, 然后做索引迁移 ,直接做索引迁移会带来一定的性能开销 、 内存抖动
- 需要一次性申请所有缓存空间。用于实现segment的RingBuf切片,从缓存被创建之后容量不可变,申请的内存会一直被占用,空间换时间。
- freecache的entry置换算法不是完全LRU,而且在某些情况下,可能会把最近经常被访问的缓存置换出去。
- entry过期后不会主动清除内存,这些entry会继续保存
slotData
切片中,会加速slotData
索引切片的扩容 - 每个entry的大小不能超过为freecache分配内存的1/1024
bigcache
介绍
bigcache以快 高并发 高性能著称,底层采用cacheShard
分片的思想,每个分片有各自的读写锁 ,大幅降低锁粒度
cacheShard
核心存储结构中 map
的key
和 value
中均不包含指针类型数据 其中key
是一个fnv64a
哈希值, value
数据结构采用 byte
切片。从宏观上看像是索引与数据分离
存储的 map
结构
每个要插入的 key-value 由 5
部分组成,分别是:
- 时间戳 (8 byte):插入的时间戳
- key 的 hash 值 (5 byte)
- key 的长度 (2 byte)
- key 的值以及 value 的值:
根据各自的长度申请
bigCache
的结构就是在原 value 的前面加多一个 header
, 为了提高效率使用 binary
库直接操作 []byte
。如果要进行查找和删除数据可以通过 map
找到数据保存的位置。
结构
type BigCache struct {
shards []*cacheShard // 缓存分片数据
lifeWindow uint64
clock clock // 时间计算函数
hash Hasher // hash 函数,在计算Key的Hash使用
config Config // Cache 配置
shardMask uint64 // 寻找分片位置时使用
maxShardSize uint32
close chan struct{}
}
type cacheShard struct {
hashmap map[uint64]uint32 // 索引列表,对应KEY在entries中的位置
entries queue.BytesQueue // 实际数据存储
lock sync.RWMutex
entryBuffer []byte
onRemove onRemoveCallback
isVerbose bool
logger Logger
clock clock
lifeWindow uint64
stats Stats
}
// 存储数据的字节队列
type BytesQueue struct {
array []byte // 一个字节数组,实际数据存储的地方
capacity int // 容量
maxCapacity int // 最大容量
head int
tail int // 下次可以插入 item 的位置
count int // 当前插入的 item 数量
rightMargin int
headerBuffer []byte // 插入前做临时 buffer 所用(slice-copy)
verbose bool // 打印 log 开关
initialCapacity int // BytesQueue 创建时,array 的初始容量
}
get
流程图
代码分析
bigcache的Get十分简单
- 获得分片
cacheShard
,对分片加锁 - 从
cacheShard
中读取key对应位置entry,校验传入的key和entry中的key
- 传入的key和entry中的key不同,返回错误
- 传入的key和entry中的key相同,从entry中读value ,返回值
func (c *BigCache) Get(key string) ([]byte, error) {
hashedKey := c.hash.Sum64(key)
shard := c.getShard(hashedKey)
return shard.get(key, hashedKey)
}
func (s *cacheShard) get(key string, hashedKey uint64) ([]byte, error) {
wrappedEntry, err := s.getWrappedEntry(hashedKey)
if err != nil { return nil, err
}
// 从entry中读key ,校验entry中的key和传入的key是否相同
if entryKey := readKeyFromEntry(wrappedEntry); key != entryKey {
s.lock.RUnlock()
return nil, ErrEntryNotFound
}
// entry中读value
entry := readEntry(wrappedEntry)
return entry, nil
}
set
流程图
代码分析
整体来说bigcache的赋值操作也不复杂 。
- 冲突检查,如果key已经存在,将key的value置为空
- 检查
bytesQueue
头部entry是否过期,如果过期则删除 - 根据入参组装
新entry
- 尝试将新entry推到
bytesQueue队尾
- 如果内存足够,entry直接放入队尾
- 如果内存不够,尝试删除队头的entry,并再次尝试步骤 4
func (s *cacheShard) set(key string, hashedKey uint64, entry []byte) error {
currentTimestamp := uint64(s.clock.Epoch())
s.lock.Lock()
// 冲突检查,将之前的key对应value置为空
if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 {
if previousEntry, err := s.entries.Get(int(previousIndex)); err == nil {
resetKeyFromEntry(previousEntry)
delete(s.hashmap, hashedKey)
}
}
// 每次插入新数据时,bigCache 都会获取 BytesQueue 头部数据,
if oldestEntry, err := s.entries.Peek(); err == nil {
// 然后判断数据是否过期,如果过期则删除
s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry)
}
// 设置缓存的值,使用 wrapEntry 对用户传入的 key 和 value 进行序列化
w := wrapEntry(currentTimestamp, hashedKey, key, entry, &s.entryBuffer)
// 设置缓存的值,使用 wrapEntry 对用户传入的 key 和 value 进行序列化
for {
// Push 会判断是不是有足够的空间,没有,则返回 err。如果有,则将entry复制到queue队尾
if index, err := s.entries.Push(w); err == nil {
s.hashmap[hashedKey] = uint32(index)
s.lock.Unlock()
return nil
}
// 没有足够内存空间,尝试删除bytesQueue头节点的数据
if s.removeOldestEntry(NoSpace) != nil {
s.lock.Unlock()
return fmt.Errorf("entry is bigger than max shard size")
}
}
}
- set的entry内存是在push方法中分配的
delete
对应的key的删除其实就是把索引置为0 ,value置为空数据,并没有实际回收内存
func (s *cacheShard) del(hashedKey uint64) error {
s.lock.RLock()
{
itemIndex := s.hashmap[hashedKey]
// 检查index 是否是合法的 .如果不合法,直接返回
if err := s.entries.CheckGet(int(itemIndex)); err != nil {
return err
}
}
{
itemIndex := s.hashmap[hashedKey]
wrappedEntry, err := s.entries.Get(int(itemIndex))
if err != nil {
return err
}
// 删除索引 ,同时将value置空 ,没有回收内存
delete(s.hashmap, hashedKey)
// 删除时的回调函数
s.onRemove(wrappedEntry, Deleted)
if s.statsEnabled {
delete(s.hashmapStats, hashedKey)
}
resetKeyFromEntry(wrappedEntry)
}
return nil
}
缓存过期
bigCache 可以为插入的数据设置过期时间,但是缺点是所有数据的过期时间都是一样的。 bigCache 中自动删除数据有两种场景:
- 在插入数据时删除过期数据
- 通过设置
CleanWindow
,启动goroutine后台定时批量
删除过期数据
func newBigCache(config Config, clock clock) (*BigCache, error) {
// ***
if config.CleanWindow > 0 {
go func() {
ticker := time.NewTicker(config.CleanWindow)
defer ticker.Stop()
for {
select {
case t := <-ticker.C:
cache.cleanUp(uint64(t.Unix()))
case <-cache.close:
return
}
}
}()
}
return cache, nil
}
func (c *BigCache) cleanUp(currentTimestamp uint64) {
for _, shard := range c.shards {
shard.cleanUp(currentTimestamp)
}
}
func (s *cacheShard) cleanUp(currentTimestamp uint64) {
s.lock.Lock()
for {
if oldestEntry, err := s.entries.Peek(); err != nil {
break
} else if evicted := s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry); !evicted {
break
}
}
s.lock.Unlock()
}
func (s *cacheShard) onEvict(oldestEntry []byte, currentTimestamp uint64, evict func(reason RemoveReason) error) bool {
oldestTimestamp := readTimestampFromEntry(oldestEntry)
if currentTimestamp-oldestTimestamp > s.lifeWindow {
evict(Expired)
return true
}
return false
}
不足
- bigcache删除数据的机制会导致bytesQueue出现很多
内存空洞
,并且这些内存空洞只有等到bytesQueue内存不够用
,执行内存清理的时候,才能把这些空洞“删除”掉 - 无法指定当个entry的过期时间
bigcache和freecache的比较
- bigcache使用map[uint64]uint32 ,freecache索引结构使用slice,并且定位索引
slotData
时有一个常数级的二分查找O(log256) - 锁粒度更细
- 读写锁性能优于互斥锁
- 注释写的少减少编译时间?
map sync.Map freecache bigcache性能数据
github.com/coocood/freecache v1.1.0
github.com/allegro/bigcache/v2 v2.1.3
github.com/allegro/bigcache/v3 v3.0.2 github.com/coocood/freecache v1.2.1
性能对比
- 整体来看,
bigCache
性能优于freeCache
如何选择
- 结合场景
- 如果需要对单个key设置过期时间,选择
freecache
- 如果不注重单个key过期时间,更注重性能,选择
bigcache
设计本地缓存需要注意的地方
- 分片+分段锁
- GC优化: uint 来做gc优化
- 底层数据结构的选型(Map 或者Slice实现O(1)的查找 )
- 数据的高效定位(hash + 连续内存地址)
- 数据淘汰策略(FIFO ? LRU ?LFU ? && LRU +LFU 结合 && 是否结合懒删除)
- 数据存储([]byte + ringbuf)
- 是否需要支持内存扩容 && 内存扩容的机制(时机) && 过程(快 + 稳)
总结
-
在程序开发过程中,我们不仅仅要学会使用某种组件,还要了解其优缺点以及同类产品的优缺点
-
理解每种程序的设计思想,能够侧面帮助我们设计出更好的程序,同时学习更多高效的编码方式
-
缓存能够大幅提升系统的并发数,但并不是解决问题的银弹,引入缓存的同时会
提升系统复杂度
,数据一致性会更难保证
,因此我们要在使用缓存之前思考引入缓存的意义
,解决了什么实际问题
,能够带来的价值,以及随之引入的问题
,最后做综合分析
转载自:https://juejin.cn/post/7101994349252050980