Todos os produtos
Search
Central de documentação

PolarDB:Use Hash Match to perform IMCI-enabled operations

Última atualização: Jun 28, 2026

Por padrão, o executor In-Memory Column Index (IMCI) no PolarDB representa resultados intermediários como números de linha. Em consultas grandes cujos dados excedem a memória disponível, essa abordagem gera E/S aleatória e repetida em excesso, reduzindo a eficiência da execução. Para resolver esse problema, o executor IMCI introduziu um conjunto de operadores que materializam os resultados intermediários. Este tópico descreve o operador Hash Match — a contraparte materializada do Hash Join — e explica como ele implementa a materialização de resultados intermediários.

Plano de execução

O operador Hash Match executa em duas fases: construção (build) e sondagem (probe).

  • Fase de construção: Os predicados de junção atuam como chaves para aplicar hash em cada linha da tabela esquerda dentro de uma tabela hash.

  • Fase de sondagem: O sistema varre a tabela direita, compara as linhas com a tabela hash usando os mesmos predicados de junção e emite o conjunto de resultados com base no tipo de junção.

Para evitar a criação de uma única tabela hash excessivamente grande que cause conflitos graves ou exija redimensionamento contínuo, o sistema particiona a tabela esquerda segundo regras específicas. Cada partição obtém sua própria tabela hash independente. Durante a fase de sondagem, o sistema consulta apenas a tabela hash na partição à qual cada linha da tabela direita pertence.

Fase de construção

A funcionalidade de construção é implementada em doOpen, que executa duas subfases sequencialmente: doBuild e doMerge. Cada subfase utiliza um grupo de threads para processamento concorrente.

doBuild

Cada thread de trabalho constrói tabelas hash independentes para cada partição usando os dados da tabela esquerda, produzindo uma matriz worker x partition de mapas hash:

Worker\Partition

Partition0

Partition1

...

PartitionN

Worker0

HashMap00

HashMap01

...

HashMap0N

Worker1

HashMap10

HashMap11

...

HashMap1N

...

...

...

...

...

WorkerM

HashMapM0

HashMapM1

...

HashMapMN

Cada entrada da tabela hash é um valor UInt64 que codifica a posição do chunk correspondente àquela chave. O valor UInt64 divide-se em três campos no nível de bits:

  • UInt16: a thread de trabalho proprietária do chunk

  • UInt16: o deslocamento do chunk

  • UInt32: o índice do array dentro do chunk

As threads de trabalho buscam tuplas da tabela esquerda em paralelo e as inserem na tabela hash designada para seu worker e partição, sem bloqueio. Esse processo continua até que todos os dados da tabela esquerda sejam processados.

doMerge

Após a execução de doBuild, cada partição possui uma tabela hash por thread de trabalho. Como a fase de sondagem requer uma única tabela hash por partição, doMerge consolida todas as tabelas hash por worker de uma partição em uma só:

Build\Partition

Partition0

Partition1

...

PartitionN

Merge

HashMap0

HashMap1

...

HashMapN

Uma única thread de trabalho mescla cada partição de forma independente, eliminando a sincronização por bloqueio. Como o número de partições excede o número de threads de trabalho, a carga distribui-se uniformemente entre os workers.

Transbordamento para disco na fase de construção

Cada thread de trabalho recebe uma parcela igual da cota total de memória. Quando a memória disponível não comporta todas as tabelas hash e chunks, o operador descarrega partições inteiras para o disco, começando pela partição de maior número, até que as partições restantes na memória possam ser processadas normalmente. Se nenhuma partição couber na memória, o sistema lança uma exceção OutOfMemoryError.

Durante a execução de doBuild, se uma thread de trabalho não conseguir adicionar um par chave-valor a uma tabela hash ou armazenar um chunk devido à memória insuficiente, ela descarrega os dados de sua partição de maior número para o disco:

  1. Os chunks são gravados em um arquivo temporário e sua memória é liberada.

  2. A tabela hash dessa partição é excluída.

  3. Quando a partição for necessária posteriormente, os chunks serão lidos do arquivo temporário e a tabela hash será reconstruída.

