在高并发的业务场景中,缓存是提升系统性能的重要手段,但并发场景下的缓存更新很容易出现数据不一致的问题,比如多个请求同时更新缓存和数据库,可能导致缓存中的数据与数据库实际数据不匹配,同时不合理的更新逻辑还会带来额外的性能损耗。如何在Golang中实现并发缓存更新,同时保证数据一致性和系统性能,是很多开发者需要解决的问题。

常见的并发缓存更新策略
要实现并发场景下的缓存更新,首先需要选择合适的更新策略,不同的策略对一致性和性能的影响差异很大。
1. 先更新数据库再删除缓存
这种策略的核心逻辑是先操作数据源,再处理缓存,避免缓存中留存旧数据。它的优势是实现简单,出现不一致的概率较低,适合大多数业务场景。
2. 先更新缓存再更新数据库
这种策略先更新缓存再操作数据库,虽然能快速返回结果,但如果数据库更新失败,就会出现缓存和数据库数据不一致的问题,一般不推荐在高一致性要求的场景使用。
3. 缓存过期兜底策略
无论采用哪种更新策略,都可以给缓存设置合理的过期时间,即使出现短暂的不一致,过期后缓存会自动失效,从数据库加载最新数据,作为最后的兜底保障。
基于Golang的实现方案
下面以先更新数据库再删除缓存的策略为例,给出具体的Golang实现代码,同时考虑并发场景下的锁机制和性能优化。
基础实现(带互斥锁)
首先定义一个简单的缓存结构和数据库模拟结构,使用互斥锁保证更新操作的原子性,避免并发更新带来的问题。
package main
import (
"fmt"
"sync"
"time"
)
// 模拟数据库存储
var dbData = make(map[string]string)
// 模拟缓存存储
var cache = make(map[string]string)
// 互斥锁,保证更新操作的并发安全
var mu sync.Mutex
// 更新数据:先更新数据库,再删除缓存
func updateData(key, value string) error {
mu.Lock()
defer mu.Unlock()
// 第一步:更新数据库
dbData[key] = value
fmt.Printf("数据库更新完成,key: %s, value: %sn", key, value)
// 第二步:删除缓存
delete(cache, key)
fmt.Printf("缓存删除完成,key: %sn", key)
return nil
}
// 查询数据:先查缓存,缓存不存在再查数据库并回写缓存
func queryData(key string) string {
// 先查缓存
if val, ok := cache[key]; ok {
fmt.Printf("从缓存获取数据,key: %s, value: %sn", key, val)
return val
}
// 缓存不存在,查数据库
if val, ok := dbData[key]; ok {
// 回写缓存,设置1分钟过期
cache[key] = val
fmt.Printf("从数据库获取数据并回写缓存,key: %s, value: %sn", key, val)
return val
}
return ""
}
func main() {
// 初始化数据库数据
dbData["user_1"] = "张三"
// 模拟并发更新
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
updateData("user_1", fmt.Sprintf("张三_%d", i))
}(i)
}
wg.Wait()
// 查询数据
queryData("user_1")
}
性能优化:使用单飞机制避免缓存击穿
上面的基础实现虽然保证了并发安全,但如果多个请求同时查询一个不存在的缓存,会同时去查询数据库,造成数据库压力,也就是缓存击穿问题。可以使用Golang的singleflight包实现单飞机制,同一个key的并发请求只让一个去查询数据库,其他请求等待结果即可。
package main
import (
"fmt"
"golang.org/x/sync/singleflight"
"sync"
"time"
)
var dbData = make(map[string]string)
var cache = make(map[string]string)
var mu sync.Mutex
// 单飞组,用于合并相同key的并发请求
var flight singleflight.Group
func updateData(key, value string) error {
mu.Lock()
defer mu.Unlock()
dbData[key] = value
delete(cache, key)
return nil
}
func queryDataWithSingleFlight(key string) string {
// 先查缓存
if val, ok := cache[key]; ok {
fmt.Printf("缓存命中,key: %s, value: %sn", key, val)
return val
}
// 缓存未命中,使用单飞机制查询数据库
val, err, _ := flight.Do(key, func() (interface{}, error) {
// 再次检查缓存,避免重复查询
mu.Lock()
if v, ok := cache[key]; ok {
mu.Unlock()
return v, nil
}
mu.Unlock()
// 查数据库
if v, ok := dbData[key]; ok {
// 回写缓存
mu.Lock()
cache[key] = v
mu.Unlock()
fmt.Printf("单飞查询数据库并回写缓存,key: %s, value: %sn", key, v)
return v, nil
}
return "", fmt.Errorf("data not found")
})
if err != nil {
return ""
}
return val.(string)
}
func main() {
dbData["user_2"] = "李四"
// 模拟10个并发请求查询同一个key
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
queryDataWithSingleFlight("user_2")
}()
}
wg.Wait()
}
不同方案的对比
可以根据实际业务场景选择合适的实现方案,下面是两种方案的对比:
| 方案 | 数据一致性 | 性能 | 适用场景 |
|---|---|---|---|
| 基础互斥锁方案 | 高 | 一般 | 并发量不高,一致性要求高的场景 |
| 单飞+互斥锁方案 | 高 | 高 | 并发量高,存在热点key查询的场景 |
注意事项
- 更新数据库和删除缓存的操作尽量放在同一个事务或者锁范围内,避免中间出现异常导致不一致。
- 缓存的过期时间不要设置过长,避免不一致的时间窗口太大,也不要设置过短,否则会增加数据库查询压力。
- 如果业务对一致性要求极高,可以考虑使用分布式锁替代本地互斥锁,适配多实例部署的场景。