Unveränderliche Trie: Finde etwas, ich weiß nicht was, aber schnell und wirf keinen Müll

Über den Präfixbaum ( Trie ) wurde viel geschrieben, auch über Habré . Hier ist ein Beispiel, wie es aussehen könnte:





Und selbst bei Implementierungen im Code, einschließlich JavaScript, gibt es viele - vom "Canonical" von John Resig über verschiedene optimierte Versionen bis hin zu einer Reihe von Modulen in NPM .



Warum mussten wir es für einen Dienst zum Sammeln und Analysieren von PostgreSQL-Plänen und sogar zum „Radfahren“ einer neuen Implementierung verwenden?



Geklebte Protokolle



Schauen wir uns einen kleinen Teil des PostgreSQL-Serverprotokolls an:



2020-09-11 14:49:53.281 MSK [80927:619/4255507] [explain.tensor.ru] 10.76.182.154(59933) pgAdmin III - ???????????????????? ???????????????? LOG:  duration: 0.016 ms  plan:
	Query Text: explain analyze
	SELECT
		*
	FROM
		pg_class
	WHERE
		relname = '
	
	';
	Index Scan using pg_class_relname_nsp_index on pg_class  (cost=0.29..2.54 rows=1 width=265) (actual time=0.014..0.014 rows=0 loops=1)
	  Index Cond: (relname = '
	
	'::name)
	  Buffers: shared hit=2


Wir können das durch die Variable log_line_prefix festgelegte Format verwenden, um die Kopfzeile zu identifizieren und zu kürzen , die mit einem Datum beginnt :



SHOW log_line_prefix;
-- "%m [%p:%v] [%d] %r %a "


Es braucht ziemlich viel Regex-Magie
const reTS = "\\d{4}(?:-\\d{2}){2} \\d{2}(?::\\d{2}){2}"
  , reTSMS = reTS + "\\.\\d{3}"
  , reTZ   = "(?:[A-Za-z]{3,5}|GMT[+\\-]\\d{1,2})";

var re = {
// : log_line_prefix
      '%a' : "(?:[\\x20-\\x7F]{0,63})"
    , '%u' : "(?:[\\x20-\\x7F]{0,63})"
    , '%d' : "[\\x20-\\x7F]{0,63}?"
    , '%r' : "(?:(?:\\d{1,3}(?:\\.\\d{1,3}){3}|[\\-\\.\\_a-z0-9])\\(\\d{1,5}\\)|\\[local\\]|)"
    , '%h' : "(?:(?:\\d{1,3}(?:\\.\\d{1,3}){3}|[\\-\\.\\_a-z0-9])|\\[local\\]|)"
    , '%p' : "\\d{1,5}"
    , '%t' : reTS + ' ' + reTZ
    , '%m' : reTSMS + ' ' + reTZ
    , '%i' : "(?:SET|SELECT|DO|INSERT|UPDATE|DELETE|COPY|COMMIT|startup|idle|idle in transaction|streaming [0-9a-f]{1,8}\/[0-9a-f]{1,8}|)(?: waiting)?"
    , '%e' : "[0-9a-z]{5}"
    , '%c' : "[0-9a-f]{1,8}\\.[0-9a-f]{1,8}"
    , '%l' : "\\d+"
    , '%s' : "\\d{4}(?:-\\d{2}){2} \\d{2}(?::\\d{2}){2} [A-Z]{3}"
    , '%v' : "(?:\\d{1,9}\\/\\d{1,9}|)"
    , '%x' : "\\d+"
    , '%q' : ""
    , '%%' : "%"
// : log_min_messages
    , '%!' : "(?:DEBUG[1-5]|INFO|NOTICE|WARNING|ERROR|LOG|FATAL|PANIC)"
// : log_error_verbosity
    , '%@' : "(?:DETAIL|HINT|QUERY|CONTEXT|LOCATION|STATEMENT)"
    };
re['%#'] = "(?:" + re['%!'] + "|" + re['%@'] + ")";

//  log_line_prefix  RegExp   
let lre = self.settings['log_line_prefix'].replace(/([\[\]\(\)\{\}\|\?\$\\])/g, "\\\$1") + '%#:  ';
self.tokens = lre.match(new RegExp('(' + Object.keys(re).join('|') + ')', 'g'));

let matcher = self.tokens.reduce((str, token) => str.replace(token, '(' + re[token] + ')'), lre);
self.matcher = new RegExp('^' + matcher, '');


Aber dann haben wir eine Anfrage zusammen mit einem Plan - und wie kann man verstehen, wo einer endet und ein anderer beginnt? ..



Es scheint, dass der Plan eine Textdarstellung eines Baumes ist , also sollte es eine "Wurzel" geben? Das heißt, die erste Zeile von unten mit der minimalen Einrückung (Auslassen Trigger ...) - die gewünschte Wurzel und der Beginn des Plans?



Leider nein. In unserem Beispiel ist eine solche Zeichenfolge der "Schwanz" '::name)aus der in Teile geteilten mehrzeiligen Zeichenfolge. Wie soll ich sein?



Benutze Trie, Luke!



Beachten Sie jedoch, dass der Plan von einem der Knoten ausgehen muss: Seq Scan, Index Scan, Sort, Aggregate, ...- weder mehr noch weniger, sondern 133 verschiedene Optionen, mit Ausnahme CTE, InitPlan SubPlanderjenigen, die nicht root sein können.



Tatsächlich wissen wir nicht, welcher der Knoten, die wir kennen, am Anfang dieser Zeile steht (und wenn überhaupt), aber wir wollen ihn finden. Dies ist , wo der Präfixbaums uns helfen wird .



Unveränderliche Trie



Aber unser Baum, den wir bauen wollen, hat mehrere Eigenschaften:



  • Kompaktheit

    Wir haben Dutzende / Hunderte von möglichen Elementen von ziemlich begrenzter Länge, daher kann es keine Situation mit einer großen Anzahl sehr langer, fast identischer Schlüssel geben, die sich nur im letzten Zeichen unterscheiden. Der längste unserer Schlüssel ist wahrscheinlich 'Parallel Index Only Scan Backward'.


  • . .


  • . , .
  • -

    , «» Garbage Collector'.


Die letzte Anforderung ist darauf zurückzuführen, dass die Analyse der Protokolle auf unseren Kollektoren im Streaming-Modus ohne Unterbrechungen durchgeführt wird. Und je weniger wir "wegwerfen" können, desto mehr Ressourcen werden wir für nützliche Aktivitäten verwenden, anstatt nach uns selbst aufzuräumen.



Zwei nützliche Funktionen helfen uns dabei:





Eine Karte erstellen



Schauen wir uns ein Beispiel an, wie Sie eine Karte erstellen können, um mit diesen beiden Operationen schnell die Elemente zu finden, die Sie aus dem ursprünglichen Satz benötigen: Hmm ... sie haben das gleiche "In" -Präfix !



Insert

Index Scan

Index Scan Backward

Index Only Scan

Index Only Scan Backward








//      Longest Common Prefix
let len, lcp;
for (let key of keys) {
  //  
  if (lcp === undefined) {
    len = key.length;
    lcp = key.slice(off);
    continue;
  }

  len = Math.min(len, key.length);
  // ,   "" LCP      
  if (lcp == '' || key.startsWith(lcp, off)) {
    continue;
  }
  //  LCP   
  for (let i = 0; i < lcp.length; i++) {
    if (lcp.charCodeAt(i) != key.charCodeAt(off + i)) {
      lcp = lcp.slice(0, i);
      break;
    }
  }
}


Und da es dasselbe ist, können wir durch Überprüfen der Symbole in keiner Weise neue Informationen erhalten - was bedeutet, dass wir nur die Symbole überprüfen müssen, die weiter gehen, bis zur Länge des kürzesten Elements . Sie helfen uns dabei, alle Elemente in mehrere Gruppen aufzuteilen: In diesem Fall spielte es keine Rolle, welches Symbol wir für die Aufteilung verwenden (z. B. 3. oder 5.) - die Zusammensetzung der Gruppen bleibt unverändert, sodass genau dieselbe Kombination der Aufteilung in Gruppen wiederholt wird es besteht keine Notwendigkeit zu verarbeiten :



Insert

Index Scan

Index Scan Backward

Index Only Scan

Index Only Scan Backward








//      
let grp = new Set();
res.pos = {};
for (let i = off + lcp.length; i < len; i++) {
  //      [i]-
  let chr = keys.reduce((rv, key) => {
    if (key.length < i) {
      return rv;
    }
    let ch = key.charCodeAt(i);
    rv[ch] = rv[ch] || [];
    rv[ch].push(key);
    return rv;
  }, {});

  //          
  let cmb = Object.values(chr)
    .map(seg => seg.join('\t'))
    .sort()
    .join('\n');
  if (grp.has(cmb)) {
    continue;
  }
  else {
    grp.add(cmb);
  }

  res.pos[i] = chr;
}


Optimale Metrik



Es bleibt nur zu verstehen - und wenn die Gruppen beim 3. und 5. Symbol unterschiedlich waren - welchen dieser Äste sollten Sie wählen? Zu diesem Zweck führen wir eine Metrik ein, die uns die Antwort auf diese Frage gibt - die Anzahl der Vergleiche einzelner Zeichen , um jeden der Schlüssel zu finden.



Hier vernachlässigen wir die Tatsache, dass einige der Knoten in den Plänen in der Realität viel häufiger vorkommen als andere, und wir betrachten sie als gleich.



, 3- 's', startsWith, , 6 , , Insert.

: 1 (.charCodeAt(2)) + 6 (.startsWith('Insert')) = 7 .



'd', 7-, , 'O' 'S'. — 'Index Scan Backward' (+19 ) 'Index Scan' (+10 ).



, 'Index Scan Backward', 19 , 'Index Scan' — 19 + 10 = 29.

: 1 (.charCodeAt(2)) + 1 (.charCodeAt(6)) + 19 + 29 (.startsWith(...)) = 50 .



In unserem Beispiel sieht die optimale Karte daher folgendermaßen aus:



{
  '$pos' : 2 //  3- 
, '$chr' : Map {
    100 => {         // 'd'
      '$pos' : 6 //  7- 
    , '$chr' : Map {
        79 => [ 'Index Only Scan Backward', 'Index Only Scan' ] // 'O'
      , 83 => [ 'Index Scan Backward', 'Index Scan' ]           // 'S'
      }
    }
  , 115 => 'Insert' // 's'
  }
}


Vzhuh!



Jetzt müssen Sie nur noch alles zusammenführen, eine Suchfunktion und einige Optimierungen hinzufügen - und Sie können Folgendes verwenden:



//   
const fill = (obj, off, hash) => {
  off = off || 0;
  hash = hash || {};

  let keys = obj.src;

  //        
  let H = keys.join('\n');
  hash[off] = hash[off] || {};
  if (hash[off][H]) {
    obj.res = hash[off][H];
    return;
  }
  obj.res = {};
  hash[off][H] = obj.res;

  let res = obj.res;

  //    -     
  if (keys.length == 1) {
    res.lst = [...keys];
    res.cmp = res.lst[0].length;
    return;
  }

  //      Longest Common Prefix
  let len, lcp;
  for (let key of keys) {
    //  
    if (lcp == undefined) {
      len = key.length;
      lcp = key.slice(off);
      continue;
    }

    len = Math.min(len, key.length);
    // ,   "" LCP      
    if (lcp == '' || key.startsWith(lcp, off)) {
      continue;
    }
    //  LCP   
    for (let i = 0; i < lcp.length; i++) {
      if (lcp.charCodeAt(i) != key.charCodeAt(off + i)) {
        lcp = lcp.slice(0, i);
        break;
      }
    }
  }

  //       
  if (off + lcp.length == len) {
    let cmp = 0;
    //       - 
    if (keys.length == 2) {
      res.lst = [...keys];
    }
    //  " "   
    else {
      res.src = keys.filter(key => key.length > off + lcp.length);
      res.lst = keys.filter(key => key.length <= off + lcp.length);
    }

    //    ,     ,   
    res.lst.sort((x, y) => y.length - x.length); // s.length DESC
    cmp += res.lst.reduce((rv, key, idx, keys) => rv + (keys.length - idx + 1) * key.length, 0);

    //    -  
    if (res.src && res.src.length) {
      fill(res, off + lcp.length + 1, hash);
      cmp += res.res.cmp;
    }
    res.cmp = cmp + 1;
    return;
  }

  //      
  let grp = new Set();
  res.pos = {};
  for (let i = off + lcp.length; i < len; i++) {
    //      [i]-
    let chr = keys.reduce((rv, key) => {
      if (key.length < i) {
        return rv;
      }
      let ch = key.charCodeAt(i);
      rv[ch] = rv[ch] || [];
      rv[ch].push(key);
      return rv;
    }, {});

    //          
    let cmb = Object.values(chr)
      .map(seg => seg.join('\t'))
      .sort()
      .join('\n');
    if (grp.has(cmb)) {
      continue;
    }
    else {
      grp.add(cmb);
    }

    let fl = true;
    let cmp = 0;
    for (let [ch, keys] of Object.entries(chr)) {
      //     
      if (keys.length == 1) {
        let key = keys[0];
        chr[ch] = key;
        cmp += key.length;
      }
      //       
      else {
        fl = false;
        chr[ch] = {src : keys};
        fill(chr[ch], i + 1, hash);
        cmp += chr[ch].res.cmp;
      }
    }

    res.pos[i] = {
      chr
    , cmp
    };

    //   "" 
    if (res.cmp === undefined || cmp + 1 < res.cmp) {
      res.cmp = cmp + 1;
      res.bst = i;
    }

    //       ,      
    if (fl) {
      res.bst = i;
      for (let j = off; j < i; j++) {
        delete res.pos[j];
      }
      break;
    }
  }
};

//      
const comp = obj => {
  //   
  delete obj.src;
  delete obj.cmp;
  if (obj.res) {
    let res = obj.res;
    if (res.pos !== undefined) {
      //      
      obj.$pos = res.bst;
      let $chr = res.pos[res.bst].chr;
      Object.entries($chr).forEach(([key, val]) => {
        //   
        comp(val);
        //      - ""    
        let keys = Object.keys(val);
        if (keys.length == 1 && keys[0] == '$lst') {
          $chr[key] = val.$lst;
        }
      });
      //    -  Map  -
      obj.$chr = new Map(Object.entries($chr).map(([key, val]) => [Number(key), val]));
    }
    //    ""     
    if (res.lst !== undefined) {
      obj.$lst = res.lst;
      delete res.lst;
      if (res.res !== undefined) {
        comp(res);
        Object.assign(obj, res);
      }
    }
    delete obj.res;
  }
};

//    -     
const find = (str, off, map) => {
  let curr = map;
  do {
    //    
    let $pos = curr.$pos;
    if ($pos !== undefined) {
      let next = curr.$chr.get(str.charCodeAt(off + $pos));
      if (typeof next === 'string') {   //  
        if (str.startsWith(next, off)) {
          return next;
        }
      }
      else if (next instanceof Array) { //    
        for (let key of next) {
          if (str.startsWith(key, off)) {
            return key;
          }
        }
      }
      else if (next !== undefined) {    //  map,  
        curr = next;
        continue;
      }
    }
    //    ,   
    if (curr.$lst) {
      for (let key of curr.$lst) {
        if (str.startsWith(key, off)) {
          return key;
        }
      }
    }
    return;
  }
  while (true);
};

function ImmutableTrie(keys) {
  this.map = {src : keys.sort((x, y) => x < y ? -1 : +1)};
  fill(this.map);
  comp(this.map);
}

const p = ImmutableTrie.prototype;

p.get = function(line, off) {
  return find(line, off || 0, this.map);
};

p.has = function(line, off) {
  return this.get(line, off) !== undefined;
};

module.exports = ImmutableTrie;


Wie Sie sehen können, wurde bei der Suche in einem solchen unveränderlichen Trie kein Tier verletzt , es werden keine neuen Objekte im Speicher erstellt, für die der GC dann gerne kommen würde.



Bonus: Jetzt können wir das gewünschte Präfix erhalten, ohne es .slicein der ursprünglichen Zeile tun zu müssen , auch wenn wir wissen, dass es am Anfang traditionell für den Plan etwas Fremdes hat:



const nodeIT = new ImmutableTrie(...);
nodeIT.get('  ->  Parallel Seq Scan on abc', 6); // 'Parallel Seq Scan'


Nun, wenn wir bereits genau auf die gleiche Weise entschieden haben, wo der Plan beginnt (aber mit Hilfe der Trie-Attributnamen), definieren wir die Zeilen, die der Anfang des Knotenattributs sind und die Fortsetzung der mehrzeiligen Zeichenfolge sind, und "kleben" sie:



Index Scan using pg_class_relname_nsp_index on pg_class  (cost=0.29..2.54 rows=1 width=265) (actual time=0.014..0.014 rows=0 loops=1)
  Index Cond: (relname = '\n\n'::name)
  Buffers: shared hit=2


Nun, in dieser Form ist es viel einfacher, es zu zerlegen.



All Articles