Важно: CAP-теорема -- упрощение. На практике выбор между C и A -- это спектр, а не бинарный выбор. Система может жертвовать консистентностью для части операций.
SQL vs NoSQL
<?php
declare(strict_types=1);
/**
* SQL approach: structured, normalized data
*/
final class SqlOrderRepository
{
public function __construct(
private readonly \PDO $db,
) {}
public function findOrderWithItems(string $orderId): array
{
// Normalized: data in separate tables, joined at query time
$stmt = $this->db->prepare(<<<SQL
SELECT
o.id, o.user_id, o.status, o.total_amount, o.created_at,
oi.product_id, oi.quantity, oi.price,
p.name as product_name, p.category
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = :order_id
SQL);
$stmt->execute(['order_id' => $orderId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
/**
* NoSQL approach: denormalized, document-oriented
*/
final class NoSqlOrderRepository
{
public function __construct(
private readonly MongoDB\Collection $collection,
) {}
public function findOrder(string $orderId): ?array
{
// Denormalized: everything in one document
return $this->collection->findOne(['_id' => $orderId]);
/*
* Returns:
* {
* "_id": "ord_123",
* "user_id": "user_456",
* "status": "completed",
* "total_amount": 99.99,
* "items": [
* {"product_id": "p1", "name": "Widget", "category": "electronics", "qty": 2, "price": 49.99}
* ],
* "created_at": "2024-01-15T10:30:00Z"
* }
*/
}
}
package storage
import (
"context"
"database/sql"
"fmt"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
// OrderItem holds a joined order row from SQL.
type OrderItem struct {
ID string `db:"id"`
UserID string `db:"user_id"`
Status string `db:"status"`
TotalAmount float64 `db:"total_amount"`
ProductID string `db:"product_id"`
Quantity int `db:"quantity"`
Price float64 `db:"price"`
ProductName string `db:"product_name"`
Category string `db:"category"`
}
// SqlOrderRepository uses normalized, relational data with JOINs.
type SqlOrderRepository struct {
db *sql.DB
}
func (r *SqlOrderRepository) FindOrderWithItems(ctx context.Context, orderID string) ([]OrderItem, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT o.id, o.user_id, o.status, o.total_amount,
oi.product_id, oi.quantity, oi.price,
p.name as product_name, p.category
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = $1`, orderID)
if err != nil {
return nil, fmt.Errorf("query order: %w", err)
}
defer rows.Close()
var items []OrderItem
for rows.Next() {
var i OrderItem
if err := rows.Scan(&i.ID, &i.UserID, &i.Status, &i.TotalAmount,
&i.ProductID, &i.Quantity, &i.Price, &i.ProductName, &i.Category); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
items = append(items, i)
}
return items, rows.Err()
}
// NoSqlOrderRepository uses denormalized, document-oriented data.
type NoSqlOrderRepository struct {
collection *mongo.Collection
}
func (r *NoSqlOrderRepository) FindOrder(ctx context.Context, orderID string) (bson.M, error) {
// Denormalized: everything in one document, no JOINs needed
var result bson.M
err := r.collection.FindOne(ctx, bson.M{"_id": orderID}).Decode(&result)
if err != nil {
return nil, fmt.Errorf("find order: %w", err)
}
return result, nil
}
using MongoDB.Bson;
using MongoDB.Driver;
using Npgsql;
// SQL approach: structured, normalized data
public sealed record OrderItemRow(
string Id,
string UserId,
string Status,
decimal TotalAmount,
string ProductId,
int Quantity,
decimal Price,
string ProductName,
string Category);
public sealed class SqlOrderRepository(NpgsqlDataSource dataSource)
{
public async Task<IReadOnlyList<OrderItemRow>> FindOrderWithItemsAsync(
string orderId,
CancellationToken ct = default)
{
// Normalized: data in separate tables, joined at query time
const string sql = """
SELECT o.id, o.user_id, o.status, o.total_amount,
oi.product_id, oi.quantity, oi.price,
p.name AS product_name, p.category
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = @orderId
""";
await using var cmd = dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("orderId", orderId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var items = new List<OrderItemRow>();
while (await reader.ReadAsync(ct))
{
items.Add(new OrderItemRow(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetDecimal(3),
reader.GetString(4),
reader.GetInt32(5),
reader.GetDecimal(6),
reader.GetString(7),
reader.GetString(8)));
}
return items;
}
}
// NoSQL approach: denormalized, document-oriented
public sealed class NoSqlOrderRepository(IMongoCollection<BsonDocument> collection)
{
public async Task<BsonDocument?> FindOrderAsync(string orderId, CancellationToken ct = default)
{
// Denormalized: everything in one document, no JOINs needed
var filter = Builders<BsonDocument>.Filter.Eq("_id", orderId);
return await collection.Find(filter).FirstOrDefaultAsync(ct);
}
}
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from motor.motor_asyncio import AsyncIOMotorCollection
from psycopg.rows import class_row
from psycopg_pool import AsyncConnectionPool
@dataclass(frozen=True, slots=True)
class OrderItemRow:
id: str
user_id: str
status: str
total_amount: Decimal
product_id: str
quantity: int
price: Decimal
product_name: str
category: str
class SqlOrderRepository:
"""SQL approach: structured, normalized data."""
def __init__(self, pool: AsyncConnectionPool) -> None:
self._pool = pool
async def find_order_with_items(self, order_id: str) -> list[OrderItemRow]:
# Normalized: data in separate tables, joined at query time
sql = """
SELECT o.id, o.user_id, o.status, o.total_amount,
oi.product_id, oi.quantity, oi.price,
p.name AS product_name, p.category
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = %s
"""
async with self._pool.connection() as conn:
async with conn.cursor(row_factory=class_row(OrderItemRow)) as cur:
await cur.execute(sql, (order_id,))
return await cur.fetchall()
class NoSqlOrderRepository:
"""NoSQL approach: denormalized, document-oriented."""
def __init__(self, collection: AsyncIOMotorCollection) -> None:
self._collection = collection
async def find_order(self, order_id: str) -> dict[str, Any] | None:
# Denormalized: everything in one document, no JOINs needed
return await self._collection.find_one({"_id": order_id})
NewSQL -- системы, которые сочетают масштабируемость NoSQL с ACID-гарантиями SQL.
БД
Описание
Особенности
CockroachDB
Geo-distributed SQL
Совместим с PostgreSQL wire protocol
TiDB
MySQL-совместимый
Горизонтально масштабируемый
YugabyteDB
PostgreSQL-совместимый
Распределённый
Vitess
Шардинг для MySQL
YouTube scale
Google Spanner
Глобальная БД
TrueTime, внешняя консистентность
Когда NewSQL
Нужны ACID-транзакции на распределённой системе
Объём данных перерос одиночный сервер PostgreSQL/MySQL
Нужна geo-распределённость с сильной консистентностью
Команда знает SQL и не хочет переучиваться
Time-Series базы данных
Оптимизированы для записи и чтения данных с временной меткой.
БД
Описание
PHP-поддержка
TimescaleDB
Расширение PostgreSQL
PDO (PostgreSQL)
InfluxDB
Нативная TSDB
HTTP API
Prometheus
Мониторинг
HTTP API
QuestDB
Высокопроизводительная
REST/PostgreSQL wire
<?php
declare(strict_types=1);
/**
* TimescaleDB: PostgreSQL extension for time-series
*/
final class MetricsRepository
{
public function __construct(
private readonly \PDO $db,
) {}
/**
* Setup hypertable for metrics
*/
public function createMetricsTable(): void
{
$this->db->exec(<<<SQL
CREATE TABLE IF NOT EXISTS metrics (
time TIMESTAMPTZ NOT NULL,
host TEXT NOT NULL,
metric_name TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL
);
-- Convert to hypertable (TimescaleDB)
SELECT create_hypertable('metrics', 'time',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
-- Compression policy
ALTER TABLE metrics SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'host,metric_name'
);
SELECT add_compression_policy('metrics', INTERVAL '7 days');
SQL);
}
/**
* Insert metrics in batch
*/
public function insertBatch(array $metrics): void
{
$stmt = $this->db->prepare(<<<SQL
INSERT INTO metrics (time, host, metric_name, value)
VALUES (:time, :host, :metric_name, :value)
SQL);
foreach ($metrics as $metric) {
$stmt->execute($metric);
}
}
/**
* Time-bucketed aggregation
*/
public function getAverageByMinute(string $host, string $metricName, int $hours = 1): array
{
$stmt = $this->db->prepare(<<<SQL
SELECT
time_bucket('1 minute', time) as bucket,
AVG(value) as avg_value,
MAX(value) as max_value,
MIN(value) as min_value
FROM metrics
WHERE host = :host
AND metric_name = :metric_name
AND time > NOW() - INTERVAL ':hours hours'
GROUP BY bucket
ORDER BY bucket DESC
SQL);
$stmt->execute([
'host' => $host,
'metric_name' => $metricName,
'hours' => $hours,
]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
package storage
import (
"context"
"database/sql"
"fmt"
"time"
)
// MetricPoint represents a single time-series data point.
type MetricPoint struct {
Time time.Time `json:"time"`
Host string `json:"host"`
MetricName string `json:"metric_name"`
Value float64 `json:"value"`
}
// BucketedMetric holds an aggregated metric over a time bucket.
type BucketedMetric struct {
Bucket time.Time `json:"bucket"`
AvgValue float64 `json:"avg_value"`
MaxValue float64 `json:"max_value"`
MinValue float64 `json:"min_value"`
}
// MetricsRepository works with TimescaleDB hypertables.
type MetricsRepository struct {
db *sql.DB
}
func NewMetricsRepository(db *sql.DB) *MetricsRepository {
return &MetricsRepository{db: db}
}
// InsertBatch writes multiple metrics in a single transaction.
func (r *MetricsRepository) InsertBatch(ctx context.Context, metrics []MetricPoint) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO metrics (time, host, metric_name, value) VALUES ($1, $2, $3, $4)`)
if err != nil {
return fmt.Errorf("prepare: %w", err)
}
defer stmt.Close()
for _, m := range metrics {
if _, err := stmt.ExecContext(ctx, m.Time, m.Host, m.MetricName, m.Value); err != nil {
return fmt.Errorf("insert metric: %w", err)
}
}
return tx.Commit()
}
// GetAverageByMinute returns time-bucketed aggregations.
func (r *MetricsRepository) GetAverageByMinute(ctx context.Context, host, metricName string, hours int) ([]BucketedMetric, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT time_bucket('1 minute', time) as bucket,
AVG(value), MAX(value), MIN(value)
FROM metrics
WHERE host = $1 AND metric_name = $2
AND time > NOW() - make_interval(hours => $3)
GROUP BY bucket ORDER BY bucket DESC`,
host, metricName, hours)
if err != nil {
return nil, fmt.Errorf("query metrics: %w", err)
}
defer rows.Close()
var results []BucketedMetric
for rows.Next() {
var m BucketedMetric
if err := rows.Scan(&m.Bucket, &m.AvgValue, &m.MaxValue, &m.MinValue); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
results = append(results, m)
}
return results, rows.Err()
}
using Npgsql;
using NpgsqlTypes;
// MetricPoint represents a single time-series data point.
public sealed record MetricPoint(DateTimeOffset Time, string Host, string MetricName, double Value);
// BucketedMetric holds an aggregated metric over a time bucket.
public sealed record BucketedMetric(DateTimeOffset Bucket, double AvgValue, double MaxValue, double MinValue);
// MetricsRepository works with TimescaleDB hypertables.
public sealed class MetricsRepository(NpgsqlDataSource dataSource)
{
// InsertBatch uses binary COPY — the fastest ingest path for time-series.
public async Task InsertBatchAsync(IEnumerable<MetricPoint> metrics, CancellationToken ct = default)
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var writer = await conn.BeginBinaryImportAsync(
"COPY metrics (time, host, metric_name, value) FROM STDIN (FORMAT BINARY)", ct);
foreach (var m in metrics)
{
await writer.StartRowAsync(ct);
await writer.WriteAsync(m.Time, NpgsqlDbType.TimestampTz, ct);
await writer.WriteAsync(m.Host, NpgsqlDbType.Text, ct);
await writer.WriteAsync(m.MetricName, NpgsqlDbType.Text, ct);
await writer.WriteAsync(m.Value, NpgsqlDbType.Double, ct);
}
await writer.CompleteAsync(ct);
}
// GetAverageByMinute returns time-bucketed aggregations.
public async Task<IReadOnlyList<BucketedMetric>> GetAverageByMinuteAsync(
string host,
string metricName,
int hours = 1,
CancellationToken ct = default)
{
const string sql = """
SELECT time_bucket('1 minute', time) AS bucket,
AVG(value) AS avg_value,
MAX(value) AS max_value,
MIN(value) AS min_value
FROM metrics
WHERE host = @host AND metric_name = @metricName
AND time > NOW() - make_interval(hours => @hours)
GROUP BY bucket
ORDER BY bucket DESC
""";
await using var cmd = dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("host", host);
cmd.Parameters.AddWithValue("metricName", metricName);
cmd.Parameters.AddWithValue("hours", hours);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var results = new List<BucketedMetric>();
while (await reader.ReadAsync(ct))
{
results.Add(new BucketedMetric(
reader.GetFieldValue<DateTimeOffset>(0),
reader.GetDouble(1),
reader.GetDouble(2),
reader.GetDouble(3)));
}
return results;
}
}
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from psycopg.rows import class_row
from psycopg_pool import AsyncConnectionPool
@dataclass(frozen=True, slots=True)
class MetricPoint:
"""A single time-series data point."""
time: datetime
host: str
metric_name: str
value: float
@dataclass(frozen=True, slots=True)
class BucketedMetric:
"""Aggregated metric over a time bucket."""
bucket: datetime
avg_value: float
max_value: float
min_value: float
class MetricsRepository:
"""Works with TimescaleDB hypertables."""
def __init__(self, pool: AsyncConnectionPool) -> None:
self._pool = pool
async def insert_batch(self, metrics: Sequence[MetricPoint]) -> None:
# COPY beats row-by-row INSERT by an order of magnitude on ingest
async with self._pool.connection() as conn, conn.cursor() as cur:
async with cur.copy(
"COPY metrics (time, host, metric_name, value) FROM STDIN"
) as copy:
for m in metrics:
await copy.write_row((m.time, m.host, m.metric_name, m.value))
async def get_average_by_minute(
self,
host: str,
metric_name: str,
hours: int = 1,
) -> list[BucketedMetric]:
sql = """
SELECT time_bucket('1 minute', time) AS bucket,
AVG(value) AS avg_value,
MAX(value) AS max_value,
MIN(value) AS min_value
FROM metrics
WHERE host = %s AND metric_name = %s
AND time > NOW() - make_interval(hours => %s)
GROUP BY bucket
ORDER BY bucket DESC
"""
async with self._pool.connection() as conn:
async with conn.cursor(row_factory=class_row(BucketedMetric)) as cur:
await cur.execute(sql, (host, metric_name, hours))
return await cur.fetchall()
## OLTP vs OLAP
Свойство
OLTP
OLAP
Назначение
Транзакции
Аналитика
Запросы
Простые, по индексу
Сложные, агрегации
Объём данных запроса
Строки/единицы
Миллионы строк
Модель
Нормализованная (3NF)
Денормализованная (Star)
Обновления
Частые
Редкие (batch load)
Примеры
PostgreSQL, MySQL
ClickHouse, BigQuery
<?php
declare(strict_types=1);
/**
* OLTP vs OLAP query patterns
*/
final class QueryPatternComparison
{
public function __construct(
private readonly \PDO $oltpDb,
private readonly \PDO $olapDb,
) {}
/**
* OLTP: get single order (milliseconds)
* Point lookup by primary key
*/
public function getOrder(string $orderId): array
{
$stmt = $this->oltpDb->prepare(
'SELECT * FROM orders WHERE id = :id'
);
$stmt->execute(['id' => $orderId]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* OLAP: aggregate revenue by month (seconds)
* Full table scan with aggregation
*/
public function getMonthlyRevenue(int $year): array
{
$stmt = $this->olapDb->prepare(<<<SQL
SELECT
toMonth(created_at) as month,
sum(total_amount) as revenue,
count() as order_count,
avg(total_amount) as avg_order
FROM orders
WHERE toYear(created_at) = :year
GROUP BY month
ORDER BY month
SQL);
$stmt->execute(['year' => $year]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
package storage
import (
"context"
"database/sql"
"fmt"
)
// QueryPatternComparison demonstrates OLTP vs OLAP access patterns.
type QueryPatternComparison struct {
oltpDB *sql.DB // PostgreSQL
olapDB *sql.DB // ClickHouse
}
// GetOrder performs an OLTP point lookup by primary key (milliseconds).
func (q *QueryPatternComparison) GetOrder(ctx context.Context, orderID string) (map[string]any, error) {
row := q.oltpDB.QueryRowContext(ctx, `SELECT id, user_id, total_amount, status FROM orders WHERE id = $1`, orderID)
var id, userID, status string
var total float64
if err := row.Scan(&id, &userID, &total, &status); err != nil {
return nil, fmt.Errorf("scan order: %w", err)
}
return map[string]any{"id": id, "user_id": userID, "total_amount": total, "status": status}, nil
}
// MonthlyRevenue holds OLAP aggregation result.
type MonthlyRevenue struct {
Month int `json:"month"`
Revenue float64 `json:"revenue"`
OrderCount int `json:"order_count"`
AvgOrder float64 `json:"avg_order"`
}
// GetMonthlyRevenue performs an OLAP aggregation query (seconds).
func (q *QueryPatternComparison) GetMonthlyRevenue(ctx context.Context, year int) ([]MonthlyRevenue, error) {
rows, err := q.olapDB.QueryContext(ctx, `
SELECT toMonth(created_at) as month,
sum(total_amount) as revenue,
count() as order_count,
avg(total_amount) as avg_order
FROM orders WHERE toYear(created_at) = ?
GROUP BY month ORDER BY month`, year)
if err != nil {
return nil, fmt.Errorf("query revenue: %w", err)
}
defer rows.Close()
var results []MonthlyRevenue
for rows.Next() {
var m MonthlyRevenue
if err := rows.Scan(&m.Month, &m.Revenue, &m.OrderCount, &m.AvgOrder); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
results = append(results, m)
}
return results, rows.Err()
}
using ClickHouse.Client.ADO;
using Npgsql;
public sealed record Order(string Id, string UserId, decimal TotalAmount, string Status);
// MonthlyRevenue holds an OLAP aggregation result.
public sealed record MonthlyRevenue(int Month, decimal Revenue, long OrderCount, decimal AvgOrder);
// QueryPatternComparison demonstrates OLTP vs OLAP access patterns.
public sealed class QueryPatternComparison(
NpgsqlDataSource oltp, // PostgreSQL
ClickHouseConnection olap) // ClickHouse
{
// OLTP: point lookup by primary key (milliseconds)
public async Task<Order?> GetOrderAsync(string orderId, CancellationToken ct = default)
{
await using var cmd = oltp.CreateCommand(
"SELECT id, user_id, total_amount, status FROM orders WHERE id = @id");
cmd.Parameters.AddWithValue("id", orderId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
{
return null;
}
return new Order(
reader.GetString(0),
reader.GetString(1),
reader.GetDecimal(2),
reader.GetString(3));
}
// OLAP: full scan with aggregation (seconds)
public async Task<IReadOnlyList<MonthlyRevenue>> GetMonthlyRevenueAsync(
int year,
CancellationToken ct = default)
{
await using var cmd = olap.CreateCommand();
cmd.CommandText = """
SELECT toMonth(created_at) AS month,
sum(total_amount) AS revenue,
count() AS order_count,
avg(total_amount) AS avg_order
FROM orders
WHERE toYear(created_at) = {year:UInt16}
GROUP BY month
ORDER BY month
""";
cmd.AddParameter("year", year);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var results = new List<MonthlyRevenue>();
while (await reader.ReadAsync(ct))
{
results.Add(new MonthlyRevenue(
reader.GetInt32(0),
reader.GetDecimal(1),
reader.GetInt64(2),
reader.GetDecimal(3)));
}
return results;
}
}
from dataclasses import dataclass
from decimal import Decimal
from clickhouse_connect.driver import AsyncClient
from psycopg.rows import class_row
from psycopg_pool import AsyncConnectionPool
@dataclass(frozen=True, slots=True)
class Order:
id: str
user_id: str
total_amount: Decimal
status: str
@dataclass(frozen=True, slots=True)
class MonthlyRevenue:
"""OLAP aggregation result."""
month: int
revenue: Decimal
order_count: int
avg_order: Decimal
class QueryPatternComparison:
"""Demonstrates OLTP vs OLAP access patterns."""
def __init__(self, oltp: AsyncConnectionPool, olap: AsyncClient) -> None:
self._oltp = oltp # PostgreSQL
self._olap = olap # ClickHouse
async def get_order(self, order_id: str) -> Order | None:
"""OLTP: point lookup by primary key (milliseconds)."""
async with self._oltp.connection() as conn:
async with conn.cursor(row_factory=class_row(Order)) as cur:
await cur.execute(
"SELECT id, user_id, total_amount, status FROM orders WHERE id = %s",
(order_id,),
)
return await cur.fetchone()
async def get_monthly_revenue(self, year: int) -> list[MonthlyRevenue]:
"""OLAP: full scan with aggregation (seconds)."""
sql = """
SELECT toMonth(created_at) AS month,
sum(total_amount) AS revenue,
count() AS order_count,
avg(total_amount) AS avg_order
FROM orders
WHERE toYear(created_at) = %(year)s
GROUP BY month
ORDER BY month
"""
result = await self._olap.query(sql, parameters={"year": year})
return [MonthlyRevenue(*row) for row in result.result_rows]
## Паттерны использования разных БД
Polyglot Persistence
Использование разных БД для разных задач в одном приложении:
Предостережение: Polyglot persistence усложняет операционную нагрузку. Начинайте с PostgreSQL и добавляйте специализированные БД только когда PostgreSQL не справляется с конкретной задачей.
Итоги
Реляционные БД (PostgreSQL) -- выбор по умолчанию для большинства задач
NoSQL решает конкретные проблемы: масштабирование, гибкая схема, geo-распределение
NewSQL даёт SQL + горизонтальное масштабирование, но сложнее в эксплуатации
Time-series БД необходимы для метрик и мониторинга
Выбирайте БД исходя из паттернов доступа к данным, а не модных трендов