Czy istnieje opcja / funkcja MySQL do śledzenia historii zmian rekordów?

122

Zapytano mnie, czy mogę śledzić zmiany w rekordach w bazie danych MySQL. Więc kiedy pole zostało zmienione, dostępne jest stare vs nowe i data, kiedy to się stało. Czy jest jakaś funkcja lub wspólna technika, aby to zrobić?

Jeśli tak, to myślałem o zrobieniu czegoś takiego. Utwórz tabelę o nazwie changes. Byłoby zawierają te same pola jak wzorcowej tabeli, ale z przedrostkiem stare i nowe, ale tylko dla tych dziedzinach, które faktycznie zostały zmienione i TIMESTAMPdla niego. Byłby indeksowany z rozszerzeniem ID. W ten sposób SELECTmożna uruchomić raport, aby pokazać historię każdego rekordu. Czy to dobra metoda? Dzięki!

Edward
źródło

Odpowiedzi:

83

To subtelne.

Jeśli wymaganiem biznesowym jest „Chcę audytować zmiany w danych - kto co zrobił i kiedy?”, Zwykle można użyć tabel audytu (zgodnie z przykładem wyzwalacza opublikowanym przez Keethanjan). Nie jestem wielkim fanem wyzwalaczy, ale ma tę wielką zaletę, że jest stosunkowo bezbolesny w implementacji - Twój istniejący kod nie musi wiedzieć o wyzwalaczach i kontrolach.

Jeśli wymaganiem biznesowym jest „pokaż mi, jaki był stan danych w danym dniu w przeszłości”, oznacza to, że aspekt zmiany w czasie wszedł do Twojego rozwiązania. Chociaż możesz po prostu zrekonstruować stan bazy danych, po prostu patrząc na tabele audytu, jest to trudne i podatne na błędy, a dla każdej skomplikowanej logiki bazy danych staje się nieporęczne. Na przykład, jeśli firma chce wiedzieć, „znajdź adresy listów, które powinniśmy byli wysłać do klientów, którzy mieli zaległe, niezapłacone faktury pierwszego dnia miesiąca”, prawdopodobnie będziesz musiał przeszukać pół tuzina tabel audytowych.

Zamiast tego możesz wprowadzić koncepcję zmiany w czasie do projektu schematu (jest to druga opcja sugerowana przez Keethanjan). Jest to zmiana w Twojej aplikacji, zdecydowanie na poziomie logiki biznesowej i trwałości, więc nie jest to trywialne.

Na przykład, jeśli masz taki stół:

CUSTOMER
---------
CUSTOMER_ID PK
CUSTOMER_NAME
CUSTOMER_ADDRESS

i chciałeś śledzić w czasie, możesz to zmienić w następujący sposób:

CUSTOMER
------------
CUSTOMER_ID            PK
CUSTOMER_VALID_FROM    PK
CUSTOMER_VALID_UNTIL   PK
CUSTOMER_STATUS
CUSTOMER_USER
CUSTOMER_NAME
CUSTOMER_ADDRESS

Za każdym razem, gdy chcesz zmienić rekord klienta, zamiast aktualizować rekord, ustawiasz VALID_UNTIL bieżącego rekordu na TERAZ () i wstawiasz nowy rekord z VALID_FROM (teraz) i null VALID_UNTIL. Ustawiasz status „CUSTOMER_USER” na identyfikator logowania bieżącego użytkownika (jeśli chcesz go zachować). Jeśli klient musi zostać usunięty, użyj flagi CUSTOMER_STATUS, aby to zaznaczyć - nigdy nie możesz usunąć rekordów z tej tabeli.

Dzięki temu zawsze możesz sprawdzić, jaki był stan tabeli klientów na dany termin - jaki był adres? Czy zmienili imię? Łącząc się z innymi tabelami z podobnymi datami valid_from i valid_until, możesz odtworzyć cały obraz historycznie. Aby znaleźć aktualny stan, wyszukujesz rekordy z zerową datą VALID_UNTIL.

