A prática de construir uma função de pesquisa no local em tempo real baseada em Kafka e Elasticsearch

No momento, estamos construindo um site multilocatário e multiprodutos. Para permitir que os usuários encontrem melhor os produtos de que precisam, precisamos criar uma função de pesquisa no site, que deve ser atualizada em tempo real. Este artigo discutirá a infraestrutura principal que cria essa funcionalidade e a pilha de tecnologia que oferece suporte a esse recurso de pesquisa.

Definição do Problema e Tomada de Decisão

Para construir um mecanismo de busca rápido e em tempo real, tivemos que tomar algumas decisões de design. Utilizamos o MySQL como principal banco de dados de armazenamento, por isso temos as seguintes opções:

  1. Consulte cada palavra-chave digitada pelo usuário na caixa de pesquisa diretamente no banco de dados MySQL, como %#{word1}%#{word2}%… assim.
  2. Use um banco de dados de pesquisa eficiente, como o Elasticsearch.

Tendo em conta que somos uma aplicação multi-inquilino , as entidades pesquisadas ao mesmo tempo podem exigir muitas operações associadas (se usarmos uma base de dados relacional como o MySQL), porque diferentes tipos de produtos têm diferentes estruturas de dados, pelo que também Pode ser necessário percorrer várias tabelas de dados ao mesmo tempo para consultar as palavras-chave inseridas pelo usuário. Portanto, decidimos não usar o esquema de consulta direta de palavras-chave no MySQL.

Portanto, tivemos que decidir sobre uma maneira eficiente e confiável de migrar dados do MySQL para o Elasticsearch em tempo real . Em seguida, você precisa tomar as seguintes decisões:

  1. Use o Worker para consultar periodicamente o banco de dados MySQL e enviar todos os dados alterados para o Elasticsearch.
  2. Use o cliente Elasticsearch no aplicativo para gravar dados no MySQL e no Elasticsearch.
  3. Usando um mecanismo de fluxo baseado em evento, as alterações de dados no banco de dados MySQL são enviadas como eventos para o servidor de processamento de fluxo e, em seguida, encaminhadas para o Elasticsearch após o processamento.

A opção 1 não é em tempo real, portanto, pode ser descartada diretamente e, mesmo se encurtarmos o intervalo de pesquisa, isso fará com que varreduras completas de tabelas causem pressão de consulta no banco de dados. Além de não ser em tempo real, a opção 1 não pode suportar a exclusão de dados. Se os dados forem excluídos, precisamos de uma tabela adicional para registrar os dados que existiam antes, para garantir que os usuários não procurem por dados sujos excluídos dados.

Para as outras duas opções, diferentes cenários de aplicação podem levar a decisões diferentes. Em nosso cenário, se escolhermos a opção 2, podemos prever alguns problemas: se for lento para estabelecer uma conexão de rede através do Elasticsearch e confirmar a atualização, isso pode tornar nosso aplicativo lento; ou ao gravar no Elasticsearch Se uma exceção desconhecida ocorrer, como podemos repetir esta operação para garantir a integridade dos dados.

É inegável que nem todos os desenvolvedores da equipe de desenvolvimento podem entender todas as funções. Se um desenvolvedor não introduzir o cliente Elasticsearch ao desenvolver uma nova lógica de negócios relacionada ao produto, atualizaremos essa alteração de dados no Elasticsearch. Consistência de dados entre MySQL e Elasticsearch não pode ser garantido.

Em seguida, devemos considerar como enviar as alterações de dados no banco de dados MySQL como eventos para o servidor de processamento de fluxo. Podemos usar o cliente do pipeline de mensagens no aplicativo para enviar eventos para o pipeline de mensagens de forma síncrona após as alterações no banco de dados, mas isso não resolve os problemas causados ​​pelo uso do cliente Elasticsearch mencionado acima, apenas remove o risco do Elasticsearch movido para pipelines de mensagens. Por fim, decidimos implementar um mecanismo de fluxo baseado em eventos coletando o MySQL Binlog e enviando o MySQL Binlog como um evento para o pipeline de mensagens . Para o conteúdo do binlog, você pode clicar no link, então não vou entrar em detalhes aqui.

Introdução ao serviço

Para fornecer uma interface de pesquisa unificada externamente, primeiro precisamos definir uma estrutura de dados para pesquisa. Para a maioria dos sistemas de pesquisa, os resultados da pesquisa exibidos aos usuários geralmente incluem título e conteúdo, que chamamos de Conteúdo pesquisável . Em um sistema multilocatário, também precisamos indicar a qual inquilino o resultado da pesquisa pertence nos resultados da pesquisa ou filtrar o conteúdo pesquisável no inquilino atual. Também precisamos de informações adicionais para ajudar os usuários a filtrar as categorias de produtos que desejam search , chamamos essa parte do conteúdo que é comum, mas não usada para pesquisar metadados (Metadados) . Por fim, quando exibimos os resultados da pesquisa, podemos fornecer diferentes efeitos de exibição de acordo com os diferentes tipos de produtos. Precisamos retornar o conteúdo original (Conteúdo bruto) necessário para essas exibições personalizadas nos resultados da pesquisa . Até agora podemos definir a estrutura geral de dados armazenada no Elasticsearch:

{
	"searchable": {
		"title": "string",
		"content": "string"
	},
	"metadata": {
		"tenant_id": "long",
		"type": "long",
		"created_at": "date",
		"created_by": "string",
		"updated_at": "date",
		"updated_by": "string"
	},
	"raw": {}
}

a infraestrutura

Apache Kafka:  Apache Kafka é uma plataforma de streaming de eventos distribuídos de código aberto. Usamos o Apache Kafka como um armazenamento persistente para eventos de banco de dados (inserir, modificar e excluir).

mysql-binlog-connector-java:  Usamos mysql-binlog-connector-java para buscar eventos de banco de dados do MySQL Binlog e enviá-los para o Apache Kafka. Iniciaremos um serviço separado para concluir esse processo.

No lado receptor, também iniciaremos um serviço separado para consumir eventos no Kafka, processar os dados e enviá-los ao Elasticsearch.

Q:为什么不使用Elasticsearch connector之类的连接器对数据进行处理并发送到Elasticsearch中?
A:在我们的系统中是不允许将大文本存入到MySQL中的,所以我们使用了额外的对象存储服务来存放我们的产品文档,所以我们无法直接使用连接器将数据发送到Elasticsearch中。
Q:为什么不在发送到Kafka前就将数据进行处理?
A:这样会有大量的数据被持久化到Kafka中,占用Kafka的磁盘空间,而这部分数据实际上也被存储到了Elasticsearch。
Q:为什么要用单独的服务来采集binlog,而不是使用Filebeat之类的agent?
A:当然可以直接在MySQL数据库中安装agent来直接采集binlog并发送到Kafka中。但是在部分情况下开发者使用的是云服务商或其他基础设施部门提供的MySQL服务器,这种情况下我们无法直接进入服务器安装agent,所以使用更加通用的、无侵入性的C/S结构来消费MySQL的binlog。

Configurar pilha de tecnologia

Usamos docker e docker-compose para configurar e implantar serviços. Para simplificar, o MySQL usa root diretamente como nome de usuário e senha. Kafka e Elasticsearch usam clusters de nó único sem definir nenhum método de autenticação. Eles são usados ​​apenas no ambiente de desenvolvimento e não devem ser usados ​​diretamente no ambiente de produção.

version: "3"
services:
  mysql:
    image: mysql:5.7
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: app
    ports:
      - 3306:3306
    volumes:
      - mysql:/var/lib/mysql
  zookeeper:
    image: bitnami/zookeeper:3.6.2
    container_name: zookeeper
    ports:
      - 2181:2181
    volumes:
      - zookeeper:/bitnami
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes
  kafka:
    image: bitnami/kafka:2.7.0
    container_name: kafka
    ports:
      - 9092:9092
    volumes:
      - kafka:/bitnami
    environment:
      - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
      - ALLOW_PLAINTEXT_LISTENER=yes
    depends_on:
      - zookeeper
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.11.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
    volumes:
      - elasticsearch:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
volumes:
  mysql:
    driver: local
  zookeeper:
    driver: local
  kafka:
    driver: local
  elasticsearch:
    driver: local

Depois que o serviço for iniciado com sucesso, precisamos criar um índice para o Elasticsearch. Aqui usamos diretamente o curl para chamar a API RESTful do Elasticsearch. Você também pode usar a imagem base do busybox para criar um serviço para concluir esta etapa.

# Elasticsearch
curl "http://localhost:9200/search" -XPUT -d '
{
  "mappings": {
    "properties": {
      "searchable": {
        "type": "nested",
        "properties": {
          "title": {
            "type": "text"
          },
          "content": {
            "type": "text"
          }
        }
      },
      "metadata": {
        "type": "nested",
        "properties": {
          "tenant_id": {
            "type": "long"
          },
          "type": {
            "type": "integer"
          },
          "created_at": {
            "type": "date"
          },
          "created_by": {
            "type": "keyword"
          },
          "updated_at": {
            "type": "date"
          },
          "updated_by": {
            "type": "keyword"
          }
        }
      },
      "raw": {
        "type": "nested"
      }
    }
  }
}'

Implementação do código principal (SpringBoot + Kotlin)

