Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Η επίδραση των φουσκωμένων πινάκων και δεικτών (bloat) είναι ευρέως γνωστή και δεν υπάρχει μόνο στο Postgres. Υπάρχουν τρόποι να το αντιμετωπίσετε "εκτός συσκευασίας", όπως το VACUUM FULL ή το CLUSTER, αλλά κλειδώνουν τραπέζια κατά τη λειτουργία και επομένως δεν μπορούν πάντα να χρησιμοποιηθούν.

Το άρθρο θα έχει κάποια θεωρία σχετικά με το πώς εμφανίζεται το bloat, πώς μπορείτε να το αντιμετωπίσετε, σχετικά με τους αναβαλλόμενους περιορισμούς και τα προβλήματα που δημιουργούν στη χρήση της επέκτασης pg_repack.

Αυτό το άρθρο βασίζεται σε ο λόγος μου στο PgConf.Russia 2020.

Γιατί υπάρχει φούσκωμα

Το Postgres βασίζεται σε ένα μοντέλο πολλαπλών εκδόσεων (MVCC). Η ουσία του είναι ότι κάθε σειρά στον πίνακα μπορεί να έχει πολλές εκδόσεις, ενώ οι συναλλαγές δεν βλέπουν περισσότερες από μία από αυτές τις εκδόσεις, αλλά όχι απαραίτητα την ίδια. Αυτό επιτρέπει σε πολλές συναλλαγές να λειτουργούν ταυτόχρονα και να έχουν ελάχιστη έως καθόλου επίδραση η μία στην άλλη.

Προφανώς, όλες αυτές οι εκδόσεις πρέπει να διατηρηθούν. Το Postgres λειτουργεί με μνήμη σελίδα προς σελίδα και μια σελίδα είναι η ελάχιστη ποσότητα δεδομένων που μπορεί να διαβαστεί από το δίσκο ή να εγγραφεί. Ας δούμε ένα μικρό παράδειγμα για να καταλάβουμε πώς συμβαίνει αυτό.

Ας πούμε ότι έχουμε έναν πίνακα στον οποίο έχουμε προσθέσει αρκετές εγγραφές. Η πρώτη σελίδα του αρχείου όπου είναι αποθηκευμένος ο πίνακας έχει νέα δεδομένα. Αυτές είναι ζωντανές εκδόσεις σειρών που είναι διαθέσιμες σε άλλες συναλλαγές μετά την δέσμευση (για λόγους απλότητας, θα υποθέσουμε ότι το επίπεδο απομόνωσης είναι Ανάγνωση δεσμευμένη).

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Στη συνέχεια, ενημερώσαμε μία από τις καταχωρίσεις και έτσι επισημάναμε την παλιά έκδοση ως μη ενημερωμένη.

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Βήμα προς βήμα, ενημερώνοντας και διαγράφοντας τις εκδόσεις σειρών, έχουμε μια σελίδα στην οποία περίπου τα μισά δεδομένα είναι "σκουπίδια". Αυτά τα δεδομένα δεν είναι ορατά σε καμία συναλλαγή.

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Η Postgres έχει μηχανισμό ΚΕΝΟ, το οποίο καθαρίζει τις παλιές εκδόσεις και αφήνει χώρο για νέα δεδομένα. Αλλά εάν δεν έχει ρυθμιστεί αρκετά επιθετικά ή είναι απασχολημένος με την εργασία σε άλλους πίνακες, τότε τα "δεδομένα σκουπιδιών" παραμένουν και πρέπει να χρησιμοποιήσουμε πρόσθετες σελίδες για νέα δεδομένα.

Έτσι, στο παράδειγμά μας, κάποια στιγμή, ο πίνακας θα αποτελείται από τέσσερις σελίδες, αλλά θα υπάρχουν μόνο τα μισά από τα ζωντανά δεδομένα σε αυτόν. Ως αποτέλεσμα, κατά την πρόσβαση στον πίνακα, θα διαβάσουμε πολύ περισσότερα δεδομένα από όσα χρειάζεται.

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Ακόμα κι αν το VACUUM αφαιρέσει τώρα όλες τις άσχετες εκδόσεις σειρών, η κατάσταση δεν θα βελτιωθεί δραματικά. Θα έχουμε ελεύθερο χώρο σε σελίδες ή ακόμα και ολόκληρες σελίδες για νέες γραμμές, αλλά θα συνεχίσουμε να διαβάζουμε περισσότερα δεδομένα από όσα χρειαζόμαστε.
Παρεμπιπτόντως, εάν μια εντελώς κενή σελίδα (η δεύτερη στο παράδειγμά μας) ήταν στο τέλος του αρχείου, τότε το VACUUM θα μπορούσε να το κόψει. Τώρα όμως είναι στη μέση, οπότε τίποτα δεν μπορεί να γίνει μαζί της.

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Όταν ο αριθμός τέτοιων κενών ή πολύ αραιών σελίδων γίνεται μεγάλος, κάτι που ονομάζεται bloat, αρχίζει να επηρεάζει την απόδοση.

Όλα όσα περιγράφονται παραπάνω είναι η μηχανική της εμφάνισης φουσκώματος στους πίνακες. Στα ευρετήρια, αυτό συμβαίνει σχεδόν με τον ίδιο τρόπο.

