Realtime և WebSockets
Թարմացումներն օգտատիրոջն ուղարկել հենց այն պահին, երբ ինչ-որ բան տեղի է ունենում։
30 վայրկյանում
Realtime ֆունկցիաները տվյալները սերվերից հասցնում են կլիենտին՝ առանց էջը թարմացնելու. պատվերի կարգավիճակ, չատ, խոհանոցի էկրաններ, կենդանի dashboard-ներ։ Կարելի է անել polling, հոսք ուղարկել Server-Sent Events-ով կամ բաց պահել երկկողմանի WebSocket։ Դժվարը ոչ թե միացումն ինքնին է, այլ մի քանի սերվերի վրա մասշտաբավորումը, ալիքների ավտորիզացիան և վերամիացումների մշակումը։
Ինչու եմ օգտագործում
- Realtime օգտագործում եմ, որովհետև հաճախորդները ակնկալում են տեսնել, թե ինչպես է իրենց պատվերը «ընդունված»-ից անցնում «ճանապարհին» կարգավիճակի՝ առանց էջը թարմացնելու։
- Socket room-ներ օգտագործում եմ, որպեսզի խոհանոցի էկրանը, դրամարկղը և մենեջերի dashboard-ը ստանան միայն իրենց համար նախատեսված իրադարձությունները։
- Redis pub/sub օգտագործում եմ, որովհետև realtime-ը պետք է շարունակի աշխատել, երբ API-ն պտտվում է մի քանի պրոցեսի վրա։
- Hosted (Pusher) և self-hosted socket-ների միջև ընտրում եմ՝ կախված բյուջեից, մասշտաբից և վերահսկողության անհրաժեշտությունից։
Ինչպես է աշխատում
01Polling, long-polling, SSE և WebSockets
Polling-ը մի քանի վայրկյանը մեկ հարցնելն է՝ «նոր բան կա՞»։ Այն պարզ է և աշխատում է ամենուր, բայց իզուր հարցումներ է վատնում և ուշացում ավելացնում։ Long-polling-ը յուրաքանչյուր հարցումը բաց է պահում, մինչև սերվերը նորություն ունենա կամ timeout-ը լրանա, հետո կլիենտը նորից է միանում. ավելի քիչ վատնում, ավելի շատ բարդություն։ Server-Sent Events-ը (SSE) բաց է պահում մեկ HTTP պատասխան, և սերվերը դրա միջով հոսքով տեքստային իրադարձություններ է ուղարկում. այն միակողմանի է (սերվերից կլիենտ), ինքնաշխատ վերամիանում է և լավ է անցնում proxy-ների ու HTTP/2-ի միջով։ WebSockets-ը մեկ TCP միացման վրա բացում է լիարժեք երկկողմանի ալիք, որը իդեալական է, երբ կլիենտն էլ է հաճախ հաղորդագրություններ ուղարկում, ինչպես չատում, համատեղ խմբագրման կամ խաղերի դեպքում։ Գործնական կանոն՝ polling՝ հազվադեպ թարմացումների համար, SSE՝ սերվերից կլիենտ հոսքերի համար, օրինակ՝ ծանուցումներ կամ AI token-ների streaming, WebSockets՝ ինտերակտիվ երկկողմանի տրաֆիկի համար։
02Ինչպես է աշխատում WebSocket միացումը
WebSocket-ը սկսվում է որպես սովորական HTTP հարցում, որը խնդրում է արձանագրությունը upgrade անել։ Եթե սերվերը համաձայն է, պատասխանում է 101 Switching Protocols, և դրանից հետո նույն TCP միացումը HTTP-ի փոխարեն WebSocket frame-ներ է կրում։ Frame-ները փոքր փաթեթներ են՝ տեքստային կամ բինար տվյալների frame-ներ, գումարած կառավարման frame-ներ ping-ի, pong-ի և close-ի համար։ Կլիենտից սերվեր գնացող frame-ները mask են արվում։ Ping-ը և pong-ը միացումը կենդանի են պահում և հայտնաբերում «մեռած» կողմերին։ Քանի որ upgrade-ը HTTP է, միացումն անցնում է load balancer-ների և reverse proxy-ների միջով, բայց դրանք պետք է կարգավորված լինեն այնպես, որ թույլ տան upgrade-ը և երկարատև միացումները։ Production-ում միշտ օգտագործեք wss:// (TLS)։
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
03socket.io. namespace-ներ, room-ներ և auth
socket.io-ն WebSockets-ի վրա կառուցված գրադարան է, որն ավելացնում է ավտոմատ վերամիացում, acknowledgement-ներ, long-polling-ի fallback և միացումները խմբավորելու պարզ մոդել։ Namespace-ը (օրինակ՝ /staff կամ /customers) առանձին ալիք է՝ իր սեփական middleware-ով, որը հարմար է տարբեր լսարանների համար։ Room-ը խումբ է namespace-ի ներսում. սերվերը socket-ները ավելացնում է room-ների մեջ և emit է անում ամբողջ room-ին միանգամից։ Տիպիկ room-ներ՝ մեկը յուրաքանչյուր tenant-ի, մասնաճյուղի, տերմինալի և օգտատիրոջ համար։ Նույնականացումն արեք handshake middleware-ում՝ մինչև միացումն ընդունվելը, և room-ի անդամակցությունը որոշեք սերվերում՝ ստուգված ինքնությունից ելնելով։ Երբեք թույլ մի տվեք կլիենտին միանալ room-ի միայն այն պատճառով, որ ինքը խնդրել է։
io.of('/staff').use(async (socket, next) => {
try {
socket.data.user = await verifyAccessToken(socket.handshake.auth.token);
next();
} catch {
next(new Error('unauthorized'));
}
});
io.of('/staff').on('connection', (socket) => {
const { companyId, branchId, sub } = socket.data.user;
socket.join(['company:' + companyId, 'branch:' + branchId, 'user:' + sub]);
});
// elsewhere, after an order changes:
io.of('/staff').to('branch:' + order.branchId).emit('order.updated', { id: order.id, status: order.status, version: order.version });
04Մասշտաբավորում մի քանի սերվերի վրա Redis-ով
Մեկ պրոցեսը գիտի միայն իրեն միացած socket-ները։ Երբ աշխատեցնում եք API-ի չորս instance, A instance-ին միացած օգտատերը չի տեսնի B instance-ում emit արված իրադարձությունը։ Լուծումը ընդհանուր message bus-ն է։ socket.io-ի Redis adapter-ը room-ի յուրաքանչյուր emit հրապարակում է Redis pub/sub-ում. ամեն instance բաժանորդագրվում է և հասցնում իր լոկալ socket-ներին։ Եթե load balancer-ի հետևում օգտագործում եք socket.io-ի long-polling fallback-ը, միացրեք sticky session-ներ, որպեսզի կլիենտի հարցումները հասնեն նույն instance-ին. միայն WebSocket transport-ի դեպքում դա պետք չէ։ Շատ մեծ fan-out-ի կամ երաշխավորված առաքման համար տարբերակներ են Redis Streams-ը կամ sharded adapter-ը։ Background worker-ներից նույնպես emit արեք նույն adapter-ի միջով (socket.io emitter փաթեթով), որպեսզի queue-ի job-ը կարողանա ուղղակիորեն ծանուցել օգտատերերին։
import { createClient } from 'redis';
import { createAdapter } from '@socket.io/redis-adapter';
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));
05Laravel. broadcasting Pusher-ով, Reverb-ով և Echo-ով
Laravel-ում broadcasting-ը ներկառուցված է։ Իրադարձությունը նշում եք ShouldBroadcast-ով, սահմանում, թե որ ալիքով է այն գնում, և Laravel-ն այն ուղարկում է driver-ի միջոցով։ Pusher-ը hosted ծառայություն է. սերվերներ պահել պետք չէ, վճարում եք ըստ միացումների և հաղորդագրությունների։ Reverb-ը Laravel-ի սեփական first-party WebSocket սերվերն է, որը խոսում է Pusher-ի արձանագրությամբ, այնպես որ կարող եք self-host անել նույն կլիենտային կոդով։ php artisan install:broadcasting-ը կարգավորում է ամեն ինչ։ Frontend-ում Laravel Echo-ն բաժանորդագրվում է ալիքներին։ Private և presence ալիքների ավտորիզացիան արվում է routes/channels.php-ում. Echo-ն կանչում է /broadcasting/auth endpoint-ը, և ձեր callback-ը որոշում է, թե արդյոք այս օգտատերը կարող է լսել։ Broadcast-ները դրեք queue-ի մեջ, որպեսզի դանդաղ socket սերվերը երբեք չդանդաղեցնի HTTP պատասխանը։
// routes/channels.php
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
return Order::whereKey($orderId)->where('customer_id', $user->id)->exists();
});
// Frontend (Echo)
echo.private('orders.' + orderId)
.listen('OrderStatusChanged', (e) => updateStatus(e.status));
06Վերամիացում, հերթականություն և բաց թողնված հաղորդագրություններ
Մոբայլ ցանցերը անընդհատ կտրվում են, ուստի նախագծեք՝ հաշվի առնելով անջատումները։ Կլիենտները վերամիանում են exponential backoff-ով և jitter-ով, որպեսզի սերվերի վերագործարկումը չառաջացնի հազարավոր միաժամանակյա վերամիացումների «ջարդ»։ Սովորական pub/sub-ում այն իրադարձությունները, որոնք ուղարկվել են, մինչ կլիենտը offline էր, կորչում են։ Socket-ին վերաբերվեք որպես «ինչ-որ բան փոխվեց» ազդանշանի, իսկ REST API-ին՝ որպես ճշմարտության աղբյուրի. վերամիանալիս նորից բեռնեք ընթացիկ վիճակը։ Յուրաքանչյուր իրադարձության մեջ ներառեք version համար կամ timestamp, որպեսզի կլիենտը կարողանա անտեսել հնացած կամ խառը հերթականությամբ եկած թարմացումները։ Եթե մեկ entity-ի համար հերթականությունը կարևոր է, նրա իրադարձություններն ուղղեք մեկ ալիքով և մշակեք հաջորդաբար։ Կրիտիկական տվյալների համար օգտագործեք acknowledgement-ներ կամ պահպանված event log՝ «since» cursor-ով։
- Payload-ները փոքր պահեք. id-ներ և կարգավիճակ, ոչ թե ամբողջական օբյեկտներ։
- Ուղարկեք heartbeat-ներ. փակեք և մաքրեք «մեռած» միացումները։
- Proxy-ի timeout-ները (nginx proxy_read_timeout) կարգավորեք ձեր ping interval-ից մեծ։
Օգտագործեք, երբ
- Պատվերի կենդանի հետևում, խոհանոցի և հերթի էկրաններ, առաքման կարգավիճակ։
- Չատ, աջակցության զրույցներ և համատեղ աշխատանքի գործիքներ։
- Կենդանի dashboard-ներ և ադմինի ծանուցումներ, որտեղ վայրկյանները կարևոր են։
- Երկար առաջադրանքների կամ AI պատասխանների ընթացքի streaming (հաճախ՝ SSE)։
Մի՛ օգտագործեք, երբ
- Տվյալներ, որոնք փոխվում են մի քանի րոպեն մեկ. polling-ը կամ թարմացնելու կոճակն ավելի պարզ է։
- Միակողմանի ծանուցումներ փակ հավելվածին. դրա փոխարեն օգտագործեք push notification-ներ (FCM/APNs)։
- Երբ hosted ծառայությունն ավելի էժան կնստի, քան սեփական socket սերվերները պահելու և մասշտաբավորելու ժամանակը։
Այլընտրանքներ
- Server-Sent Events
Միակողմանի streaming HTTP-ով. ավտոմատ վերամիացում, շատ պարզ։
- Pusher / Ably
Hosted realtime. ոչ մի ենթակառուցվածք, վճարում ըստ օգտագործման։
- Laravel Reverb
Laravel-ի first-party self-hosted WebSocket սերվեր՝ Pusher-ի արձանագրությամբ։
- Push notifications
FCM-ը և APNs-ը հասնում են օգտատերերին նույնիսկ այն ժամանակ, երբ հավելվածը փակ է։
Լավագույն պրակտիկաներ
- Նույնականացումն արեք handshake-ի ժամանակ և room-ները նշանակեք սերվերում։
- Օգտագործեք Redis adapter (կամ hosted ծառայություն), հենց որ մեկից ավելի instance եք աշխատեցնում։
- Իրադարձություններին վերաբերվեք որպես հուշումների և վերամիանալիս վիճակը նորից բեռնեք API-ից։
- Իրադարձություններին version տվեք, որպեսզի կլիենտները կարողանան դեն նետել հնացած կամ կրկնվող թարմացումները։
- Վերամիացեք exponential backoff-ով և jitter-ով։
- Broadcast արեք queue-ներից, որպեսզի դանդաղ socket-ները երբեք չարգելափակեն HTTP հարցումները։
Տարածված սխալներ
- Թույլ տալ կլիենտներին բաժանորդագրվել իրենց ուղարկած ցանկացած room-ի կամ ալիքի անվան։
- Մասշտաբավորել մի քանի instance-ի և զարմանալ, թե ինչու օգտատերերի կեսը բաց է թողնում իրադարձությունները։
- Ենթադրել, որ յուրաքանչյուր հաղորդագրություն հասնում է, ճիշտ հերթականությամբ և ճիշտ մեկ անգամ։
- Broadcast payload-ներում ուղարկել տվյալների բազայի ամբողջական գրառումներ, ներառյալ գաղտնի դաշտերը։
- Proxy-ի timeout-ներ, որոնք ավելի կարճ են, քան heartbeat interval-ը, ինչը մշտական վերամիացումներ է առաջացնում։
Խորը վերլուծություն
07Թողունակություն՝ file descriptor-ներ, proxy-ներ և heartbeat-ներ
Յուրաքանչյուր բաց WebSocket բաց file descriptor է socket սերվերի և դրա առջև կանգնած յուրաքանչյուր proxy-ի վրա։ Linux-ը սահմանափակում է դրանք ըստ պրոցեսի, հաճախ լռելյայն 1,024-ով, ուստի scaling-ի առաջին պատը սովորաբար «too many open files»-ն է, ոչ թե CPU-ն։ Բարձրացրեք սահմանը ծառայության համար (ulimit -n, systemd-ում LimitNOFILE, supervisor-ում minfds), իսկ nginx-ում բարձրացրեք worker_connections-ը և worker_rlimit_nofile-ը՝ հիշելով, որ proxy արված socket-ը nginx-ում օգտագործում է երկու կապ՝ client-ի և upstream-ի կողմերում։ Հաջորդ սահմանը մեկ կապի հիշողությունն է, ուստի չափեք այն բեռի տակ։ Proxy-ները փակում են անգործուն կապերը. nginx-ի proxy_read_timeout-ը լռելյայն 60 վայրկյան է, իսկ socket.io-ն լռելյայն ping է անում 25 վայրկյանը մեկ, ուստի timeout-ը պետք է մնա ping-ի ինտերվալից բարձր։ Laravel Reverb-ն ունի հատուկ թակարդ. դրա լռելյայն stream_select loop-ը սահմանափակված է մոտ 1,024 կապով, և դրանից ավելիի համար ձեզ պետք է ext-uv ընդլայնումը։
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /socket.io/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 75s; # above the 25 s ping interval plus timeout
proxy_send_timeout 75s;
}
}
08Առաքման երաշխիքներ՝ acknowledgement-ներ և վիճակի վերականգնում
Պարզ emit-ը «ուղարկիր և մոռացիր» է. եթե socket-ը մի պահ առաջ կտրվել է, հաղորդագրությունը կորած է։ socket.io-ն առաջարկում է երկու գործիք։ Acknowledgement-ները թույլ են տալիս ստացողին հաստատել մշակումը, իսկ emitWithAck-ը timeout-ով դա վերածում է Promise-ի, որը կարող եք await անել, այնպես որ սերվերը գիտի՝ դրամարկղն իրոք տպե՞լ է կտրոնը, և եթե ոչ, կարող է կրկին փորձել queue-ի միջոցով։ Connection state recovery-ն թույլ է տալիս client-ին, որը վերամիանում է որոշակի պատուհանի ընթացքում՝ լռելյայն երկու րոպե, հետ ստանալ իր id-ն, room-երը և բաց թողած փաթեթները։ Այն ունի սահմանափակումներ. ծածկում է միայն կարճ անջատումները, չի աջակցվում ամեն adapter-ի կողմից (դասական Redis pub/sub adapter-ը չի աջակցում, Redis Streams adapter-ը՝ աջակցում է), և լռելյայն վերականգնման ժամանակ բաց է թողնում ձեր auth middleware-ը, ինչը գուցե ցանկանաք անջատել։ Այն ամենի համար, ինչը երբեք չպետք է կորչի, հուսալի պատասխանը մնում է cursor-ով պահպանվող event log-ը։
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: false, // re-run auth on recovered connections
},
});
try {
const replies = await io.to('terminal:' + terminalId)
.timeout(5_000)
.emitWithAck('receipt.print', { orderId });
logger.info({ replies }, 'terminal confirmed');
} catch {
await printQueue.add('retry', { orderId, terminalId }); // no ack in time
}
09Fan-out, «տաք» room-եր և սահմանափակված թարմացումներ
Fan-out-ը մեկ event է, որը հասցվում է բազմաթիվ socket-ների։ Կենդանի dashboard-ի 50,000 դիտողով room-ը մեկ emit-ը վերածում է 50,000 գրառման, որը adapter-ի միջոցով բազմապատկվում է նաև instance-ների միջև։ Երեք տեխնիկա այն ողջամիտ են պահում։ Միավորեք (coalesce). յուրաքանչյուր փոփոխություն ուղարկելու փոխարեն պահեք յուրաքանչյուր entity-ի վերջին վիճակը և ուղարկեք առավելագույնը մի քանի հարյուր միլիվայրկյանը մեկ, այնպես որ վայրկյանում 40 պահեստային թարմացումը դառնում է երկու հաղորդագրություն։ Օգտագործեք volatile emit-ներ այն տվյալների համար, որտեղ կարևոր է միայն նորագույն արժեքը, օրինակ՝ cursor-ների դիրքեր կամ կենդանի մետրիկաներ. socket.volatile.emit-ը բաց է թողնում փաթեթը, եթե client-ը պատրաստ չէ, այն buffer անելու փոխարեն։ Payload-ները պահեք փոքր՝ միայն id-ներ և փոփոխված դաշտեր, և մեծ ծավալի դեպքում դիտարկեք msgpack-ի նման binary parser։ Բազմաթիվ instance-ների համար socket.io-ի sharded Redis adapter-ը օգտագործում է Redis 7-ի sharded pub/sub-ը, որպեսզի տրաֆիկը բաշխվի Redis Cluster-ով, փոխարենը որ յուրաքանչյուր հանգույց ստանա յուրաքանչյուր հաղորդագրություն։
- Չափեք հաղորդագրությունները վայրկյանում ըստ room-ի և ըստ instance-ի, ոչ միայն կապերի քանակը։
- perMessageDeflate սեղմումը socket.io-ում լռելյայն անջատված է. այն հիշողություն է արժենում յուրաքանչյուր socket-ի համար, ուստի միացրեք միայն մեծ payload-ների դեպքում։
- Շատ «տաք» room-երը կարելի է բաժանել ըստ տարածաշրջանի կամ shard-ի, որտեղ client-ները միանում են մեկ shard-ի։
10Երկարակյաց կապերի անվտանգությունը
WebSocket-ները շրջանցում են որոշ պաշտպանություններ, որոնց վրա հենվում եք HTTP-ի դեպքում։ Բրաուզերները WebSocket handshake-ի հետ ուղարկում են cookie-ներ, բայց CORS-ը դրա վրա չի տարածվում, ուստի չարամիտ էջը կարող է socket բացել ձեր սերվերի հետ օգտատիրոջ cookie-ներով. սա cross-site WebSocket hijacking է։ Cookie auth օգտագործելիս միշտ ստուգեք Origin header-ը allow-list-ով։ Միացման պահին ստուգված token-ը կարող է ժամկետանց լինել կապի փակվելուց ժամեր առաջ, ուստի պլանավորեք անջատում token-ի ժամկետի ավարտին կամ պահանջեք, որ client-ը թարմացնի և socket-ի միջոցով կրկին նույնականացվի։ Յուրաքանչյուր մուտքային հաղորդագրություն դիտարկեք HTTP հարցման body-ի պես. վավերացրեք սխեմայով, ստուգեք թույլտվությունը կոնկրետ գործողության համար և rate limit դրեք հաղորդագրությունների վրա ըստ socket-ի։ Սահմանափակեք հաղորդագրության չափը, քանի որ socket.io-ն լռելյայն ընդունում է մինչև 1 MB մեկ հաղորդագրության համար, ինչը շատ ավելին է, քան պետք է հավելվածների մեծ մասին։
const io = new Server(httpServer, {
maxHttpBufferSize: 64 * 1024, // default is 1 MB
allowRequest: (req, cb) => cb(null, ALLOWED_ORIGINS.has(req.headers.origin ?? '')),
});
io.on('connection', (socket) => {
const expiresInMs = socket.data.user.exp * 1000 - Date.now();
const timer = setTimeout(() => socket.disconnect(true), expiresInMs);
socket.on('disconnect', () => clearTimeout(timer));
socket.on('cart.update', (raw, ack) => {
const msg = CartUpdateSchema.safeParse(raw);
if (!msg.success) return ack?.({ error: 'invalid_payload' });
// permission check, then handle msg.data
});
});
Իրական իրավիճակներ
Խոհանոցի էկրանը բաց է թողնում պատվերները Wi-Fi-ի կտրվելուց հետո
Խնդիր
Ռեստորանի աշխատակիցները հայտնում են, որ նոր պատվերները երբեմն երբեք չեն հայտնվում խոհանոցի էկրանին։ Դա միշտ պատահում է պլանշետի Wi-Fi-ի կարճատև կտրվելուց հետո։
Ինչպես լուծել
Event-ները, որոնք emit են արվել, մինչ պլանշետը offline էր, ոչ ոքի չեն հասել։ Ուղղեք երկու կողմում էլ։ Վերամիանալիս client-ը պետք է REST API-ից նորից բեռնի բաց պատվերների ցուցակը, որը ճշմարտության աղբյուրն է, և միավորի ըստ պատվերի id-ի և version-ի։ Որպես անվտանգության ցանց ավելացրեք պարբերական ֆոնային թարմացում, օրինակ՝ 60 վայրկյանը մեկ։ Սերվերում payload-ները պահեք փոքր և տարբերակավորված, որպեսզի հնացած թարմացումներն անտեսվեն։ Եթե ուշացումը շատ կարևոր է, միացրեք connection state recovery-ն այն աջակցող adapter-ով, կամ թող էկրանը acknowledge անի նոր պատվերները, իսկ դրամարկղում աշխատակիցները ահազանգ ստանան, երբ պատվերը մեկ րոպե մնում է չհաստատված։
Scaling-ից հետո օգտատերերի կեսը դադարեց event-ներ ստանալ
Խնդիր
Թիմը socket սերվերը մեկ instance-ից դարձրեց երեք։ Հիմա որոշ օգտատերեր ստանում են կենդանի թարմացումներ, մյուսները՝ ոչ, իսկ լոգերը ցույց են տալիս պարբերական «Session ID unknown» սխալներ։
Ինչպես լուծել
Երկու առանձին խնդիր։ Բացակայող event-ները նշանակում են, որ instance-ները չեն կիսում emit-ները. ավելացրեք socket.io Redis adapter-ը կամ Redis 7 cluster-ների համար sharded adapter-ը, որպեսզի մի instance-ի emit-ը հասնի մյուսների socket-ներին, և ֆոնային worker-ներին էլ ստիպեք emit անել Redis emitter-ի միջոցով։ «Session ID unknown» սխալները գալիս են HTTP long-polling transport-ից, որի հարցումները պետք է հասնեն նույն instance-ին. load balancer-ում միացրեք sticky session-ներ, օրինակ՝ ըստ cookie-ի կամ IP hash-ի, կամ client-ները կարգավորեք transports: ['websocket']-ով, եթե polling fallback-ը պետք չէ։ Այնուհետև հաջորդ release-ից առաջ load test արեք մի քանի instance-ով։
Reverb-ը ընկնում է մոտ հազար կապի դեպքում
Խնդիր
Laravel հավելվածը Pusher-ից տեղափոխվեց self-hosted Reverb։ Թեստավորման ժամանակ ամեն ինչ աշխատում էր, բայց production-ում կապերը սկսում են ձախողվել, հենց որ մոտ հազար օգտատեր online է։
Ինչպես լուծել
Այդ թիվը լռելյայն event loop-ի սահմանն է. Reverb-ը օգտագործում է stream_select, եթե ext-uv տեղադրված չէ, իսկ stream_select-ը մշակում է մոտ 1,024 բաց ֆայլ։ Տեղադրեք uv ընդլայնումը PECL-ով, որպեսզի Reverb-ը ավտոմատ փոխի loop-ը։ Այնուհետև բարձրացրեք ֆայլերի բոլոր սահմանները շղթայում. օգտատիրոջ nofile սահմանը limits.conf-ում կամ systemd unit-ում, minfds-ը supervisord.conf-ում, և worker_connections-ը գումարած worker_rlimit_nofile-ը nginx-ում։ Ստուգեք local port range-ը proxy հոսթում։ Կապերը մոնիթորինգ արեք Pulse ինտեգրացիայով։ Երբ մեկ սերվերը բավական չէ, սահմանեք REVERB_SCALING_ENABLED=true, բոլոր հանգույցներն ուղղեք ընդհանուր Redis-ի և դրեք load balancer-ի հետևում։
Արագ ուղեցույց
- Միանալ WebSocket-ին տերմինալից
npx wscat -c wss://api.example.com/ws- socket.io սերվերի մանրամասն լոգեր
DEBUG=socket.io* node dist/main.js- Client առանց long-polling fallback-ի
io('https://api.example.com', { transports: ['websocket'] })- Ուղարկել միայն, եթե client-ը պատրաստ է
socket.volatile.emit('metrics', snapshot)- Ստուգել բաց ֆայլերի սահմանը
ulimit -n- Գործարկել Reverb-ը
php artisan reverb:start --host=0.0.0.0 --port=8080- Deploy-ի ժամանակ նրբորեն վերագործարկել Reverb-ը
php artisan reverb:restart- Reverb-ը գործարկել մի քանի սերվերի վրա
REVERB_SCALING_ENABLED=true- SSE պատասխանի header-ներ
Content-Type: text/event-stream; Cache-Control: no-cache; X-Accel-Buffering: no- Վերսկսել SSE stream-ը
Սերվերը յուրաքանչյուր event-ի հետ ուղարկում է 'id: 42'. բրաուզերը վերամիանում է Last-Event-ID: 42-ով
Իմ իրական նախագծերում
- Multi-tenant POS SaaS-ում socket.io room-ները սահմանափակված են ըստ ընկերության, մասնաճյուղի, տերմինալի և օգտատիրոջ, իսկ instance-ների միջև fan-out-ն արվում է Redis pub/sub-ով։
- Ռեստորանային ցանցի պլատֆորմում պատվերի կենդանի թարմացումները հաճախորդներին հասնում են Pusher-ի և Laravel Echo-ի միջոցով։
- Գեղեցկության e-commerce-ի ադմինը real-time թարմացումներ է ստանում Pusher-ի միջոցով։
Հարցեր և պատասխաններ
Q1WebSockets, թե՞ Server-Sent Events. ինչպե՞ս եք որոշում։
Նայում եմ տրաֆիկի ուղղությանը։ Եթե սերվերը հիմնականում ուղարկում է կլիենտին, օրինակ՝ ծանուցումներ, կենդանի կարգավիճակ կամ AI output-ի streaming, SSE-ն ավելի պարզ է. դա սովորական HTTP է, ավտոմատ վերամիանում է, լավ է աշխատում HTTP/2-ի և proxy-ների հետ, իսկ կլիենտը գործողություններն այնուամենայնիվ ուղարկում է սովորական API կանչերով։ Եթե կլիենտն էլ է հաճախ հաղորդագրություններ ուղարկում, ինչպես չատում, համատեղ խմբագրման, խաղերում կամ POS տերմինալի կողմից վիճակի հաղորդման դեպքում, WebSockets-ն ավելի լավ է համապատասխանում, որովհետև մեկ միացումը երկու ուղղություններն էլ կրում է ցածր overhead-ով։ SSE-ն կրում է միայն տեքստ, ուստի բինար տվյալների համար WebSockets-ն է նախընտրելի։ Երկուսն էլ ուշադրություն են պահանջում proxy-ների և timeout-ների հարցում։
Q2Ինչպե՞ս եք մասշտաբավորում WebSockets-ը մի քանի սերվերի վրա։
Յուրաքանչյուր սերվեր գիտի միայն իր միացումները, ուստի ընդհանուր bus է պետք։ socket.io-ի դեպքում Redis adapter-ը room-ների emit-ները հրապարակում է Redis pub/sub-ում, և ամեն instance հասցնում է իր լոկալ անդամներին։ Background worker-ները emit են անում նույն bus-ի միջով։ Եթե long-polling fallback-ը միացված է, load balancer-ին sticky session-ներ են պետք, միայն WebSocket transport-ի դեպքում՝ ոչ։ Նաև հետևում եմ յուրաքանչյուր instance-ի միացումների քանակին, file descriptor-ների սահմանաչափերին և մեկ socket-ի հիշողությանը, իսկ վերամիացումները jitter-ով բաշխում։ Շատ մեծ մասշտաբի դեպքում hosted ծառայությունը, ինչպես Pusher-ը կամ Ably-ն, կամ առանձին gateway շերտը, այս աշխատանքը հանում է API սերվերների վրայից։
Q3Ինչպե՞ս եք ավտորիզացնում private ալիքները։
Երկու քայլով։ Առաջինը՝ նույնականացնել հենց միացումը. handshake-ի ժամանակ ստուգել token-ը կամ session-ը և մերժել չնույնականացված socket-ները։ Երկրորդը՝ յուրաքանչյուր ալիք կամ room ավտորիզացնել սերվերում։ socket.io-ում room-ի անդամակցությունը հաշվարկում եմ ստուգված օգտատիրոջից, օրինակ՝ նրա tenant-ից, մասնաճյուղից և user id-ից, այլ ոչ թե վստահում կլիենտի ուղարկած room-ի անվանը։ Laravel-ում private և presence ալիքներն անցնում են routes/channels.php-ով, որտեղ callback-ը ստուգում է, որ օգտատերը տիրապետում է ռեսուրսին կամ կարող է այն տեսնել։ Payload-ները միևնույն է պետք է պարունակեն միայն այն, ինչ այդ լսարանը կարող է տեսնել, իսկ token-ները պետք է վերստուգվեն վերամիանալիս։
Q4Ի՞նչ է լինում այն հաղորդագրությունների հետ, որոնք ուղարկվել են, մինչ կլիենտն անջատված էր։
Սովորական pub/sub-ի դեպքում դրանք կորչում են, որովհետև սերվերը հասցնում է միայն այդ պահին միացած socket-ներին։ Այդ պատճառով socket-ը նախագծում եմ որպես ծանուցումների ալիք, ոչ թե տվյալների բազա։ Վերամիանալիս կլիենտը նորից բեռնում է ընթացիկ վիճակը REST API-ից, որը ճշմարտության աղբյուրն է։ Իրադարձությունները կրում են version կամ updated-at արժեք, որպեսզի կլիենտը կարողանա դեն նետել հնացած թարմացումները։ Որտեղ հաղորդագրություն բաց թողնելն իսկապես կարևոր է, օգտագործում եմ acknowledgement-ներ՝ retry-ներով, կամ պահպանված event log, որը կլիենտը կարող է կարդալ իր վերջին cursor-ից։ socket.io-ի connection state recovery-ն օգնում է միայն կարճ անջատումների դեպքում։
Q5Pusher, Reverb, թե՞ սեփական socket.io սերվեր։
Pusher-ով ամենաարագն է թողարկել. ոչ մի սերվեր, գլոբալ ենթակառուցվածք, լավ կլիենտային գրադարաններ։ Փոխզիջումն այն է, որ արժեքը աճում է միացումների և հաղորդագրությունների հետ, և տվյալներն անցնում են երրորդ կողմի միջով։ Reverb-ը լավ միջին տարբերակ է Laravel հավելվածների համար. self-hosted է, համատեղելի Pusher-ի արձանագրության հետ, այնպես որ Echo-ն աշխատում է առանց փոփոխության, և կարող է հորիզոնական մասշտաբավորվել Redis-ով։ Սեփական socket.io սերվերն իմաստ ունի Node backend-ում, որին հարուստ երկկողմանի տրամաբանություն, custom room-ներ և acknowledgement-ներ են պետք, ինչպես տերմինալներով POS-ում։ Ընտրում եմ ըստ թիմի stack-ի, սպասվող միացումների, բյուջեի և այն բանի, թե արդյոք operations թիմը կարող է հուսալիորեն ևս մեկ ծառայություն պահել։
Սենիոր մակարդակի հարցեր
S1Ինչպե՞ս կնախագծեիք realtime 100,000 միաժամանակյա կապի համար։
Socket շերտը առանձնացրեք API-ից։ Gateway instance-ների առանձին խումբը պահում է կապերը և գրեթե բիզնես տրամաբանություն չի անում. այն նույնականացնում է handshake-ի ժամանակ, միացնում room-երին և client-ի գործողությունները փոխանցում API-ին կամ queue-ին։ Instance-ները emit-ները կիսում են sharded Redis adapter-ի կամ message bus-ի միջոցով, իսկ ֆոնային worker-ները event-ները հրապարակում են այդ bus-ում։ Յուրաքանչյուր gateway հանգույց ստանում է բարձրացված ֆայլերի սահմաններ և kernel-ի կարգավորում, և ես load test եմ անում՝ գտնելու մեկ հանգույցի իրատեսական կապերի քանակը, հաճախ տասնյակ հազարներ թեթև տրաֆիկի դեպքում։ Վերամիացումներն օգտագործում են exponential backoff jitter-ով, որպեսզի deploy-ը thundering herd չստեղծի, իսկ deploy-ները հանգույցները դատարկում են աստիճանաբար։ Payload-ները փոքր են և միավորված։ Այլընտրանքը hosted ծառայությունն է, օրինակ՝ Ably-ն կամ Pusher-ը, որտեղ կապի արժեքը փոխանակվում է զրո շահագործման հետ։
S2Ինչպե՞ս եք երաշխավորում realtime event-ների հերթականությունը։
Բաշխված համակարգում հերթականություն կարող եք երաշխավորել միայն այնտեղ, որտեղ կա մեկ հաջորդականություն։ Մեկ WebSocket կապը հերթականացված է, բայց event-ները կարող են գալ մի քանի API instance-ներից և worker-ներից, ուստի կարող են emit արվել խառը հերթականությամբ։ Ես յուրաքանչյուր entity-ի տալիս եմ մոնոտոն աճող version, որը սահմանում է բազան փոփոխության նույն տրանզակցիայի ներսում, և ներառում այն յուրաքանչյուր event-ում։ Client-ները պահում են յուրաքանչյուր entity-ի ամենաբարձր version-ը և անտեսում ավելի հինը, իսկ եթե բաց են հայտնաբերում, այդ entity-ն նորից բեռնում են API-ից։ Երբ մշակման խիստ հերթականությունը կարևոր է, ինչպես տերմինալին ուղղված հրամանների դեպքում, ես այդ entity-ի բոլոր հաղորդագրություններն ուղղորդում եմ մեկ queue partition-ով կամ մեկ worker-ով։ Փոխզիջումը թողունակությունն է. ըստ entity-ի հերթականությունն էժան է, գլոբալ հերթականությունը՝ թանկ և հազվադեպ է պետք։
S3Ինչպե՞ս եք մշակում authentication-ը ժամերով բաց մնացող կապերի վրա։
Token-ը ստուգեք handshake-ի ժամանակ, բայց մի վստահեք դրան հավերժ։ Կարճատև access token-ները ժամկետանց են դառնում խոհանոցի էկրանի անջատվելուց շատ առաջ։ Ես պլանավորում եմ անջատում token-ի ժամկետի ավարտին, և client-ը թարմացնում է իր token-ը սովորական API-ով ու վերամիանում. այլապես client-ը socket-ով ուղարկում է թարմ token, իսկ սերվերը կրկին ստուգում է այն և երկարացնում վերջնաժամկետը։ Չեղարկումը նույնպես պետք է հասնի կենդանի կապերին. երբ օգտատերն անջատվում է կամ նրա session-ները չեղարկվում են, հրապարակեք event, որը ստիպում է յուրաքանչյուր gateway-ի անջատել այդ օգտատիրոջ socket-ները։ Cookie auth-ի դեպքում handshake-ի ժամանակ ստուգում եմ Origin-ը allow-list-ով։ Յուրաքանչյուր հաղորդագրություն դեռ անցնում է authorization իր կոնկրետ գործողության համար, որովհետև վավեր կապը չի նշանակում, որ ամեն գործողություն թույլատրված է։
S4Բիզնես գործողությունները պետք է գնա՞ն WebSocket-ով, թե՞ REST API-ով։
Իմ լռելյայն ընտրությունը REST-ն է հրամանների համար և socket-ը՝ ծանուցումների համար։ REST-ը ձեզ անվճար տալիս է status code-եր, idempotency key-եր, middleware, rate limiting, լոգավորում և թեստեր, իսկ նույն endpoint-ը սպասարկում է նաև socket չունեցող client-ներին։ Այնուհետև socket-ը հայտարարում է «order 42-ը փոխվել է, version 7»։ Գործողությունները socket-ով ուղարկելը իմաստ ունի, երբ ուշացումն ու հաճախականությունը բարձր են, իսկ հաղորդագրությունները՝ փոքր, օրինակ՝ համատեղ cursor-ներ, խաղեր, չատում typing indicator-ներ կամ POS տերմինալ, որը stream է անում կարգավիճակը, որովհետև դա խուսափում է յուրաքանչյուր հաղորդագրության համար HTTP հարցումից։ Այդ դեպքում ես HTTP-ի կարգապահությունը վերակառուցում եմ բացահայտ. սխեմայի վավերացում, թույլտվությունների ստուգում, acknowledgement-ներ արդյունքի կոդերով և idempotency id-ներ retry-ների համար։ Փոխզիջումը ցածր ուշացումն է՝ լավ հասկացված HTTP գործիքների կորստի դիմաց։
Բառարան
- Frame
- WebSocket-ի տվյալների միավորը՝ տեքստային, binary կամ կառավարման frame-եր, ինչպիսիք են ping-ը, pong-ը և close-ը։
- Heartbeat
- Կանոնավոր ping և pong հաղորդագրություններ, որոնք կենդանի են պահում կապը և բացահայտում լուռ անհետացած կողմերին։
- Room
- socket.io-ում socket-ների սերվերային խումբ. room-ին emit անելը հասնում է բոլոր անդամներին։
- Adapter
- socket.io-ի բաղադրիչ, որը հետևում է room-երին և Redis-ի հետ emit-ները փոխանցում սերվերի instance-ների միջև։
- Sticky session
- Load balancer-ի ուղղորդում, որը մեկ client-ի հարցումները պահում է նույն backend instance-ի վրա։
- Fan-out
- Մեկ event-ի հասցնելը բազմաթիվ ստացողների, օրինակ՝ room-ի յուրաքանչյուր socket-ի։
- Presence channel
- Ալիք, որը նաև հետևում է, թե ով է բաժանորդագրված. օգտագործվում է «ով է online» ֆունկցիաների համար։
- Volatile emit
- Հաղորդագրություն, որը կարող է բաց թողնվել, եթե client-ը պատրաստ չէ. հարմար է անընդհատ թարմացվող տվյալների համար։
- CSWSH
- Cross-site WebSocket hijacking՝ չարամիտ էջը socket է բացում զոհի cookie-ներով. կանխվում է Origin-ի ստուգմամբ։
Շարունակեք սովորել
Պրոդուկտի գաղափար ունե՞ք։Եկեք ստեղծենք այն։
Պատմեք ձեր գաղափարի, պրոդուկտի կամ թիմի մասին։ Սովորաբար պատասխանում եմ մեկ աշխատանքային օրվա ընթացքում։