O maior número de partição descarregada é visível para todas as threads de trabalho. Se a partição descarregada mais alta de um worker exceder o máximo global atual, ele atualiza o valor global e libera a memória correspondentemente. Para partições acima do maior número atual em memória, as threads de trabalho ignoram a construção de tabelas hash e, em vez disso, gravam dados diretamente em chunks, descarregando para o disco quando um chunk estiver cheio.

Fase de sondagem

A fase de sondagem lê a tabela direita e compara as linhas com as tabelas hash construídas na fase de construção, seguindo as mesmas regras de particionamento. Operações de busca de memória de nós pai impulsionam essa fase, com um grupo de threads gerenciando o processamento concorrente.

doFetch

As threads de trabalho buscam dados da tabela direita e procuram tuplas correspondentes na tabela hash da partição de cada linha. As tuplas correspondidas são processadas de acordo com o tipo de junção. Esse ciclo se repete até que todas as threads de trabalho consumam a tabela direita.

Transbordamento para disco na fase de sondagem

Quando a fase de construção descarrega algumas partições para o disco, a fase de sondagem trata as partições em memória e em disco separadamente.

Durante a execução de doFetch, após uma thread de trabalho buscar uma tupla da tabela direita:

  • Se a partição da tupla estiver na memória, o worker pesquisa diretamente na tabela hash.

  • Caso a partição esteja no disco, a tupla é armazenada no chunk dessa partição. Quando o chunk estiver cheio, ele é liberado para o disco e a memória do chunk é liberada.

Depois que todos os workers terminam de ler a tabela direita, as partições em memória são processadas e sua memória é liberada. Em seguida, as partições em disco são processadas uma por vez, cada uma tratada independentemente por uma única thread de trabalho (sem necessidade de bloqueio). Como o número de partições excede o número de threads de trabalho, a carga de trabalho permanece equilibrada.

O processamento de cada partição em disco segue o mesmo padrão de construção e depois sondagem:

  1. Construção: As threads de trabalho leem os dados da partição da tabela esquerda dos arquivos temporários, desserializam os chunks e constroem a tabela hash.

  2. Sondagem: As threads de trabalho leem os dados da partição da tabela direita dos arquivos temporários, desserializam os chunks e buscam correspondências.

Após o processamento de todas as partições, o operador Hash Match é concluído. Embora o processamento de partições em memória e em disco seja descrito separadamente aqui, ambos os caminhos compartilham uma única implementação de código unificada.

Etapas do processo de sondagem

Todas as operações de sondagem passam por três etapas, cada uma executada pela função probe:

  1. ProbeMem: Lê os dados da tabela direita e encaminha cada tupla para processamento em memória (busca na tabela hash) ou armazenamento em disco (gravação em arquivo temporário para processamento posterior no ProbeDisk).

  2. ProbeLeft: Trata variantes de LEFT JOIN (LEFT OUTER JOIN, LEFT SEMI JOIN, LEFT ANTI SEMI JOIN). Percorre todos os pares chave-valor na tabela hash e filtra tuplas correspondidas ou não correspondidas.

  3. ProbeDisk: Processa partições em disco uma por vez, carregando chunks de arquivos temporários e executando a função probe. Para operações LEFT JOIN, a lógica de JOIN também é aplicada a cada partição de disco.

Lógica de junção

O operador Hash Match suporta os seguintes tipos de junção:

Tipo de junção

Saída

Suporte a PostFilter

INNER JOIN

Tuplas esquerda + direita correspondidas

Não

LEFT OUTER JOIN

Tuplas esquerda + direita correspondidas; tuplas esquerda não correspondidas com NULL para a direita

Sim

RIGHT OUTER JOIN

Tuplas esquerda + direita correspondidas; tuplas direita não correspondidas com NULL para a esquerda

Sim

LEFT SEMI JOIN

Tuplas esquerda (com NULL/TRUE/FALSE) com base na tabela verdade

Sim

LEFT ANTI SEMI JOIN

Tuplas esquerda para linhas não correspondentes com base na tabela verdade

Sim

RIGHT SEMI JOIN

Tuplas direita (com NULL/TRUE/FALSE) com base na tabela verdade

