Par défaut, l'exécuteur In-Memory Column Index (IMCI) de PolarDB représente les résultats intermédiaires sous forme de numéros de ligne. Pour les requêtes volumineuses dont les données dépassent la mémoire disponible, cette approche engendre des entrées/sorties aléatoires et répétées excessives, ce qui dégrade l'efficacité d'exécution. Pour y remédier, l'exécuteur IMCI introduit un ensemble d'opérateurs qui matérialisent les résultats intermédiaires. Cette rubrique décrit l'opérateur Hash Match — équivalent matérialisé du Hash Join — et explique comment il implémente la matérialisation des résultats intermédiaires.
Plan d'exécution
L'opérateur Hash Match s'exécute en deux phases : la phase de construction et la phase de sondage.
Phase de construction : Les prédicats de jointure servent de clés pour hacher chaque ligne de la table de gauche dans une table de hachage.
Phase de sondage : Le système parcourt la table de droite, compare les lignes à la table de hachage à l'aide des mêmes prédicats de jointure, puis émet le jeu de résultats selon le type de jointure.
Afin d'éviter la création d'une table de hachage unique trop volumineuse, source de conflits importants ou de redimensionnements continus, la table de gauche est partitionnée selon des règles spécifiques. Chaque partition dispose ainsi de sa propre table de hachage indépendante. Lors de la phase de sondage, le système interroge uniquement la table de hachage correspondant à la partition de chaque ligne de la table de droite.
Phase de construction
La fonctionnalité de construction est implémentée dans doOpen, qui exécute séquentiellement deux sous-phases : doBuild et doMerge. Chaque sous-phase utilise un groupe de threads pour le traitement concurrent.
doBuild
Chaque thread worker construit des tables de hachage indépendantes pour chaque partition à partir des données de la table de gauche, produisant ainsi une matrice worker x partition de tables de hachage :
| Worker\Partition | Partition0 | Partition1 | ... | PartitionN |
|---|---|---|---|---|
| Worker0 | HashMap00 | HashMap01 | ... | HashMap0N |
| Worker1 | HashMap10 | HashMap11 | ... | HashMap1N |
| ... | ... | ... | ... | ... |
| WorkerM | HashMapM0 | HashMapM1 | ... | HashMapMN |
Chaque entrée de la table de hachage est une valeur UInt64 qui encode la position du chunk correspondant à cette clé. La valeur UInt64 se divise en trois champs au niveau binaire :
UInt16 : le thread worker propriétaire du chunk
UInt16 : le décalage du chunk
UInt32 : l'index du tableau au sein du chunk
Les threads workers récupèrent les tuples de la table de gauche en parallèle et les insèrent dans la table de hachage assignée à leur worker et à leur partition, sans verrouillage. Ce processus se poursuit jusqu'au traitement complet des données de la table de gauche.
doMerge
Après l'exécution de doBuild, chaque partition possède une table de hachage par thread worker. La phase de sondage nécessitant une seule table de hachage par partition, doMerge consolide toutes les tables de hachage par worker d'une partition en une seule :
| Build\Partition | Partition0 | Partition1 | ... | PartitionN |
|---|---|---|---|---|
| Merge | HashMap0 | HashMap1 | ... | HashMapN |
Un seul thread worker fusionne chaque partition de manière indépendante, ce qui élimine la synchronisation par verrous. Comme le nombre de partitions dépasse celui des threads workers, la charge de travail se répartit uniformément entre les workers.
Déversement sur disque lors de la phase de construction
Chaque thread worker reçoit une part égale du quota mémoire total. Lorsque la mémoire disponible ne peut pas contenir toutes les tables de hachage et tous les chunks, l'opérateur déverse des partitions entières sur le disque, en commençant par la partition au numéro le plus élevé, jusqu'à ce que les partitions restantes en mémoire puissent être traitées normalement. Si aucune partition ne tient en mémoire, une erreur OutOfMemoryError est levée.
Pendant l'exécution de doBuild, si un thread worker ne peut pas ajouter une paire clé-valeur à une table de hachage ou stocker un chunk en raison d'un manque de mémoire, il déverse les données de sa partition au numéro le plus élevé sur le disque :
Les chunks sont écrits dans un fichier temporaire et leur mémoire est libérée.
La table de hachage de cette partition est supprimée.
Lorsque la partition devient nécessaire ultérieurement, les chunks sont lus depuis le fichier temporaire et la table de hachage est reconstruite.
Tous les threads workers voient le numéro de la partition déversée la plus élevée. Si la partition déversée la plus élevée d'un worker dépasse le maximum global actuel, il met à jour la valeur globale et libère la mémoire en conséquence. Pour les partitions dont le numéro est supérieur à celui de la partition en mémoire la plus élevée actuelle, les threads workers ne construisent pas de tables de hachage ; ils écrivent plutôt les données directement dans des chunks, qu'ils déversent sur le disque une fois pleins.
Phase de sondage
La phase de sondage lit la table de droite et compare les lignes aux tables de hachage construites lors de la phase de construction, en suivant les mêmes règles de partitionnement. Des opérations de récupération en mémoire provenant des nœuds parents pilotent cette phase, un groupe de threads assurant le traitement concurrent.
doFetch
Les threads workers récupèrent les données de la table de droite et recherchent les tuples correspondants dans la table de hachage de la partition de chaque ligne. Les tuples trouvés sont traités selon le type de jointure. Ce cycle se répète jusqu'à ce que tous les threads workers aient consommé la table de droite.
Déversement sur disque lors de la phase de sondage
Lorsque la phase de construction déverse certaines partitions sur le disque, la phase de sondage traite séparément les partitions en mémoire et celles sur disque.
Pendant l'exécution de doFetch, après qu'un thread worker a récupéré un tuple de la table de droite :
Si la partition du tuple est en mémoire, le worker effectue une recherche directe dans la table de hachage.
Si la partition est sur disque, le tuple est stocké dans le chunk de cette partition. Une fois le chunk plein, il est vidé sur le disque et la mémoire du chunk est libérée.
Une fois que tous les workers ont terminé la lecture de la table de droite, les partitions en mémoire sont traitées et leur mémoire est libérée. Les partitions sur disque sont ensuite traitées une par une, chacune étant gérée indépendamment par un seul thread worker (aucun verrouillage requis). Étant donné que le nombre de partitions dépasse celui des threads workers, la charge de travail reste équilibrée.
Le traitement de chaque partition sur disque suit le même schéma construction-sondage :
Construction : Les threads workers lisent les données de partition de la table de gauche depuis les fichiers temporaires, désérialisent les chunks et construisent la table de hachage.
Sondage : Les threads workers lisent les données de partition de la table de droite depuis les fichiers temporaires, désérialisent les chunks et recherchent les correspondances.
Une fois toutes les partitions traitées, l'opérateur Hash Match se termine. Bien que le traitement des partitions en mémoire et sur disque soit décrit séparément ici, les deux chemins partagent une implémentation de code unique et unifiée.
Étapes du processus de sondage
Toutes les opérations de sondage passent par trois étapes, chacune exécutée par la fonction probe :
ProbeMem : Lit les données de la table de droite et dirige chaque tuple soit vers un traitement en mémoire (recherche dans la table de hachage), soit vers un stockage sur disque (écriture dans un fichier temporaire pour un traitement ultérieur dans ProbeDisk).
ProbeLeft : Gère les variantes de LEFT JOIN (LEFT OUTER JOIN, LEFT SEMI JOIN, LEFT ANTI SEMI JOIN). Parcourt toutes les paires clé-valeur de la table de hachage et filtre les tuples correspondants ou non correspondants.
ProbeDisk : Traite les partitions sur disque une par une en chargeant les chunks depuis les fichiers temporaires et en exécutant la fonction
probe. Pour les opérations LEFT JOIN, la logique de jointure s'applique également à chaque partition sur disque.
Logique de jointure
L'opérateur Hash Match prend en charge les types de jointure suivants :
| Type de jointure | Sortie | Prise en charge de PostFilter |
|---|---|---|
| INNER JOIN | Tuples gauche + droite correspondants | Non |
| LEFT OUTER JOIN | Tuples gauche + droite correspondants ; tuples gauche non correspondants avec NULL pour la droite | Oui |
| RIGHT OUTER JOIN | Tuples gauche + droite correspondants ; tuples droite non correspondants avec NULL pour la gauche | Oui |
| LEFT SEMI JOIN | Tuples gauche (avec NULL/TRUE/FALSE) selon la table de vérité | Oui |
| LEFT ANTI SEMI JOIN | Tuples gauche pour les lignes non correspondantes selon la table de vérité | Oui |
| RIGHT SEMI JOIN | Tuples droite (avec NULL/TRUE/FALSE) selon la table de vérité | Oui |
| RIGHT ANTI SEMI JOIN | Tuples droite pour les lignes non correspondantes selon la table de vérité | Oui |
Tous les types de jointure partagent la même structure de phase de construction, avec des différences mineures dans la gestion des valeurs NULL. La logique de la phase de sondage diffère selon le type de jointure.
Inner
Lorsqu'un tuple de la table de droite est non NULL et correspond à la table de hachage construite à partir de la table de gauche, les tuples de gauche et de droite sont émis.
LeftOuter
Lorsqu'un tuple de la table de droite est non NULL et correspond, les tuples de gauche et de droite sont émis. Les tuples de la table de gauche sans correspondance sont émis avec des valeurs NULL aux positions de la table de droite. Si un PostFilter est présent, le PostFilter vérifie les entrées correspondantes de la table de hachage avant l'émission.
RightOuter
Lorsqu'un tuple de la table de droite est non NULL et correspond, les tuples de gauche et de droite sont émis. Les tuples de la table de droite sans correspondance sont émis avec des valeurs NULL aux positions de la table de gauche. Si un PostFilter est présent, le PostFilter vérifie les entrées correspondantes de la table de hachage avant l'émission.
LeftSemi
LEFT SEMI JOIN suit une structure similaire à LEFT OUTER JOIN, mais seuls les tuples de la table de gauche sont émis, et non ceux de la table de droite. La sortie dépend des résultats de correspondance et de l'état de semi_probe_, conformément à cette table de vérité :
//+------------------------------+--------------+----------------+
//| 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 |
//+------------------------------+--------------+----------------+
Si un PostFilter est présent, le PostFilter vérifie les entrées correspondantes de la table de hachage.
LeftAntiSemi
LEFT ANTI SEMI JOIN est similaire à LEFT OUTER JOIN. Seuls les tuples de la table de gauche sont émis, selon la table de vérité suivante :
//+------------------------------+----------------+
//| ! 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) |
//+------------------------------+----------------+
Si un PostFilter est présent, le PostFilter vérifie les entrées correspondantes de la table de hachage.
RightSemi
RIGHT SEMI JOIN est similaire à RIGHT OUTER JOIN. Seuls les tuples de la table de droite sont émis, selon cette table de vérité :
//+------------------------------+--------------+----------------+
//| 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 |
//+------------------------------+--------------+----------------+
Si un PostFilter est présent, le PostFilter vérifie les entrées correspondantes de la table de hachage.
RightAntiSemi
RIGHT ANTI SEMI JOIN est similaire à RIGHT OUTER JOIN. Seuls les tuples de la table de droite sont émis, selon la table de vérité suivante :
//+------------------------------+----------------+
//| ! 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) |
//+------------------------------+----------------+
Si un PostFilter est présent, le PostFilter vérifie les entrées correspondantes de la table de hachage avant l'émission.
Implémentation de l'opérateur Hash Match
L'opérateur Hash Match unifie le traitement des partitions en mémoire, le traitement des partitions sur disque, plusieurs types de jointure et la fonctionnalité PostFilter dans une seule implémentation.
HashMap
La HashMap expose deux opérations principales :
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);
Deux itérateurs parcourent la table de hachage :
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: parcourt toutes les paires clé-valeur de la table de hachage ; utilisé par LEFT OUTER JOIN, LEFT ANTI SEMI JOIN et LEFT SEMI JOIN.ValueIterator: parcourt tous les blocs de données pour une paire clé-valeur donnée.
Les deux itérateurs prennent en charge trois modèles itératifs — Normal, NoneMark et Mark — afin de répondre aux exigences spécifiques de chaque type de jointure.
Info
HMInfo contient l'état global partagé par tous les threads workers, y compris les numéros de partitions en mémoire et les objets de partition. Chaque partition stocke :
La table de hachage fusionnée pour cette partition
Les jeux de chunks pour les tables de gauche et de droite
Les fichiers temporaires pour les tables de gauche et de droite
Un fichier temporaire est créé par partition. Les workers effectuent des lectures et des écritures atomiques à l'aide de la fonction pread, de la fonction pwrite et d'une variable atomique de décalage.
Local info
HMLocalInfo contient l'état privé de chaque thread worker, y compris son numéro de partition en mémoire local et ses objets HMLocalPartition de gauche et de droite. Chaque objet HMLocalPartition stocke la table de hachage du worker pour cette partition, le jeu de chunks et le chunk en cours d'écriture.
Fetcher
HashMatchFetcher abstrait toutes les opérations de récupération de données utilisées dans les phases de construction et de sondage :
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_;
};
FetchMem récupère les données depuis l'opérateur enfant de gauche ou de droite. FetchDisk lit et désérialise les objets chunk depuis la mémoire Info ou LocalInfo, y compris le chargement depuis des fichiers temporaires.
Builder
HashMatchBuilder gère la phase de construction pour les partitions en mémoire et sur disque :
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: récupère les données de la table de gauche et les stocke dans le jeu de chunks. Pour les partitions en mémoire, il écrit dans la table de hachage. Pour les partitions sur disque, il déverse les données du chunk sur le disque.DiskBuilder: lit le jeu de chunks depuis un fichier temporaire et construit la table de hachage pour cette partition.