Jest nieporęczny (ściśle mówiąc, nie potrzebujesz parametru valid_from, ale sprawia to, że zapytania są trochę łatwiejsze). To komplikuje projekt i dostęp do bazy danych. Ale to znacznie ułatwia odbudowę świata.

Neville Kuyt
źródło
Ale dodałoby zduplikowane dane dla tych pól, które nie są aktualizowane? Jak tym zarządzać?
itzmukeshy7
W przypadku drugiego podejścia pojawia się problem z generowaniem raportów, jeśli rekord klienta jest edytowany przez pewien czas, trudno jest rozpoznać, czy dany wpis należy do tego samego klienta, czy do innego.
Akshay Joshi
Najlepsza sugestia dotycząca tego problemu, jaką widziałem
Worthy7
Aha, a w odpowiedzi na komentarze, co powiesz na zapisanie wartości null dla wszystkiego innego, co się nie zmieniło? Tak więc najnowsza wersja będzie zawierać wszystkie najnowsze dane, ale jeśli 5 dni temu nazywano się „Bob”, wystarczy mieć jeden wiersz, nazwa = bob i ważna do 5 dni temu.
Worthy7
2
Kombinacja customer_id i daty to klucz podstawowy, dzięki czemu będą one niepowtarzalne.
Neville Kuyt
186

Oto prosty sposób, aby to zrobić:

Najpierw utwórz tabelę historii dla każdej tabeli danych, którą chcesz śledzić (przykładowe zapytanie poniżej). Ta tabela będzie miała wpis dla każdego zapytania wstawiania, aktualizowania i usuwania wykonanego w każdym wierszu tabeli danych.

Struktura tabeli historii będzie taka sama, jak tabela danych, którą śledzi, z wyjątkiem trzech dodatkowych kolumn: kolumny do przechowywania operacji, która miała miejsce (nazwijmy to „akcją”), daty i godziny operacji oraz kolumny do przechowywania numeru kolejnego („poprawka”), który zwiększa się dla każdej operacji i jest grupowany według kolumny klucza podstawowego tabeli danych.

Aby wykonać to zachowanie sekwencjonowania, tworzony jest indeks dwukolumnowy (złożony) w kolumnie klucza podstawowego i kolumnie zmiany. Zauważ, że możesz wykonywać sekwencjonowanie w ten sposób tylko wtedy, gdy silnik używany przez tabelę historii to MyISAM ( zobacz „MyISAM Notes” na tej stronie)

Tabelę historii można dość łatwo utworzyć. W zapytaniu ALTER TABLE poniżej (oraz w zapytaniach wyzwalających poniżej) zamień „primary_key_column” na rzeczywistą nazwę tej kolumny w tabeli danych.

CREATE TABLE MyDB.data_history LIKE MyDB.data;

ALTER TABLE MyDB.data_history MODIFY COLUMN primary_key_column int(11) NOT NULL, 
   DROP PRIMARY KEY, ENGINE = MyISAM, ADD action VARCHAR(8) DEFAULT 'insert' FIRST, 
   ADD revision INT(6) NOT NULL AUTO_INCREMENT AFTER action,
   ADD dt_datetime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER revision,
   ADD PRIMARY KEY (primary_key_column, revision);

Następnie tworzysz wyzwalacze:

DROP TRIGGER IF EXISTS MyDB.data__ai;
DROP TRIGGER IF EXISTS MyDB.data__au;
DROP TRIGGER IF EXISTS MyDB.data__bd;

CREATE TRIGGER MyDB.data__ai AFTER INSERT ON MyDB.data FOR EACH ROW
    INSERT INTO MyDB.data_history SELECT 'insert', NULL, NOW(), d.* 
    FROM MyDB.data AS d WHERE d.primary_key_column = NEW.primary_key_column;

