LinkTracker
GitHub-ի և Stack Overflow-ի թարմացումներին հետևող ծառայություն՝ Python-ով գրված երեք ասինխրոն բաղադրիչ, ծանուցումների առաքում Kafka-ով և պահեստային HTTP ուղիով, երկմակարդակ քեշ Valkey-ով և մետրիկաներ Prometheus-ում և Grafana-ում։
Տեխնոլոգիաներ
- Python
- FastAPI
- Apache Kafka
- PostgreSQL
- gRPC
- asyncio
- aiogram
- Avro
- Valkey
- Prometheus
- Grafana
- Docker
- Testcontainers
- GitHub Actions
Խնդիրը
Օգտատերը ցանկանում է հետևել GitHub-ի ռեպոզիտորիաների և Stack Overflow-ի հարցերի թարմացումներին և ծանուցումներ ստանալ Telegram-ում։ Պարզունակ լուծումը բոլոր հղումները հերթով ստուգելն է նույն պրոցեսից, որը պատասխանում է օգտատիրոջը։ Այն բախվում է արտաքին API-ների սահմանափակումներին, խափանումների ժամանակ կորցնում է ծանուցումները և թույլ չի տալիս հասկանալ, թե կոնկրետ ինչ է կատարվում ներսում։
Լուծումը
Համակարգը բաժանված է երեք ասինխրոն բաղադրիչի։ Bot Service-ը պատասխանատու է օգտատիրոջ հետ երկխոսության և բաժանորդագրության հրամանների համար, Scrapper Service-ը՝ արտաքին աղբյուրների հարցման, իսկ դրանց միջև կանգնած մշակման շերտն ընտրում, առաջնահերթություն է տալիս և խմբավորում թարմացումները։ Թարմացումներն առաքվում են Kafka-ով, իսկ դրա անհասանելիության դեպքում մնում է ուղիղ HTTP ուղին։ Ծառայությունների միջև կանչերն անցնում են առանձին՝ gRPC-ով և REST-ով։
Ճարտարապետություն
Հիմնական տեխնիկական որոշումները
- Bot Service-ի և Scrapper Service-ի բաժանումը մեկ պրոցեսի փոխարեն. արտաքին API-ների հարցումը չի արգելափակում օգտատիրոջն ուղղված պատասխանները։
- Ծանուցումների տրանսպորտը սահմանվում է կոնֆիգուրացիայով. լռելյայն օգտագործվում է Kafka-ն, ընդ որում երկու տրանսպորտներից յուրաքանչյուրը կարող է պահեստային լինել մյուսի համար։ Կոմպոզիտ ուղարկիչը փոխում է ուղին միայն առաքման սխալի դեպքում, իսկ հենց կոդի սխալները դուրս է նետում, այլ ոչ թե քողարկում ուղու փոխարկմամբ։
- Kafka-ի հաղորդագրությունները՝ JSON կամ Avro՝ Schema Registry-ով. հաղորդագրության սխեման ֆիքսվում է, այլ ոչ թե ենթադրվում։
- Այն հաղորդագրությունները, որոնք չհաջողվեց մշակել, գնում են dead letter queue, այլ ոչ թե լուռ կորչում։
- Timeout, կրկնափորձեր էքսպոնենցիալ հետաձգմամբ և circuit breaker՝ արտաքին API-ներին ուղղված բոլոր հարցումների վրա, որպեսզի GitHub-ի կամ Stack Overflow-ի դեգրադացիան շարքից չհանի ամբողջ ծառայությունը։
- Երկմակարդակ քեշ սեփական
GET /links-ի համար. երեք հանգույցից Valkey-կլաստեր, իսկ դրա առջև՝ ոչ պարտադիր ներպրոցեսային LRU+TTL։ Բանալիները հեշավորվում են ըստ զրույցի նույնացուցիչի, ուստի մեկ զրույցի բոլոր էջերն ընկնում են կլաստերի նույն slot-ը, և գրառման ժամանակ չեղարկումը մնում է մեկ անցումով։ - REST և gRPC ծառայությունների միջև. gRPC՝ ներքին տրաֆիկի համար, REST՝ որպես հասկանալի արտաքին սահման։
- Ընտրությունը, առաջնահերթությունները և խմբավորումը տեղափոխված են ծառայությունների միջև առանձին շերտ, այլ ոչ թե Scrapper. բոտը ստանում է արդեն ֆիլտրված և խմբավորված թարմացումներ։
Ինչպես է աշխատում
Օգտատերն ավելացնում է հղում GitHub-ի ռեպոզիտորիային կամ Stack Overflow-ի հարցին՝ Telegram-բոտի միջոցով։ Bot Service-ն ընդունում է օգտատիրոջ հրամանները և բաժանորդագրությունները փոխանցում Scrapper Service-ին REST-ով և gRPC-ով։ Scrapper-ը ժամանակացույցով հարցումներ է անում արտաքին API-ներին, համեմատում վիճակը և գտնված թարմացումները հրապարակում Kafka-ում։ Այնտեղից դրանք վերցնում է մշակման շերտը, ընտրում է անհրաժեշտը և խմբավորում, իսկ Bot Service-ը կարդում է արդեն պատրաստը և ուղարկում օգտատիրոջը։ Scrapper-ը հղումները մշակում է խմբաքանակներով՝ մի քանի զուգահեռ առաջադրանքով։ Մեկ հղման սխալը չի կանգնեցնում մնացածը և ներառվում է ամփոփիչ հաշվետվության մեջ։
Եթե Kafka-ն անհասանելի է, Scrapper-ը թարմացումն ուղարկում է ուղիղ HTTP-ով՝ ուղին այլ է, արդյունքն օգտատիրոջ համար՝ նույնը։ Այն հաղորդագրությունները, որոնք չհաջողվեց մշակել, գնում են dead letter queue, այլ ոչ թե անհետանում։ Հրապարակային REST-ը սահմանափակված է հարցումների հաճախականությամբ՝ ըստ IP-ի, յուրաքանչյուր endpoint-ի համար առանձին bucket-ով։
Կայունություն արտաքին խափանումների նկատմամբ
Արտաքին API-ներին ուղղված բոլոր հարցումներն ունեն timeout, կրկնափորձեր և circuit breaker։ Երբ GitHub-ը կամ Stack Overflow-ը պատասխանում են դանդաղ կամ ընդհանրապես չեն պատասխանում, հարցումներն ընդհատվում են timeout-ով և կրկնվում հետաձգմամբ, իսկ կայուն սխալների դեպքում շղթան բացվում է։ Ծառայությունը դադարում է դիմել չաշխատող աղբյուրին և շարունակում սպասարկել մնացածը։
Թարմացումների ընտրություն և խմբավորում
Scrapper-ի և բոտի միջև կանգնած է մշակման առանձին շերտ՝ նույնպես Kafka-ի սպառող։ Այն կարդում է հում թարմացումները մեկ topic-ից, դեն է նետում ֆիլտրման կանոններին համապատասխանողները, մնացածին տալիս է առաջնահերթություն ըստ հիմնաբառերի, կրճատում է չափից երկար տեքստերը, կարճ ժամանակահատվածում խմբավորում է նույն զրույցին ուղղվածը և արդյունքը հրապարակում մեկ այլ topic-ում։ Բոտը կարդում է արդեն պատրաստը։
Շերտը միտումնավոր դետերմինիստական է. ֆիլտրումը, առաջնահերթությունները և տեքստի կրճատումը կանոններ են, ոչ թե մոդել։ Ներսում ոչ մի LLM չկա։ Ընդ որում կրճատումը դուրս է բերված ինտերֆեյսի հետևում, ուստի կանոնը մոդելով կարելի է փոխարինել՝ առանց հենց pipeline-ին դիպչելու։ Շերտի կոդը ոչինչ չի ներմուծում Scrapper-ից և գտնվում է առանձին փաթեթում։ Այն ինքնուրույն ծառայության հանելու համար ոչինչ փոխել պետք չի լինի։
Քեշը և ինչ տվեց այն
Հետևվող հղումների ցանկը տրվում է երկմակարդակ քեշով. երեք հանգույցից Valkey-կլաստեր, իսկ դրա առջև՝ ոչ պարտադիր ներպրոցեսային LRU+TTL։ Բանալիները հեշավորվում են ըստ զրույցի նույնացուցիչի, ուստի մեկ զրույցի բոլոր էջերն ընկնում են կլաստերի նույն slot-ը, և գրառումը դրանք չեղարկում է մեկ անցումով՝ առանց հարևաններին դիպչելու։
Բեռնվածության թեստն անցկացվել է երեք կոնֆիգուրացիայով անընդմեջ։ Սա էլ հենց պատասխանն է այն հարցին, թե արդյոք արժեր ջանք թափել քեշի վրա.
| Կոնֆիգուրացիա | RPS | p50 | p99 |
|---|---|---|---|
| առանց քեշի | 146 | 250 մվ | 690 մվ |
| Valkey | 710 | 22 մվ | 250 մվ |
| Valkey + ներպրոցեսային | 876 | 11 մվ | 230 մվ |
Հիմնական շահույթը տալիս է առաջին մակարդակը՝ գրեթե հնգապատիկ թողունակությամբ և մեկ կարգով՝ մեդիանով։ Երկրորդն ավելացնում է ևս մեկ քառորդ և կիսով չափ կրճատում մնացած հապաղումը։ Շահույթը փոքր է, քանի որ Valkey-ն առանց այդ էլ արագ է պատասխանում loopback-ով։
Գրման հարցումների արագությունն այդ ընթացքում սահմանափակված է PostgreSQL-ով, և քեշը դրանք չի արագացնում. POST-ը և DELETE-ը երկրորդ և երրորդ սցենարների միջև մնում են 15%-ի սահմաններում։ Առաջինի համեմատ դրանց տեսանելի բարելավումը տալիս է ոչ թե քեշն ինքնին, այլ այն, որ ընթերցումը դադարել է ռեսուրսներ խլել գրառումից։
Դիտարկելիություն (observability)
Երկու ծառայություններն էլ /metrics են տալիս Prometheus-ի ձևաչափով՝ RED-մետրիկաներ
(հարցումների հաճախականություն, սխալների բաժին, տևողություն) գումարած կիրառական
հաշվիչ links_on_track_total, որը ցույց է տալիս, թե քանի հղում է այս պահին հետևվում։
Dashboard-ները հավաքված են Grafana-ում, այնպես որ համակարգի վիճակը երևում է ոչ թե
լոգերից, այլ գրաֆիկներից։
Թեստեր և բիլդ
Թեստերը բաժանված են չորս շերտի՝ unit, ինտեգրացիոն, e2e և բեռնվածության։ Ինտեգրացիոնները Testcontainers-ով գործարկում են իրական Kafka, PostgreSQL և Valkey, ուստի ստուգվում է իրական վարքը, ոչ թե mock-երի վարքը։ Բեռնվածության թեստերը տվել են այն թվերը, որոնք վերևում՝ մետրիկաներում են։ Այս ամենն անցնում է GitHub Actions-ում յուրաքանչյուր push-ի ժամանակ՝ լինտերների հետ միասին, իսկ ծառայությունները փաթեթավորված են Docker-ում։
Արդյունքներ
- Բեռնվածության թեստը երեք կոնֆիգուրացիայով ցույց տվեց, թե ինչ տվեց քեշի յուրաքանչյուր մակարդակը. առանց քեշի՝ 146 RPS, p50 250 մվ, Valkey-ով՝ 710 RPS, p50 22 մվ, Valkey-ով և ներպրոցեսային քեշով՝ 876 RPS, p50 11 մվ և p99 230 մվ։
- Երեք անցումներն էլ ավարտվեցին առանց մեկ խափանման. լավագույն սցենարում՝ 314 045 հարցում, զրո սխալ և զրո բացառություն։
- Գրման հարցումների արագությունը սահմանափակված է PostgreSQL-ով, և քեշը դրանք չի արագացնում, դա երևում է թվերից։
- Ծանուցումների առաքումը դիմանում է հիմնական տրանսպորտի խափանմանը՝ պահեստայինի հաշվին։
Մետրիկաներ
- Բեռնվածության թեստ
- 876 RPS
- Հապաղման մեդիան
- 11 ms
- 99-րդ պերցենտիլ
- 230 ms