Arhitectură LMS scalabilă: model multi-chiriași
Piața globală a Sistemul de management al învățării (LMS) a trecut de i 28 de miliarde de dolari în 2025, cu o proiecție de creștere de aproape 70 de miliarde până în 2030. În spatele platformelor precum Moodle, Canvas, Docebo și Coursera ascund provocări arhitecturale enorme: deservirea a mii de organizații simultan, asigurați izolarea datelor, scalați orizontal în timpul utilizării de vârf și menține latențe sub secunde pentru milioane de studenți conectați simultan.
La multi-chiriere și modelul arhitectural care face toate acestea posibile. Într-un LMS cu mai mulți chiriași, o singură instanță de aplicație deservește mai multe organizații (chiriași), fiecare cu propriii utilizatori, cursuri, conținut și configurații, dar partajând infrastructura de bază. Alegerea modelului Multi-chirierea corectă determină costurile operaționale, securitatea, performanța și capacitatea de scalare de la 10 la 10.000 de chiriași fără a rescrie sistemul.
În acest articol inaugural al seriei Inginerie EdTech, vom analiza în profunzime arhitecturi multi-locatari pentru platforme LMS, compararea tiparelor de izolare a datelor, strategii de scalare, modele de date optimizate și modele bazate pe evenimente pentru capabilități în timp real. Fiecare concept va fi însoțit de exemple concrete de cod în TypeScript și Python.
Ce veți învăța în acest articol
- Cele trei modele de bază de închiriere multiplă și când să le folosiți pe fiecare
- Cum să proiectați un model de date LMS care se extinde cu mii de chiriași
- Autentificare și autorizare în contexte multi-locatari cu JWT și RBAC
- Proiectați API-ul REST și GraphQL pentru rezolvarea chiriașilor
- Arhitectură bazată pe evenimente pentru notificări și analize în timp real
- Descompunerea unei platforme LMS pentru întreprinderi în microservicii
- Comparație între arhitecturile Moodle, Canvas, Blackboard și platformele moderne
- Benchmark-uri de performanță și strategii de stocare în cache pentru LMS cu trafic ridicat
Prezentare generală a seriei EdTech Engineering
| # | Articol | Concentrează-te |
|---|---|---|
| 1 | Sunteți aici - Scalable LMS Architecture | Modele multi-chiriași pentru platformele LMS |
| 2 | Streaming video educațional | Arhitectura de distribuție a conținutului video |
| 3 | Learning Analytics și xAPI | Colectarea și analiza datelor de învățare |
| 4 | SCORM și ambalarea conținutului | Standarde pentru conținutul educațional |
| 5 | Învățare adaptivă cu AI | Personalizarea cursurilor de formare |
| 6 | Gamificare în platforme | Insigne, clasament și motivație |
| 7 | Supraveghere și evaluare | Examene online sigure și antifraudă |
| 8 | Colaborare în timp real | Tablă partajată și editare în colaborare |
| 9 | Învățare pe mobil și offline | PWA și sincronizare offline |
| 10 | LLM ca tutor virtual | Integrarea modelelor lingvistice în e-learning |
Peisajul sistemului de management al învățării
Un LMS modern nu mai este un simplu depozit de cursuri cu un sistem de urmărire. Platformele de astăzi se integrează streaming video adaptiv, evaluări interactive, analiză predictivă, colaborare în timp real si, din ce in ce mai des, tutori bazați pe inteligență artificială. Această complexitate funcțională se traduce direct în complexitatea arhitecturală.
Platformele LMS sunt împărțite în trei macro-categorii bazate pe modelul de implementare:
- Auto-găzduit (on-premise): Moodle, Open edX. Organizația gestionează întreaga infrastructură. Control maxim, cap operațional maxim.
- SaaS cu mai mulți chiriași: Canvas Cloud, TalentLMS, Docebo. Vânzătorul se ocupă de tot. Fiecare chiriaș are propriul său spațiu izolat logic.
- PaaS/Hibrid: Blackboard Learn Ultra, Brightspace. Infrastructură gestionată de furnizor, dar cu opțiuni avansate de personalizare și implementări private.
Tendința dominantă în 2025-2026 este către arhitectură multi-chiriaș nativ în cloud, cu o creștere de 34% de la an la an pentru platformele SaaS, comparativ cu 7% pentru soluțiile on-premise. Această schimbare este determinată de nevoia de a reduce costurile de operare și de a accelera timpul de lansare pe piață de noi funcții și deservesc un număr tot mai mare de organizații cu echipe mici de inginerie.
Provocările cheie ale platformelor LMS cu mai mulți chiriași
- Izolarea datelor: Datele de la o universitate nu trebuie să fie niciodată accesibile de la alta
- Vecinul zgomotos: Un chiriaș cu 50.000 de studenți nu ar trebui să degradeze performanța pentru chiriașii mici
- Personalizare: Fiecare chiriaș dorește branding, flux de lucru și integrări diferite
- Conformitate: GDPR, FERPA, SOC 2 necesită garanții specifice privind rezidența și prelucrarea datelor
- Scalare neuniformă: Vârfurile de utilizare sunt sezoniere (începutul semestrului, examene) și variază în funcție de chiriaș
Cele trei modele ale multi-chiriei
Alegerea modelului multi-chiriaș este cea mai critică decizie arhitecturală în proiectarea unui LMS. Există trei abordări fundamentale, fiecare cu un profil diferit de izolare, cost și complexitate.
Model 1: bază de date per chiriaș (model siloz)
Fiecare chiriaș primește o bază de date dedicată. Această abordare oferă cel mai înalt nivel de izolare: datele sunt separate fizic, performanța unui chiriaș nu îi afectează pe ceilalți și operațiunile backup/restaurare sunt independente. Este modelul preferat de platformele care servesc mari întreprindere cu cerințe stricte de conformitate.
- Izolare: Maxim. Separarea fizică completă a datelor.
- Cost: Ridicat. Fiecare bază de date consumă resurse dedicate (conexiuni, stocare, backup).
- Complexitate: Ridicat. Migrările de schemă trebuie efectuate pe fiecare bază de date.
- Scalabilitate: Linear, dar scump. Fiecare chiriaș nou = infrastructură nouă.
- Caz de utilizare ideal: Întreprindere cu mai puțin de 100 de organizații mari, cerințe FERPA/HIPAA.
Model 2: Schema de chiriași (model de pod)
Toți chiriașii au aceeași instanță de bază de date, dar fiecare are propria sa schemă (spațiu de nume). În PostgreSQL, de exemplu, fiecare chiriaș operează în propria sa schemă cu tabele identice, dar date izolate. Această abordare echilibrează izolarea și costul operațional.
- Izolare: Puternic. Separarea logică la nivel de schemă. Contaminarea încrucișată este imposibilă fără erori explicite.
- Cost: Mediu. Un singur cluster DB deservește toți chiriașii.
- Complexitate: Medie. Migrațiile necesită iterare în toate schemele, dar pot fi automatizate.
- Scalabilitate: Bun până la ~500-1.000 de scheme per instanță PostgreSQL, atunci este nevoie de sharding.
- Caz de utilizare ideal: SaaS cu 50-1.000 de chiriași de dimensiuni medii, necesită personalizare pe schemă.
Model 3: Schemă partajată cu ID-ul chiriașului (Model de grup)
Toți chiriașii împart aceleași mese. Izolația este garantată de o coloană tenant_id
prezent in fiecare tabel si din Securitate la nivel de rând (RLS) la nivel de bază de date. Și modelul
cel mai eficient din punct de vedere al costurilor și cel mai simplu de gestionat operațional, dar necesită disciplină
riguros în codul aplicației.
- Izolare: Logic. Depinde de RLS și validarea aplicației. O eroare în cod poate expune date între locatari.
- Cost: Bas. O singură instanță de bază de date deservește mii de chiriași.
- Complexitate: Scăzut pentru migrare (schemă unică), ridicat pentru securitate (fiecare interogare trebuie să includă filtru pentru chiriași).
- Scalabilitate: Excelent. Acceptă mii de chiriași cu sharding bazat pe tenant_id (Citus pentru PostgreSQL).
- Caz de utilizare ideal: SaaS de mare volum cu mii de chiriași mici/medii.
Comparație între modele
| Criteriu | Baza de date pentru chiriași | Schema chiriașilor | Schemă partajată + RLS |
|---|---|---|---|
| Izolarea datelor | Fizic (maximum) | logic (puternic) | logic (RLS) |
| Cost pe chiriaș | Mare (50-200 USD/lună) | Medie (10-50 USD/lună) | Scăzut (1-10 USD/lună) |
| Migrari de schema | N execuții (1 per DB) | N execuții (1 per schemă) | 1 executie |
| Max chiriași recomandati | 50-200 | 500-2.000 | 10.000+ |
| Risc vecin zgomotos | Nimeni | Mediu (I/O partajat) | Ridicat (atenuabil cu fragmentare) |
| Conformitate FERPA/GDPR | Excelent | Bun | Necesită audituri suplimentare |
| Complexitatea operațională | Ridicat | Medie | Scăzut |
| Personalizare model | Total | Per-schemă | Limitat (câmpuri personalizate) |
Rezolvarea chiriașilor: Middleware și strategii
Într-un sistem multi-locatari, fiecare cerere HTTP trebuie să fie asociată cu chiriașul corect Înainte a oricărei logici de aplicație. Acest proces, numit rezoluție locatarului, iar primul stratul arhitecturii și un punct critic pentru securitate. Dacă rezoluția eșuează sau este ocolită, toată izolarea se prăbușește.
Strategii de rezoluție
Există patru strategii principale de identificare a chiriașului unei cereri:
- Subdomeniu:
mit.lms-platform.com,stanford.lms-platform.com. Cea mai comună, necesită configurarea DNS cu wildcard. - Prefixul căii:
lms-platform.com/tenants/mit/courses. Simplu, dar poluează căile API. - Antete HTTP:
X-Tenant-ID: mit-university. Flexibil pentru API machine-to-machine. - Revendicare JWT: Chiriașul este codificat în jetonul de autentificare. Securizat, dar necesită re-autentificare pentru a schimba chiriașii.
Implementarea Middleware-ului de rezoluție (TypeScript/Express)
Următorul middleware Express implementează o strategie combinată: subdomeniu ca principal, Antetul HTTP ca alternativă, cu validare față de un registru de locatari.
import { Request, Response, NextFunction } from 'express';
import { LRUCache } from 'lru-cache';
// Interfaccia tenant immutabile
interface TenantConfig {
readonly id: string;
readonly slug: string;
readonly dbSchema: string;
readonly plan: 'free' | 'pro' | 'enterprise';
readonly region: 'eu-west-1' | 'us-east-1' | 'ap-southeast-1';
readonly features: ReadonlyArray<string>;
readonly maxUsers: number;
readonly isActive: boolean;
}
// Cache per evitare lookup al DB su ogni richiesta
const tenantCache = new LRUCache<string, TenantConfig>({
max: 5000,
ttl: 1000 * 60 * 5, // 5 minuti
});
// Repository per il lookup dei tenant
interface TenantRepository {
findBySlug(slug: string): Promise<TenantConfig | null>;
}
export function createTenantMiddleware(repo: TenantRepository) {
return async (req: Request, res: Response, next: NextFunction) => {
// 1. Estrai slug dal sottodominio
const host = req.hostname;
let tenantSlug = extractSubdomain(host);
// 2. Fallback: header X-Tenant-ID
if (!tenantSlug) {
tenantSlug = req.headers['x-tenant-id'] as string | undefined
?? null;
}
// 3. Validazione: slug obbligatorio
if (!tenantSlug) {
res.status(400).json({
error: 'TENANT_NOT_RESOLVED',
message: 'Unable to determine tenant from request',
});
return;
}
// 4. Lookup con cache
let config = tenantCache.get(tenantSlug) ?? null;
if (!config) {
config = await repo.findBySlug(tenantSlug);
if (config) {
tenantCache.set(tenantSlug, config);
}
}
// 5. Tenant non trovato o disattivato
if (!config || !config.isActive) {
res.status(404).json({
error: 'TENANT_NOT_FOUND',
message: `Tenant '${tenantSlug}' not found or inactive`,
});
return;
}
// 6. Inietta il contesto tenant nella request
(req as any).tenantConfig = config;
(req as any).tenantId = config.id;
next();
};
}
function extractSubdomain(host: string): string | null {
const parts = host.split('.');
// es: "mit.lms-platform.com" -> ["mit", "lms-platform", "com"]
if (parts.length >= 3) {
const subdomain = parts[0];
// Escludi subdomini di sistema
const reserved = ['www', 'api', 'admin', 'status'];
return reserved.includes(subdomain) ? null : subdomain;
}
return null;
}
Implementarea Python (FastAPI)
Pentru cei care folosesc FastAPI, aici este echivalentul cu injecție de dependență pentru rezoluția chiriașului:
from fastapi import Request, HTTPException, Depends
from functools import lru_cache
from dataclasses import dataclass, field
from typing import Optional
import asyncio
@dataclass(frozen=True)
class TenantConfig:
"""Configurazione immutabile del tenant."""
id: str
slug: str
db_schema: str
plan: str # 'free' | 'pro' | 'enterprise'
region: str
features: tuple[str, ...] = field(default_factory=tuple)
max_users: int = 100
is_active: bool = True
class TenantResolver:
"""Risolve il tenant dalla richiesta HTTP."""
def __init__(self, repository):
self._repo = repository
self._cache: dict[str, TenantConfig] = {}
async def resolve(self, request: Request) -> TenantConfig:
# 1. Sottodominio
slug = self._extract_subdomain(request.headers.get("host", ""))
# 2. Fallback: header
if not slug:
slug = request.headers.get("x-tenant-id")
if not slug:
raise HTTPException(
status_code=400,
detail="Unable to determine tenant from request"
)
# 3. Cache lookup
if slug in self._cache:
config = self._cache[slug]
else:
config = await self._repo.find_by_slug(slug)
if config:
self._cache[slug] = config
if not config or not config.is_active:
raise HTTPException(
status_code=404,
detail=f"Tenant '{slug}' not found or inactive"
)
return config
@staticmethod
def _extract_subdomain(host: str) -> Optional[str]:
parts = host.split(".")
if len(parts) >= 3:
subdomain = parts[0]
reserved = {"www", "api", "admin", "status"}
return None if subdomain in reserved else subdomain
return None
# Dependency injection in FastAPI
async def get_tenant(request: Request) -> TenantConfig:
resolver = request.app.state.tenant_resolver
return await resolver.resolve(request)
Model de date pentru un LMS cu mai mulți chiriași
Modelul de date al unui LMS trebuie să surprindă entități complexe și relațiile acestora: utilizatori, organizații, cursuri, module, lecții, evaluare, depunere, înscriere, progres, certificate. Într-un context multi-chiriaș, fiecare entitate trebuie să fie asociată cu chiriașul proprietar, iar interogările trebuie să fie eficient atât pentru operațiuni cu chiriaș unic (normă), cât și pentru chiriași încrucișați (raportare administrativă).
Schema de bază a bazei de date
Următoarea schemă SQL reprezintă nucleul unui LMS multi-locatari cu model „schemă partajată + tenant_id”. Utilizați PostgreSQL cu Securitate la nivel de rând (RLS) pentru a asigura izolarea.
-- ============================================
-- TABELLA TENANT (registro delle organizzazioni)
-- ============================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(63) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
plan VARCHAR(20) NOT NULL DEFAULT 'free',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- ============================================
-- TABELLA UTENTI
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'student',
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
-- ============================================
-- TABELLA CORSI
-- ============================================
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
instructor_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(500) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
max_enrollment INTEGER,
settings JSONB NOT NULL DEFAULT '{}',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
-- ============================================
-- MODULI E LEZIONI (struttura gerarchica)
-- ============================================
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content_type VARCHAR(30) NOT NULL, -- 'video', 'text', 'quiz', 'assignment'
content_ref TEXT, -- URL o ID del contenuto
duration_min INTEGER,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- ISCRIZIONI (enrollments)
-- ============================================
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
course_id UUID NOT NULL REFERENCES courses(id),
status VARCHAR(20) NOT NULL DEFAULT 'active',
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, course_id)
);
-- ============================================
-- PROGRESSI (lesson completion tracking)
-- ============================================
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
lesson_id UUID NOT NULL REFERENCES lessons(id),
status VARCHAR(20) NOT NULL DEFAULT 'not_started',
progress_pct SMALLINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, lesson_id)
);
-- ============================================
-- ASSESSMENT E SUBMISSION
-- ============================================
CREATE TABLE assessments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id),
title VARCHAR(500) NOT NULL,
assessment_type VARCHAR(30) NOT NULL, -- 'quiz', 'exam', 'assignment', 'peer_review'
max_score DECIMAL(6,2) NOT NULL DEFAULT 100,
passing_score DECIMAL(6,2) NOT NULL DEFAULT 60,
time_limit_min INTEGER,
max_attempts INTEGER DEFAULT 1,
questions JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
assessment_id UUID NOT NULL REFERENCES assessments(id),
user_id UUID NOT NULL REFERENCES users(id),
attempt_number SMALLINT NOT NULL DEFAULT 1,
answers JSONB NOT NULL DEFAULT '{}',
score DECIMAL(6,2),
status VARCHAR(20) NOT NULL DEFAULT 'submitted',
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
graded_at TIMESTAMPTZ
);
-- ============================================
-- ROW-LEVEL SECURITY (RLS)
-- ============================================
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE submissions ENABLE ROW LEVEL SECURITY;
-- Policy: ogni utente vede solo i dati del proprio tenant
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_courses ON courses
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_enrollments ON enrollments
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- INDICI per performance multi-tenant
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_courses_tenant ON courses(tenant_id);
CREATE INDEX idx_enrollments_tenant_user ON enrollments(tenant_id, user_id);
CREATE INDEX idx_enrollments_tenant_course ON enrollments(tenant_id, course_id);
CREATE INDEX idx_lesson_progress_tenant_user ON lesson_progress(tenant_id, user_id);
CREATE INDEX idx_submissions_tenant_assessment ON submissions(tenant_id, assessment_id);
Atenție: Indexuri cu mai multe coloane cu ID de chiriaș
Într-o schemă LMS partajată, fiecare index trebuie să includă tenant_id ca prima coloana.
Un index pe (email) și inutil pentru interogările filtrate de chiriaș; ajută (tenant_id, email).
Cu Citus/sharding distribuit, coloana de distribuție (tenant_id) trebuie să fie prezent
în fiecare cheie primară compusă și în fiecare constrângere UNICĂ.
Diagrama relațiilor
| Entitate | Raport | Entitate conectată | Cardinal |
|---|---|---|---|
| Chiriaş | detine | Utilizatori, Cursuri | 1:Nu |
| Utilizator | la care te înscrii | Curs (prin înscriere) | N:M |
| Curs | conţine | Module | 1:Nu |
| Modul | conţine | Lecții | 1:Nu |
| Curs | ha | Evaluări | 1:Nu |
| Utilizator | depune | Trimiteri (prin evaluare) | 1:Nu |
| Utilizator | urmăriți progresul | Progresul lecției (prin lecție) | 1:Nu |
Autentificare și autorizare multi-chiriași
Într-un LMS multi-locatari, autentificarea trebuie rezolvată doua probleme in acelasi timp: verificați identitatea utilizatorului și stabiliți cărui chiriaș îi aparține utilizatorul. Autorizarea adaugă un al treilea nivel: ce resurse poate accesa utilizatorul în cadrul chiriașului său.
JWT cu Claim Chiriaș
Cea mai populară abordare utilizează Token web JSON (JWT) cu o cerere dedicată pentru chiriaș. Tokenul este emis după autentificare și conține toate informațiile necesare rezolvării a contextului fără căutări suplimentare în baza de date.
import jwt from 'jsonwebtoken';
// Payload JWT immutabile
interface LmsTenantJwtPayload {
readonly sub: string; // User ID
readonly tenantId: string; // Tenant UUID
readonly tenantSlug: string; // Per display e logging
readonly role: 'student' | 'instructor' | 'admin' | 'super_admin';
readonly permissions: ReadonlyArray<string>;
readonly iat: number;
readonly exp: number;
}
// Generazione token con contesto tenant
function generateTenantToken(
user: { id: string; role: string },
tenant: { id: string; slug: string },
permissions: ReadonlyArray<string>
): string {
const payload: Omit<LmsTenantJwtPayload, 'iat' | 'exp'> = {
sub: user.id,
tenantId: tenant.id,
tenantSlug: tenant.slug,
role: user.role as LmsTenantJwtPayload['role'],
permissions,
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '8h',
issuer: 'lms-platform',
audience: tenant.slug,
});
}
// Middleware di validazione
function validateTenantToken(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'MISSING_TOKEN' });
}
try {
const token = authHeader.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'lms-platform',
}) as LmsTenantJwtPayload;
// Verifica coerenza tenant tra URL e token
const urlTenantId = (req as any).tenantId;
if (urlTenantId && decoded.tenantId !== urlTenantId) {
return res.status(403).json({
error: 'TENANT_MISMATCH',
message: 'Token tenant does not match request tenant',
});
}
(req as any).user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
}
Model RBAC pentru LMS
Autorizarea într-un LMS urmează de obicei un șablon Controlul accesului bazat pe roluri (RBAC) cu patru roluri principale, fiecare cu permisiuni cumulate:
| Rol | Permisiuni cheie | Mături |
|---|---|---|
| Student | Vizualizați cursurile înscrise, trimiteți teme, vedeți notele | Doar cursurile la care esti inscris |
| Instructor | Creați/editați cursuri, evaluați trimiterile, vizualizați analizele cursurilor dvs | Doar propriile cursuri |
| Admin | Gestionați utilizatorii, configurați chiriașul, vizualizați toate cursurile, exportați rapoarte | Întregul chiriaș |
| Super admin | Gestionați chiriașii, facturarea, semnalizatoarele de caracteristici, asistența între chiriași | Toți chiriașii (operator de platformă) |
Design API pentru LMS cu mai mulți chiriași
API-urile unui LMS multi-locatari trebuie să se echilibreze simplitatea utilizării pentru dezvoltatori client (frontend, mobil, integrări) cu securitate riguroasă în izolarea datelor. Alegerea între REST și GraphQL depinde de tiparele de acces predominante.
API REST: Convenții pentru locații multiple
Într-o arhitectură REST pentru LMS, chiriașul este rezolvat de middleware (subdomeniu sau antet), prin urmare, căile de resurse nu includ ID-ul locatarului. Acest lucru simplifică API-urile și le face portabil între medii.
# Tenant risolto via sottodominio: mit.lms-api.com
# Il tenant_id e iniettato dal middleware, NON presente nei path
# === CORSI ===
GET /api/v1/courses # Lista corsi del tenant
GET /api/v1/courses/:courseId # Dettaglio corso
POST /api/v1/courses # Crea corso (instructor+)
PUT /api/v1/courses/:courseId # Aggiorna corso
DELETE /api/v1/courses/:courseId # Archivia corso (admin+)
# === ISCRIZIONI ===
POST /api/v1/courses/:courseId/enroll # Iscrivi utente corrente
GET /api/v1/enrollments # Le mie iscrizioni
GET /api/v1/courses/:courseId/students # Studenti iscritti (instructor+)
# === CONTENUTI ===
GET /api/v1/courses/:courseId/modules # Moduli del corso
GET /api/v1/modules/:moduleId/lessons # Lezioni del modulo
PUT /api/v1/lessons/:lessonId/progress # Aggiorna progresso
# === ASSESSMENT ===
GET /api/v1/courses/:courseId/assessments # Assessment del corso
POST /api/v1/assessments/:assessmentId/submissions # Sottometti risposta
GET /api/v1/submissions/:submissionId # Dettaglio submission
# === ANALYTICS (instructor/admin) ===
GET /api/v1/analytics/courses/:courseId/completion # Tassi completamento
GET /api/v1/analytics/tenant/overview # Overview tenant
GraphQL: Interogări flexibile pentru front-end
GraphQL este deosebit de eficient pentru clienții LMS, deoarece paginile o necesită adesea agregari complexe: un tablou de bord pentru studenți pentru cursurile înscrise, progresul, cursurile viitoare atribuire și notificări într-o singură cerere.
# Il contesto tenant e iniettato automaticamente dal middleware
# Ogni resolver filtra per tenant_id dal contesto
type Query {
# Corsi disponibili per il tenant corrente
courses(
status: CourseStatus
search: String
pagination: PaginationInput
): CourseConnection!
# Dettaglio corso (solo se nel tenant corrente)
course(id: ID!): Course
# Dashboard studente: dati aggregati in una singola query
myDashboard: StudentDashboard!
# Analytics per instructor
courseAnalytics(courseId: ID!): CourseAnalytics!
}
type Course {
id: ID!
title: String!
description: String
instructor: User!
modules: [Module!]!
enrollmentCount: Int!
completionRate: Float
status: CourseStatus!
createdAt: DateTime!
}
type StudentDashboard {
enrolledCourses: [EnrolledCourseProgress!]!
upcomingAssessments: [Assessment!]!
recentActivity: [ActivityEvent!]!
overallProgress: Float!
certificatesEarned: Int!
}
type EnrolledCourseProgress {
course: Course!
progressPercent: Float!
lastAccessedAt: DateTime
nextLesson: Lesson
}
type Mutation {
enrollInCourse(courseId: ID!): Enrollment!
updateLessonProgress(lessonId: ID!, progress: Int!): LessonProgress!
submitAssessment(assessmentId: ID!, answers: JSON!): Submission!
createCourse(input: CreateCourseInput!): Course!
}
type Subscription {
# Notifiche real-time per lo studente
studentNotifications: Notification!
# Aggiornamenti live del progresso (per instructor dashboard)
courseProgressUpdates(courseId: ID!): ProgressUpdate!
}
Livrare de conținut și streaming media
Un LMS gestionează conținut eterogen: documente PDF, prezentări, videoclipuri la cerere, streaming live, chestionare interactive și ateliere virtuale. Arhitectura de livrare a conținutului trebuie să garanteze latență scăzută, izolarea per chiriaș e controlul accesului granular.
Strategie de stocare cu mai mulți chiriași
Alegerea modelului de stocare reflectă cea a bazei de date: mai multă izolare implică costuri mai mari. Cea mai comună abordare pentru SaaS LMS utilizează a o singură găleată S3 cu izolare bazată pe prefix:
# Pattern di organizzazione S3 per LMS multi-tenant
# Bucket: lms-content-production
# Contenuti del corso
s3://lms-content/{tenant_id}/courses/{course_id}/videos/lecture-01.mp4
s3://lms-content/{tenant_id}/courses/{course_id}/documents/syllabus.pdf
s3://lms-content/{tenant_id}/courses/{course_id}/images/cover.webp
# Submission degli studenti
s3://lms-content/{tenant_id}/submissions/{assessment_id}/{user_id}/file.pdf
# Asset del tenant (logo, branding)
s3://lms-content/{tenant_id}/branding/logo.svg
s3://lms-content/{tenant_id}/branding/theme.json
# Bucket policy limita l'accesso per prefix = tenant_id
# Signed URLs con expiry di 1 ora per accesso ai contenuti
Arhitectura CDN pentru videoclipuri educaționale
Videoclipul reprezintă 70-80% din traficul unui LMS modern. Arhitectura de livrare videoclipul necesită:
- Transcodare adaptivă: Fiecare videoclip este codificat în mai multe rezoluții (360p, 720p, 1080p) cu HLS/DASH pentru streaming adaptiv cu rata de biți.
- CDN cu autentificare cu simbol: CloudFront sau Cloudflare cu adrese URL semnate pentru a preveni accesul neautorizat la conținutul plătit.
- Memorarea în cache Edge per locatar: Conținutul de la chiriașii mai mari este preîncărcat în marginile PoP pentru a reduce TTFB.
- Filigran dinamic: Suprapunere invizibilă cu User ID pentru a urmări scurgerile de conținut.
Arhitectura conductei video
Fluxul complet pentru un videoclip încărcat de un instructor:
- Încărcări: Adresa URL presemnată pentru încărcare directă în S3 (ocolirea serverului de aplicații)
- Eveniment S3: Activați funcția Lambda/Cloud
s3:ObjectCreated - Transcodare: AWS MediaConvert sau FFmpeg pe ECS/Cloud Run pentru a genera HLS multi-bitrate
- Metadate: Durata, miniatura, dimensiunea scrisă în baza de date LMS
- Invalidare CDN: Încălzirea cache-ului pentru chiriașul proprietar
- Notificare: Eveniment publicat pentru a notifica instructorul prin WebSocket
Arhitectură bazată pe evenimente pentru funcționalitate în timp real
Un LMS modern necesită multe funcții asincrone și în timp real: notificări push atunci când un instructor postați un vot, actualizări live de clasare, urmărire în timp real a progresului pe tabloul de bord instructor, mementouri automate pentru termenele limită viitoare. Aceste nevoi impun aarhitectură condusă de evenimente.
Evenimente de domeniu pentru LMS
Primul pas este identificarea evenimentelor semnificative din domeniul platformei. Fiecare eveniment este un fapt imuabil care descrie ceva ce sa întâmplat în sistem.
// Evento base immutabile
interface LmsDomainEvent {
readonly eventId: string;
readonly eventType: string;
readonly tenantId: string;
readonly userId: string;
readonly timestamp: string; // ISO 8601
readonly version: number; // Schema versioning
}
// === ENROLLMENT EVENTS ===
interface StudentEnrolledEvent extends LmsDomainEvent {
readonly eventType: 'student.enrolled';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly enrollmentId: string;
};
}
// === PROGRESS EVENTS ===
interface LessonCompletedEvent extends LmsDomainEvent {
readonly eventType: 'lesson.completed';
readonly payload: {
readonly courseId: string;
readonly moduleId: string;
readonly lessonId: string;
readonly progressPercent: number; // 0-100 per il corso
};
}
// === ASSESSMENT EVENTS ===
interface AssessmentSubmittedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.submitted';
readonly payload: {
readonly assessmentId: string;
readonly submissionId: string;
readonly courseId: string;
readonly attemptNumber: number;
};
}
interface AssessmentGradedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.graded';
readonly payload: {
readonly submissionId: string;
readonly assessmentId: string;
readonly score: number;
readonly maxScore: number;
readonly passed: boolean;
};
}
// === COURSE LIFECYCLE EVENTS ===
interface CoursePublishedEvent extends LmsDomainEvent {
readonly eventType: 'course.published';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly instructorId: string;
readonly moduleCount: number;
readonly lessonCount: number;
};
}
// Event bus con partizionamento per tenant
class LmsEventBus {
constructor(private readonly kafka: KafkaProducer) {}
async publish(event: LmsDomainEvent): Promise<void> {
await this.kafka.send({
topic: `lms.events.${event.eventType.split('.')[0]}`,
messages: [{
key: event.tenantId, // Partizionamento per tenant
value: JSON.stringify(event),
headers: {
'event-type': event.eventType,
'tenant-id': event.tenantId,
'event-version': String(event.version),
},
}],
});
}
}
Consumator și proiecții
Evenimentele sunt consumate de manageri specializați care efectuează efecte secundare: notificări, actualizare vizualizare materializată, analiză, programare e-mail. Modelul consumator pe domeniu menține coeziunea.
| Eveniment | Consumator | Acţiune |
|---|---|---|
student.enrolled | Serviciul de notificare | E-mail de bun venit + notificare push |
student.enrolled | Serviciul de analiză | Actualizați contorul de înregistrare la curs |
lesson.completed | ProgressService | Recalculați progresul general al cursului |
lesson.completed | Serviciul de gamification | Vezi dacă deblochează o insignă |
assessment.graded | Serviciul de notificare | Anunțați elevul despre nota |
assessment.graded | CertificateService | Generați certificat dacă cursul este finalizat |
course.published | SearchIndexService | Actualizați catalogul Elasticsearch index |
course.published | Serviciul de notificare | Anunțați profesorul elevilor înscriși |
Descompunerea în microservicii
Pentru platformele LMS de întreprindere care deservesc mai mult de 100.000 de utilizatori concurenți, un monolit devine un blocaj. Descompunerea în microservicii vă permite să scalați independent componentele cele mai solicitate. Cheia este identificarea context mărginit corect urmând principiile Domain-Driven Design.
Contextul delimitat al unui LMS
| Microserviciu | Responsabilitate | Baze de date | Protocol |
|---|---|---|---|
| Serviciu chiriaș | Registrul chiriașilor, configurații, facturare, semnalizări de caracteristici | PostgreSQL dedicat | REST + gRPC |
| Serviciul de identitate | Autentificare, SSO (SAML/OIDC), gestionare utilizatori, RBAC | PostgreSQL + Redis (sesiuni) | REST + OAuth 2.0 |
| Serviciul de cursuri | Cursuri, module, lecții, catalog, căutare CRUD | PostgreSQL + Elasticsearch | REST + GraphQL |
| Serviciul de înscrieri | Înregistrări, progres, completări, certificate | PostgreSQL + Redis (cache de progres) | Evenimente REST + Kafka |
| Serviciul de Evaluare | Motor de chestionare, notare, rubrică, evaluare inter pares | PostgreSQL + S3 (trimitere fișier) | Evenimente REST + Kafka |
| Serviciul de conținut | Încărcare, transcodare, stocare, CDN, ambalare SCORM | S3 + DynamoDB (metadate) | Evenimente REST + S3 |
| Serviciul de notificare | E-mail, push, în aplicație, WebSocket, programare digest | Redis + SQS | Evenimente Kafka + WebSocket |
| Serviciul de analiză | xAPI/Caliper, tablouri de bord, rapoarte, export de date | ClickHouse + S3 (lacul de date) | Evenimente Kafka + REST |
Comunicarea între Servicii
Comunicarea urmează două modele fundamentale:
- Sincron (REST/gRPC): Pentru operațiuni care necesită un răspuns imediat. Exemplu: Serviciul de curs apelează Serviciul de identitate pentru a verifica permisiunile utilizatorului înainte de a crea un curs.
- Asincron (Kafka): Pentru operațiuni care nu necesită un răspuns imediat. Exemplu: Când un student finalizează o lecție, evenimentul este publicat și consumat independent de Notificare, Analytics și Gamification.
Regula de aur: preferați evenimentele decât apelurile API
Dacă o acțiune nu necesită un răspuns sincron, folosește întotdeauna un eveniment. Acest lucru reduce cuplarea dintre servicii, îmbunătățește reziliența (eșecul unui consumator nu blochează producătorul) și permite reluarea evenimentelor pentru depanare și recuperare. Într-un LMS, raportul ideal este de aproximativ 70% evenimente / 30% apeluri sincrone.
Scalabilitate: Caching, Sharding și CDN
O întreprindere LMS trebuie să facă față vârfurilor dramatice: prima zi a unui semestru universitar, zeci de mii de studenți se conectează în același timp. Platforma de examen online suferă încărcări de 10 ori în timpul sesiunilor de examen. Fără o strategie de scalare bine concepută, sistemul se prăbușește exact atunci când este cel mai necesar.
Strategie de stocare în cache pe mai multe niveluri
| Nivel | Tehnologie | Cachati date | TTL |
|---|---|---|---|
| L1: Browsere | Service Worker + API Cache | Elemente statice, conținutul lecției (offline) | 24 de ore (invalidare prin versiunea) |
| L2: CDN Edge | CloudFront / Cloudflare | Videoclipuri HLS, imagini, CSS/JS, documente PDF | 7 zile (invalidare la publicare) |
| L3: Gateway API | Redis/Lac | GET răspunsuri API (catalog de cursuri, profil de utilizator) | 5-15 minute |
| L4: Aplicație | Clusterul Redis | Sesiuni, configurarea chiriașului, progresul elevilor, permisiunile RBAC | 5-30 minute |
| L5: Baze de date | Vizualizări materializate PostgreSQL | Analize agregate, clasament, statistici de curs | Reîmprospătați la fiecare 5-60 de minute |
Partajarea cu Citus pentru PostgreSQL
Pentru LMS cu milioane de utilizatori și model „schemă partajată”, Citus (Extensia PostgreSQL
pentru calcul distribuit) vă permite să distribuiți date pe mai multe noduri, menținând în același timp compatibilitatea SQL.
Strategia naturală de sharding pentru un LMS multi-locatari și partiționare pentru tenant_id.
-- Distribuire le tabelle principali per tenant_id
SELECT create_distributed_table('users', 'tenant_id');
SELECT create_distributed_table('courses', 'tenant_id');
SELECT create_distributed_table('enrollments', 'tenant_id');
SELECT create_distributed_table('lesson_progress', 'tenant_id');
SELECT create_distributed_table('submissions', 'tenant_id');
-- Tabelle di riferimento (replicate su ogni nodo)
SELECT create_reference_table('tenants');
-- Query automaticamente distribuite
-- Citus instrada le query al nodo corretto basandosi su tenant_id
SELECT c.title, COUNT(e.id) AS enrolled_students
FROM courses c
JOIN enrollments e ON c.id = e.course_id AND c.tenant_id = e.tenant_id
WHERE c.tenant_id = 'uuid-tenant-mit'
GROUP BY c.title
ORDER BY enrolled_students DESC;
-- Performance: query single-tenant eseguita su un singolo nodo
-- Nessun shuffle di dati tra nodi = latenza minima
Benchmark-uri de performanță
Următoarele valori de referință se bazează pe un LMS cu 5.000 de chiriași și 2 milioane de utilizatori în total,
implementat pe AWS cu PostgreSQL 16 + Citus, 3 noduri r6g.2xlarge:
| Operațiunea | Fără Citus (nod unic) | Cu Citus (3 noduri) | Îmbunătăţire |
|---|---|---|---|
| Lista cursurilor (per chiriaș) | 45 ms | 12 ms | 3,7x |
| Tabloul de bord pentru studenți | 180 ms | 35 ms | 5,1x |
| Analiza cursului (agregare) | 2.400 ms | 320 ms | 7,5x |
| Rafală de înscriere (1.000 simultan) | Timeout (>5s) | 89 ms (p99) | >56x |
| Raportare între chiriași (administrator) | 12.000 ms | 1.800 ms | 6,7x |
Comparație arhitecturală: Platforme LMS reale
Pentru a înțelege modul în care modelele teoretice se traduc în implementări reale, să analizăm arhitectura principalelor platforme LMS de pe piață.
| Platformă | Arhitectură | Multi-Tenancy | Stack Tehnic | Scalabilitate |
|---|---|---|---|---|
| Moodle | Monolit modular | Baza de date pentru fiecare locatar (MoodleCloud) sau pentru un singur chiriaș (auto-găzduită). | PHP 8, MySQL/PostgreSQL, joburi cron | Verticală, limitată la ~10.000 de utilizatori per instanță, fără reglaj intens |
| Canvas LMS | Monolit cu servicii prin satelit | Schemă partajată cu shard de instituție (bijuterie Switchman) | Ruby on Rails, PostgreSQL, Redis, S3, Consul | Orizontală prin sharding, deservind milioane de utilizatori |
| Blackboard Learn Ultra | Microservicii (migrare din monolit Java) | Hibrid: bază de date per chiriaș (întreprindere), schemă partajată (SaaS) | Java/Spring Boot, AWS, microservicii, Kafka | Auto-scaling nativ în cloud, per serviciu |
| Deschide edX | Orientat către servicii (servicii Django) | Instanță per organizație (tutor) sau multi-site | Python/Django, MySQL, MongoDB, Telina, RabbitMQ | Scalare bazată pe Kubernetes, per serviciu |
| Docebo | SaaS nativ în cloud | Schemă partajată cu tenant_id + RLS | Proprietar (presumat Node.js/Go), AWS, nativ AI | Scalare automată, deservind peste 3.800 de clienți întreprinderi |
| Coursera | Microservicii bazate pe evenimente | Multi-chiriaș nativ cu sharding per partener | Scala, Python, Cassandra, Kafka, Kubernetes | Global, peste 130 de milioane de utilizatori, multi-regiune |
Lecții de la platformele reale
- Pânză: Partajarea prin intermediul Switchman gem demonstrează că un monolit bine conceput se poate scala la milioane de utilizatori fără a fi nevoie să rescrieți microservicii.
- Tablă: Migrarea de la monolit Java la microservicii a durat peste 4 ani și o investiție semnificativă. Lecția învățată: Descompuneți devreme sau deloc.
- Coursera: Arhitectura bazată pe evenimente cu Kafka ca coloană vertebrală vă permite să adăugați noi funcții (recomandări AI, analize) fără a modifica serviciile existente.
- Deschide edX: Abordarea multi-servicii Django demonstrează că chiar și cu un cadru „monolitic” se poate realiza o bună separare a contextelor mărginite.
Ghid pentru alegerea arhitecturii
Nu există un model universal cel mai bun. Alegerea depinde de profilul produsului, de numărul așteptat de chiriași și de echipa de ingineri disponibilă. Iată un arbore decizional practic:
Arborele de decizie
- Câți chiriași prevedeți în 2 ani?
- Sub 50, toate întreprinderile cu contracte vamale → Baza de date per chirias
- 50-2.000, mix de dimensiuni → Schemă per chiriaș
- Peste 2.000, majoritatea IMM-uri → Schemă partajată + RLS
- Cât de critică este conformitatea?
- FERPA/HIPAA cu pistă de audit riguroasă → Preferați izolarea fizică (DB per chiriaș)
- GDPR standard → Schemă de chiriaș sau RLS cu audit adecvat
- Cât de mare este echipa de ingineri?
- 3-5 dezvoltatori → Schemă partajată (mai puțină suprasarcină operațională)
- 10-20 de dezvoltatori → Schemă de chiriași sau microservicii ușoare
- Peste 20 de dezvoltatori → Microservicii complete cu proprietatea echipei
- Microservicii sau monolit?
- Mai puțin de 50.000 de utilizatori concurenți → Monolit modular (a la Canvas)
- 50K-500K utilizatori concurenți → Servicii Monolith + satelit pentru media și analiză
- Peste 500.000 de utilizatori concurenți → Microservicii complete cu magistrala de evenimente
Concluzii și pașii următori
Proiectarea unui LMS multi-chiriat scalabil necesită decizii arhitecturale care vor avea impact de ani de zile pe capacitatea produsului de a crește, securitatea datelor și costurile de operare. Nu există o soluție unică: cele mai de succes platforme și-au evoluat arhitectura de-a lungul timpului, adesea pornind de la un monolit cu un model comun și descompunându-se treptat cele mai critice servicii.
Punctele cheie ale acestui articol:
- Alegeți modelul multi-locatari în funcție de profilul dvs. de client, nu moda tehnologica. Schema partajată + RLS este suficientă pentru majoritatea LMS-urilor SaaS.
- Rezolvarea chiriașilor și fundamentul securității. Implementați-l ca middleware robust, cu stocare în cache și validare riguroasă.
- PostgreSQL Row-Level Security este cel mai bun aliat al tău pentru a oferi izolare la nivel de bază de date fără complexitate excesivă a aplicației.
- Arhitectura bazată pe evenimente este indispensabilă pentru notificări, analize și toate caracteristicile în timp real ale unui LMS modern.
- Citus pentru PostgreSQL vă permite să scalați orizontal cu modificări minime ale aplicației atunci când un singur nod nu mai este suficient.
- Începeți cu un monolit modular și să se descompună în microservicii numai atunci când scala sau organizarea echipei o solicită.
În următorul articol din serie, vom exploraArhitectură educațională de streaming video, abordarea transcodării adaptive, DRM pentru conținut protejat și latență ultra-scăzută pentru cursurile live. Infrastructura video este adesea cea mai costisitoare și complexă componentă a unui LMS și merită analizată temeinic.
Resurse pentru a afla mai multe
- Blog Crunchy Data: „Proiectarea bazei de date Postgres pentru închiriere multiplă” - ghid practic pentru închirierea multiplă cu PostgreSQL
- Documentatie Citus: Tutorial SaaS multi-tenant cu exemple de sharding din lumea reală
- Canvas LMS (GitHub): Cod open source cu Switchman for Rails + sharding PostgreSQL
- Deschideți arhitectura edX: Documentația oficială a arhitecturii serviciilor Open edX
- Modele de locatari multiplu Microsoft Azure: Ghidul arhitecților pentru cloud SaaS multi-locatari