CREATE TRIGGER MyDB.data__au AFTER UPDATE ON MyDB.data FOR EACH ROW
    INSERT INTO MyDB.data_history SELECT 'update', NULL, NOW(), d.*
    FROM MyDB.data AS d WHERE d.primary_key_column = NEW.primary_key_column;

CREATE TRIGGER MyDB.data__bd BEFORE DELETE ON MyDB.data FOR EACH ROW
    INSERT INTO MyDB.data_history SELECT 'delete', NULL, NOW(), d.* 
    FROM MyDB.data AS d WHERE d.primary_key_column = OLD.primary_key_column;

I jesteś skończony. Teraz wszystkie wstawienia, aktualizacje i usunięcia w „MyDb.data” zostaną zapisane w „MyDb.data_history”, co daje taką tabelę historii (bez wymyślonej kolumny „data_columns”)

ID    revision   action    data columns..
1     1         'insert'   ....          initial entry for row where ID = 1
1     2         'update'   ....          changes made to row where ID = 1
2     1         'insert'   ....          initial entry, ID = 2
3     1         'insert'   ....          initial entry, ID = 3 
1     3         'update'   ....          more changes made to row where ID = 1
3     2         'update'   ....          changes made to row where ID = 3
2     2         'delete'   ....          deletion of row where ID = 2 

Aby wyświetlić zmiany dla danej kolumny lub kolumn od aktualizacji do aktualizacji, musisz dołączyć do siebie tabelę historii na kluczu podstawowym i kolumnach sekwencji. W tym celu możesz utworzyć widok, na przykład:

CREATE VIEW data_history_changes AS 
   SELECT t2.dt_datetime, t2.action, t1.primary_key_column as 'row id', 
   IF(t1.a_column = t2.a_column, t1.a_column, CONCAT(t1.a_column, " to ", t2.a_column)) as a_column
   FROM MyDB.data_history as t1 INNER join MyDB.data_history as t2 on t1.primary_key_column = t2.primary_key_column 
   WHERE (t1.revision = 1 AND t2.revision = 1) OR t2.revision = t1.revision+1
   ORDER BY t1.primary_key_column ASC, t2.revision ASC

Edycja: O wow, ludzie lubią moją tabelę historii sprzed 6 lat: P

Moja realizacja wciąż nuci, jak sądzę, staje się większa i bardziej nieporęczna. Napisałem widoki i całkiem niezły interfejs użytkownika, aby spojrzeć na historię w tej bazie danych, ale nie sądzę, aby była kiedykolwiek używana zbyt często. Tak to idzie.

Aby odnieść się do niektórych komentarzy w przypadkowej kolejności:

  • Zrobiłem własną implementację w PHP, która była nieco bardziej zaangażowana i uniknąłem niektórych problemów opisanych w komentarzach (co ważne, przenosząc indeksy. Jeśli przeniesiesz unikalne indeksy do tabeli historii, wszystko się zepsuje. Istnieją rozwiązania dla to w komentarzach). Podążanie za tym postem do listu może być przygodą, w zależności od tego, jak ugruntowana jest Twoja baza danych.

  • Jeśli relacja między kluczem podstawowym a kolumną wersji wydaje się być wyłączona, zwykle oznacza to, że klucz złożony został w jakiś sposób zepsuty. W kilku rzadkich przypadkach zdarzyło mi się to i nie wiedziałem, co jest przyczyną.

  • Okazało się, że to rozwiązanie jest dość wydajne, używając wyzwalaczy. Ponadto MyISAM jest szybki we wkładkach, co jest wszystkim, co robią wyzwalacze. Możesz to jeszcze bardziej poprawić dzięki inteligentnemu indeksowaniu (lub jego braku ...). Wstawienie pojedynczego wiersza do tabeli MyISAM z kluczem podstawowym nie powinno być operacją, którą musisz zoptymalizować, tak naprawdę, chyba że masz poważne problemy w innym miejscu. Przez cały czas, gdy korzystałem z bazy danych MySQL, ta implementacja tabeli historii była włączona, nigdy nie było to przyczyną żadnego z (wielu) problemów z wydajnością, które się pojawiły.

  • jeśli otrzymujesz powtarzające się wstawienia, sprawdź, czy w warstwie oprogramowania nie ma zapytań typu INSERT IGNORE. Hrmm, nie pamiętam teraz, ale myślę, że są problemy z tym schematem i transakcjami, które ostatecznie kończą się niepowodzeniem po uruchomieniu wielu akcji DML. Przynajmniej coś, na co należy zwrócić uwagę.

  • Ważne jest, aby pola w tabeli historii i tabeli danych były zgodne. Albo raczej, że twoja tabela danych nie ma WIĘCEJ kolumn niż tabela historii. W przeciwnym razie zapytania insert / update / del w tabeli danych zakończą się niepowodzeniem, gdy operacje wstawiania do tabel historii wstawią kolumny w zapytaniu, które nie istnieją (z powodu d. * W zapytaniach wyzwalających), a wyzwalacz nie powiedzie się. Byłoby wspaniale, gdyby MySQL miał coś w rodzaju wyzwalaczy schematu, w którym można by zmienić tabelę historii, gdyby kolumny zostały dodane do tabeli danych. Czy MySQL ma to teraz? Obecnie Reaguję: P.