Έχω φούσκωμα;

Υπάρχουν διάφοροι τρόποι για να διαπιστώσετε εάν έχετε φούσκωμα. Η ιδέα του πρώτου είναι να χρησιμοποιήσετε εσωτερικά στατιστικά στοιχεία της Postgres, τα οποία περιέχουν κατά προσέγγιση πληροφορίες σχετικά με τον αριθμό των σειρών στους πίνακες, τον αριθμό των "ζωντανών" σειρών κ.λπ. Υπάρχουν πολλές παραλλαγές έτοιμων σεναρίων στο Διαδίκτυο. Πήραμε ως βάση γραφή από την PostgreSQL Experts, η οποία μπορεί να αξιολογήσει το bloat τραπέζι μαζί με τους δείκτες toast και bloat btree. Σύμφωνα με την εμπειρία μας, το σφάλμα του είναι 10-20%.

Ένας άλλος τρόπος είναι να χρησιμοποιήσετε την επέκταση pgstattuple, που σας επιτρέπει να κοιτάξετε μέσα στις σελίδες και να λάβετε τόσο την εκτιμώμενη όσο και την ακριβή τιμή του bloat. Αλλά στη δεύτερη περίπτωση, θα πρέπει να σαρώσετε ολόκληρο τον πίνακα.

Μια μικρή ποσότητα φουσκώματος, έως και 20%, είναι αποδεκτή. Μπορεί να θεωρηθεί ως ανάλογο του παράγοντα πλήρωσης για τραπέζια и δείκτες. Στο 50% και άνω, ενδέχεται να ξεκινήσουν προβλήματα απόδοσης.

Τρόποι αντιμετώπισης του φουσκώματος

Υπάρχουν αρκετοί out-of-the-box τρόποι για να αντιμετωπίσετε το φούσκωμα στο Postgres, αλλά απέχουν πολύ από πάντα και μπορεί να μην ταιριάζουν σε όλους.

Ρυθμίστε το AUTOVACUUM έτσι ώστε να μην εμφανίζεται φούσκωμα. Και για να είμαι πιο ακριβής, να το κρατήσω σε αποδεκτό επίπεδο για εσάς. Αυτό φαίνεται σαν μια συμβουλή «καπετάνιου», αλλά στην πραγματικότητα αυτό δεν είναι πάντα εύκολο να επιτευχθεί. Για παράδειγμα, έχετε μια ενεργή ανάπτυξη με μια τακτική αλλαγή στο σχήμα δεδομένων ή πραγματοποιείται κάποιο είδος μετεγκατάστασης δεδομένων. Ως αποτέλεσμα, το προφίλ φόρτωσης μπορεί να αλλάζει συχνά και τείνει να είναι διαφορετικό για διαφορετικούς πίνακες. Αυτό σημαίνει ότι πρέπει να είστε συνεχώς λίγο μπροστά από την καμπύλη και να προσαρμόζετε το AUTOVACUUM στο μεταβαλλόμενο προφίλ κάθε τραπεζιού. Αλλά είναι προφανές ότι αυτό δεν είναι εύκολο να γίνει.

Ένας άλλος συνηθισμένος λόγος που το AUTOVACUUM αποτυγχάνει να επεξεργαστεί πίνακες είναι η παρουσία μακροχρόνιων συναλλαγών που το εμποδίζουν να καθαρίσει τα δεδομένα λόγω του γεγονότος ότι είναι διαθέσιμο σε αυτές τις συναλλαγές. Η σύσταση εδώ είναι επίσης προφανής - απαλλαγείτε από τις «κρεμαστές» συναλλαγές και ελαχιστοποιήστε τον χρόνο των ενεργών συναλλαγών. Αλλά αν το φορτίο στην εφαρμογή σας είναι ένα υβρίδιο OLAP και OLTP, τότε μπορείτε να έχετε ταυτόχρονα πολλές συχνές ενημερώσεις και σύντομα ερωτήματα, καθώς και μακροπρόθεσμες λειτουργίες - για παράδειγμα, δημιουργία αναφοράς. Σε μια τέτοια κατάσταση, θα πρέπει να σκεφτείτε την κατανομή του φορτίου σε διαφορετικές βάσεις, κάτι που θα σας επιτρέψει να ρυθμίσετε με ακρίβεια κάθε μία από αυτές.

Ένα άλλο παράδειγμα - ακόμα κι αν το προφίλ είναι ομοιογενές, αλλά η βάση δεδομένων είναι υπό πολύ υψηλό φορτίο, τότε ακόμη και το πιο επιθετικό AUTOVACUUM μπορεί να μην είναι σε θέση να αντεπεξέλθει και θα προκληθεί φούσκωμα. Η κλιμάκωση (κάθετη ή οριζόντια) είναι η μόνη λύση.

Πώς να βρεθείτε σε μια κατάσταση όταν ρυθμίσατε το AUTOVACUUM, αλλά το φούσκωμα συνεχίζει να αυξάνεται.

