MySQL 数据同步 ElasticSearch 使用之旅

2,252 阅读3分钟

前言

本文主要介绍 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"])
	}
}

参考资料

Nats 官方文档

如何订阅binlog

github.com/elastic/go-…

docs.es.shiyueshuyi.xyz/#/getting_s…