מדוע ספריות מוכנות לא עומדות בעומס של נתונים גדולים
תארו לעצמכם אתר מסחר אלקטרוני עם 10 מיליון הזמנות. מנהל צריך דוח מכירות רבעוני מקובץ לפי קטגוריה וחודש. ייצוא אקסל רגיל לוקח 30 דקות ומכביד על מסד הנתונים. טבלת ציר עם צבירה בצד השרת מחזירה נתונים ב-200 אלפיות השנייה ומאפשרת למשתמש לשנות חיתוכים בזמן אמת. בפועל, ספריות מוכנות מתקשות עם עומס כזה: react-pivottable נחנקת על 200k+ שורות, AG Grid דורשת רישיון ארגוני למצב שרת ($2k לשנה), ו-Flexmonster לא תומכת בשאילתות אד-הוק מול מסדי נתונים יחסיים. אנו משלבים ספריות עם צבירה בצד השרת שלנו כדי למלא את הפערים הללו. החיסכון ברישיונות יכול לעלות על $10k לשנה עבור צוות של 5 מפתחים.
ויקיפדיה מגדירה טבלת צור ככלי לניתוח נתונים שמצבר נתונים לפי ממדים. בפרויקטים מסחריים, לעיתים קרובות נדרשת התאמה אישית – דבר שפתרונות מוכנים לא מספקים.
איך אנו בונים צבירה בצד השרת עבור מיליוני שורות
עבור נתונים גדולים, תצורת הציר עוברת לשרת. אנו משתמשים ב-ClickHouse או PostgreSQL עם אינדקסים. כל שינוי ציר שולח שאילתה, השרת מחזיר צבירות ב-100–500 אלפיות השנייה. אנו שומרים תוצאות במטמון לפי מפתח תצורה. להלן דוגמה ליצירת SQL מתצורה.
// Сервер — генерация SQL из конфига
function buildPivotQuery(config: PivotConfig, dateRange: [Date, Date]): string {
const rowsExpr = config.rows.map(r => `"${r}"`).join(', ');
const colsExpr = config.cols.map(c => `"${c}"`).join(', ');
const valExpr = config.values[0]; // упрощение
const aggExpr = {
sum: `SUM("${valExpr}")`,
count: `COUNT(*)`,
avg: `AVG("${valExpr}")::numeric(18,2)`,
min: `MIN("${valExpr}")`,
max: `MAX("${valExpr}")`,
}[config.aggFn];
return `
SELECT ${rowsExpr}, ${colsExpr}, ${aggExpr} AS value
FROM events
WHERE created_at BETWEEN $1 AND $2
GROUP BY ${rowsExpr}, ${colsExpr}
ORDER BY ${rowsExpr}, ${colsExpr}
`;
} מדריך שלב אחר שלב
- ניתוח מבנה הנתונים. זיהוי שדות לקיבוץ (שורות, עמודות) ומידות (סכום, ממוצע).
- עיצוב שאילתות SQL. יצירת שאילתות דינמיות עם GROUP BY.
- הגדרת ClickHouse/PostgreSQL. יצירת אינדקסים, הגדרת מטמון תוצאות.
- אינטגרציה עם הלקוח. שליחת שאילתה על כל שינוי תצורה.
השוואה: צבירה בצד הלקוח מול צבירה בצד השרת
| מאפיין | צד הלקוח | צד השרת |
|---|---|---|
| מספר רשומות מקסימלי | ~200k | ללא הגבלה |
| זמן תגובה | מיידי | 100–500 אלפיות השנייה |
| עלות תשתית | נמוכה (חינם) | גבוהה יותר (שרת + מטמון ~$100 לחודש) |
| גמישות | רק נתונים שנטענו מראש | כל שאילתת SQL |
| מדרגיות | מוגבל לדפדפן | עד מיליארדי שורות |
צבירה בצד הלקוח מהירה פי 5 על נתונים קטנים, אבל צד השרת מתאים למיליארדי שורות. הבחירה תלויה בנפח הנתונים ובצרכי האינטראקטיביות.
איך ליישם צבירה בצד הלקוח: מדריך שלב אחר שלב
- סינון נתונים – שמירה רק על רשומות שתואמות למסננים שהופעלו.
- קיבוץ – הפקת מפתחות לשורות ועמודות לפי ערכי השדות שנבחרו.
- צבירה – חישוב הערך לכל תא (סכום, ספירה, ממוצע, מינימום, מקסימום).
- עיבוד טבלה – פלט טבלת HTML עם סיכומים וכותרת קבועה.
קוד פונקציית הצבירה המלא (TypeScript)
type AggregateFunction = 'sum' | 'count' | 'avg' | 'min' | 'max';
interface PivotConfig {
rows: string[];
cols: string[];
values: string[];
aggFn: AggregateFunction;
filters: Record<string, string[]>;
}
interface PivotResult {
rowKeys: string[][];
colKeys: string[][];
data: Map<string, Map<string, number>>;
}
function computePivot(rawData: Record<string, any>[], config: PivotConfig): PivotResult {
const { rows, cols, values, aggFn, filters } = config;
const filtered = rawData.filter(row =>
Object.entries(filters).every(([field, allowed]) =>
!allowed.length || allowed.includes(String(row[field]))
)
);
const rowKeySet = new Set<string>();
const colKeySet = new Set<string>();
const accumulator = new Map<string, Map<string, number[]>>();
filtered.forEach(row => {
const rowKey = rows.map(r => String(row[r] ?? '(пусто)')).join('||');
const colKey = cols.map(c => String(row[c] ?? '(пусто)')).join('||');
rowKeySet.add(rowKey);
colKeySet.add(colKey);
const numVal = values.reduce((sum, v) => sum + (Number(row[v]) || 0), 0);
if (!accumulator.has(rowKey)) accumulator.set(rowKey, new Map());
const colMap = accumulator.get(rowKey)!;
if (!colMap.has(colKey)) colMap.set(colKey, []);
colMap.get(colKey)!.push(numVal);
});
const aggregated = new Map<string, Map<string, number>>();
accumulator.forEach((colMap, rowKey) => {
const row = new Map<string, number>();
colMap.forEach((vals, colKey) => {
let result: number;
switch (aggFn) {
case 'sum':
result = vals.reduce((a, b) => a + b, 0);
break;
case 'count':
result = vals.length;
break;
case 'avg':
result = vals.reduce((a, b) => a + b, 0) / vals.length;
break;
case 'min':
result = Math.min(...vals);
break;
case 'max':
result = Math.max(...vals);
break;
}
row.set(colKey, result);
});
aggregated.set(rowKey, row);
});
return {
rowKeys: Array.from(rowKeySet).sort().map(k => k.split('||')),
colKeys: Array.from(colKeySet).sort().map(k => k.split('||')),
data: aggregated,
};
} קוד רכיב PivotTable המלא (React+TypeScript)
function PivotTable({ result, config, format }: { result: PivotResult; config: PivotConfig; format?: (val: number) => string; }) {
const fmt = format ?? (v => v.toLocaleString('ru-RU'));
const rowTotals = result.rowKeys.map(rk => {
const rowKey = rk.join('||');
let total = 0;
result.colKeys.forEach(ck => {
total += result.data.get(rowKey)?.get(ck.join('||')) ?? 0;
});
return total;
});
const grandTotal = rowTotals.reduce((a, b) => a + b, 0);
return (
<div className="overflow-auto max-h-[600px]">
<table className="text-sm border-collapse w-full">
<thead className="sticky top-0 bg-white z-10">
<tr>
{config.rows.map(r => (
<th key={r} className="border px-3 py-2 text-left bg-gray-50 font-medium">{r}</th>
))}
{result.colKeys.map(ck => (
<th key={ck.join('/')} className="border px-3 py-2 text-right bg-gray-50 font-medium whitespace-nowrap">
{ck.join(' / ')}
</th>
))}
<th className="border px-3 py-2 text-right bg-blue-50 font-semibold">Итого</th>
</tr>
</thead>
<tbody>
{result.rowKeys.map((rk, ri) => {
const rowKey = rk.join('||');
return (
<tr key={rowKey} className="hover:bg-gray-50">
{rk.map((label, i) => (
<td key={i} className="border px-3 py-1.5 font-medium">{label}</td>
))}
{result.colKeys.map(ck => {
const val = result.data.get(rowKey)?.get(ck.join('||'));
return (
<td key={ck.join('/')} className="border px-3 py-1.5 text-right tabular-nums">
{val != null ? fmt(val) : '—'}
</td>
);
})}
<td className="border px-3 py-1.5 text-right tabular-nums font-medium bg-blue-50">
{fmt(rowTotals[ri])}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="font-semibold bg-gray-100">
<td colSpan={config.rows.length} className="border px-3 py-2">Итого</td>
{result.colKeys.map(ck => {
const colTotal = result.rowKeys.reduce((sum, rk) => {
return sum + (result.data.get(rk.join('||'))?.get(ck.join('||')) ?? 0);
}, 0);
return (
<td key={ck.join('/')} className="border px-3 py-2 text-right tabular-nums">{fmt(colTotal)}</td>
);
})}
<td className="border px-3 py-2 text-right tabular-nums bg-blue-100">{fmt(grandTotal)}</td>
</tr>
</tfoot>
</table>
</div>
);
} ייצוא לאקסל
אנו משתמשים ב-// Сервер — генерация SQL из конфига function buildPivotQuery(config: PivotConfig, dateRange: [Date, Date]): string { const rowsExpr = config.rows.map(r => `"${r}"`).join(', '); const colsExpr = config.cols.map(c => `"${c}"`).join(', '); const valExpr = config.values[0]; // упрощение const aggExpr = { sum: `SUM("${valExpr}")`, count: `COUNT(*)`, avg: `AVG("${valExpr}")::numeric(18,2)`, min: `MIN("${valExpr}")`, max: `MAX("${valExpr}")`, }[config.aggFn]; return ` SELECT ${rowsExpr}, ${colsExpr}, ${aggExpr} AS value FROM events WHERE created_at BETWEEN $1 AND $2 GROUP BY ${rowsExpr}, ${colsExpr} ORDER BY ${rowsExpr}, ${colsExpr} `; } כדי ליצור .xlsx בצד הלקוח:
import ExcelJS from 'exceljs';
async function exportToExcel(result: PivotResult, config: PivotConfig) {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Сводная таблица');
const headers = [...config.rows, ...result.colKeys.map(k => k.join(' / ')), 'Итого'];
ws.addRow(headers).font = { bold: true };
result.rowKeys.forEach(rk => {
const rowKey = rk.join('||');
const row = [...rk];
result.colKeys.forEach(ck => {
row.push(String(result.data.get(rowKey)?.get(ck.join('||')) ?? ''));
});
ws.addRow(row);
});
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'pivot.xlsx';
a.click();
} איך לבחור בין צבירה בצד הלקוח לצבירה בצד השרת?
אם הנתונים שלכם עד 200k שורות ואינם דורשים סינון מורכב, צד הלקוח מהיר וזול יותר. עבור מיליוני שורות ושאילתות אד-הוק, צבירה בצד השרת היא חובה. אנו עוזרים ללקוחות לבחור את הארכיטקטורה האופטימלית. עם ניסיון של 7+ שנים ו-50+ פרויקטי ציר מוצלחים, אנו מבטיחים פתרון מותאם אישית שעונה על צרכי הביצועים שלכם.
שלבי פיתוח ולוח זמנים משוער
| שלב | משך | תיאור |
|---|---|---|
| ניתוח נתונים | 2–3 ימים | לימוד מבנה הנתונים, דרישות למדדים וסינון |
| עיצוב סכמת צבירה | 1–2 ימים | הגדרת שדות, מדדים, אינדקסים עבור ClickHouse/PostgreSQL |
| פיתוח ממשק משתמש | 1–2 שבועות | יישום קונפיגורטור גרור-ושחרר, טבלה עם סיכומים וכותרת דביקה |
| צבירה בצד השרת (במידת הצורך) | 1–2 שבועות | הגדרת ClickHouse, מטמון, יצירת SQL |
| ייצוא לאקסל/CSV | 2–3 ימים | אינטגרציה עם exceljs, בדיקת עיצוב |
| בדיקות עומס | 2–3 ימים | בדיקה עם 10M שורות, אופטימיזציה של שאילתות N+1 |
ציר בצד הלקוח עבור עד 50k שורות עם קונפיגורטור גרור-ושחרר וייצוא לאקסל: 2–3 שבועות. מצב שרת עם ClickHouse או PostgreSQL, מטמון תוצאות ותמיכה במספר ערכים: תוספת של 1–2 שבועות. פתרון מפתח כולל כל השלבים עולה מ-$5k ומועבר תוך 4–6 שבועות.
היקף העבודה (כלול)
- ניתוח נתונים ועיצוב סכמת צבירה
- פיתוח ממשק משתמש עם גרור-ושחרר (React, TypeScript, Tailwind)
- צבירה בצד השרת על ClickHouse/PostgreSQL עם מטמון
- ייצוא לאקסל/CSV, הדפסה
- בדיקות עומס עד 10M שורות
- תיעוד והעברת קוד מקור
- 3 חודשי תמיכה לאחר השקה עם זמן תגובה מובטח
אנו נעריך את הפרויקט שלכם תוך יום אחד. צרו קשר כדי לדון בנתונים שלכם, במדדים ולמצוא את הפתרון הטוב ביותר. קבלו ייעוץ והצעת מחיר מותאמת אישית עוד היום. אנו גם מציעים אחריות שביעות רצון – אם הפתרון לא עומד ביעדי הביצועים המוסכמים, אנו נמשיך לשפר אותו עד שיעמוד בהם.