Ομάδα ΚΕΝΟ ΠΛΗΡΕΣ αναδημιουργεί τα περιεχόμενα των πινάκων και των ευρετηρίων και αφήνει μόνο ενημερωμένα δεδομένα σε αυτά. Για την εξάλειψη του bloat, λειτουργεί άψογα, αλλά κατά την εκτέλεσή του, καταγράφεται ένα αποκλειστικό κλείδωμα στο τραπέζι (AccessExclusiveLock), το οποίο δεν θα επιτρέπει ερωτήματα σε αυτόν τον πίνακα, ακόμη και επιλέγει. Εάν έχετε την οικονομική δυνατότητα να σταματήσετε την υπηρεσία σας ή μέρος αυτής για κάποιο χρονικό διάστημα (από δεκάδες λεπτά έως αρκετές ώρες ανάλογα με το μέγεθος της βάσης δεδομένων και το υλικό σας), τότε αυτή η επιλογή είναι η καλύτερη. Δυστυχώς, δεν έχουμε χρόνο να τρέξουμε το VACUUM FULL κατά την προγραμματισμένη συντήρηση, οπότε αυτή η μέθοδος δεν μας ταιριάζει.

Ομάδα CLUSTER αναδημιουργεί τα περιεχόμενα των πινάκων με τον ίδιο τρόπο όπως το VACUUM FULL, ενώ σας επιτρέπει να καθορίσετε ένα ευρετήριο σύμφωνα με το οποίο τα δεδομένα θα ταξινομηθούν φυσικά στο δίσκο (αλλά η σειρά δεν είναι εγγυημένη για νέες σειρές στο μέλλον). Σε ορισμένες περιπτώσεις, αυτή είναι μια καλή βελτιστοποίηση για έναν αριθμό ερωτημάτων - με την ανάγνωση πολλών εγγραφών στο ευρετήριο. Το μειονέκτημα της εντολής είναι το ίδιο με αυτό της VACUUM FULL - κλειδώνει το τραπέζι κατά τη λειτουργία.

Ομάδα REINDEX παρόμοια με τα δύο προηγούμενα, αλλά δημιουργεί εκ νέου ένα συγκεκριμένο ευρετήριο ή όλα τα ευρετήρια σε έναν πίνακα. Οι κλειδαριές είναι ελαφρώς πιο αδύναμες: το ShareLock σε έναν πίνακα (αποτρέπει τις τροποποιήσεις, αλλά επιτρέπει τις επιλογές) και το AccessExclusiveLock σε ένα ευρετήριο με δυνατότητα ανακατασκευής (αποκλείει ερωτήματα χρησιμοποιώντας αυτό το ευρετήριο). Ωστόσο, η Postgres 12 εισήγαγε την επιλογή ΣΥΓΧΡΟΝΩΣ, το οποίο σας επιτρέπει να δημιουργήσετε ξανά ένα ευρετήριο χωρίς να αποκλείσετε την ταυτόχρονη προσθήκη, τροποποίηση ή διαγραφή εγγραφών.

Σε προηγούμενες εκδόσεις του Postgres, μπορείτε να επιτύχετε ένα αποτέλεσμα παρόμοιο με το REINDEX ΤΥΓΟΡΑ με ΔΗΜΙΟΥΡΓΗΣΤΕ ΕΥΡΕΤΗ ΣΥΓΧΡΟΝΑ. Σας επιτρέπει να δημιουργήσετε ένα ευρετήριο χωρίς ισχυρό κλείδωμα (ShareUpdateExclusiveLock, το οποίο δεν παρεμβαίνει σε παράλληλα ερωτήματα), στη συνέχεια να αντικαταστήσετε το παλιό ευρετήριο με ένα νέο και να διαγράψετε το παλιό ευρετήριο. Αυτό σας επιτρέπει να εξαλείψετε το bloat του δείκτη χωρίς να παρεμβαίνετε στην εφαρμογή σας. Είναι σημαντικό να λάβετε υπόψη ότι κατά την ανακατασκευή ευρετηρίων, θα υπάρχει πρόσθετο φορτίο στο υποσύστημα του δίσκου.

Έτσι, αν υπάρχουν τρόποι για τους δείκτες για την εξάλειψη του καυτού φουσκώματος, τότε δεν υπάρχουν για πίνακες. Εδώ μπαίνουν στο παιχνίδι οι εξωτερικές επεκτάσεις: pg_repack (πρώην pg_reorg), pgcompact, pgcompacttable και άλλοι. Στα πλαίσια αυτού του άρθρου, δεν θα τα συγκρίνω και θα μιλήσω μόνο για το pg_repack, το οποίο, μετά από κάποια βελτίωση, χρησιμοποιούμε στο σπίτι.

Πώς λειτουργεί το pg_repack

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί
Ας πούμε ότι έχουμε έναν αρκετά συνηθισμένο πίνακα - με δείκτες, περιορισμούς και, δυστυχώς, με bloat. Ως πρώτο βήμα, το pg_repack δημιουργεί έναν πίνακα καταγραφής για να παρακολουθεί όλες τις αλλαγές ενώ εκτελείται. Το έναυσμα θα αναπαράγει αυτές τις αλλαγές σε κάθε εισαγωγή, ενημέρωση και διαγραφή. Στη συνέχεια δημιουργείται ένας πίνακας που μοιάζει με τον αρχικό στη δομή, αλλά χωρίς ευρετήρια και περιορισμούς, ώστε να μην επιβραδύνεται η διαδικασία εισαγωγής δεδομένων.