Prober
HashMatchProber exécute ProbeMem, ProbeLeft et ProbeDisk via une interface probe unique :
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
};
HashMatchProber::PostFilter gère les jointures comportant une condition PostFilter. Les résultats de la phase de sondage transitent par le PostFilter avant d'être émis :
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;
};
HashMatchProber utilise trois types d'itérateurs, tous dérivés d'une classe de base Iterator commune :
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_;
};
LeftIterator utilise HashMap::ValueIterator pour parcourir toutes les valeurs d'une clé spécifique, puis localise le tuple de chunk correspondant. Cela fournit tous les tuples d'une clé donnée à la logique de jointure et au 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_;
};
RightIterator récupère en continu des jeux de chunks depuis la table de droite ou des fichiers temporaires à l'aide de HashMatchFetcher, parcourant ainsi tous les chunks et tuples. Tous les types de jointure utilisent RightIterator dans ProbeMem ou ProbeDisk pour récupérer des chunks, puis rechercher la partition correspondante dans la table de hachage. Lorsqu'une correspondance est trouvée, un LeftIterator est créé pour parcourir tous les tuples de cette clé. Les jointures LEFT JOIN nécessitent en outre une passe 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;
};
TraverseIterator est utilisé pour les variantes de LEFT JOIN (LEFT OUTER JOIN, LEFT SEMI JOIN, LEFT ANTI SEMI JOIN). Il utilise HashMap::TableIterator pour analyser l'intégralité de la table de hachage et filtrer les clés correspondantes ou non, puis utilise LeftIterator pour récupérer tous les tuples de chaque clé. Après que ProbeMem ou ProbeDisk a marqué les entrées correspondantes dans la table de hachage, ProbeLeft utilise TraverseIterator pour filtrer les paires clé-valeur appropriées :
// for ProbeLeft
struct TraverseIterator final : public Iterator {
bool Valid(Context &context) override;
void Next() override;
TableIterator tit_;
LeftIterator lit_;
IteratorType it_type_ = IteratorType::END;
};

Test de performance
Le test suivant compare Hash Join et Hash Match sur la requête TPC-H Q14 avec un jeu de données de 1 To. Les deux opérateurs utilisent des algorithmes similaires ; la différence principale réside dans le fait que Hash Match matérialise les résultats intermédiaires, contrairement à Hash Join.
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;
Avec un cache Least Recently Used (LRU) et 100 Go de mémoire d'exécution :
| Requête (TPC-H, 1 To) | Hash Join | Hash Match |
|---|---|---|
| Q14 | 23,96 s | 12,56 s |
Avec un cache Least Recently Used (LRU) et 32 Go de mémoire d'exécution :
| Requête (TPC-H, 1 To) | Hash Join | Hash Match |
|---|---|---|
| Q14 | > 10 minutes | 35,73 s |
Avec 100 Go de mémoire d'exécution, Hash Match termine Q14 en environ la moitié du temps nécessaire à Hash Join. Cet avantage est encore plus marqué sous contrainte mémoire : avec 32 Go, Hash Join dépasse 10 minutes en raison d'E/S excessives causées par des résultats intermédiaires non matérialisés, tandis que Hash Match — en déversant les partitions sur disque de manière organisée — termine l'opération en 35,73 secondes.