前言
本文主要介绍 MySQL 如何实时同步数据到 ES 中的设计思路与实现。
设计思路
为了不影响业务,实现方式选择解析 MySQL 的 binlog 来同步 ES,同步过程可以使用消息队列来削峰解耦,消息队列可以使用 Go 生态下的 NATS 或者 业内流行的 kafka。
graph TD
A(MySQL) --> B(Binlog)
B --> |生产| C(Nats/kafka)
C --> |消费| D(ElasticSearch)
Docker 安装 MySQL、elasticsearch、kibana、NATS
MySQL 安装
docker pull mysql:5.7.30
docker run --name mysql57 -p 3307:3306 -e MYSQL_ROOT_PASSWORD=rootroot. -d --restart always -v /Users/rbowind/Docker/mysql57_data:/var/lib/mysql mysql:5.7.30
开启 binlog
docker exec mysql57 bash -c "echo 'log-bin=/var/lib/mysql/mysql-bin' >> /etc/mysql/mysql.conf.d/mysqld.cnf"
docker exec mysql57 bash -c "echo 'server-id=7' >> /etc/mysql/mysql.conf.d/mysqld.cnf"
docker restart mysql57
ES 安装
docker pull docker.elastic.co/elasticsearch/elasticsearch:7.14.0
touch $HOME/Docker/elasticsearch/config/elasticsearch.yml
echo 'cluster.name: "docker-cluster"' >> $HOME/Docker/elasticsearch/config/elasticsearch.yml
echo "network.host: 0.0.0.0" >> $HOME/Docker/elasticsearch/config/elasticsearch.yml
# 持久化方案
# 如果遇到 es 启动一会后就挂掉,可能是 docker 分配的内存不够导致的,去 docker 设置里多分配些内存
docker run --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" \
-v $HOME/Docker/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v $HOME/Docker/elasticsearch/data:/usr/share/elasticsearch/data \
-v $HOME/Docker/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
-d docker.elastic.co/elasticsearch/elasticsearch:7.14.0
修改配置,添加插件
由于 elasticsearch.yml 挂载在本地,所以直接编辑本地的elasticsearch.yml。
vim $HOME/Docker/elasticsearch/config/elasticsearch.yml
在末尾添加:
http.cors.enabled: true
http.cors.allow-origin: "*"
IK 分词器,进入容器中:
cd /usr/share/elasticsearch/plugins/
elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.14.0/elasticsearch-analysis-ik-7.14.0.zip
docker restart elasticsearch
Kibana 安装
docker pull kibana:7.14.0
docker run --name kibana -p 5601:5601 -d --link elasticsearch:elasticsearch kibana:7.14.0
docker start kibana
汉化
去config/kibana.yml 中添加i18n.locale: "zh-CN"
NATS 安装
docker pull nats:latest
docker run --name nats-server -ti -p 4222:4222 -p 8222:8222 -p 6222:6222 -d -v /Users/rbowind/Docker/nats/:/var/lib/nats nats:latest
封装 NATS 客户端
// dao/server.go
type MessageQueue interface {
Pub(key string, data []byte) error
Sub(key string, mh MsgHandler) error
}
// dao/nats.go
var NatsMQ MessageQueue
func InitNats() {
nc, err := nats.Connect(nats.DefaultURL, nats.Name("xxx"))
if err != nil {
log.Fatal(err)
}
NatsMQ = NMQImpl{NC: nc}
}
type NMQImpl struct {
NC *nats.Conn
}
func (nmq NMQImpl) Pub(key string, data []byte) error {
return nmq.NC.Publish(key, data)
}
func (nmq NMQImpl) Sub(key string, mh MsgHandler) error {
_, err := nmq.NC.Subscribe(key, func(msg *nats.Msg) {
err := mh(msg.Data)
if err != nil {
panic(err)
}
})
return err
}
消费 MySQL 的 binlog
binlog的监听需要单独起一个服务。
//main.go
var s = flag.String("s", "http", "Input your server type")
func main() {
defer func() {
if err := recover(); err != nil {
log.Println(err)
}
}()
flag.Parse()
switch *s {
case "http":
startHttp()
case "binlog": //go run main.go -s='binlog'
startBinlog()
default:
startHttp()
}
}
解析 binlog 推送到消息队列中
// dao/binlog.go
//startBinlog() 相关内容
type MyEventHandler struct {
canal.DummyEventHandler
}
//OnRow 这个方法会被循环调用
func (h *MyEventHandler) OnRow(ev *canal.RowsEvent) error {
rowData := make(map[string]interface{})
for columnIndex, currColumn := range ev.Table.Columns {
//字段名,字段的索引顺序,字段对应的值
//row := fmt.Sprintf("%v %v %v\n", currColumn.Name, columnIndex, ev.Rows[len(ev.Rows)-1][columnIndex])
rowData[currColumn.Name] = ev.Rows[len(ev.Rows)-1][columnIndex]
}
rowJson, err := json.Marshal(rowData)
if err != nil {
panic(err)
}
//把数据发给 NATS
err = NatsMQ.Pub("commodity", rowJson)
if err != nil {
panic(err)
}
return nil
}
func (h *MyEventHandler) String() string {
return "MyEventHandler"
}
func BinlogSyncInit() {
cfg := canal.NewDefaultConfig()
db := viper.GetStringMapString("db")
cfg.Addr = db["addr"]
cfg.User = db["username"]
cfg.Password = db["password"]
cfg.ServerID = 7
cfg.ReadTimeout = 60 * time.Second
cfg.Dump.TableDB = db["name"]
//这里放需要同步 binlog 的表名
cfg.Dump.Tables = []string{"commodity"}
c, err := canal.NewCanal(cfg)
if err != nil {
panic(err)
}
// Register a handler to handle RowsEvent
c.SetEventHandler(&MyEventHandler{})
// Start canal
err = c.Run()
if err != nil {
panic(err)
}
}
从消息队列中拿到 binlog 具体内容
在一个地方订阅 NATS,把数据写到 Elasticsearch 中,可以单独起一个服务,或者暂时先写到 web 服务中先走通流程。
//dao/elasticsearch.go
var ESClient *es.Client
func InitES() {
client, err := es.NewClient(es.Config{
Addresses: []string{
"http://localhost:9200",
},
})
if err != nil {
panic(err)
}
ESClient = client
//ES的一些业务需求,可以抽离出去优化
commoditySync()
}
func commoditySync() {
//订阅 nats 中的数据
err := NatsMQ.Sub("commodity", func(data []byte) error {
//拿到数据 放到 es 中
var commodity entity.CommodityES
err := json.Unmarshal(data, &commodity)
if err != nil {
panic(err)
}
println(commodity.Name)
//只是创建文档
//_, err := es.Create("commodity", strconv.Itoa(commodity.ID), strings.NewReader(string(data)), es.Create.WithPretty())
//覆盖更新
_, err = ESClient.Index("commodity", strings.NewReader(string(data)), ESClient.Index.WithDocumentID(strconv.Itoa(commodity.ID)))
if err != nil {
return err
}
return nil
})
if err != nil {
panic(err)
}
}
提供 API 使用 ES 提供查询功能
/**
可以根据先写 SQL ,然后用 DSL 转成 json
POST _sql/translate
{
"query": "SELECT * FROM commodity where status=1 and name like '%测试%' order by id desc"
}
*/
func CommoditySearch(c *gin.Context) {
if name, ok := c.GetQuery("name"); ok {
es := dao.ESClient
query := `{
"size" : 1000,
"query" : {
"bool" : {
"must" : [
{
"term" : {
"status" : {
"value" : 1,
"boost" : 1.0
}
}
},
{
"wildcard" : {
"name.keyword" : {
"wildcard" : "*` + name + `*",
"boost" : 1.0
}
}
}
],
"adjust_pure_negative" : true,
"boost" : 1.0
}
},
"sort" : [
{
"id" : {
"order" : "desc",
"missing" : "_first",
"unmapped_type" : "long"
}
}
]
}`
res, err := es.Search(
es.Search.WithIndex("commodity"),
es.Search.WithBody(strings.NewReader(query)),
es.Search.WithPretty(),
)
if err != nil {
response.Send(c, errno.InternalServerError, err.Error())
return
}
data, err := io.ReadAll(res.Body)
if err != nil {
response.Send(c, errno.InternalServerError, err.Error())
return
}
defer res.Body.Close()
var dat map[string]interface{}
err = json.Unmarshal(data, &dat)
if err != nil {
response.Send(c, errno.InternalServerError, err.Error())
return
}
response.Send(c, errno.OK, dat["hits"])
}
}