Στη συνέχεια, το pg_repack μεταφέρει δεδομένα από τον παλιό πίνακα στον νέο πίνακα, φιλτράροντας αυτόματα όλες τις άσχετες σειρές και, στη συνέχεια, δημιουργεί ευρετήρια για τον νέο πίνακα. Κατά την εκτέλεση όλων αυτών των λειτουργιών, οι αλλαγές συσσωρεύονται στον πίνακα καταγραφής.

Το επόμενο βήμα είναι να μεταφέρετε τις αλλαγές στον νέο πίνακα. Η μετεγκατάσταση γίνεται σε πολλές επαναλήψεις και όταν απομένουν λιγότερες από 20 καταχωρήσεις στον πίνακα καταγραφής, το pg_repack αποκτά ένα ισχυρό κλείδωμα, μετεγκαθιστά τα πιο πρόσφατα δεδομένα και αντικαθιστά τον παλιό πίνακα με τον νέο στους πίνακες συστήματος Postgres. Αυτή είναι η μόνη και πολύ σύντομη στιγμή που δεν θα μπορείτε να δουλέψετε με το τραπέζι. Μετά από αυτό, ο παλιός πίνακας και ο πίνακας με τα αρχεία καταγραφής διαγράφονται και ελευθερώνεται χώρος στο σύστημα αρχείων. Η διαδικασία ολοκληρώθηκε.

Θεωρητικά, όλα φαίνονται υπέροχα, αλλά τι γίνεται στην πράξη; Δοκιμάσαμε το pg_repack χωρίς φορτίο και υπό φορτίο και ελέγξαμε τη λειτουργία του σε περίπτωση πρόωρης διακοπής (με άλλα λόγια, με Ctrl+C). Όλα τα τεστ ήταν θετικά.

Πήγαμε στο prod - και μετά όλα πήγαν στραβά, όπως περιμέναμε.

Πρώτη τηγανίτα σε πώληση

Στο πρώτο σύμπλεγμα, λάβαμε ένα σφάλμα σχετικά με μια μοναδική παραβίαση περιορισμών:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

Αυτός ο περιορισμός είχε το όνομα που δημιουργήθηκε αυτόματα index_16508, που δημιουργήθηκε από το pg_repack. Με τα χαρακτηριστικά που περιλαμβάνονται στη σύνθεσή του, προσδιορίσαμε τον «δικό μας» περιορισμό που αντιστοιχεί σε αυτόν. Το πρόβλημα αποδείχθηκε ότι δεν πρόκειται για έναν συνηθισμένο περιορισμό, αλλά για έναν καθυστερημένο περιορισμό (αναβαλλόμενος περιορισμός), δηλ. Η επικύρωσή του εκτελείται αργότερα από την εντολή sql, η οποία οδηγεί σε απροσδόκητες συνέπειες.

Αναβαλλόμενοι περιορισμοί: γιατί χρειάζονται και πώς λειτουργούν

Λίγη θεωρία για τους αναβαλλόμενους περιορισμούς.
Εξετάστε ένα απλό παράδειγμα: έχουμε έναν πίνακα καταλόγου αυτοκινήτου με δύο χαρακτηριστικά - το όνομα και τη σειρά του αυτοκινήτου στον κατάλογο.
Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

create table cars
(
  name text constraint pk_cars primary key,
  ord integer not null constraint uk_cars unique
);



Ας υποθέσουμε ότι έπρεπε να αλλάξουμε το πρώτο και το δεύτερο αυτοκίνητο σε θέσεις. Η γενική λύση είναι να ενημερώσετε την πρώτη τιμή στη δεύτερη και τη δεύτερη στην πρώτη:

begin;
  update cars set ord = 2 where name = 'audi';
  update cars set ord = 1 where name = 'bmw';
commit;

Αλλά όταν εκτελούμε αυτόν τον κώδικα, αναμένεται να λάβουμε μια παραβίαση περιορισμών επειδή η σειρά των τιμών στον πίνακα είναι μοναδική:

[23305] ERROR: duplicate key value violates unique constraint “uk_cars”
Detail: Key (ord)=(2) already exists.

Πώς να το κάνετε διαφορετικά; Επιλογή πρώτη: προσθέστε μια επιπλέον αντικατάσταση της τιμής με μια παραγγελία που είναι εγγυημένη ότι δεν υπάρχει στον πίνακα, για παράδειγμα "-1". Στον προγραμματισμό, αυτό ονομάζεται "ανταλλαγή των τιμών δύο μεταβλητών μέσω μιας τρίτης". Το μόνο μειονέκτημα αυτής της μεθόδου είναι η επιπλέον ενημέρωση.