przejściowe zamknięcie
źródło
3
bardzo podoba mi się to rozwiązanie. jednak jeśli twoja główna tabela nie ma klucza podstawowego lub nie wiesz, jaki jest podstawowy, jest to trochę trudne.
Benjamin Eckstein
1
Niedawno napotkałem problem z użyciem tego rozwiązania w projekcie, z powodu tego, jak wszystkie indeksy z oryginalnej tabeli są kopiowane do tabeli historii (ze względu na to, jak działa CREATE TABLE ... LIKE ....). Posiadanie unikalnych indeksów w tabeli historii może spowodować, że zapytanie INSERT w wyzwalaczu AFTER UPDATE będzie barf, więc należy je usunąć. W skrypcie php, który mam, który robi to wszystko, odpytuję o wszelkie unikalne indeksy w nowo utworzonych tabelach historii (z "POKAŻ INDEKS FROM data_table WHERE Key_name! = 'PRIMARY' i Non_unique = 0"), a następnie je usuwam.
przejściowe zamknięcie
3
Tutaj za każdym razem otrzymujemy powtarzające się dane wstawiane do tabeli kopii zapasowej. Niech jeśli mamy 10 pól w tabeli i zaktualizowaliśmy 2, to dodajemy powtórzone dane dla pozostałych 8 pól. Jak to przezwyciężyć?
itzmukeshy7
6
Możesz uniknąć przypadkowego przeniesienia różnych indeksów, zmieniając instrukcję create table naCREATE TABLE MyDB.data_history as select * from MyDB.data limit 0;
Eric Hayes,
4
@transientclosure Jak zaproponowałbyś dodanie do historii innych pól, które nie były częścią pierwotnego zapytania? np. chcę śledzić, kto wprowadza te zmiany. do wstawienia ma już ownerpole, a do aktualizacji mógłbym dodać updatedbypole, ale do usunięcia nie jestem pewien, jak mógłbym to zrobić za pomocą wyzwalaczy. aktualizacja data_historywiersza z identyfikatorem użytkownika w jest brudna: P
Koń
16

Możesz stworzyć wyzwalacze, aby rozwiązać ten problem. Oto poradnik, jak to zrobić (link zarchiwizowany).

Ustawienie ograniczeń i reguł w bazie danych jest lepsze niż pisanie specjalnego kodu do wykonania tego samego zadania, ponieważ uniemożliwi innemu programistowi napisanie innego zapytania, które pomija cały specjalny kod i może pozostawić bazę danych ze słabą integralnością danych.

