前言
本文详细讲解了一个完整的Go示例项目,演示如何通过
Gin + Redis + Mysql + Gorm + Canal + Kafka
构建一个支持用户信息修改的双写一致性系统。
该示例将包括:
使用延迟双删策略保证 Redis 和 MySQL 一致性
使用 Kafka/Canal 实现 Binlog 驱动的最终一致性同步机制
Docker 支持部署 Redis、MySQL、Kafka、Zookeeper、Canal 及应用服务
清晰的项目结构和详细的代码注释
一、项目概述
本示例项目基于Gin框架构建了一个用户服务,演示如何在用户信息更新时保持 MySQL 与 Redis 缓存的一致性。
1.1 核心思想:
- 写操作先采用“延迟双删”策略(先删缓存 -> 写数据库 -> 延迟再删缓存),同时通过Canal+Kafka监听 MySQL Binlog,将变更消息发送到 Kafka 并由消费者更新或清除 Redis 缓存,以最终保证数据一致性。
- 参考:cloud.tencent.com 、developer.aliyun.com
1.2 整体流程:
客户端发起更新请求后,服务先删除对应的 Redis 缓存,再更新 MySQL 数据库;与此同时,Canal 作为 MySQL 的伪从库捕获Binlog 变更,将事件推送到 Kafka 指定主题,后端的 Kafka 消费者接收事件后对 Redis 进行相应更新或删除操作,从而实现 最终一致性。
由于延迟双删只能保证最终一致性,并且存在短暂不一致,因此加入 Canal+Kafka 机制可进一步增强数据一致性保证。
1.3 图示:
二、技术栈与架构
- Web 框架:Gin(处理 HTTP 请求)
- ORM:GORM(操作 MySQL 数据库)
- Redis 客户端:go-redis v9(操作缓存)
- 消息队列:Kafka(接收 Canal 发送的 Binlog 变更消息)
- Canal:监听 MySQL Binlog,将数据变更推送到 Kafka
- Docker Compose:部署 MySQL、Redis、Kafka、ZooKeeper、Canal-server 和 Go 应用
基于以上组件,系统流程如下:
1、用户更新请求:客户端调用
/user/update
接口发送用户 ID 和新用户名。
---------
2、延迟双删策略:在写入数据库前,先调用Redis 客户端删除user:{id}
缓存,再写入MySQL;写库完成后延迟一定时间(例如500ms),再次删除缓存,降低并发情况下读到旧数据的概率。
参考:developer.aliyun.com
---------
3、Canal 同步:Canal 作为 MySQL 的订阅节点,自动监听 Binlog 变更。每当用户表发生更新时,Canal将变更数据发送到 Kafka 指定 Topic。
---------
4、Kafka 消费者:Go 服务中启动一个后台消费者监听该 Topic,接收到UPDATE
消息后,解析用户 ID,并对 Redis 做补充操作(可删除缓存或重写最新数据),确保数据最终一致。
三、项目结构
user-service/
├── docker-compose.yml # Docker Compose 配置:MySQL, Redis, ZooKeeper, Kafka, Canal, 应用
├── go.mod # Go 模块文件
├── main.go # 程序入口:初始化 DB、Redis、Kafka 消费者,配置 Gin 路由
├── models/
│ └── user.go # GORM User 模型
├── service/
│ └── user_service.go # 用户服务逻辑:更新用户、延迟双删策略
├── cache/
│ └── redis_client.go # Redis 客户端封装
└── consumer/
└── canal_consumer.go # Kafka 消费者:监听 Canal 发送的变更消息并更新缓存
- docker-compose.yml:定义了 MySQL(开启 Binlog)、Redis、ZooKeeper、Kafka、Canal-server 和 Go 应用容器,以及它们的网络关系。
- main.go:程序入口,使用 GORM 连接 MySQL,go-redis 连接 Redis,启动 Canal/Kafka 消费者,并使用 Gin 注册
/user/update
接口。- models/user.go:定义
User
结构体和 GORM 映射表,用于描述用户数据。- service/user_service.go:实现用户更新逻辑,包含延迟双删策略:删除缓存、写库、睡眠、再删缓存。
- cache/redis_client.go:封装 Redis 连接和常用操作函数,如
Delete
、Set
、Get
等。- consumer/canal_consumer.go:启动 Kafka 消费者,监听 Canal 发送的
user
表更新消息并解析,根据操作类型(增删改)来更新或删除 Redis 缓存,实现最终一致性。
四、关键代码片段
4.1 数据模型(models/user.go)
package models
import "gorm.io/gorm"
// User 定义用户模型(演示用,简化字段)
type User struct {
gorm.Model
Username string `gorm:"type:varchar(255)"`
}
- 该结构体映射到 MySQL 中的
users
表。GORM 的AutoMigrate
可根据该模型自动创建表结构。
4.2 初始化数据库和缓存(main.go)
// 初始化 MySQL 连接
db, err := gorm.Open(mysql.Open("root:password@tcp(mysql:3306)/userdb?parseTime=true"), &gorm.Config{})
if err != nil { log.Fatal(err) }
db.AutoMigrate(&models.User{})
// 初始化 Redis 连接
redisClient = redis.NewClient(&redis.Options{
Addr: "redis:6379", Password: "", DB: 0,
})
if err := redisClient.Ping(ctx).Err(); err != nil { log.Fatal(err) }
- 在
main
函数中,先通过 GORM 连接 MySQL(数据库名userdb
),并执行AutoMigrate
保证有users
表;- 然后用 go-redis 连接到 Redis 服务器,并验证连通性。
4.3 用户更新接口(main.go)
router.PUT("/user/update", func(c *gin.Context) {
// 解析请求参数
var req struct { UserID uint; NewName string }
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()}); return
}
// 延迟双删:先删除缓存
cacheKey := fmt.Sprintf("user:%d", req.UserID)
redisClient.Del(ctx, cacheKey)
// 更新数据库
if err := db.Model(&models.User{}).Where("id = ?", req.UserID).
Update("username", req.NewName).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()}); return
}
// 延时再删缓存
go func() {
time.Sleep(500 * time.Millisecond)
redisClient.Del(ctx, cacheKey)
}()
c.JSON(200, gin.H{"status": "updated"})
})
- 此接口接受 JSON 请求体,例如
{"user_id":1,"new_name":"alice"}
。逻辑为:
- 先删除缓存(
redisClient.Del
),再执行 MySQL 更新,之后启动一个协程延迟 500ms后再次删除缓存。- 上述流程即实现了延迟双删策略。参考:developer.aliyun.com
- 在高并发场景下可有效减少读到过期缓存的可能性。
4.4 Canal+Kafka 消费者(consumer/canal_consumer.go)
package consumer
import (
"context"; "encoding/json"; "fmt"
"github.com/segmentio/kafka-go"
)
func StartCanalConsumer(ctx context.Context, redisClient *redis.Client) {
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{"kafka:9092"}, Topic: "canal_topic", GroupID: "user-service-group",
})
for {
msg, err := reader.ReadMessage(ctx)
if err != nil {
log.Println("Kafka read error:", err); continue
}
// 假设 Canal 消息为 JSON 格式:{ "table": "users", "type": "UPDATE", "data": {"id":1,"username":"alice"}, ... }
var event struct {
Table string `json:"table"`
Type string `json:"type"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(msg.Value, &event); err != nil {
log.Println("JSON parse error:", err); continue
}
// 仅处理用户表的变更事件
if event.Table == "users" && event.Type == "UPDATE" {
userID := fmt.Sprintf("%v", event.Data["id"])
// 再次删除或更新缓存
redisClient.Del(ctx, "user:"+userID)
}
}
}
- 在后台启动的 Kafka 消费者不断读取 Canal 推送到
canal_topic
主题的消息。解析后针对users
表的更新事件执行缓存操作。- 这里示例仅对 Redis
user:{id}
做删除,也可根据需要改为重写最新数据。通过此方式,即使延迟双删未能完全覆盖的短暂不一致,也能被消费者最终处理,保证最终一致性。- 参考:cloud.tencent.com
4.5 Docker Compose 配置(docker-compose.yml)
version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: userdb
ports: ["3306:3306"]
command: ["--default-authentication-plugin=mysql_native_password","--log-bin=mysql-bin","--binlog-format=ROW"]
redis:
image: redis:6
ports: ["6379:6379"]
zookeeper:
image: bitnami/zookeeper:latest
environment: { ALLOW_ANONYMOUS_LOGIN: 'yes' }
kafka:
image: bitnami/kafka:latest
environment:
KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092
ports: ["9092:9092"]
depends_on: [zookeeper]
canal:
image: canal/canal-server:latest
environment:
canal.destinations: example
canal.mq.servers: kafka:9092
canal.instance.master.address: mysql:3306
canal.instance.dbUsername: canal
canal.instance.dbPassword: canalPwd
canal.mq.topic: canal_topic
ports: ["11111:11111"] # Canal admin 端口
depends_on: [mysql,kafka]
user-service:
build: .
image: user-service:latest
environment: { DB_HOST: mysql, REDIS_HOST: redis, KAFKA_HOST: kafka }
depends_on: [mysql, redis, kafka, canal]
ports: ["8080:8080"]
- mysql:启用
binlog (--log-bin)
并设置ROW
模式,让 Canal 能读取行级变更。- canal:使用官方
canal-server
镜像(需自行配置 Canal 用户和 topic),将订阅的 Binlog 事件发送到 Kafka 的canal_topic
。- user-service:Go 应用容器,部署编译后的服务镜像,链接到其他服务。
五、构建与运行
5.1 准备:安装 Docker 和 Docker Compose。
5.2 代码编译:在 user-service
目录下执行 go mod tidy && go build -o user-service main.go
。
5.3 启动服务:运行 docker-compose up -d
,它会启动 MySQL、Redis、ZooKeeper、Kafka、Canal和 Go 应用。确保 MySQL Binlog 和 Canal 用户配置正确。
5.4 测试接口:使用 curl
或 Postman
调用:
curl -X PUT http://localhost:8080/user/update \
-H "Content-Type: application/json" \
-d '{"user_id":1,"new_name":"alice"}'
- 该请求会触发延迟双删和数据库更新。
- 可检查 MySQL 中
users
表和Redis 缓存
:查询 MySQL 得到新用户名,查询 Redis 的user:1
应该为空(缓存已被删除)。- 稍后 Canal 消费器会再次清理或更新缓存(根据实现)
六、总结
- 通过上述示例,展示了使用 延迟双删策略和 Canal+Kafka 机制实现 MySQL 与 Redis 缓存最终一致性的完整流程。
- 项目代码清晰、注释详细,可以作为缓存一致性方案的学习参考。
- 参考资料:
- Redis与MySQL一致性问题解析:developer.aliyun.com
- 缓存与数据库一致性常见解决方案:cloud.tencent.com