Επιλογή δύο: Επανασχεδιάστε τον πίνακα για να χρησιμοποιήσετε έναν τύπο δεδομένων κινητής υποδιαστολής για την τιμή του εκθέτη αντί για ακέραιους. Στη συνέχεια, κατά την ενημέρωση της τιμής από 1, για παράδειγμα, σε 2.5, η πρώτη καταχώρηση θα «σταθεί» αυτόματα μεταξύ της δεύτερης και της τρίτης. Αυτή η λύση λειτουργεί, αλλά υπάρχουν δύο περιορισμοί. Πρώτον, δεν θα λειτουργήσει για εσάς εάν η τιμή χρησιμοποιείται κάπου στη διεπαφή. Δεύτερον, ανάλογα με την ακρίβεια του τύπου δεδομένων, θα έχετε περιορισμένο αριθμό πιθανών εισαγωγών πριν υπολογίσετε ξανά τις τιμές όλων των εγγραφών.

Επιλογή τρίτη: να αναβληθεί ο περιορισμός έτσι ώστε να ελέγχεται μόνο τη στιγμή της δέσμευσης:

create table cars
(
  name text constraint pk_cars primary key,
  ord integer not null constraint uk_cars unique deferrable initially deferred
);

Δεδομένου ότι η λογική του αρχικού μας αιτήματος διασφαλίζει ότι όλες οι τιμές είναι μοναδικές μέχρι τη στιγμή που θα γίνει η δέσμευση, η δέσμευση θα πετύχει.

Το παράδειγμα που συζητήθηκε παραπάνω είναι, φυσικά, πολύ συνθετικό, αλλά αποκαλύπτει την ιδέα. Στην εφαρμογή μας, χρησιμοποιούμε αναβαλλόμενους περιορισμούς για να εφαρμόσουμε τη λογική που είναι υπεύθυνη για την επίλυση διενέξεων όταν οι χρήστες αλληλεπιδρούν με κοινόχρηστα αντικείμενα γραφικών στοιχείων στον πίνακα ταυτόχρονα. Η χρήση τέτοιων περιορισμών μας επιτρέπει να κάνουμε τον κώδικα της εφαρμογής λίγο πιο απλό.

Γενικά, ανάλογα με τον τύπο περιορισμού στο Postgres, υπάρχουν τρία επίπεδα ευαισθησίας της επικύρωσής τους: γραμμή, συναλλαγή και έκφραση.
Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί
Πηγή: παρακαλώ

Το CHECK και το NOT NULL ελέγχονται πάντα σε επίπεδο σειράς, για άλλους περιορισμούς, όπως φαίνεται από τον πίνακα, υπάρχουν διαφορετικές επιλογές. Μπορείτε να διαβάσετε περισσότερα εδώ.

Για να συνοψίσουμε εν συντομία, οι αναβαλλόμενοι περιορισμοί σε ορισμένες περιπτώσεις οδηγούν σε πιο ευανάγνωστο κώδικα και λιγότερες εντολές. Ωστόσο, πρέπει να πληρώσετε για αυτό περιπλέκοντας τη διαδικασία εντοπισμού σφαλμάτων, καθώς η στιγμή που παρουσιάζεται το σφάλμα και η στιγμή που θα το μάθετε διαχωρίζονται χρονικά. Ένα άλλο πιθανό πρόβλημα είναι ότι ο προγραμματιστής μπορεί να μην είναι πάντα σε θέση να δημιουργήσει ένα βέλτιστο σχέδιο εάν εμπλέκεται καθυστερημένος περιορισμός στο ερώτημα.

Βελτίωση pg_repack

Καλύψαμε τι είναι οι αναβαλλόμενοι περιορισμοί, αλλά πώς σχετίζονται με το πρόβλημά μας; Θυμηθείτε το σφάλμα που είχαμε νωρίτερα:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

Εμφανίζεται όταν τα δεδομένα αντιγράφονται από τον πίνακα καταγραφής σε έναν νέο πίνακα. Αυτό φαίνεται παράξενο, γιατί Τα δεδομένα στον πίνακα καταγραφής δεσμεύονται μαζί με τα δεδομένα στον αρχικό πίνακα. Εάν ικανοποιούν τους περιορισμούς του αρχικού πίνακα, πώς μπορούν να παραβιάσουν τους ίδιους περιορισμούς στον νέο;

Όπως αποδείχθηκε, η ρίζα του προβλήματος βρίσκεται στο προηγούμενο βήμα του pg_repack, το οποίο δημιουργεί μόνο ευρετήρια, αλλά όχι περιορισμούς: ο παλιός πίνακας είχε έναν μοναδικό περιορισμό και ο νέος δημιούργησε ένα μοναδικό ευρετήριο.

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Είναι σημαντικό να σημειωθεί εδώ ότι εάν ο περιορισμός είναι κανονικός και δεν έχει αναβληθεί, τότε ο μοναδικός δείκτης που δημιουργείται αντί αυτού είναι ισοδύναμος με αυτόν τον περιορισμό, επειδή Οι μοναδικοί περιορισμοί στο Postgres υλοποιούνται με τη δημιουργία ενός μοναδικού ευρετηρίου. Αλλά στην περίπτωση καθυστερημένου περιορισμού, η συμπεριφορά δεν είναι η ίδια, επειδή το ευρετήριο δεν μπορεί να αναβληθεί και ελέγχεται πάντα τη στιγμή που εκτελείται η εντολή sql.