Przez długi czas kopiowałem informacje do innej tabeli za pomocą skryptu, ponieważ MySQL nie obsługiwał wtedy wyzwalaczy. Teraz odkryłem, że ten wyzwalacz jest bardziej skuteczny w śledzeniu wszystkiego.

Ten wyzwalacz skopiuje starą wartość do tabeli historii, jeśli zostanie zmieniona, gdy ktoś edytuje wiersz. Editor IDi last modsą przechowywane w oryginalnej tabeli za każdym razem, gdy ktoś edytuje ten wiersz; czas odpowiada momentowi zmiany do obecnej postaci.

DROP TRIGGER IF EXISTS history_trigger $$

CREATE TRIGGER history_trigger
BEFORE UPDATE ON clients
    FOR EACH ROW
    BEGIN
        IF OLD.first_name != NEW.first_name
        THEN
                INSERT INTO history_clients
                    (
                        client_id    ,
                        col          ,
                        value        ,
                        user_id      ,
                        edit_time
                    )
                    VALUES
                    (
                        NEW.client_id,
                        'first_name',
                        NEW.first_name,
                        NEW.editor_id,
                        NEW.last_mod
                    );
        END IF;

        IF OLD.last_name != NEW.last_name
        THEN
                INSERT INTO history_clients
                    (
                        client_id    ,
                        col          ,
                        value        ,
                        user_id      ,
                        edit_time
                    )
                    VALUES
                    (
                        NEW.client_id,
                        'last_name',
                        NEW.last_name,
                        NEW.editor_id,
                        NEW.last_mod
                    );
        END IF;

    END;
$$

Innym rozwiązaniem byłoby pozostawienie pola Rewizja i zaktualizowanie tego pola przy zapisywaniu. Możesz zdecydować, że maksymalna jest najnowszą wersją lub że 0 to najnowszy wiersz. To zależy od Ciebie.

Keethanjan
źródło
9

Oto jak to rozwiązaliśmy

Tabela użytkowników wyglądała tak

Users
-------------------------------------------------
id | name | address | phone | email | created_on | updated_on

Zmieniły się wymagania biznesowe i musieliśmy sprawdzić wszystkie poprzednie adresy i numery telefonów, jakie kiedykolwiek posiadał użytkownik. nowy schemat wygląda tak