Sim

RIGHT ANTI SEMI JOIN

Tuplas direita para linhas não correspondentes com base na tabela verdade

Sim

Todos os tipos de junção compartilham a mesma estrutura de fase de construção, com pequenas diferenças no tratamento de valores NULL. A lógica da fase de sondagem difere conforme o tipo de junção.

Inner

Quando uma tupla da tabela direita é não NULL e corresponde à tabela hash construída a partir da tabela esquerda, ambas as tuplas (esquerda e direita) são emitidas.

LeftOuter

Se uma tupla da tabela direita for não NULL e houver correspondência, ambas as tuplas (esquerda e direita) são emitidas. Tuplas da tabela esquerda sem correspondência são emitidas com NULL nas posições da tabela direita. Caso exista um PostFilter, as entradas correspondidas da tabela hash são verificadas pelo PostFilter antes da saída.

RightOuter

Se uma tupla da tabela direita for não NULL e houver correspondência, ambas as tuplas (esquerda e direita) são emitidas. Tuplas da tabela direita sem correspondência são emitidas com NULL nas posições da tabela esquerda. Caso exista um PostFilter, as entradas correspondidas da tabela hash são verificadas pelo PostFilter antes da saída.

LeftSemi

O LEFT SEMI JOIN segue uma estrutura semelhante ao LEFT OUTER JOIN, mas apenas as tuplas da tabela esquerda são emitidas, não as da tabela direita. A saída depende dos resultados de correspondência e se semi_probe_ está definido, conforme esta tabela verdade:

//+------------------------------+--------------+----------------+
//|           matched            | semi_probe_  | ! semi_probe_  |
//+------------------------------+--------------+----------------+
//| normal true                  | (left, TRUE) |   (left, ONLY) |
//+------------------------------+--------------+----------------+
//+------------------------------+--------------+----------------+
//|        ! matched             | semi_probe_  | ! semi_probe_  |
//+------------------------------+--------------+----------------+
//|NULL v.s. (empty set)         |              |                |
//|e.g., NULL IN (empty set)     | (left, FALSE)|  NO_OUTPUT     |
//+------------------------------+--------------+----------------+
//|NULL v.s. (set)               |              |                |
//|e.g., NULL IN (1, 2, 3)       | (left, NULL) |  NO_OUTPUT     |
//+------------------------------+--------------+----------------+
//|left_row v.s. (set with NULL) |              |                |
//|e.g., 10 IN (1, NULL, 3)      | (left, NULL) |  NO_OUTPUT     |
//+------------------------------+--------------+----------------+
//|normal false                  |              |                |
//|e.g., 10 IN (1, 2, 3)         | (left, FALSE)|  NO_OUTPUT     |
//+------------------------------+--------------+----------------+

Caso exista um PostFilter, as entradas correspondidas da tabela hash são verificadas pelo PostFilter.

LeftAntiSemi

O LEFT ANTI SEMI JOIN é semelhante ao LEFT OUTER JOIN. Apenas as tuplas da tabela esquerda são emitidas, com base na seguinte tabela verdade:

//+------------------------------+----------------+
//|        ! matched             | ! semi_probe_  |
//+------------------------------+----------------+
//|NULL v.s. (empty set)         |                |
//|e.g., NULL NOT IN (empty set) |   (left, ONLY) |
//+------------------------------+----------------+
//|NULL v.s. (set)               |                |
//|e.g., NULL NOT IN (1, 2, 3)   |   (left, ONLY) |
//+------------------------------+----------------+
//|left_row v.s. (set with NULL) |                |
//|e.g., 10 NOT IN (1, NULL, 3)  |   (left, ONLY) |
//+------------------------------+----------------+
//|normal false                  |                |
//|e.g., 10 NOT IN (1, 2, 3)     |   (left, ONLY) |
//+------------------------------+----------------+

Caso exista um PostFilter, as entradas correspondidas da tabela hash são verificadas pelo PostFilter.

RightSemi

O RIGHT SEMI JOIN é semelhante ao RIGHT OUTER JOIN. Apenas as tuplas da tabela direita são emitidas, com base nesta tabela verdade:

//+------------------------------+--------------+----------------+
//|           matched            | semi_probe_  | ! semi_probe_  |
//+------------------------------+--------------+----------------+
//| normal true                  | (right, TRUE)|  (right, ONLY) |
//+------------------------------+--------------+----------------+
//+------------------------------+--------------+----------------+
//|        ! matched             | semi_probe_  | ! semi_probe_  |
//+------------------------------+--------------+----------------+
//|NULL v.s. (empty set)         |              |                |
//|e.g., NULL IN (empty set)     |(right, FALSE)|  NO_OUTPUT     |
//+------------------------------+--------------+----------------+
//|NULL v.s. (set)               |              |                |
//|e.g., NULL IN (1, 2, 3)       |(right, NULL) |  NO_OUTPUT     |
//+------------------------------+--------------+----------------+
//|left_row v.s. (set with NULL) |              |                |
//|e.g., 10 IN (1, NULL, 3)      |(right, NULL) |  NO_OUTPUT     |
//+------------------------------+--------------+----------------+
//|normal false                  |              |                |
//|e.g., 10 IN (1, 2, 3)         |(right, FALSE)|  NO_OUTPUT     |
//+------------------------------+--------------+----------------+

Caso exista um PostFilter, as entradas correspondidas da tabela hash são verificadas pelo PostFilter.

RightAntiSemi

O RIGHT ANTI SEMI JOIN é semelhante ao RIGHT OUTER JOIN. Apenas as tuplas da tabela direita são emitidas, com base na seguinte tabela verdade:

//+------------------------------+----------------+
//|        ! matched             | ! semi_probe_  |
//+------------------------------+----------------+
//|NULL v.s. (empty set)         |                |
//|e.g., NULL NOT IN (empty set) |  (right, ONLY) |
//+------------------------------+----------------+
//|NULL v.s. (set)               |                |
//|e.g., NULL NOT IN (1, 2, 3)   |  (right, ONLY) |
//+------------------------------+----------------+
//|left_row v.s. (set with NULL) |                |
//|e.g., 10 NOT IN (1, NULL, 3)  |  (right, ONLY) |
//+------------------------------+----------------+
//|normal false                  |                |
//|e.g., 10 NOT IN (1, 2, 3)     |  (right, ONLY) |
//+------------------------------+----------------+

Caso exista um PostFilter, as entradas correspondidas da tabela hash são verificadas pelo PostFilter antes da saída.

Implementação do operador Hash Match

O operador Hash Match unifica o processamento de partições em memória, o processamento de partições em disco, vários tipos de junção e o recurso PostFilter em uma única implementação.

HashMap

O HashMap expõe duas operações principais:

size_t PutValue(uint64_t hash_code, const char *key_buf, uint64_t key_len, const uint64_t tuple);
ValueIterator FindValue(uint64_t hash_code, const char *key_data, const uint64_t key_len, const bool need_mark = false);

Dois iteradores percorrem a tabela hash:

enum IteratorType { Normal = 0, NoneMark = 1, Mark = 2, END };

class TableIterator {
 public:
  void Next();
  bool IsValid() const { return valid_; }
  ValueIterator GetIterator(IteratorType type);

 private:
  IteratorType type_ = IteratorType::END;
};

class ValueIterator {
  struct Listener {
    virtual void BlockEvent() {}
  };
  void SetListener(Listener *listener) { listener_ = listener; }

  void Next();
  bool IsValid() const { return valid_; }

 private:
  IteratorType type_ = IteratorType::Normal;
  Listener *listener_ = nullptr;
};
  • TableIterator: percorre todos os pares chave-valor na tabela hash; usado por LEFT OUTER JOIN, LEFT ANTI SEMI JOIN e LEFT SEMI JOIN.

  • ValueIterator: percorre todos os blocos de dados para um determinado par chave-valor.

Ambos os iteradores suportam três modelos iterativos — Normal, NoneMark e Mark — para atender aos diferentes requisitos de cada tipo de junção.

Info

O objeto HMInfo mantém o estado global compartilhado por todas as threads de trabalho, incluindo números de partições em memória e objetos de partição. Cada partição armazena:

  • A tabela hash mesclada para essa partição

  • Conjuntos de chunks para as tabelas esquerda e direita

  • Arquivos temporários para as tabelas esquerda e direita

Um arquivo temporário é criado por partição. Os workers realizam leituras e gravações atômicas usando a função pread, a função pwrite e uma variável atômica de deslocamento.

Local info

O objeto HMLocalInfo mantém o estado privado de cada thread de trabalho, incluindo seu número de partição em memória local e seus objetos HMLocalPartition esquerdo e direito. Cada HMLocalPartition armazena a tabela hash do worker para aquela partição, o conjunto de chunks e o chunk que está sendo gravado atualmente.

Fetcher

A classe HashMatchFetcher abstrai todas as operações de busca de dados usadas nas fases de construção e sondagem:

class HashMatchFetcher final {
  bool Fetch(Context &context, TupleChunk *&mat_chunk);
  // fetch from left or right child
  bool FetchMem(Context &context);
  // fetch from info chunks (include load from temp files)
  bool FetchDisk(Context &context, TupleChunk *&mat_chunk);

  size_t part_index_ = 0;
  TupleChunk chunk_;
};

O método FetchMem busca dados do operador filho esquerdo ou direito. Já o método FetchDisk lê e desserializa objetos chunk da memória de Info ou LocalInfo, incluindo o carregamento a partir de arquivos temporários.

Builder

A classe HashMatchBuilder gerencia a fase de construção tanto para partições em memória quanto em disco:

class HashMatchBuilder {
  void Build();
  virtual void ChunkResult(const size_t offset, const bool is_null,
                           const size_t part_index, const uint64_t hash_val,
                           const char *key_data, const size_t key_len) = 0;
  virtual void ChunkDone() = 0;

  HashMatchFetcher fetcher_;
};

class HashMatchMemBuilder final : public HashMatchBuilder {
  void ChunkResult(const size_t offset, const bool is_null,
                   const size_t part_index, const uint64_t hash_val,
                   const char *key_data, const size_t key_len) override;
  void ChunkDone() override;

  TupleChunk origin_chunk_;
};

class HashMatchDiskBuilder final : public HashMatchBuilder {
  void ChunkResult(const size_t offset, const bool is_null,
                   const size_t part_index, const uint64_t hash_val,
                   const char *key_data, const size_t key_len) override;
  void ChunkDone() override;

  const size_t part_index_ = 0;
};
  • MemBuilder: Busca dados da tabela esquerda e os armazena no conjunto de chunks. Para partições em memória, grava na tabela hash. Para partições em disco, descarrega os dados do chunk para o disco.

  • DiskBuilder: Lê o conjunto de chunks de um arquivo temporário e constrói a tabela hash para essa partição.

image..png

Prober

A classe HashMatchProber executa ProbeMem, ProbeLeft e ProbeDisk por meio de uma única interface probe:

class HashMatchProber final {
 public:
  void ProbeResult(TupleChunk *tpchunk, size_t &chunk_off, const size_t chunk_size);
  bool ProbeIter(Context &context, TupleChunk *tpchunk, size_t &chunk_off, const size_t chunk_size);
  bool Probe(Context &context, TupleChunk *tpchunk, size_t &chunk_off, const size_t chunk_size, const bool disk);

 private:
  const HashMatch &join_;
  HMInfo *info_ = nullptr;
  HMLocalInfo *local_info_ = nullptr;
  size_t part_index_ = 0;

  PostFilter filter_;

  LeftIterator lit_;
  RightIterator rit_;
  TraverseIterator tit_;  // used for probe left
};

O componente HashMatchProber::PostFilter trata junções que possuem uma condição PostFilter. Os resultados da fase de sondagem passam pelo PostFilter antes de serem emitidos:

struct PostFilter final {
  bool Evaluate();
  bool Probe(TupleChunk *tpchunk, size_t &chunk_off, const size_t chunk_size);

  const HashMatchProber &prober_;
  const RTExprTreePtr &post_expr_;
  const HashMatchExpr &left_expr_;
  const HashMatchExpr &right_expr_;
  std::shared_ptr<Expressions::ExprEnv> post_env_ = nullptr;
};

O HashMatchProber usa três tipos de iteradores, todos derivados de uma base comum Iterator:

struct Iterator {
  virtual void InitExpr() {}
  virtual void FiniExpr() {}
  virtual void Init(const size_t part_index);
  virtual void Fini();
  virtual bool Valid(Context &context) { return false; }
  virtual void Next() = 0;

  HashMatchProber &prober_;
  PostFilter &filter_;
};

O LeftIterator usa HashMap::ValueIterator para percorrer todos os valores de uma chave específica e, em seguida, localiza a tupla do chunk correspondente. Isso fornece todas as tuplas de uma determinada chave para a lógica de junção e para o PostFilter:

// for Probe
struct LeftIterator final : public Iterator, public ValueIterator::Listener {
  void BlockEvent() override;

  bool Valid(Context &context) override;
  void Next() override;
  bool Find(const size_t part_index, const uint64_t hash_val,
            const char *key_data, const uint64_t key_len);

  ValueIterator it_;
};

O RightIterator busca continuamente conjuntos de chunks da tabela direita ou de arquivos temporários usando HashMatchFetcher, percorrendo todos os chunks e tuplas. Todos os tipos de junção usam RightIterator em ProbeMem ou ProbeDisk para buscar chunks e, em seguida, consultar a partição correspondente da tabela hash. Quando uma correspondência é encontrada, um LeftIterator é criado para percorrer todas as tuplas daquela chave. Os LEFT JOINs exigem adicionalmente uma passagem ProbeLeft:

// for Probe
struct RightIterator : public Iterator {
  bool Valid(Context &context) override;
  void Next() override;

  HashMatchFetcher fetcher_;
  TupleChunk origin_chunk_;
  size_t chunk_size_ = 0;
};

O TraverseIterator é utilizado para variantes de LEFT JOIN (LEFT OUTER JOIN, LEFT SEMI JOIN, LEFT ANTI SEMI JOIN). Ele usa HashMap::TableIterator para varrer toda a tabela hash e filtrar chaves correspondidas ou não correspondidas, e então usa LeftIterator para recuperar todas as tuplas de cada chave. Depois que ProbeMem ou ProbeDisk marcam as entradas correspondidas na tabela hash, ProbeLeft usa TraverseIterator para filtrar os pares chave-valor apropriados:

// for ProbeLeft
struct TraverseIterator final : public Iterator {
  bool Valid(Context &context) override;
  void Next() override;

  TableIterator tit_;
  LeftIterator lit_;
  IteratorType it_type_ = IteratorType::END;
};

image..png

Teste de desempenho

O teste a seguir compara Hash Join e Hash Match na consulta TPC-H Q14 com um conjunto de dados de 1 TB. Ambos os operadores usam algoritmos semelhantes; a principal diferença é que o Hash Match materializa resultados intermediários, enquanto o Hash Join não.

select
    100.00 * sum(case
                when p_type like 'PROMO%'
                then l_extendedprice * (1 - l_discount)
                else 0
                end) / sum(l_extendedprice * (1 - l_discount)) as promo_revenue
from
    lineitem,
    part
where
    l_partkey = p_partkey
    and l_shipdate >= date '1995-09-01'
    and l_shipdate < date '1995-09-01' + interval '1' month;

Com cache Least Recently Used (LRU) e 100 GB de memória do executor:

Consulta (TPC-H, 1 TB)

Hash Join

Hash Match

Q14

23,96 s

12,56 s

Com cache Least Recently Used (LRU) e 32 GB de memória do executor:

Consulta (TPC-H, 1 TB)

Hash Join

Hash Match

Q14

> 10 minutos

35,73 s

Com 100 GB de memória do executor, o Hash Match conclui a Q14 em aproximadamente metade do tempo do Hash Join. A vantagem é mais pronunciada sob pressão de memória: com 32 GB, o Hash Join excede 10 minutos devido à E/S excessiva causada por resultados intermediários não materializados, enquanto o Hash Match — ao descarregar partições para o disco de maneira organizada — termina em 35,73 segundos.