Έτσι, η ουσία του προβλήματος έγκειται στον «αναβαλλόμενο» έλεγχο: στον αρχικό πίνακα, εμφανίζεται τη στιγμή της δέσμευσης και στον νέο, τη στιγμή της εκτέλεσης της εντολής sql. Πρέπει λοιπόν να φροντίσουμε οι έλεγχοι να γίνονται με τον ίδιο τρόπο και στις δύο περιπτώσεις: είτε πάντα με καθυστέρηση, είτε πάντα άμεσα.

Τι ιδέες είχαμε λοιπόν.

Δημιουργήστε ευρετήριο παρόμοιο με το deferred

Η πρώτη ιδέα είναι να εκτελέσετε και τους δύο ελέγχους σε άμεση λειτουργία. Αυτό μπορεί να οδηγήσει σε πολλά ψευδώς θετικά στοιχεία του περιορισμού, αλλά αν υπάρχουν λίγα από αυτά, τότε αυτό δεν θα πρέπει να επηρεάσει την εργασία των χρηστών, καθώς τέτοιες συγκρούσεις είναι μια φυσιολογική κατάσταση για αυτούς. Εμφανίζονται, για παράδειγμα, όταν δύο χρήστες αρχίζουν να επεξεργάζονται το ίδιο γραφικό στοιχείο ταυτόχρονα και ο πελάτης του δεύτερου χρήστη δεν έχει χρόνο να λάβει πληροφορίες ότι το γραφικό στοιχείο είναι ήδη αποκλεισμένο για επεξεργασία από τον πρώτο χρήστη. Σε μια τέτοια περίπτωση, ο διακομιστής απαντά στον δεύτερο χρήστη με μια άρνηση και ο πελάτης του επαναφέρει τις αλλαγές και κλειδώνει το γραφικό στοιχείο. Λίγο αργότερα, όταν ο πρώτος χρήστης ολοκληρώσει την επεξεργασία, ο δεύτερος θα λάβει πληροφορίες ότι το widget δεν είναι πλέον αποκλεισμένο και θα μπορεί να επαναλάβει τη δράση του.

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Για να διασφαλίσουμε ότι οι έλεγχοι είναι πάντα σε μη αναβαλλόμενη λειτουργία, δημιουργήσαμε ένα νέο ευρετήριο παρόμοιο με τον αρχικό αναβαλλόμενο περιορισμό:

CREATE UNIQUE INDEX CONCURRENTLY uk_tablename__immediate ON tablename (id, index);
-- run pg_repack
DROP INDEX CONCURRENTLY uk_tablename__immediate;

Στο περιβάλλον δοκιμής, λάβαμε μόνο μερικά αναμενόμενα σφάλματα. Επιτυχία! Τρέξαμε ξανά το pg_repack στο prod και λάβαμε 5 σφάλματα στο πρώτο σύμπλεγμα σε μια ώρα εργασίας. Αυτό είναι ένα αποδεκτό αποτέλεσμα. Ωστόσο, ήδη στο δεύτερο σύμπλεγμα, ο αριθμός των σφαλμάτων αυξήθηκε σημαντικά και έπρεπε να σταματήσουμε το pg_repack.

Γιατί συνέβη? Η πιθανότητα εμφάνισης σφάλματος εξαρτάται από το πόσοι χρήστες εργάζονται ταυτόχρονα με τα ίδια widgets. Προφανώς, εκείνη τη στιγμή, υπήρχαν πολύ λιγότερες ανταγωνιστικές αλλαγές με τα δεδομένα που ήταν αποθηκευμένα στο πρώτο σύμπλεγμα από ό,τι στο υπόλοιπο, δηλ. είμαστε απλώς «τυχεροί».

Η ιδέα δεν λειτούργησε. Εκείνη τη στιγμή, είδαμε δύο άλλες λύσεις: να ξαναγράψουμε τον κώδικα της εφαρμογής μας για να εγκαταλείψουμε τους αναβαλλόμενους περιορισμούς ή να "διδάξουμε" στο pg_repack να εργάζεται με αυτούς. Εμείς επιλέξαμε το δεύτερο.

Αντικαταστήστε τα ευρετήρια σε νέο πίνακα με αναβαλλόμενους περιορισμούς από τον αρχικό πίνακα

Ο σκοπός της αναθεώρησης ήταν προφανής - εάν ο αρχικός πίνακας έχει αναβαλλόμενο περιορισμό, τότε για τον νέο είναι απαραίτητο να δημιουργηθεί ένας τέτοιος περιορισμός και όχι ένας δείκτης.

Για να δοκιμάσουμε τις αλλαγές μας, γράψαμε ένα απλό τεστ:

  • πίνακας με αναβαλλόμενο περιορισμό και μία εγγραφή.
  • εισάγουμε δεδομένα στον βρόχο που έρχονται σε διένεξη με την υπάρχουσα εγγραφή.
  • κάντε μια ενημέρωση - τα δεδομένα δεν έρχονται πλέον σε διένεξη.
  • διαπράττουν αλλαγές.

create table test_table
(
  id serial,
  val int,
  constraint uk_test_table__val unique (val) deferrable initially deferred 
);

INSERT INTO test_table (val) VALUES (0);
FOR i IN 1..10000 LOOP
  BEGIN
    INSERT INTO test_table VALUES (0) RETURNING id INTO v_id;
    UPDATE test_table set val = i where id = v_id;
    COMMIT;
  END;
