We wpisie Chmura tagów w PHP, w którym został przedstawiony problem budowy chmury tagów zapisałem przykładowe zapytanie prezentujące przykładowe dane dla klasy, które dosłownie zabija bazę danych zliczając za każdym razem ilość występowań tagów. Dostając feedbacki, zauważyłem, że problem ten jest bagatelizowany przez wiele osób. Spróbujmy zbudować bardziej optymalne rozwiązanie zarządzania strukturą danych w taki sposób, aby dane wyciągać bardzo bezboleśnie.
Zbudujmy przykładową strukturę bazy danych tagów, do której będziemy przypinać różne rzeczy – newsy, artykuły, galerie zdjęć, zdjęcia, cokolwiek.
Najprostsza tabela db_tags o polach:
CREATE TABLE db_tags ( tag_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY , tag_name VARCHAR(255) NOT NULL , tag_count INT UNSIGNED NOT NULL ) ENGINE = INNODB;
Zastanówmy się, po czym będziemy sortować tagi. Warto założyć klucz na pole tag_count, znacznie przyspieszy późniejsze sortowanie wyników po najpopularniejszych tagach. Jeżeli chcemy sortować po liczbie występowań tagu oraz nazwie (aby chmura była alfabetycznie), warto założyć wspólny klucz na tag_name oraz tag_count. Osobiście sortowanie alfabetyczne zostawiam implementacji klasie tagów dla ksort(), bowiem zapytanie wyciągające tagi jest obarczone limitem, zatem wspólny klucz w bazie danych nie jest mi potrzebny – mniej danych w indeksach.
ALTER TABLE db_tags ADD INDEX (tag_count);
Tworzymy dowolną strukturę danych, która będzie podpinała się do naszych tagów. Pamiętajmy, że do tagów może podpinać się (a przynajmniej powinno, zależy od założeń początkowych projektu) wiele struktur jednocześnie. Wybrałem najbardziej pospolite – newsy w tabeli db_news.
CREATE TABLE db_news ( news_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, news_title TEXT NOT NULL, news_content TEXT NOT NULL ) ENGINE = INNODB;
Pozostało nam stworzyć tabelę wiążącą nasze newsy z tagami (nie tagi z newsami). Tabelę nazwałem db_news_tags. Zawierać ona będzie tylko dwa pola przechowujące identyfikator newsa oraz przypisanego do niego tagu, zachowując typ danych wiążących, czyli INT UNSIGNED. Zakładam wspólny primary key dla obu pól.
CREATE TABLE db_news_tags ( handler_item INT UNSIGNED NOT NULL, handler_node INT UNSIGNED NOT NULL, PRIMARY KEY (handler_item, handler_node) ) ENGINE = INNODB;
Buduję relacyjną bazę danych. Gdy jakiś tag zostanie usunięty, bądź gdy jakiś news zostanie usunięty, automatycznie powinien zniknąć wpis z tabeli db_news_tags, zatem używamy kluczy obcych:
ALTER TABLE db_news_tags ADD FOREIGN KEY (handler_item) REFERENCES db_news (news_id) ON DELETE CASCADE; ALTER TABLE db_news_tags ADD FOREIGN KEY (handler_node) REFERENCES db_tags (tag_id) ON DELETE CASCADE;
Tak zaprojektowaną strukturę danych mogę spokojnie używać do przechowywania danych. Pozostaje kwestia obliczania ilości występowań tagów. Istnieją co najmniej dwie szkoły.
Luźny komentarz techniczny (problems, tips & tricks): Aby ominąć problemy wynikłe z założenia w punkcie pierwszym, równie dobrze możemy napisać procedury, które inkrementują/dekrementują liczbę tagów w zależności od warunków (np. tylko wtedy, kiedy tag jest aktywny i widoczny w serwisie). Nikt nie powiedział, że procedury muszą liczyć wszystko od początku możemy się na takie rozwiązanie zgodzić, rezygnujemy natomiast z synchronizacji licznika podczas zmiany warunków, wówczas podczas każdej zmiany warunków, trzeba przekręcić licznik od początku, zliczając wszystkie rekordy wg. ustalonych warunków ręcznie. Triggera należałoby również umieścić w UPDATE (zmiana stanu tagu, np. z niewidocznego na widoczny, z aktywnego na nieaktywny). I to jest najrozsądniejsze rozwiązanie.
W naszym przypadku ograniczymy się do dwóch triggerów, które będą trzymały rękę na pulsie w momencie przypisania tagu do struktury INSERT oraz zerwaniu przypisania DELETE. Zatem:
CREATE TRIGGER NewsTagsCountInsert AFTER INSERT ON db_news_tags FOR EACH ROW BEGIN UPDATE db_tags SET tag_count = tag_count + 1 WHERE tag_id = NEW.handler_node; END CREATE TRIGGER NewsTagsCountDelete AFTER DELETE ON db_news_tags FOR EACH ROW BEGIN UPDATE db_tags SET tag_count = tag_count - 1 WHERE tag_id = OLD.handler_node; END
Komentarz: bardziej eleganckim w większej strukturze danych byłoby wywołanie procedur inkrementujących i dekrementujących licznik – wówczas wykonywalibyśmy procedury (nie zapytania) w wielu strukturach wiązanych (nie tylko newsy, a video, ankiety, etc). Zmiana implementacji liczenia tagów byłaby wówczas wiele prostsza – zmienialibyśmy tylko procedurę, a nie każdy TRIGGER z osobna, zatem:
CREATE PROCEDURE TagsCountIncrement(IN iTagID INT) BEGIN UPDATE db_tags SET tag_count = tag_count + 1 WHERE tag_id = iTagID; END CREATE PROCEDURE TagsCountDecrement(IN iTagID INT) BEGIN UPDATE db_tags SET tag_count = tag_count - 1 WHERE tag_id = iTagID; END CREATE TRIGGER NewsTagsCountInsert AFTER INSERT ON db_news_tags FOR EACH ROW BEGIN CALL TagsCountIncrement(NEW.handler_node); END CREATE TRIGGER NewsTagsCountDelete AFTER DELETE ON db_news_tags FOR EACH ROW BEGIN CALL TagsCountDecrement(OLD.handler_node); END
Finalnie, z czystym sumieniem:
SELECT tag_name, tag_count FROM db_tags ORDER BY tag_count LIMIT 0, 50
Składowanie danych w kilku tabelach połączonych relacyjnie to bardzo dobry pomysł. Chyba najprostszym przykładem jest forum dyskusyjne: struktura oraz content postów mogą spokojnie być trzymane w osobnych tabelach. To samo tyczy się danych użytkowników. Rekordy rozbite na kilka tabel stają się mniej rozbudowane, o ile w ogóle występują - istnienie zawartości pola nie jest wtedy wymagane (użytkownik nie podał danych = nie ma rekordu).
Zaznaczając JOIN‘ujemy tabele w zależności od potrzeb, co zdarza się bardzo często. O UPDATE JOIN już wspominałem, też bardzo wygodna operacja, natomiast, co w przypadku, gdy musimy usunąć rekord uzależniony od wartości pola w innej tabeli? Sprawa jest banalnie prosta.
Na początek kilka technicznych uwag, na które łatwo można się nadziać:
Przykłady.
Dane userów mam składowane w dwóch tabelach – w jednej podstawowe dane (id, name, pass, pass_salt, mail, status_active), w kolejnej dane (data [jako handler user -> user_data], data_* [* - jakieśdane]). Chcę usunąć wszystkich użytkowników, którzy zarejestrowali się przed 48-godzinami i nie aktywowali swoich kont, aby zwolnić unikalne nazwy użytkowników i adresy email. Jednym kryterium jest user_status_active z tabeli users, kolejnym jest data user_data_join z tabeli users_data. Jako, że mam założony kaskadowy foregin key na pole user_data w tabeli users_data, przy usunięciu rekordu z tabeli users pozbędę się również jego danych, o co dbać nie muszę przy wypisywaniu alias_tabeli.*. W przypadku, kiedy nie miałbym założonego foregin key, musiałbym obsłużyć usunięcie rekordu z users_data wypisując po przecinku tabelę. Zatem:
DELETE item.* FROM `cms_members` AS `item` INNER JOIN `cms_members_data` AS `item_data` ON (item.user_id = item_data.user_data) WHERE item.user_state_active = 0 AND item_data.user_data_join < NOW()
~Tiraeth przesłał rozwiązanie beż użycia aliasów i słowa kluczowego JOIN, odwołujemy się po nazwie tabeli:
DELETE cms_members.* FROM `cms_members`, `cms_members_data` WHERE cms_members.user_id = cms_members_data.user_data AND user_state_active = 0 AND user_data_join < NOW()
Podzapytanie oraz INNER JOIN generuje nam iloczyn kartezjański:
INNER JOIN and , (comma) are semantically equivalent in the absence of a join condition: both produce a Cartesian product between the specified tables (that is, each and every row in the first table is joined to each and every row in the second table).
Mam nadzieję, że komuś się przyda.
Ostatnimi czasy tworząc projekty coraz częściej zwracam uwagę na usability, poprawiając przy tym grafików. Dziś krótko, bo o tym, jak dostosować nagłówki <meta>, aby jak najlepiej były przeklejane do okienka udostępniania linków na Facebook’u.
Czasem cennymi danymi są te, które widzi Google oraz użytkownik (meta description i bezpośrednio w pasku przeglądarki title), czasem zależy nam na dostosowaniu prezentacji danych agregatorów, w naszym przypadku Facebook’a, aby użytkownik wklejając linka nie musiał się dodatkowo w nic angażować.
if(preg_match('/^facebookexternalhit/', $_SERVER['HTTP_USER_AGENT']))
<link rel="image_src" href=" ... " />Efekt? Sprawdź sam na Nowiny365.pl.
Przeczytaj również o OpenGraph.
Odzwyczajony od blogowania, zaabsorbowany przez niekorzystnego dla planet i blogosfery stanu rzeczy Social Media, postanowiłem wrzucić kolejny wpis, w podzięce za porady uzyskane z blogów kolegów z branży. Ostatnio moim zadaniem było zaprojektowanie struktury tabel bazy danych, która wraz z drzewem z zagłębieniem kategorii, ma przechowywać dane na temat cen za ogłoszenia publikowane w tych kategoriach.
Założenia wstępne wyglądały następująco:
Potrzebne mi było zapytanie, które zwróci mi zwyczajnie listę wszystkich kategorii wraz z cenami, uwzględniając zagłębienia, w jakich dana kategoria się znajduje. Podejścia składowania cen są dwa:
Sposób drugi wydaje się być bardziej rozsądny w przypadku większej ilości zapytań podejmujących dane z bazy. Pierwszy odwrotnie – częściej wpisujemy coś do bazy. Z początku wybrałem pierwszy sposób, przy założeniu, że ceny będą generowane tylko w panelu administracyjnym, zatem zapytanie nie będzie wykonywane często. Niestety, moje myślenie jest bardziej abstrakcyjne, nastawione na elastyczność rozwiązań, w przyszłości cel istnienia bazy może się zmienić.
Tak czy siak, dla obu rozwiązań, trzeba stworzyć procedurę sprawdzającą ceny po rodzicach. Wygląda mniej więcej tak:
CREATE FUNCTION AnnouncementsCategoriesGetPrice(iCategoryId INT(11)) RETURNS FLOAT(6,2) BEGIN DECLARE iResult FLOAT(6,2); DECLARE iPointer INT(11); SET iPointer = iCategoryId; SET iResult = (SELECT category_data_price FROM cms_announcements_categories WHERE category_id = iPointer); WHILE (iResult IS NULL AND iPointer IS NOT NULL) DO SET iResult = (SELECT category_data_price FROM cms_announcements_categories WHERE category_id = iPointer); SET iPointer = (SELECT category_parent FROM cms_announcements_categories WHERE category_id = iPointer); END WHILE; RETURN iResult; END
Stosować ją można dla SELECT’u kategorii lub do pozyskania wartości pola tymczasowego przy zapisie do bazy.
Mam nadzieję, że komuś się przyda.
Podczas świadczenia usług abonamentowych podejmujemy się analizy konkurencji, bądź dostajemy bodźce od operatorów. Stara oferta może nam jak najbardziej odpowiadać, często konsultanci przed możliwością przedłużenia umowy proponują różnego rodzaju bonusy, czasem traktowanie abonenta to kpina. Zatem najczęściej wtedy jesteśmy szarpani na smyczy i zastanawiamy się nad zmianą dostawcy usług. Od jakiegoś czasu można przejść do innego operatora sieci telefonii komórkowej, nie tracąc przy tym swojego numeru. Ważnym jest przejść do innego operatora, a nie odstąpić od umowy u operatora macierzystego. Miałem okazję przechodzić ten proces, a że jestem pedantem wypytałem o wszystkie szczegóły i sytuacje krytyczne, zatem podzielę się wiedzą, którą zgromadziłem na temat tej materii. Żeby nikomu się nie zdarzyło popełnić idiotycznego błędu. Potencjalne błędy oznaczyłem na czerwono, kroki pogrubiłem, a istotne rzeczy podkreśliłem.
1. Rejestracja numeru telefonu
W momencie, gdy nie jesteśmy użytkownikami abonamentowymi lub tzw. mix, nie jesteśmy automatycznie właścicielami numeru telefonu. Karty pre-paid (na doładowania na czas nieokreślony) nie wiążą klienta w żaden sposób z operatorem – możemy taką kartę wyrzucić w każdej chwili.
Jeżeli jesteśmy użytkownikami abonamentu, bądź mixa, problem mamy z głowy. W przypadku kart pre-paid’owych, możemy za darmo zarejestrować numer telefonu na nasze nazwisko. Robimy to ostrożnie, bowiem właściciel numeru musi mieć zdolność abonamentową u operatora, do którego przejść. Tj. mieć stałe źródło dochodów lub okazać legitymację studencką. Jeżeli nie masz płynności finansowej, najlepiej zarejestrować numer na rodziców.
2. Aktualizacja danych osobowych u swojego operatora
Pierwszym krokiem, jaki powinien wykonać klient (Ty) to aktualizacja danych osobowych u operatora macierzystego (który w tej chwili świadczy Ci usługi). Najczęstszą przyczyną nieaktualnych danych osobowych jest zmiana miejsca zamieszkania, aktualizacja dowodu osobistego, który wygasł, zgubiliśmy, zniszczył się, etc. W każdym z tych przypadków ulega co najmniej numer i seria dowodu osobistego, o czym koniecznie trzeba poinformować swojego operatora przed podjęciem procedury migracji. Jeżeli jesteś święcie przekonany, że nie nastąpiła zmiana danych w dowodzie – i tak pro forma idź je zaktualizować, nic Cię to nie kosztuje, a zaoszczędzisz stresu i utwierdzisz się w przekonaniu, że wszystko będzie ok.
Proces aktualizacji danych trwa do 24 godzin. Zazwyczaj aktualizacja następuje od razu, ale inni operatorzy, którzy mają wgląd do globalnej bazy, zmiany zaobserwują za maksymalnie 24 godziny. Warto mieć na uwadze te opóźnienie, przed podjęciem kolejnego kroku.
3. Podpisanie umowy
Ważnym jest, żeby nie wypowiadać umowy operatorowi macierzystemu! Tracimy wówczas prawo do swojego numeru telefonu. Umowę “wypowiada” nowy operator, a raczej prosi o przepisanie numeru wraz z końcem świadczonych usług. Idziesz zatem do nowego operatora! U nowego operatora otrzymasz dwa dokumenty: umowę abonamentową oraz pełnomocnictwo wobec wykonanie czynności prawnych dotyczących Twojego numeru telefonu, którego jesteś właścicielem. W skrócie: przenosisz wszystkie obowiązki na nowego operatora. To Cię nic nie kosztuje, a nawet możesz otrzymać bonus, o który warto negocjować.
Kolejną istotną rzeczą jest to, że dane wpisane na nowej umowie i pełnomocnictwie muszą zgadzać się z danymi u starego operatora. W przeciwnym wypadku nowy użytkownik nie ma praw do przejęcia numeru, bo de facto nie jest w jego posiadaniu.
Jeżeli umowę u starego operatora masz podpisaną na rodzica/opiekuna, niech ta sama osoba podpiszę umowę u nowego operatora. Analogicznie: w przypadku karty pre-paid, jeżeli nie masz płynności finansowej, bądź nie jesteś studentem, zarejestruj numer na osobę, która spełnia wymagania, a następnie idź z nią do nowego operatora.
Po całym procesie można (nie trzeba) w każdej chwili wykonać cesję umowy – zmianę danych osobowych wobec której jest ona świadczona. Usługa ta jest bezpłatna, ale czasochłonna, na szczęście nas proszą tylko o zapłacenie pierwszej faktury (choć jedna musi być zapłacona “za kadencji” starego abonenta), a następnie o skan dowodu osobistego. Kolejna faktura przyjdzie już na nowego abonenta.
4. Uregulowanie faktur za ostatnie miesiące u starego operatora
Bez bałaganu, bo mogą nagle wyłączyć nam nowy abonament. Oczywista oczywistość.
Mam nadzieję, że przybliżyłem temat osobom, które biorą pod uwagę zmianę operatora, bądź mieli niewystarczającą wiedzę na temat migracji. Leave feedback if u like it.
Oblicza MySQL nie są do końca znane przy tworzeniu aplikacji, a problemy optymalizacyjne stają się nie lada problemem przy funkcjonowaniu wersji produkcyjnej projektu. Nie sposób przewidzieć wszystkich możliwości użycia pól, założenia zarówno wspólnych, jak i pojedynczych indeksów posiadających zakładaną przez nas moc i zajętą pamięć na dysku.
Ostatnimi czasy budowałem dość skomplikowany projekt, jeżeli chodzi o złożoność zapytań i wykonywanych przez nie operacje matematyczne. Pomimo tego, że aplikacja była doskonale przemyślana, a struktury bazy danych perfekcyjnie jej podporządkowane, gdzieś tkwił problem, bowiem jedno z zapytań generowało pozornie prosty (wizualnie) rezultat, baza reagowała na zapytanie dopiero po 2.5 sekundy dla 30k+ rekordów. Patrząc na strukturę kluczy i zapytania, zwłaszcza, że pola, na których operowałem były różnego rodzaju liczbami i datami zacząłem się poważnie martwić i rozkładać zapytanie na czynniki pierwsze, kończąc na warunkach. Wyobraźcie sobie moje zdziwienie, gdy doszedłem do tego, że całe obciążenie (ponad 2.3 sekundy) generował warunek:
WHERE DATE(ticket_date) >= " ... "
Gdzie ticket_date to pole typu DATETIME. Od razu doszedłem do wniosku, że w parze idzie złe przygotowanie danych przez PHP, a angażowana jest w to wszystko baza, na której forsuje się użycie funkcji DATE(). Przynajmniej dla 30k+ rekordów zindeksowanego pola. Prosty zabieg zamiany jednej linijki kodu na drugą przyniósł porządane efekty.
$aTerms[] = 'DATE(ticket_date) >= "' . $sDate . '"'
$aTerms[] = 'ticket_date >= "' . date('Y-m-d H:i:s', strtotime($sDate)) . '"'
Budując aplikację zwracam szczególną uwagę na strukturę bazy, indeksowanie pól, rysuję diagramy przewidujące wykorzystanie danych pod różne zapytania, ale… tak banalny błąd przy przeanalizowanej aplikacji rozłożył mnie na łopatki. Z drugiej strony, zapomniałem o jednej bardzo ważnej rzeczy: maksymalnym odciążeniu bazy danych przy preparowaniu argumentów warunków, skoro warunki te mogą być w odpowiedni i przede wszystkim szybki sposób spreparowane na poziomie modelu (abstrakcyjnie rzecz ujmując, pozbywam się pojęcia PHP), który przygotują zapytanie tylko do wykonania operacji na surowych danych, bez konieczności ich ewentualnego przeliczania. Oczywiście nie zawsze taki efekt da się uzyskać, ale należy to maksymalnie optymalizować.
Jedno jest wiadome: przeliczanie DATE() dla rekordów w warunku jest nieoptymalne dla pola DATETIME.