go zero 实践:缓存一致性保证、缓存击穿、缓存穿透与缓存雪崩解决方案
缓存 作为一种重要的技术手段,可以有效提高系统的响应速度,降低对数据库的压力。但是缓存的使用伴随一些常见问题,如缓存一致性、缓存击穿、缓存穿透和缓存雪崩。下面我们将结合 go zero 框架,深入剖析这些问题的概念以及对应的解决方案。
一、项目构建
本文项目都基于下面的文件构建,通过文章的增删改查,来演示缓存实践
1.SQL
CREATE TABLE `article` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题' COLLATE 'utf8mb4_bin',
`content` TEXT NOT NULL COMMENT '内容' COLLATE 'utf8_unicode_ci',
`cover` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封面' COLLATE 'utf8mb4_bin',
`description` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '描述' COLLATE 'utf8mb4_bin',
`author_id` BIGINT UNSIGNED NOT NULL DEFAULT '0' COMMENT '作者ID',
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '状态 0:待审核 1:审核不通过 2:可见 3:用户删除',
`comment_num` INT NOT NULL DEFAULT '0' COMMENT '评论数',
`like_num` INT NOT NULL DEFAULT '0' COMMENT '点赞数',
`collect_num` INT NOT NULL DEFAULT '0' COMMENT '收藏数',
`view_num` INT NOT NULL DEFAULT '0' COMMENT '浏览数',
`share_num` INT NOT NULL DEFAULT '0' COMMENT '分享数',
`tag_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标签ID' COLLATE 'utf8mb4_bin',
`publish_time` TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP) COMMENT '发布时间',
`create_time` TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP) COMMENT '创建时间',
`update_time` TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP) ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `ix_author_id` (`author_id`) USING BTREE,
INDEX `ix_update_time` (`update_time`) USING BTREE
)
COMMENT='文章表'
COLLATE='utf8mb4_bin'
ENGINE=InnoDB
AUTO_INCREMENT=103
;
2.API文件
syntax = "v1"
type (
Token {
AccessToken string `json:"access_token"`
AccessExpire int64 `json:"access_expire"`
}
VerificationRequest {
Mobile string `json:"mobile"`
}
VerificationResponse {}
RegisterRequest {
Name string `json:"name"`
Mobile string `json:"mobile"`
Password string `json:"password"`
VerificationCode string `json:"verification_code"`
}
RegisterResponse {
UserId int64 `json:"user_id"`
Token Token `json:"token"`
}
LoginRequest {
Mobile string `json:"mobile"`
Password string `json:"password"`
VerificationCode string `json:"verification_code"`
}
LoginResponse {
UserId int64 `json:"userId"`
Token Token `json:"token"`
}
UserInfoResponse {
UserId int64 `json:"user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
)
@server (
prefix: /v1
)
service user-api {
@handler RegisterHandler
post /register (RegisterRequest) returns (RegisterResponse)
@handler VerificationHandler
post /verification (VerificationRequest) returns (VerificationResponse)
@handler LoginHandler
post /login (LoginRequest) returns (LoginResponse)
}
@server (
prefix: /v1/user
signature: true
jwt: Auth
)
service user-api {
@handler UserInfoHandler
get /info returns (UserInfoResponse)
}
3.PROTO文件
syntax = "proto3";
package userpb;
option go_package="./userpb";
service User {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc FindById(FindByIdRequest) returns (FindByIdResponse);
rpc FindByMobile(FindByMobileRequest) returns (FindByMobileResponse);
rpc SendSms(SendSmsRequest) returns (SendSmsResponse);
}
message RegisterRequest {
string username = 1;
string mobile = 2;
string avatar = 3;
string password = 4;
}
message RegisterResponse {
int64 userId = 1;
}
message FindByIdRequest {
int64 userId = 1;
}
message FindByIdResponse {
int64 userId = 1;
string username = 2;
string password =3;
string mobile = 4;
string avatar = 5;
}
message FindByMobileRequest {
string mobile = 1;
}
message FindByMobileResponse {
int64 userId = 1;
string username = 2;
string password =3;
string mobile = 4;
string avatar = 5;
}
message SendSmsRequest {
int64 userId = 1;
string mobile = 2;
}
message SendSmsResponse {
string code =1;
}
二、缓存一致性
1.概念
缓存一致性 指缓存数据与数据库中的数据保持一致性。如果缓存数据过时或被修改后未及时更新,可能导致业务逻辑错误。
2.现象
- 缓存中存储了过期数据,而数据库已经更新,导致查询结果不一致。
- 多服务环境下,缓存与数据库之间的数据同步问题尤为显著。
3.解决方案
读操作:
- 先从缓存读取数据。
- 如果缓存中存在,直接返回。
- 如果缓存中不存在(缓存未命中),则从数据库查询,返回结果后将数据写入缓存。
写操作(更新、删除):
- 先更新数据库中的数据。
- 再删除或更新缓存中的数据,保证缓存中的数据是最新的。
4.代码演示
go-zero 除了提供 sqlx.SqlConn, 我们也提供了一个 sqlc.CachedConn 的封装,用于sql 数据库缓存的支持。
当我们使用goctl model -c
生成model的代码,model的方法都带有缓存管理,所以我们不需要对单独的数据做缓存处理,我们使用Redis
的 有序集合
,来做文章列表的缓存。
发布文章
下面通过文章的发布,向有序集合写入缓存(即向文章列表缓存添加文章信息),来演示缓存的一致性:
func (l *PublishLogic) Publish(in *pb.PublishRequest) (*pb.PublishResponse, error) {
// todo: add your logic here and delete this line
if in.UserId <= 0 {
return nil, errors.New("用户ID不合法")
}
if len(in.Title) == 0 || len(in.Content) == 0 {
return nil, errors.New("标题或者文章内容不能为空")
}
//文章数据插入数据库
//调用Insert方法,自动写入行缓存
result, err := l.svcCtx.ArticleModel.Insert(l.ctx, &model.Article{
Title: in.Title,
Content: in.Content,
AuthorId: uint64(in.UserId),
Cover: "",
TagIds: "",
Status: 1, // 0 未发布 1 代表已发布 2 待审核 3 仅自己可见
PublishTime: time.Now(),
CreateTime: time.Now(),
UpdateTime: time.Now(),
})
if err != nil {
return nil, err
}
//获取插入后的文章 ID
articleId, err := result.LastInsertId()
if err != nil {
return nil, errors.New("返回ID失败")
}
//缓存键生成
// 0为发布时间排序 1为点赞数排序 -默认按发布时间排序
//"biz#articles#ID#SortType"
publishTimeKey := fmt.Sprintf("biz#articles#%d#0", in.UserId)
likeNumKey := fmt.Sprintf("biz#articles#%d#1", in.UserId)
articleIdStr := strconv.FormatInt(articleId, 10)
//缓存存在性检查与更新
//如果对应的缓存键存在,使用 Redis 的 ZADD 命令将文章 ID 和分数添加到有序集合中。
isExits, _ := l.svcCtx.Rds.ExistsCtx(l.ctx, publishTimeKey)
if isExits {
//Redis Zadd 命令用于将一个或多个成员元素及其分数值加入到有序集当中。
_, err := l.svcCtx.Rds.ZaddCtx(l.ctx, publishTimeKey, time.Now().Unix(), articleIdStr)
if err != nil {
return nil, err
}
}
isExits, _ = l.svcCtx.Rds.ExistsCtx(l.ctx, likeNumKey)
if isExits {
//Redis Zadd 命令用于将一个或多个成员元素及其分数值加入到有序集当中。
_, err := l.svcCtx.Rds.ZaddCtx(l.ctx, likeNumKey, time.Now().Unix(), articleIdStr)
if err != nil {
return nil, err
}
}
return &pb.PublishResponse{ArticleId: articleId}, nil
}
删除文章
func (l *ArticleDeleteLogic) ArticleDelete(in *pb.ArticleDeleteRequest) (*pb.ArticleDeleteResponse, error) {
// todo: add your logic here and delete this line
if in.UserId <= 0 {
return nil, errors.New("用户ID不合法")
}
if in.ArticleId <= 0 {
return nil, errors.New("文章ID不合法")
}
//判断文章ID是否存在
article, err := l.svcCtx.ArticleModel.FindOne(l.ctx, uint64(in.ArticleId))
if err != nil {
return nil, err
}
//检查是否是自己的文章
if article.AuthorId != uint64(in.UserId) {
return nil, errors.New("您没有权限删除该文章")
}
//删除采用软删除,修改文章状态为4 不可见状态,
article.Status = 4
err = l.svcCtx.ArticleModel.Update(l.ctx, article)
if err != nil {
return nil, err
}
//从有序集合中删除该条文章缓存
publishTimeKey := fmt.Sprintf("biz#articles#%d#0", in.UserId)
likeNumKey := fmt.Sprintf("biz#articles#%d#1", in.UserId)
//Redis Zrem 命令用于移除有序集中的一个或多个成员,不存在的成员将被忽略。
//删除不需要检查是否存在,因为不存在也不会报错
l.svcCtx.Rds.ZremCtx(l.ctx, publishTimeKey, in.ArticleId)
l.svcCtx.Rds.ZremCtx(l.ctx, likeNumKey, in.ArticleId)
return &pb.ArticleDeleteResponse{}, nil
}
三、缓存击穿
1.概念
缓存击穿 是指当热点数据的缓存过期时,大量并发请求同时查询该数据,导致数据库瞬间负载过高。
2.现象
- 热点数据过期导致大量请求打到数据库,瞬时压力增大。
- 容易导致数据库响应变慢甚至崩溃。
3.解决方案
- 设置热点数据的合理过期时间,每次查询缓存的时候使用Exists来判断key是否存在,如果存在就使用Expire给缓存续期,既然是热点数据通过不断地续期也就不会过期了
- 利用互斥锁:只允许一个请求更新缓存,其他请求等待。
4.代码演示
方法一:延迟缓存过期时间
// 缓存续期函数
func (l *ArticlesLogic) extendCacheExpiration(ctx context.Context, key string) error {
exists, err := l.svcCtx.Rds.ExistsCtx(ctx, key)
if err != nil || !exists {
return err
}
return l.svcCtx.Rds.ExpireCtx(ctx, key, articlesExpire+rand.Intn(60))
}
方法二:加锁
在singleflight 包提供了重复函数调用抑制机制。github.com/golang/groupcache/singleflight
在svc引用singleflight:
type ServiceContext struct {
SingleFlightGroup singleflight.Group
}
如果缓存没有命中,只允许一个请求更新缓存:
/*
......
*/
articlesT, _ := l.svcCtx.SingleFlightGroup.Do(fmt.Sprintf("ArticlesByUserId:%d:%d", in.UserId, in.SortType),
func() (interface{}, error) {
//最大查询200条
//ArticlesByUserId 为自定义方法
return l.svcCtx.ArticleModel.ArticlesByUserId(l.ctx, in.UserId, sortLikeNum, sortPublishTime, sortField, 200)
})
if articlesT != nil {
//将查询结果转换为 []*model.Article 类型
articles = articlesT.([]*model.Article)
}
/*
......
*/
四、缓存穿透
1.概念
缓存穿透 是指查询的数据在缓存和数据库中都不存在,导致每次查询都需要访问数据库。
2.现象
- 恶意用户频繁查询不存在的数据,导致缓存被绕过,数据库压力过大。 恶意用户频繁请求 article🆔99999(数据库和缓存均不存在)
- 这种情况容易造成数据库崩溃。
3.解决方案
- 缓存空值:当数据库中查询结果为空时,将空值缓存起来,避免重复查询数据库。
- 布隆过滤器:使用布隆过滤器拦截无效请求,过滤掉不存在的数据。
4.代码演示
方法一:缓存空值
这部分功能,go zero以及帮我们实现,当我们访问不存在的数据的时候,go-zero框架会帮我们自动加上空缓存,go zero会把不存在的数据的值设置为"*"
方法二:布隆过滤器
布隆过滤器的核心思想是用一个空间高效的位数组快速判断一个元素是否可能存在。如果布隆过滤器认为某个键不存在,则可以直接返回,不再查询缓存或数据库
go zero也为我们提供了 布隆过滤器 ,github.com/zeromicro/go-zero/core/bloom
import (
"fmt"
"github.com/zeromicro/go-zero/core/bloom"
"github.com/zeromicro/go-zero/core/stores/redis"
)
func main() {
redisStore := redis.MustNewRedis(redis.RedisConf{
Host: "redis-16976.c340.ap-northeast-2-1.ec2.redns.redis-cloud.com:16976",
Pass: "lb8ZWuQwJENyzRiHUFjNnGJG0fgnKx5y",
Type: "node",
})
filter := bloom.New(redisStore, "articleId", 10000)
//模拟从数据库中添加数据到布隆过滤器
for i := 1; i <= 50; i++ {
key := fmt.Sprintf("article:id:%d", i)
err := filter.Add([]byte(key))
if err != nil {
return
}
}
//从布隆过滤器中查询KEY是否存在
for i := 40; i <= 60; i++ {
key := fmt.Sprintf("article:id:%d", i)
b, _ := filter.Exists([]byte(key))
fmt.Printf("%s %v\n", key, b)
}
}
五、缓存雪崩
1.概念
缓存雪崩 是指大量的请求,无法在Redis中进行处理,然后所有的请求同时访问数据库,导致数据库被打挂。
2.现象
- 短时间内大量缓存失效,导致数据库瞬间压力过大。
- 系统性能明显下降,甚至导致宕机。
3.解决方案
- 设置随机过期时间:为缓存的过期时间增加随机值,避免大量缓存同时过期。
- 分批加载缓存:将缓存重建的任务分批进行,减少瞬时压力。
- 预热缓存:系统上线或重启时,提前加载热点数据到缓存。
- 熔断处理 :让数据库压力比较大的时候就触发熔断,忽略部分请求,尽可能的维持服务。但是这个方法是有损的
4.代码演示
方法一:设置随机过期时间
key := fmt.Sprintf("biz#articles#%d#%d, userId, sortType)
//检查缓存是否存在
isExists, err := l.svcCtx.Rds.ExistsCtx(ctx, key)
if err != nil {
return nil, err
}
//给缓存设置随机过期时间
if isExists {
err := l.svcCtx.Rds.ExpireCtx(ctx, key, 3600 * 24 * 2+rand.Intn(60))
if err != nil {
return nil, err
}
}
方法二:熔断处理
go zero 自带熔断处理中间件
// BreakerHandler returns a break circuit middleware.
func BreakerHandler(method, path string, metrics *stat.Metrics) func(http.Handler) http.Handler {
brk := breaker.NewBreaker(breaker.WithName(strings.Join([]string{method, path}, breakerSeparator)))
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
promise, err := brk.Allow()
if err != nil {
metrics.AddDrop()
logx.Errorf("[http] dropped, %s - %s - %s",
r.RequestURI, httpx.GetRemoteAddr(r), r.UserAgent())
w.WriteHeader(http.StatusServiceUnavailable)
return
}
cw := response.NewWithCodeResponseWriter(w)
defer func() {
if cw.Code < http.StatusInternalServerError {
promise.Accept()
} else {
promise.Reject(fmt.Sprintf("%d %s", cw.Code, http.StatusText(cw.Code)))
}
}()
next.ServeHTTP(cw, r)
})
}
}