END LOOP;

Η αρχική έκδοση του pg_repack πάντα κολλούσε στο πρώτο ένθετο, η τροποποιημένη έκδοση λειτουργούσε χωρίς σφάλματα. Εξαιρετική.

Πηγαίνουμε στην παραγωγή και πάλι λαμβάνουμε ένα σφάλμα στην ίδια φάση αντιγραφής δεδομένων από τον πίνακα καταγραφής σε νέο:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

Μια κλασική κατάσταση: όλα λειτουργούν σε περιβάλλοντα δοκιμής, αλλά όχι στην παραγωγή;!

APPLY_COUNT και η διασταύρωση δύο παρτίδων

Αρχίσαμε να αναλύουμε τον κώδικα κυριολεκτικά γραμμή προς γραμμή και ανακαλύψαμε ένα σημαντικό σημείο: τα δεδομένα μεταφέρονται από τον πίνακα καταγραφής στον νέο κατά παρτίδες, η σταθερά APPLY_COUNT έδειξε το μέγεθος της παρτίδας:

for (;;)
{
num = apply_log(connection, table, APPLY_COUNT);

if (num > MIN_TUPLES_BEFORE_SWITCH)
     continue;  /* there might be still some tuples, repeat. */
...
}

Το πρόβλημα είναι ότι τα δεδομένα της αρχικής συναλλαγής, στην οποία πολλές λειτουργίες ενδέχεται να παραβιάσουν τον περιορισμό, μπορούν να καταλήξουν στη διασταύρωση δύο παρτίδων κατά τη μεταφορά - οι μισές εντολές θα δεσμευτούν στην πρώτη παρτίδα και οι άλλες μισές στο δεύτερο. Και εδώ, πόσο τυχερός: αν οι ομάδες της πρώτης παρτίδας δεν παραβιάσουν τίποτα, τότε όλα είναι καλά, αλλά αν το κάνουν, προκύπτει σφάλμα.

Το APPLY_COUNT ισούται με 1000 εγγραφές, γεγονός που εξηγεί γιατί οι δοκιμές μας ήταν επιτυχείς - δεν κάλυπταν την περίπτωση "διασταύρωσης παρτίδας". Χρησιμοποιήσαμε δύο εντολές - εισαγωγή και ενημέρωση, έτσι ακριβώς 500 συναλλαγές δύο εντολών τοποθετούνταν πάντα σε μια παρτίδα και δεν αντιμετωπίσαμε προβλήματα. Μετά την προσθήκη της δεύτερης ενημέρωσης, η επεξεργασία μας σταμάτησε να λειτουργεί:

FOR i IN 1..10000 LOOP
  BEGIN
    INSERT INTO test_table VALUES (1) RETURNING id INTO v_id;
    UPDATE test_table set val = i where id = v_id;
    UPDATE test_table set val = i where id = v_id; -- one more update
    COMMIT;
  END;
END LOOP;

Έτσι, η επόμενη εργασία είναι να βεβαιωθείτε ότι τα δεδομένα από τον αρχικό πίνακα που άλλαξε σε μία συναλλαγή εισέρχονται στον νέο πίνακα επίσης μέσα σε μία συναλλαγή.

Απόρριψη παρτίδας

Και πάλι είχαμε δύο λύσεις. Πρώτον: ας αρνηθούμε να χωριστούμε σε παρτίδες και να κάνουμε τη μεταφορά δεδομένων μία μόνο συναλλαγή. Υπέρ αυτής της απόφασης ήταν η απλότητά της - οι απαιτούμενες αλλαγές κώδικα είναι ελάχιστες (παρεμπιπτόντως, σε παλαιότερες εκδόσεις τότε, το pg_reorg λειτουργούσε έτσι). Αλλά υπάρχει ένα πρόβλημα - δημιουργούμε μια μακροπρόθεσμη συναλλαγή και αυτό, όπως αναφέρθηκε προηγουμένως, αποτελεί απειλή για την εμφάνιση ενός νέου φουσκώματος.

Η δεύτερη λύση είναι πιο περίπλοκη, αλλά πιθανώς πιο σωστή: δημιουργήστε μια στήλη στον πίνακα καταγραφής με το αναγνωριστικό της συναλλαγής που πρόσθεσε δεδομένα στον πίνακα. Στη συνέχεια, κατά την αντιγραφή δεδομένων, μπορούμε να τα ομαδοποιήσουμε με βάση αυτό το χαρακτηριστικό και να διασφαλίσουμε ότι οι σχετικές αλλαγές μεταφέρονται μαζί. Η παρτίδα θα σχηματιστεί από πολλές συναλλαγές (ή μία μεγάλη) και το μέγεθός της θα ποικίλλει ανάλογα με το πόσα δεδομένα έχουν αλλάξει σε αυτές τις συναλλαγές. Είναι σημαντικό να σημειωθεί ότι δεδομένου ότι τα δεδομένα διαφορετικών συναλλαγών εισέρχονται στον πίνακα καταγραφής με τυχαία σειρά, δεν θα είναι πλέον δυνατή η διαδοχική ανάγνωση, όπως ήταν πριν. Το seqscan σε κάθε αίτημα που φιλτράρεται από το tx_id είναι πολύ ακριβό, χρειάζεστε ένα ευρετήριο, αλλά θα επιβραδύνει επίσης τη μέθοδο λόγω του γενικού κόστους της ενημέρωσης. Γενικά, όπως πάντα, πρέπει να θυσιάσετε κάτι.