Terminal de coleta de log binário:

    override fun run() {
        client.serverId = properties.serverId
        val eventDeserializer = EventDeserializer()
        eventDeserializer.setCompatibilityMode(
            EventDeserializer.CompatibilityMode.DATE_AND_TIME_AS_LONG
        )
        client.setEventDeserializer(eventDeserializer)
        client.registerEventListener {
            val header = it.getHeader<EventHeader>()
            val data = it.getData<EventData>()
            if (header.eventType == EventType.TABLE_MAP) {
                tableRepository.updateTable(Table.of(data as TableMapEventData))
            } else if (EventType.isRowMutation(header.eventType)) {
                val events = when {
                    EventType.isWrite(header.eventType) -> mapper.map(data as WriteRowsEventData)
                    EventType.isUpdate(header.eventType) -> mapper.map(data as UpdateRowsEventData)
                    EventType.isDelete(header.eventType) -> mapper.map(data as DeleteRowsEventData)
                    else -> emptyList()
                }
                logger.info("Mutation events: {}", events)
                for (event in events) {
                    kafkaTemplate.send("binlog", objectMapper.writeValueAsString(event))
                }
            }
        }
        client.connect()
    }

Neste código, primeiro inicializamos o cliente binlog e, em seguida, começamos a ouvir os eventos binlog. Existem muitos tipos de eventos binlog, a maioria dos quais não precisamos nos preocupar, só precisamos prestar atenção em TABLE_MAP e WRITE/UPDATE/DELETE. Quando recebermos o evento TABLE_MAP, atualizaremos a estrutura da tabela do banco de dados na memória e, nos eventos WRITE/UPDATE/DELETE subsequentes, usaremos a estrutura do banco de dados em cache de memória para mapeamento. Todo o processo é mais ou menos assim:

Table: ["id", "title", "content",...]
Row: [1, "Foo", "Bar",...]
=>
{
	"id": 1,
	"title": "Foo",
	"content": "Bar"
}

Em seguida, enviamos os eventos coletados para o Kafka para consumo e processamento pelo Event Processor.

manipulador de eventos

@Component
class KafkaBinlogTopicListener(
    val binlogEventHandler: BinlogEventHandler
) {

    companion object {
        private val logger = LoggerFactory.getLogger(KafkaBinlogTopicListener::class.java)
    }

    private val objectMapper = jacksonObjectMapper()

    @KafkaListener(topics = ["binlog"])
    fun process(message: String) {
        val binlogEvent = objectMapper.readValue<BinlogEvent>(message)
        logger.info("Consume binlog event: {}", binlogEvent)
        binlogEventHandler.handle(binlogEvent)
    }
}

Primeiro, use as anotações fornecidas pelo SpringBoot Message Kafka para consumir o evento e, em seguida, delegue o evento ao binlogEventHandler para processamento. Na verdade, BinlogEventHandler é uma interface funcional personalizada. Depois de implementar essa interface, nosso manipulador de eventos personalizado a injeta no KafkaBinlogTopicListener por meio do Spring Bean.

@Component
class ElasticsearchIndexerBinlogEventHandler(
    val restHighLevelClient: RestHighLevelClient
) : BinlogEventHandler {
    override fun handle(binlogEvent: BinlogEvent) {
        val payload = binlogEvent.payload as Map<*, *>
        val documentId = "${binlogEvent.database}_${binlogEvent.table}_${payload["id"]}"
        // Should delete from Elasticsearch
        if (binlogEvent.eventType == EVENT_TYPE_DELETE) {
            val deleteRequest = DeleteRequest()
            deleteRequest
                .index("search")
                .id(documentId)
            restHighLevelClient.delete(deleteRequest, DEFAULT)
        } else {
            // Not ever WRITE or UPDATE, just reindex
            val indexRequest = IndexRequest()
            indexRequest
                .index("search")
                .id(documentId)
                .source(
                    mapOf<String, Any>(
                        "searchable" to mapOf(
                            "title" to payload["title"],
                            "content" to payload["content"]
                        ),
                        "metadata" to mapOf(
                            "tenantId" to payload["tenantId"],
                            "type" to payload["type"],
                            "createdAt" to payload["createdAt"],
                            "createdBy" to payload["createdBy"],
                            "updatedAt" to payload["updatedAt"],
                            "updatedBy" to payload["updatedBy"]
                        )
                    )
                )
            restHighLevelClient.index(indexRequest, DEFAULT)
        }
    }
}

Aqui, precisamos apenas julgar se é uma operação de exclusão. Se for uma operação de exclusão, os dados precisam ser excluídos no Elasticsearch, e se for uma operação sem exclusão, precisamos apenas reindexar o documento em Elasticsearch. Esse código simplesmente usa o método mapOf fornecido em Kotlin para mapear os dados. Se outro processamento complexo for necessário, você só precisará escrever o processador na forma de código Java.

Resumir

Na verdade, existem muitos mecanismos de processamento de código aberto na parte de processamento do Binlog, incluindo o Alibaba Canal . O método de processamento manual usado neste artigo também é uma solução semelhante para outros alunos que usam fontes de dados não MySQL. Você pode pegar o que precisa, adaptar as medidas às condições locais e criar seu próprio mecanismo de pesquisa em tempo real para o seu site!

Acho que você gosta

Origin blog.csdn.net/dyuan134/article/details/130222021
Recomendado
Clasificación