Users (the data that won't change over time)
-------------
id | name

UserData (the data that can change over time and needs to be tracked)
-------------------------------------------------
id | id_user | revision | city | address | phone | email | created_on
 1 |   1     |    0     | NY   | lake st | 9809  | @long | 2015-10-24 10:24:20
 2 |   1     |    2     | Tokyo| lake st | 9809  | @long | 2015-10-24 10:24:20
 3 |   1     |    3     | Sdny | lake st | 9809  | @long | 2015-10-24 10:24:20
 4 |   2     |    0     | Ankr | lake st | 9809  | @long | 2015-10-24 10:24:20
 5 |   2     |    1     | Lond | lake st | 9809  | @long | 2015-10-24 10:24:20

Aby znaleźć aktualny adres dowolnego użytkownika, wyszukujemy dane użytkownika z wersją DESC i LIMIT 1

Aby uzyskać adres użytkownika między określonym przedziałem czasu, możemy użyć created_on bewteen (data1, data 2)

Zenex
źródło
Jest to rozwiązanie, które chcę mieć, ale chcę wiedzieć. Jak wstawić id_user do tej tabeli za pomocą wyzwalacza?
thecassion
1
Co się stało revision=1z id_user=1? Najpierw pomyślałem, że liczyłeś, 0,2,3,...ale potem zobaczyłem, że dla id_user=2liczenia rewizji jest0,1, ...
Pathros
1
Nie potrzebujesz idi id id_userkolumny . Just use a group ID of (identyfikator użytkownika) i revision.
Gajus
6

MariaDB obsługuje wersję systemu od 10.3, która jest standardową funkcją SQL, która robi dokładnie to, co chcesz: przechowuje historię rekordów tabeli i zapewnia dostęp do niej za pośrednictwem SELECTzapytań. MariaDB to rozwidlenie MySQL dla otwartego rozwoju. Więcej informacji na temat wersji systemu można znaleźć pod tym linkiem:

https://mariadb.com/kb/en/library/system-versioned-tables/

midenok
źródło
Zwróć uwagę na następujący link powyżej: „mysqldump nie odczytuje historycznych wierszy z wersjonowanych tabel, więc dane historyczne nie zostaną zarchiwizowane. Ponadto przywrócenie sygnatur czasowych nie byłoby możliwe, ponieważ nie można ich zdefiniować za pomocą funkcji insert / użytkownik."
Daniel
4

Dlaczego po prostu nie użyć plików dziennika bin? Jeśli replikacja jest ustawiona na serwerze MySQL, a format pliku binlog jest ustawiony na ROW, wówczas wszystkie zmiany mogą zostać przechwycone.

Można użyć dobrej biblioteki Pythona o nazwie noplay. Więcej informacji tutaj .

Ouroboros
źródło
2
Binlog może być używany, nawet jeśli nie masz / nie potrzebujesz replikacji. Binlog ma wiele korzystnych przypadków użycia. Replikacja jest prawdopodobnie najczęstszym przypadkiem użycia, ale można ją również wykorzystać do tworzenia kopii zapasowych i historii inspekcji, jak wspomniano tutaj.
webaholik
3

Tylko moje 2 centy. Stworzyłbym rozwiązanie, które dokładnie rejestruje, co się zmieniło, bardzo podobne do rozwiązania transienta.

Moja tabela zmian byłaby prosta:

DateTime | WhoChanged | TableName | Action | ID |FieldName | OldValue

1) Kiedy cały wiersz zostanie zmieniony w głównej tabeli, do tej tabeli trafi wiele wpisów, ALE jest to bardzo mało prawdopodobne, więc nie jest to duży problem (ludzie zwykle zmieniają tylko jedną rzecz) 2) OldVaue (i NewValue, jeśli want) musi być jakimś epickim „dowolnym typem”, ponieważ mogą to być dowolne dane, może istnieć sposób na zrobienie tego z typami RAW lub po prostu za pomocą ciągów JSON do konwersji wejścia i wyjścia.

Minimalne użycie danych, przechowuje wszystko, czego potrzebujesz i może być używane dla wszystkich tabel jednocześnie. Sam to badam w tej chwili, ale może to się skończyć.

W przypadku tworzenia i usuwania tylko identyfikator wiersza, żadne pola nie są potrzebne. Po usunięciu flagi na głównej tabeli (aktywna?) Byłaby dobra.

Godny 7
źródło
0

Bezpośrednim sposobem na to jest utworzenie wyzwalaczy w tabelach. Ustaw warunki lub metody mapowania. Gdy nastąpi aktualizacja lub usunięcie, zostanie automatycznie wstawiony do tabeli zmian.

Ale najważniejsze jest to, że mamy dużo kolumn i dużo tabel. Musimy wpisać nazwę każdej kolumny każdej tabeli. Oczywiście szkoda czasu.

Aby lepiej sobie z tym poradzić, możemy stworzyć pewne procedury lub funkcje do pobierania nazw kolumn.

W tym celu możemy również użyć narzędzia trzeciej części. Tutaj piszę program java Mysql Tracker

goforu
źródło
jak mogę używać twojego MySQL Tracker?
webchun
1
1. Upewnij się, że w każdej tabeli masz kolumnę id jako klucz podstawowy. 2. Skopiuj plik java do lokalnego (lub IDE). 3. Zaimportuj biblioteki i edytuj zmienne statyczne z linii 9-15 zgodnie z konfiguracją i strukturą bazy danych. 4. Przeanalizuj i uruchom plik java 5. Skopiuj dziennik konsoli i wykonaj go jako polecenia MySQL
goforu
create table like tableMyślę, że łatwo powiela wszystkie kolumny
Jonathan