Έτσι, αποφασίσαμε να ξεκινήσουμε με την πρώτη επιλογή, ως πιο απλή. Πρώτον, ήταν απαραίτητο να κατανοήσουμε εάν μια μακροχρόνια συναλλαγή θα ήταν πραγματικό πρόβλημα. Δεδομένου ότι η κύρια μεταφορά δεδομένων από τον παλιό πίνακα στον νέο πραγματοποιείται επίσης σε μια μεγάλη συναλλαγή, το ερώτημα έχει μετατραπεί σε "πόσο θα αυξήσουμε αυτήν τη συναλλαγή;" Η διάρκεια της πρώτης συναλλαγής εξαρτάται κυρίως από το μέγεθος του τραπεζιού. Η διάρκεια του νέου εξαρτάται από το πόσες αλλαγές θα συγκεντρωθούν στον πίνακα κατά τη μεταφορά δεδομένων, δηλ. στην ένταση του φορτίου. Η εκτέλεση pg_repack έλαβε χώρα σε μια στιγμή ελάχιστου φόρτου υπηρεσίας και το μέγεθος της αλλαγής ήταν ασύγκριτα μικρό σε σύγκριση με το αρχικό μέγεθος του πίνακα. Αποφασίσαμε ότι μπορούμε να παραμελήσουμε τον χρόνο μιας νέας συναλλαγής (για σύγκριση, ο μέσος όρος είναι 1 ώρα και 2-3 λεπτά).

Τα πειράματα ήταν θετικά. Κυκλοφορεί και σε έκπτωση. Για λόγους σαφήνειας, εδώ είναι μια εικόνα με το μέγεθος μιας από τις βάσεις μετά το τρέξιμο:

Postgres: bloat, pg_repack και αναβαλλόμενοι περιορισμοί

Δεδομένου ότι αυτή η λύση μας ταίριαζε απόλυτα, δεν προσπαθήσαμε να εφαρμόσουμε τη δεύτερη, αλλά εξετάζουμε το ενδεχόμενο να τη συζητήσουμε με τους προγραμματιστές της επέκτασης. Η τρέχουσα αναθεώρησή μας, δυστυχώς, δεν είναι ακόμη έτοιμη για δημοσίευση, καθώς λύσαμε το πρόβλημα μόνο με μοναδικούς καθυστερημένους περιορισμούς και για μια πλήρη ενημέρωση κώδικα, πρέπει να γίνει υποστήριξη για άλλους τύπους. Ελπίζουμε να μπορέσουμε να το κάνουμε αυτό στο μέλλον.

Ίσως έχετε μια ερώτηση, γιατί εμπλακήκαμε σε αυτήν την ιστορία με τη βελτίωση του pg_repack και δεν χρησιμοποιήσαμε, για παράδειγμα, τα ανάλογα του; Κάποια στιγμή το σκεφτήκαμε κι εμείς, αλλά η θετική εμπειρία χρήσης του νωρίτερα, σε τραπέζια χωρίς καθυστερήσεις, μας παρακίνησε να προσπαθήσουμε να κατανοήσουμε την ουσία του προβλήματος και να το διορθώσουμε. Επιπλέον, η χρήση άλλων λύσεων απαιτεί επίσης χρόνο για τη διεξαγωγή δοκιμών, επομένως αποφασίσαμε ότι θα προσπαθήσουμε πρώτα να διορθώσουμε το πρόβλημα σε αυτό και εάν συνειδητοποιούσαμε ότι δεν μπορούσαμε να το κάνουμε σε εύλογο χρονικό διάστημα, τότε θα αρχίζαμε να εξετάζουμε ανάλογα.

Ευρήματα

Τι μπορούμε να προτείνουμε με βάση τη δική μας εμπειρία:

  1. Παρακολουθήστε το φούσκωμα σας. Με βάση τα δεδομένα παρακολούθησης, θα μπορείτε να καταλάβετε πόσο καλά είναι συντονισμένη η αυτόματη ηλεκτρική σκούπα.
  2. Ρυθμίστε το AUTOVACUUM για να διατηρείτε το φούσκωμα σε αποδεκτό επίπεδο.
  3. Εάν το φούσκωμα εξακολουθεί να μεγαλώνει και δεν μπορείτε να το αντιμετωπίσετε με εργαλεία εκτός συσκευασίας, μην φοβάστε να χρησιμοποιήσετε εξωτερικές επεκτάσεις. Το κύριο πράγμα είναι να δοκιμάσετε τα πάντα καλά.
  4. Μην φοβάστε να τροποποιήσετε εξωτερικές λύσεις για να ταιριάζουν στις ανάγκες σας - μερικές φορές μπορεί να είναι πιο αποτελεσματικό και ακόμη πιο εύκολο από το να αλλάξετε τον δικό σας κώδικα.

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο