Škálovatelná architektura LMS: Vzor pro více nájemců
Globální trh Systém řízení výuky (LMS) prošel i 28 miliard dolarů v roce 2025, s projekcí růstu téměř 70 miliard do roku 2030. Za platformami jako je např Moodle, Canvas, Docebo a Coursera skrývají obrovské architektonické výzvy: slouží tisícům organizací současně zajistit izolaci dat, horizontálně škálovat během špičkového využití a udržovat subsekundové latence pro miliony studentů připojených současně.
La vícenájem a architektonický vzor, který to vše umožňuje. V LMS s více nájemci jedna instance aplikace slouží více organizacím (tenantům), z nichž každá má své vlastní uživatele, kurzy, obsah a konfigurace, ale sdílení základní infrastruktury. Výběr vzoru Správný multi-tenancy určuje provozní náklady, bezpečnost, výkon a schopnost škálování od 10 do 10 000 nájemců bez přepisování systému.
V tomto úvodním článku seriálu EdTech Engineering, budeme analyzovat do hloubky multi-tenant architektury pro platformy LMS, porovnání vzorů izolace dat, škálovací strategie, optimalizované datové modely a vzory řízené událostmi pro funkce v reálném čase. Každý koncept bude doprovázen konkrétními příklady kódu v TypeScriptu a Pythonu.
Co se dozvíte v tomto článku
- Tři základní vzory více nájmu a kdy je použít
- Jak navrhnout datový model LMS, který se přizpůsobí tisícům tenantů
- Autentizace a autorizace v kontextu více nájemců s JWT a RBAC
- Navrhněte REST API a GraphQL pro rozlišení tenantů
- Architektura řízená událostmi pro oznámení a analýzy v reálném čase
- Dekompozice podnikové LMS platformy na mikroslužby
- Porovnání architektur Moodle, Canvas, Blackboard a moderních platforem
- Výkonnostní benchmarky a strategie ukládání do mezipaměti pro vysoce provozované LMS
Přehled EdTech Engineering Series
| # | Položka | Soustředit |
|---|---|---|
| 1 | Jste zde - Škálovatelná architektura LMS | Vzory pro více nájemců pro platformy LMS |
| 2 | Vzdělávací video streaming | Architektura distribuce videoobsahu |
| 3 | Výuka Analytics a xAPI | Sběr a analýza učebních dat |
| 4 | SCORM a balení obsahu | Standardy pro vzdělávací obsah |
| 5 | Adaptivní učení s umělou inteligencí | Personalizace vzdělávacích kurzů |
| 6 | Gamifikace na platformách | Odznaky, žebříček a motivace |
| 7 | Proctoring a Assessment | Bezpečné a proti podvodům online zkoušky |
| 8 | Spolupráce v reálném čase | Sdílené tabule a společné úpravy |
| 9 | Mobilní a offline výuka | PWA a offline synchronizace |
| 10 | LLM jako virtuální lektor | Integrace jazykových modelů v e-learningu |
Krajina systému řízení výuky
Moderní LMS již není jednoduchým úložištěm kurzů se systémem sledování. Platformy dnes integrovat adaptivní streamování videa, interaktivní hodnocení, prediktivní analytika, spolupráce v reálném čase a čím dál častěji, učitelé na bázi umělé inteligence. Tato funkční složitost se překládá přímo do architektonické složitosti.
Platformy LMS jsou rozděleny do tří makrokategorií na základě modelu nasazení:
- Vlastní hostování (on-premise): Moodle, Open edX. Organizace spravuje celou infrastrukturu. Maximální kontrola, maximální provozní režie.
- SaaS pro více nájemců: Canvas Cloud, TalentLMS, Docebo. Prodejce vše řeší. Každý nájemce má svůj logicky izolovaný prostor.
- PaaS/hybridní: Blackboard Learn Ultra, Brightspace. Infrastruktura spravovaná dodavatelem, ale s pokročilými možnostmi přizpůsobení a soukromými nasazeními.
Dominantním trendem v letech 2025-2026 je architektura cloud-nativní multi-tenant, s 34% meziročním růstem u platforem SaaS ve srovnání se 7% u on-premise řešení. Tento posun je řízen potřebou snížit provozní náklady a urychlit dobu uvedení na trh nových funkcí a slouží rostoucímu počtu organizací s malými inženýrskými týmy.
Klíčové výzvy platforem LMS pro více nájemců
- Izolace dat: Data z jedné univerzity nesmí být nikdy dostupná z jiné
- Hlučný soused: Nájemník s 50 000 studenty by neměl snižovat výkon pro malé nájemníky
- Přizpůsobení: Každý nájemce chce jiný branding, pracovní postup a integrace
- Dodržování: GDPR, FERPA, SOC 2 vyžadují specifické záruky týkající se bydliště a zpracování údajů
- Nerovnoměrné měřítko: Špičky využití jsou sezónní (začátek semestru, zkoušky) a liší se podle nájemce
Tři vzory vícenásobného nájmu
Výběr vzoru pro více nájemců je nejdůležitějším architektonickým rozhodnutím při návrhu LMS. Existují tři základní přístupy, z nichž každý má odlišný profil izolace, nákladů a složitosti.
Vzor 1: Databáze na nájemce (model sila)
Každý nájemce obdrží vyhrazenou databázi. Tento přístup nabízí nejvyšší úroveň izolace: data jsou fyzicky oddělena, výkon jednoho tenanta neovlivňuje ostatní a provoz zálohování/obnova jsou nezávislé. Je to vzor preferovaný platformami, které slouží velkým podnik s přísnými požadavky na shodu.
- Izolace: Maximum. Kompletní fyzické oddělení dat.
- Náklady: Vysoký. Každá databáze spotřebovává vyhrazené zdroje (připojení, úložiště, zálohování).
- Složitost: Vysoký. Migrace schémat musí být provedena v každé databázi.
- Škálovatelnost: Lineární, ale drahé. Každý nový nájemce = nová infrastruktura.
- Ideální případ použití: Podnik s méně než 100 velkými organizacemi, požadavky FERPA/HIPAA.
Vzor 2: Schéma nájemce (model mostu)
Všichni tenanti sdílejí stejnou instanci databáze, ale každý má své vlastní schéma (namespace). V PostgreSQL například každý tenant pracuje ve svém vlastním schématu s identickými tabulkami, ale izolovanými daty. Tento přístup vyvažuje izolaci a provozní náklady.
- Izolace: Silný. Logická separace na úrovni schématu. Křížová kontaminace není možná bez explicitních chyb.
- Náklady: Střední. Jediný DB cluster slouží všem tenantům.
- Složitost: Průměrný. Migrace vyžadují iteraci napříč všemi schématy, ale lze je automatizovat.
- Škálovatelnost: Dobré až ~500-1000 schémat na instanci PostgreSQL, pak je potřeba sharding.
- Ideální případ použití: SaaS s 50–1 000 středně velkými nájemci vyžaduje přizpůsobení podle schématu.
Vzor 3: Sdílené schéma s ID tenanta (model fondu)
Všichni nájemci sdílejí stejné stoly. Izolaci zajišťuje sloupek tenant_id
přítomné v každé tabulce a od Zabezpečení na úrovni řádků (RLS) na úrovni databáze. A vzor
nákladově nejefektivnější a provozně nejjednodušší, ale vyžaduje disciplínu
přísné v kódu aplikace.
- Izolace: Logický. Záleží na RLS a validaci aplikace. Chyba v kódu může odhalit data mezi klienty.
- Náklady: Bas. Jedna instance databáze slouží tisícům nájemců.
- Složitost: Nízká pro migrace (jedno schéma), vysoká pro zabezpečení (každý dotaz musí obsahovat filtr tenanta).
- Škálovatelnost: Vynikající. Podporuje tisíce tenantů se sdílením založeným na tenant_id (Citus pro PostgreSQL).
- Ideální případ použití: Velkoobjemové SaaS s tisíci malých/středních nájemců.
Porovnání mezi vzory
| Kritérium | Databáze pro nájemníky | Schéma nájemníků | Sdílené schéma + RLS |
|---|---|---|---|
| Izolace dat | Fyzické (maximálně) | Logický (silný) | Logické (RLS) |
| Náklady na nájemníka | Vysoká (50–200 $ měsíčně) | Střední (10–50 $ měsíčně) | Nízká (1–10 $ měsíčně) |
| Migrace schémat | N provedení (1 na DB) | N provedení (1 na schéma) | 1 provedení |
| Maximální doporučené nájemníky | 50-200 | 500-2000 | 10 000+ |
| Riziko hlučného souseda | Nikdo | Střední (sdílené I/O) | Vysoká (zmírnit pomocí střepů) |
| Soulad s FERPA/GDPR | Vynikající | Dobrý | Vyžaduje další audity |
| Provozní složitost | Vysoký | Průměrný | Nízký |
| Přizpůsobení vzoru | Celkový | Podle schématu | Omezené (vlastní pole) |
Řešení nájemců: Middleware a strategie
V systému s více nájemci musí být každý požadavek HTTP přidružen ke správnému tenantovi Před jakékoli aplikační logiky. Tento proces, tzv rozlišení nájemcea první vrstva architektury a kritický bod pro bezpečnost. Pokud rozlišení selže nebo je vynecháno, celá izolace se zhroutí.
Strategie rozlišení
Existují čtyři hlavní strategie pro identifikaci nájemce požadavku:
- Subdoména:
mit.lms-platform.com,stanford.lms-platform.com. Nejběžnější vyžaduje konfiguraci DNS se zástupnými znaky. - Předpona cesty:
lms-platform.com/tenants/mit/courses. Jednoduché, ale znečišťuje to cesty API. - HTTP hlavičky:
X-Tenant-ID: mit-university. Flexibilní pro rozhraní Machine-to-Machine API. - Tvrzení JWT: Tenant je zakódován v ověřovacím tokenu. Zabezpečené, ale ke změně nájemců vyžaduje opětovné ověření.
Implementace Resolution Middleware (TypeScript/Express)
Následující expresní middleware implementuje kombinovanou strategii: subdoména jako primární, Záhlaví HTTP jako záložní s ověřením proti registru tenanta.
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;
}
Implementace Pythonu (FastAPI)
Pro ty, kteří používají FastAPI, je zde ekvivalent s injekce závislosti pro rozlišení nájemníka:
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)
Datový model pro LMS s více nájemci
Datový model LMS musí zachycovat složité entity a jejich vztahy: uživatele, organizace, kurzy, moduly, lekce, hodnocení, odevzdání, zápis, pokrok, certifikáty. V kontextu multi-tenant, každá entita musí být přidružena k vlastnícímu tenantovi a dotazy musí být efektivní pro operace s jedním nájemcem (norma) i pro vícenájemce (administrativní reporting).
Základní schéma databáze
Následující schéma SQL představuje jádro LMS pro více tenantů se vzorem „sdílené schéma + tenant_id“. Použijte PostgreSQL s Zabezpečení na úrovni řádků (RLS), aby byla zajištěna izolace.
-- ============================================
-- 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);
Upozornění: Vícesloupcové indexy s ID tenanta
Ve sdíleném schématu LMS, každý index musí obsahovat tenant_id jako první sloupec.
Index zapnutý (email) a nepoužitelné pro dotazy filtrované podle tenanta; pomáhá to (tenant_id, email).
S distribuovaným Citus/sharding, distribuční sloupec (tenant_id) musí být přítomen
v každém složeném primárním klíči a v každém UNIKÁTNÍM omezení.
Diagram vztahů
| Entita | Vztah | Připojená entita | Kardinál |
|---|---|---|---|
| Nájemce | vlastní | Uživatelé, kurzy | 1: Ne |
| Uživatel | přihlásíte se | Kurz (prostřednictvím přihlášky) | N:M |
| Kurs | obsahuje | Moduly | 1: Ne |
| Modul | obsahuje | Lekce | 1: Ne |
| Kurs | ha | Hodnocení | 1: Ne |
| Uživatel | předkládá | Příspěvky (prostřednictvím hodnocení) | 1: Ne |
| Uživatel | sledovat pokrok | Průběh lekce (přes lekci) | 1: Ne |
Autentizace a autorizace pro více nájemců
V LMS s více nájemci je potřeba vyřešit ověřování dva problémy současně: ověřit identitu uživatele a určit, ke kterému tenantovi uživatel patří. Autorizace dodává třetí úroveň: ke kterým zdrojům má uživatel přístup v rámci svého tenanta.
JWT s nájemcem nároku
Nejoblíbenější způsob použití Webový token JSON (JWT) s vyhrazeným nárokem pro nájemce. Token je vydán po ověření a obsahuje všechny informace potřebné k vyřešení kontextu bez dalšího vyhledávání v databázi.
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 pro LMS
Autorizace v LMS se obvykle řídí šablonou Řízení přístupu na základě rolí (RBAC) se čtyřmi hlavními rolemi, z nichž každá má kumulativní oprávnění:
| Role | Klíčová oprávnění | Košťata |
|---|---|---|
| Student | Zobrazte zapsané kurzy, odešlete úkoly, zobrazte své hodnocení | Pouze kurzy, do kterých jste zapsáni |
| Instruktor | Vytvářejte/upravujte kurzy, vyhodnocujte příspěvky, zobrazujte analýzy svých kurzů | Pouze vlastní kurzy |
| Admin | Správa uživatelů, konfigurace tenanta, zobrazení všech kurzů, export sestav | Celý nájemník |
| Super správce | Správa nájemců, fakturace, příznaky funkcí, podpora mezi nájemci | Všichni nájemci (operátor platformy) |
Návrh API pro LMS s více nájemci
Rozhraní API LMS s více nájemci musí být v rovnováze jednoduchost použití pro vývojáře klient (frontend, mobilní, integrace) s přísné zabezpečení v izolaci dat. Volba mezi REST a GraphQL závisí na převládajícím vzoru přístupu.
REST API: Konvence pro více nájemců
V architektuře REST pro LMS je tenant řešen middlewarem (subdoménou nebo hlavičkou), proto cesty prostředků nezahrnují ID tenanta. To zjednodušuje API a vytváří je přenosné mezi prostředími.
# 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: Flexibilní dotazy pro frontend
GraphQL je zvláště efektivní pro klienty LMS, protože to stránky často vyžadují komplexní agregace: panel studentů pro zapsané kurzy, pokrok, nadcházející kurzy přiřazení a oznámení v jedné žádosti.
# 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!
}
Doručování obsahu a streamování médií
LMS spravuje heterogenní obsah: dokumenty PDF, prezentace, videa na vyžádání, živé vysílání, interaktivní kvízy a virtuální workshopy. Architektura poskytování obsahu musí zaručit nízká latence, izolace na nájemce e řízení přístupu zrnitý.
Strategie úložiště pro více nájemců
Výběr vzoru úložiště odráží vzor databáze: větší izolace znamená vyšší náklady. Nejběžnější přístup pro SaaS LMS používá a jeden kbelík S3 s izolací na základě předpon:
# 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
Architektura CDN pro vzdělávací videa
Video představuje 70–80 % provozu moderního LMS. Architektura doručení video vyžaduje:
- Adaptivní překódování: Každé video je zakódováno v několika rozlišeních (360p, 720p, 1080p) s HLS/DASH pro adaptivní přenos datového toku.
- CDN s ověřením pomocí tokenu: CloudFront nebo Cloudflare s podepsanými URL, aby se zabránilo neoprávněnému přístupu k placenému obsahu.
- Edge caching na tenanta: Obsah od větších nájemců je předem načten do okrajů PoP, aby se snížilo TTFB.
- Dynamický vodoznak: Neviditelné překrytí s ID uživatele pro sledování úniků obsahu.
Architektura videopotrubí
Kompletní postup pro video nahrané instruktorem:
- Nahrání: Předepsaná adresa URL pro přímé nahrání do S3 (bypass aplikačního serveru)
- Akce S3: Zapnout funkci Lambda/Cloud
s3:ObjectCreated - Překódování: AWS MediaConvert nebo FFmpeg na ECS/Cloud Run pro generování multibitového HLS
- Metadata: Doba trvání, miniatura, velikost zapsaná do databáze LMS
- Zneplatnění CDN: Zahřívání mezipaměti pro vlastníka nájemce
- Oznámení: Událost byla zveřejněna za účelem upozornění instruktora prostřednictvím WebSocket
Architektura řízená událostmi pro funkčnost v reálném čase
Moderní LMS vyžaduje mnoho asynchronních funkcí a funkcí v reálném čase: push notifikace, když je instruktor zveřejnit hlasování, živé aktualizace hodnocení, sledování pokroku v reálném čase na řídicím panelu instruktor, automatické připomenutí nadcházejících termínů. Tyto potřeby vyžadují audálostmi řízená architektura.
Doménové události pro LMS
Prvním krokem je identifikovat významné události domény platformy. Každá událost je neměnný fakt, který popisuje něco, co se v systému stalo.
// 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),
},
}],
});
}
}
Spotřebitel a projekce
Události jsou spotřebovávány specializovanými obslužnými programy, které mají vedlejší účinky: oznámení, aktualizace materializovaného pohledu, analýzy, plánování e-mailů. Vzor spotřebitele na doménu udržuje soudržnost.
| Událost | Spotřebitel | Akce |
|---|---|---|
student.enrolled | NotificationService | Uvítací e-mail + upozornění push |
student.enrolled | Služba Analytics | Aktualizujte počítadlo registrací kurzů |
lesson.completed | ProgressService | Přepočítat celkový průběh kurzu |
lesson.completed | Gamification Service | Podívejte se, zda odemkne odznak |
assessment.graded | NotificationService | Informujte studenta o známce |
assessment.graded | CertificateService | Vygenerujte certifikát, pokud je kurz dokončen |
course.published | SearchIndexService | Aktualizujte katalog Elasticsearch index |
course.published | NotificationService | Informujte registrované studenty učitele |
Rozklad na mikroslužby
Pro podnikové platformy LMS obsluhující více než 100 000 souběžných uživatelů se stává monolit úzké hrdlo. Dekompozice na mikroslužby umožňuje nezávislé škálování nejvíce namáhané komponenty. Klíčem je identifikovat ohraničený kontext správně podle principů Domain-Driven Design.
Ohraničený kontext LMS
| Mikroservis | Odpovědnost | Databáze | Protokol |
|---|---|---|---|
| Služba nájemníků | Registr nájemců, konfigurace, fakturace, příznaky funkcí | Dedikovaný PostgreSQL | REST + gRPC |
| Služba identity | Autentizace, SSO (SAML/OIDC), správa uživatelů, RBAC | PostgreSQL + Redis (relace) | REST + OAuth 2.0 |
| Kurzový servis | CRUD kurzy, moduly, lekce, katalog, vyhledávání | PostgreSQL + Elasticsearch | REST + GraphQL |
| Služba registrace | Registrace, postup, dokončení, certifikáty | PostgreSQL + Redis (mezipaměť průběhu) | Akce REST + Kafka |
| Assessment Service | Kvízový engine, hodnocení, rubrika, peer review | PostgreSQL + S3 (odeslání souboru) | Akce REST + Kafka |
| Služba obsahu | Nahrávání, překódování, ukládání, CDN, SCORM balení | S3 + DynamoDB (metadata) | Akce REST + S3 |
| Notifikační služba | E-mail, push, in-app, WebSocket, plánování digestu | Redis + SQS | Akce Kafka + WebSocket |
| Analytická služba | xAPI/Caliper, dashboardy, reporty, export dat | ClickHouse + S3 (datové jezero) | Akce Kafka + REST |
Komunikace mezi službami
Komunikace má dva základní vzorce:
- Synchronní (REST/gRPC): Pro operace, které vyžadují okamžitou reakci. Příklad: Služba kurzu zavolá službu identity, aby před vytvořením kurzu zkontrolovala oprávnění uživatele.
- Asynchronní (Kafka): Pro operace, které nevyžadují okamžitou reakci. Příklad: Když student dokončí lekci, událost je publikována a spotřebována nezávisle na Notification, Analytics a Gamification.
Zlaté pravidlo: Upřednostněte události před voláním API
Pokud akce nevyžaduje synchronní odezvu, vždy použijte událost. To snižuje vazbu mezi službami, zlepšuje odolnost (selhání spotřebitele neblokuje producenta) a umožňuje přehrávání událostí pro ladění a obnovu. V LMS je ideální poměr přibližně 70 % událostí / 30 % synchronních hovorů.
Škálovatelnost: Caching, Sharding a CDN
Podnikový LMS musí zvládnout dramatické vrcholy: první den univerzitního semestru, současně se přihlašují desítky tisíc studentů. Online zkušební platforma při zkouškách trpí 10x zátěží. Bez dobře navržené strategie škálování, systém se zhroutí přesně tehdy, když je to nejvíce potřeba.
Víceúrovňová strategie ukládání do mezipaměti
| Úroveň | Technologie | Údaje z Cachati | TTL |
|---|---|---|---|
| L1: Prohlížeče | Service Worker + Cache API | Statické podklady, obsah lekce (offline) | 24 hodin (zrušení platnosti prostřednictvím verzování) |
| L2: CDN Edge | CloudFront / Cloudflare | HLS videa, obrázky, CSS/JS, dokumenty PDF | 7 dní (zneplatnění při zveřejnění) |
| L3: API Gateway | Redis/Lak | GET API odpovědi (katalog kurzů, uživatelský profil) | 5-15 minut |
| L4: Aplikace | Cluster Redis | Relace, konfigurace tenanta, postup studentů, oprávnění RBAC | 5-30 minut |
| L5: Databáze | Materializované pohledy PostgreSQL | Souhrnná analytika, žebříček, statistiky kurzu | Aktualizujte každých 5-60 minut |
Sdílení s Citus pro PostgreSQL
Pro LMS s miliony uživatelů a vzorem „sdíleného schématu“ Citus (rozšíření PostgreSQL
pro distribuované výpočty) umožňuje distribuovat data mezi více uzlů při zachování kompatibility SQL.
Přirozená strategie sdílení pro LMS s více nájemci a rozdělení pro 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
Výkonnostní benchmarky
Následující benchmarky jsou založeny na LMS s 5 000 nájemci a 2 miliony celkovými uživateli,
nasazeno na AWS s PostgreSQL 16 + Citus, 3 uzly r6g.2xlarge:
| Operace | Bez Citus (jeden uzel) | S Citus (3 uzly) | Zlepšení |
|---|---|---|---|
| Seznam kurzů (na nájemce) | 45 ms | 12 ms | 3,7x |
| Studentský panel | 180 ms | 35 ms | 5,1x |
| Analytika kurzu (agregace) | 2 400 ms | 320 ms | 7,5x |
| Série registrací (1 000 souběžných) | Časový limit (>5 s) | 89 ms (p99) | >56x |
| Hlášení mezi nájemci (admin) | 12 000 ms | 1800 ms | 6,7x |
Architektonické srovnání: Skutečné platformy LMS
Abychom pochopili, jak se teoretické vzorce převádějí do skutečných implementací, pojďme analyzovat architektura hlavních LMS platforem na trhu.
| Platforma | Architektura | Vícenásobný nájem | Technický zásobník | Škálovatelnost |
|---|---|---|---|---|
| Moodle | Modulární monolit | Databáze pro jednoho tenanta (MoodleCloud) nebo jednoho klienta (s vlastním hostitelem). | PHP 8, MySQL/PostgreSQL, úlohy cron | Vertikální, omezeno na ~10 000 uživatelů na instanci bez náročného ladění |
| Canvas LMS | Monolit se satelitními službami | Sdílené schéma s fragmentem podle instituce (switchman drahokam) | Ruby on Rails, PostgreSQL, Redis, S3, Consul | Horizontální přes sharding, sloužící milionům uživatelů |
| Blackboard Learn Ultra | Mikroslužby (migrace z Java monolitu) | Hybridní: Databáze na tenanta (podnik), sdílené schéma (SaaS) | Java/Spring Boot, AWS, mikroslužby, Kafka | Cloudové nativní automatické škálování pro jednotlivé služby |
| Otevřete edX | Orientované na služby (služby Django) | Instance na organizaci (Tutor) nebo na více míst | Python/Django, MySQL, MongoDB, Celer, RabbitMQ | Škálování na základě služby Kubernetes |
| Docebo | Cloudově nativní SaaS | Sdílené schéma s tenant_id + RLS | Proprietární (předpokládá se Node.js/Go), AWS, nativní AI | Automatické škálování, obsluhující více než 3 800 podnikových zákazníků |
| Coursera | Mikroslužby řízené událostmi | Nativní multi-tenant se sdílením podle jednotlivých partnerů | Scala, Python, Cassandra, Kafka, Kubernetes | Globální, více než 130 milionů uživatelů, více regionů |
Lekce ze skutečných platforem
- Plátno: Sharding prostřednictvím klenotu Switchman ukazuje, že dobře navržený monolit se může rozšířit na miliony uživatelů bez nutnosti přepisování mikroslužeb.
- Tabule: Migrace z monolitu Java na mikroslužby trvala více než 4 roky a byla to značná investice. Ponaučení: Rozkládejte se brzy nebo vůbec.
- kurz: Architektura řízená událostmi s Kafkou jako páteří vám umožňuje přidávat nové funkce (doporučení AI, analýzy) bez úprav stávajících služeb.
- Otevřít edX: Django multi-service přístup ukazuje, že i s „monolitickým“ rámcem lze dosáhnout dobrého oddělení ohraničených kontextů.
Průvodce výběrem architektury
Neexistuje žádný univerzálně nejlepší vzor. Výběr závisí na profilu produktu, podle očekávaného počtu nájemců a dostupného inženýrského týmu. Zde je praktický rozhodovací strom:
Rozhodovací strom
- Kolik nájemníků předpokládáte za 2 roky?
- Méně než 50, všechny podniky s vlastními smlouvami → Databáze na nájemce
- 50-2000, mix velikostí → Schéma na nájemce
- Více než 2 000, většinou SMB → Sdílené schéma + RLS
- Jak zásadní je dodržování předpisů?
- FERPA/HIPAA s přísným auditním záznamem → Preferuji fyzickou izolaci (DB na nájemce)
- Standardní GDPR → Nájemce nebo schéma RLS s příslušným auditem
- Jak velký je inženýrský tým?
- 3–5 vývojářů → Sdílené schéma (méně provozní režie)
- 10-20 vývojářů → Schéma tenantů nebo odlehčených mikroslužeb
- 20+ vývojářů → Kompletní mikroslužby s týmovým vlastnictvím
- Mikroslužby nebo monolit?
- Méně než 50 tisíc souběžných uživatelů → Modulární monolit (a la Canvas)
- 50–500 tisíc souběžných uživatelů → Monolith + satelitní služby pro média a analýzy
- Více než 500 tisíc souběžných uživatelů → Kompletní mikroslužby se sběrnicí událostí
Závěry a další kroky
Návrh škálovatelného LMS pro více nájemců vyžaduje architektonická rozhodnutí, která budou mít dopad roky na schopnosti produktu růst, zabezpečení dat a provozních nákladech. Neexistuje jediné řešení: nejúspěšnější platformy vyvinuly svou architekturu v průběhu času, často vycházející z monolitu se sdíleným vzorem a postupně se rozkládající nejkritičtější služby.
Klíčové body tohoto článku:
- Vyberte vzor pro více nájemců na základě profilu zákazníka, nikoli technologická móda. Sdílené schéma + RLS je dostatečné pro většinu SaaS LMS.
- Řešení nájemců a základ bezpečnosti. Nasaďte jej jako robustní middleware s ukládáním do mezipaměti a přísným ověřováním.
- PostgreSQL Row-Level Security je váš nejlepší spojenec poskytovat izolaci na úrovni databáze bez nadměrné složitosti aplikace.
- Architektura řízená událostmi je nepostradatelná pro oznámení, analýzy a všechny funkce moderního LMS v reálném čase.
- Citus pro PostgreSQL umožňuje horizontálně škálovat s minimálními změnami aplikace, když jeden uzel již nestačí.
- Začněte s modulárním monolitem a rozkládají se na mikroslužby pouze tehdy, když to vyžaduje škála nebo týmová organizace.
V dalším článku série prozkoumámevzdělávací architektura streamování videa, řešení adaptivního překódování, DRM pro chráněný obsah a ultranízká latence pro živé kurzy. Video infrastruktura je často nejdražší a nejsložitější součástí LMS a zaslouží si analýzu důkladný.
Zdroje, kde se dozvíte více
- Crunchy Data Blog: "Design Your Postgres Database for Multi-Tenancy" - praktický průvodce multi-tenancy s PostgreSQL
- Dokumentace Citus: Výukový program SaaS pro více nájemců s příklady sdílení v reálném světě
- Canvas LMS (GitHub): Open source kód s Switchman for Rails + sharding PostgreSQL
- Otevřete architekturu edX: Oficiální dokumentace architektury služeb Open edX
- Vzory více nájemců Microsoft Azure: Průvodce architekty pro cloud SaaS pro více nájemců







