Jak przekonałem klienta do przyspieszenia systemu routingu
„Przedwczesna optymalizacja jest źródłem wszelkiego (lub przynajmniej większości) zła w programowaniu”. Ten cytat z Donalda Knutha absolutnie nie traci na aktualności, wręcz przeciwnie – w epoce chmury, dostęp do zasobów jest prosty i tani jak nigdy. Z biznesowego punktu widzenia, najczęściej – nomen omen – optymalne jest zastosowanie szybkiego w implementacji, wolnego w działaniu rozwiązania i skalowanie go poprzez zwiększanie mocy infrastruktury. Nadchodzi jednak moment, że przestaje to wystarczać.
Krzysztof Bujniewicz. Aktualnie engineering manager w Google, wcześniej m.in. country manager w Plecto, executive director w Goldman Sachs, senior software developer w Telmediq i team lead w Pulsie Biznesu. Zajmuje się głównie backendem – zarówno architekturą, jak i implementacją, najczęściej pracując w Pythonie. Wolny czas lubi spędzać z grami RPG – zarówno w roli gracza, jak i MG. Wszystkie wyrażone opinie są wyłącznie personalne i nie odzwierciedlają opinii pracodawcy.
Spis treści
Wprowadzenie
Parę lat temu pracowałem nad implementacją drugiej edycji oprogramowania służącego – w przybliżeniu – kontekstowej komunikacji w szpitalach. Wersja pierwsza (nazwijmy ją Mars) została stworzona przez zewnętrznego dostawcę, który ze względów geopolitycznych nie był w stanie dalej świadczyć usług. Jak często w takich sytuacjach bywa, kod był bardzo kiepskiej jakości, a technologia niezbyt popularna (Grails). CEO podjął decyzję o zawieszeniu rozwoju istniejącego rozwiązania i stworzeniu wewnętrznego zespołu, który zbuduje wersję drugą (nazwijmy ją Jupiter).
Parę miesięcy później Jupiter, stworzony w Django, implementował już 98% funkcjonalności Marsa (oraz, oczywiście, sporo dodatkowych) i obsługiwał około jedną czwartą klientów firmy. Koszt infrastruktury na użytkownika w nowej implementacji był około dwudziestokrotnie mniejszy niż w starej, więc osiągnięcie stu procent kompatybilności i migracja wszystkich użytkowników było dla nas priorytetem. Niestety, po drodze leżała jeszcze jedna przeszkoda.
Brakującą funkcjonalnością był system routingu: warunkowych akcji dotyczących niektórych typów obiektów powstających w systemie. Przykładem może być dodanie do listy odbiorców wiadomości od dyżurnego lekarza odpowiedniego departamentu, w przypadku gdy priorytet wiadomości jest „pilny”, do wiadomości jest załączony pacjent oraz godzina nadania w czasie. Zasady i akcje muszą być jednak konfigurowalne przez klientów. Mars implementował taką funkcjonalność poprzez umożliwienie dowolnego zagnieżdżania w sobie zasad, które podczas tworzenia każdego nowego wątku były rekurencyjnie pobierane do aplikacji i ewaluowane – bez limitu poziomu zagłębienia.
Niestety, implementacja ta była zbyt wolna. Czas potrzebny na routing pojedynczego wątku u największego klienta wynosił około 40 sekund. Biorąc pod uwagę zastosowania produktu, było to niesamowicie problematyczne dla klientów i uniemożliwiało rozwój w kierunku np. obsługi krytycznych powiadomień.
O rzeczy
Wymagania dotyczące implementacji routingu w Jupiterze były proste:
- Musi wspierać wszystko to, co jest możliwe w Marsie.
- Musi umożliwiać konfigurację na poziomie poszczególnych użytkowników.
- Musi działać o rząd wielkości szybciej (poniżej sekundy).
Moim pierwszym pomysłem była prosta modyfikacja istniejącego algorytmu, aby zamiast wielu zapytań zastosować jedno, pobierające wszystkie zasady, które mogą dotyczyć przetwarzanego obiektu. Po paru godzinach miałem na środowisku stagingowym Marsa zmodyfikowany kod. Wspólnie z DevOpsem zabraliśmy się za testowanie wydajności. Początkowe rezultaty były obiecujące: z 40 sekund zeszliśmy do 10. Kolejną poprawką było rozbicie procesowania zasad na wątki (w końcu serwery aplikacji skalują się horyzontalnie łatwiej, niż relacyjna baza danych). Niestety, nie przyniosło to oczekiwanych rezultatów: rozwiązanie dawało rezultat w około dwie sekundy.
Postanowiłem porządnie się przespać i spróbować przemyśleć sam podstawowy problem, bez żadnego względu na obecnie istniejące rozwiązanie.
Zanim jednak zdążyłem wyjść z biura, postanowiłem wdrożyć minimalną formę rozwiązującą taki sam problem matematyczny, ale nieco inny biznesowo: każdą formułę logiczną można zapisać w koniunkcyjnej oraz dysjunkcyjnej formie normalnej (CNF oraz DNF), które z definicji mają dokładnie dwa poziomy zagłębienia. Po intensywnym wieczorze miałem parę zapytań SQL oraz wszystkie zasady naszego testowego klienta zapisane w bazie w dysjunkcyjnej postaci normalnej.
Dzięki takiemu podejściu, po stronie aplikacji należy wyłącznie zbudować odpowiednie zapytanie (które wykorzystuje jedynie nierekurencyjne zapytania wewnętrzne), a z bazy danych otrzymamy listę wszystkich spełnionych RoutingPolicies.
Benchmark wypadł niezwykle obiecująco: przy takiej samej wielkości zbioru danych, na o wiele słabszej maszynie (2GB RAM, 2 rdzenie vagrant na macbooku) średni czas odpowiedzi bazy danych to około 20 ms.
Zasadniczą różnicą między rozwiązaniem istniejącym w Marsie a planowanym przeze mnie jest konfiguracja, ponieważ nowe rozwiązanie wymusza dokładnie dwa poziomy zagłębienia. Oczywiście, możliwa jest transformacja warunków zapisanych rekurencyjnie na dysjunkcyjną postać normalną, ale z punktu widzenia obsługi klienta jest to złe rozwiązanie: faktycznie stosowane zasady są inne niż te zdefiniowane przez użytkownika, przez co rozwiązywanie problemów konfiguracyjnych oraz korekcja błędnych założeń w zapisanej logice są znacząco trudniejsze.
Ruszyłem więc na misję przekonania biznesu do tego rozwiązania, mając do dyspozycji proof of concept, bardzo obiecujące liczby oraz dużo entuzjazmu.
Wyboje
Moim głównym argumentem była wydajność lepsza o około trzy rzędy wielkości – a co za tym idzie, znacząco zmniejszająca koszty infrastruktury. Niestety, dla biznesu było to za mało: koszty szkolenia obsługi klienta i samych klientów oraz koszty migracji przeważały zyski ze zmiany rozwiązania, a model z ukrytą normalizacją nie został zaakceptowany przez wymienione wyżej problemy. Ponownie więc wróciłem do tablicy, tym razem z zamiarem znalezienia pozytywów rozwiązania, które zmienią decyzję CEO.
Ostatecznie do głowy przyszły mi trzy istotne punkty:
- Możliwość podglądu na żywo (podczas tworzenia) co dokładnie stanie się z wiadomością.
- Szczegóły zasad powiązanych z RoutingPolicy widoczne na jednym ekranie (w Marsie każda zasada było konfigurowana w oddzielnym widoku, więc odgórne spojrzenie obejmujące wszystkie zagnieżdżone zasady nie było możliwe).
- Wygodne budowanie interfejsu użytkownika: każdy duży klocek (podtyp Condition) składa się z mniejszych klocków (pól definiujących ten typ Condition), których jest skończona ilość – backend może wystawiać endpoint opisujący te zależności, a frontend prezentować je bazując wyłącznie na prezentacji kontenerów i pól (bez występującej w kodzie wiedzy o poszczególnych typach Condition). W rezultacie znacząco zmniejszy to czas potrzebny na wprowadzanie nowych typów zasad do aplikacji.
Po kolejnym spotkaniu z udziałem CEO udało mi się przekonać cały zespół do swojej wizji, zabrałem się więc za dokładną implementację.
Jak to działa
Niestety nie mogę pokazać zrzutów ekranu ani makiet interfejsu użytkownika, ale mogę zaprezentować nieco uproszczony sposób funkcjonowania backendu. Sam front-end umożliwiał dodawanie i konfigurację klocków – ConditionSet oraz konkretnych implementacji Action – do RoutingPolicy oraz konkretnych implementacji Condition do ConditionSet dodanego wcześniej do RoutingPolicy, a następnie przesyłał definiujący konfigurację json do backendu.
Backend korzystał z Django oraz PostgreSQL, więc bazowe klasy były modelami abstrakcyjnymi, a konkretne typy Conditions i Actions używały single-table inheritance z wartościami należącymi do konkretnego typu zapisywanymi w polu JSONB indeksowanym za pomocą GIN.
Konkretna implementacja musiała tylko zdefiniować relacje oraz podtypy Action i Condition definiujące logikę biznesową, dla przykładu:
class MessageTypeCondition(Condition): @classmethod @Condition.compiler(Condition.Fields.LIST, 'communication.MessageType') def message_type_id__in(cls, message, lookup, negate): params = [message.message_type.id] value = "'%s'" return cls.compile_template( lookup, JSONOperators.one_exists, value, params=params, negate=negate, reverse=False)
class StopSendingAction(Action): @Action.executor(Action.Fields.NONE, ChangeNames.STOP_SENDING, serializers.BooleanField) def stop_sending(self, routing_policy, message, lookup, changename): message._action = MessageActions.STOP message._changes[routing_policy.name][self.name][changename] = True
Jak widać, w powyższym kodzie ciała metod są bardzo proste, ale otoczone dekoratorami. Całe rozwiązanie wykorzystywało metaklasy oraz dekoratory, aby przekazać dodatkowe informacje dotyczące funkcji. Służyło to, aby:
- Automatycznie generować serializery dla konkretnych klas, potrzebne do komunikacji z frontendem.
- Dodać metodę do listy zawierającej funkcje do wykonania podczas budowania zapytania do bazy danych.
- Przekazać podczas wywołania funkcji dodatkowe parametry kontekstowe.
Ponieważ ORM Django niezbyt nadaje się do budowania zapytań nieopartych bezpośrednio na modelach, konieczne było stworzenie własnego query buildera – uproszczonego, ponieważ w praktyce do wyszukiwania spełniającego potrzeby biznesowe potrzebna jest skończona, niewielka ilość instrukcji. W dużym skrócie nasz query builder potrzebował wybierać jedynie pole id, zawsze z jednej tabeli, przypisanej do klasy Condition, więc musieliśmy jedynie zbudować część WHERE zapytania. Odbywało się to w następujący sposób:
- Wywołanie RoutingPolicy.find(object).
- Wywołanie ConditionSet.find(object).
- Wywołanie Condition.find(object).
- Dla każdej podklasy Condition, wywołanie Condition.compile(object).
- Wywołanie każdej zarejestrowanej dekoratorem Condition.compiler metody w podklasie Condition, przekazując parametr object.
- Każda wywołana wyżej metoda zwraca krotkę składającą się z fragmentu trafiającego do WHERE w zapytaniu oraz listy parametrów do przekazania do bazy danych.
- Rezultaty wywołania wszystkich zarejestrowanych metod z jednej klasy są łączone poprzez AND i zwracane jako rezultat Condition.compile(object).
- Rezultaty Condition.compile(object) są łączone poprzez OR oraz konkatenowane z pozostałą częścią zapytania SQL. Efekt tego działania może być wysyłany do bazy danych, razem z odpowiednimi parametrami. W efekcie otrzymamy id wszystkich spełnionych instancji Condition. Jednakże z Condition.find(object) zwracamy samo zapytanie wraz z parametrami.
- W ConditionSet.find(object) wykonywane jest zapytanie korzystające z COUNT i CASE, aby ustalić, które ConditionSet są spełnione. Jest to już normalne zapytanie w Django ORM, korzystające z rezultatu Condition.find(object) jako zapytania wewnętrznego. Zapytanie jest zwracane z ConditionSet.find(object).
- Analogicznie w RoutingPolicy.find(object): zapytanie korzystające z COUNT oraz CASE, aby określić, które RoutingPolicy są spełnione. Zwracane są odpowiednie instancje RoutingPolicy.
Rozwiązanie po paru dniach dopracowywania i z dwoma konkretnymi implementacjami (na poziomie konta oraz użytkowników) trafia na staging.
Ostatnie problemy
W środowisku stagingowym rozwiązanie sprawdza się znakomicie. Konfiguracja jest prosta – członkowie zespołu obsługi klienta potrzebowali dosłownie pięciu minut, by zapoznać się z nowym interfejsem i zacząć z niego sprawnie korzystać. Podgląd pokazujący rezultat routingu w oknie tworzenia wiadomości nie powoduje nadmiernego obciążenia serwera. 95% zapytań do routingu jest wykonywanych w poniżej 40ms, a 50% poniżej 30ms – o trzy rzędy wielkości szybciej, niż w starej implementacji.
Rozpoczyna się żmudny proces migracji konfiguracji routingu z Marsa do Jupitera dla paru zainteresowanych klientów. Wstępne dane wygenerowane są automatycznie, korzystając z algorytmu konwertującego wyrażenie logiczne na DNF. Niestety rezultaty, choć prawidłowe, nie zawsze są czytelne dla człowieka. Powoduje to konieczność poświęcenia kilkudziesięciu godzin ludzkiej pracy na ręczną konwersję. Na szczęście, był to ostatni problem na drodze do wdrożenia produkcyjnego.
Po około trzech tygodniach od rozpoczęcia prac nad zadaniem, pierwsi nowi klienci oraz klienci przeniesieni z Marsa zaczynają korzystać z routingu w Jupiterze. Niespodziewanym efektem była samodzielna konfiguracja wykonywana przez klientów, bez pomocy obsługi klienta. Wedle szacunków tego zespołu, nowa implementacja oszczędziła im ok. 20 godzin pracy w tygodniu.
Epilog
Korekta założeń biznesowych okazała się sporym sukcesem. Przez kolejne półtora roku czasy odpowiedzi nie wzrosły względem tych uzyskanych przy testach na środowisku stagingowym. Firma oszczędziła tysiące dolarów miesięcznie dzięki znacząco mniejszym kosztom infrastruktury. Nowa funkcjonalność – podgląd rezultatów routingu – znacząco zwiększyła zrozumienie użytkowników dla działania aplikacji, a prostota interfejsu pozwoliła obsłudze klienta zmniejszyć czas reakcji i zwiększyć poziom obsługi. Było to istotne zwłaszcza podczas onboardingu nowych klientów.
Dodatkowo, od strony technicznej – konkretne implementacje tego rozwiązania zostały później użyte w innych miejscach aplikacji, np. w celu automatycznego tagowania encji.
Najważniejszy – moim zdaniem – wniosek, płynący z tej historii, to fakt, że istniejące procesy biznesowe nie muszą być najlepsze. Gdy tylko sytuacja na to pozwala, warto skupić się na rozwiązaniu problemu jak najlepiej wykorzystując możliwości technologii, a nie implementacji istniejących już procesów (czy to w świecie rzeczywistym, czy w starszym systemie).
Zdjęcie główne artykułu pochodzi z stocksnap.io.