Към съдържанието
wannacode

Пул от връзки, резервни копия и права

Накратко: Базата на работещ сайт има нужда от пул, който преизползва отворените връзки, от резервни копия, от които наистина се възстановява, и от потребител само с нужните права.

Проблем

Петък вечер

Магазинът пуска промоция и за пет минути идват 400 души. Сайтът спира и в лога пише:

FATAL: sorry, too many clients already

Програмата отваря нова връзка с базата за всяка страница и я затваря накрая. Отварянето не е безплатно: път по мрежата, вход с парола и в PostgreSQL нов процес в сървъра. А сървърът приема ограничен брой връзки (max_connections, по подразбиране 100). Четиристотин страници наведнъж са четиристотин връзки.

Идеята

Пул от връзки

Пулът държи няколко отворени връзки. Страницата взима свободна, ползва я и я връща, а следващата страница взима същата. Отварянето се плаща веднъж, а базата никога не вижда повече връзки, отколкото е размерът на пула.

Ето малък пул от три връзки, написан на ръка, за да се види какво прави. borrow дава свободна връзка, а ако няма, отваря нова, но не повече от три. giveBack я връща:

static final int MAX = 3;
static final Deque<Connection> free = new ArrayDeque<>();
static int opened = 0;
static Connection borrow() throws SQLException {
if (!free.isEmpty()) return free.pop();
if (opened == MAX) throw new SQLException("пулът е празен");
opened++;
Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:");
setup(conn);
return conn;
}
static void giveBack(Connection conn) {
free.push(conn);
}
static String productName(int id) throws SQLException {
Connection conn = borrow();
try (PreparedStatement ps = conn.prepareStatement("SELECT name FROM products WHERE id = ?")) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return "няма такъв продукт"; // връзката не се връща
String name = rs.getString("name");
giveBack(conn);
return name;
}
}
}
for (int id : new int[] { 1, 99, 5, 98, 97, 7 }) {
try {
System.out.println("продукт " + id + ": " + productName(id));
} catch (SQLException e) {
System.out.println("продукт " + id + ": грешка: " + e.getMessage());
}
}
System.out.println("отворени връзки: " + opened + ", свободни: " + free.size());

Виж го

Изтичане на връзка

Функцията за името на продукта има два изхода. Когато продуктът го има, връща връзката. Когато го няма, излиза с return по-рано и връзката остава взета:

static String productName(int id) throws SQLException {
Connection conn = borrow();
try (PreparedStatement ps = conn.prepareStatement("SELECT name FROM products WHERE id = ?")) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return "няма такъв продукт"; // връзката не се връща
String name = rs.getString("name");
giveBack(conn);
return name;
}
}
}
Изход
продукт 1: Бял хляб
продукт 99: няма такъв продукт
продукт 5: Прясно мляко
продукт 98: няма такъв продукт
продукт 97: няма такъв продукт
продукт 7: грешка: пулът е празен
отворени връзки: 3, свободни: 0

Три търсения на продукт, който го няма, и пулът е празен. Шестата заявка е напълно нормална, но не получава връзка. Истинският пул не дава грешка веднага: чака свободна връзка (в HikariCP до 30 секунди), и страницата виси. Затова изтичането се вижда като „сайтът е бавен“, а после „сайтът не отговаря“.

Идеята

Връщането е във finally

Поправката е връзката да се връща, по какъвто и път да излезе функцията:

static String productName(int id) throws SQLException {
Connection conn = borrow();
try (PreparedStatement ps = conn.prepareStatement("SELECT name FROM products WHERE id = ?")) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("name") : "няма такъв продукт";
}
} finally {
giveBack(conn);
}
}
Изход
продукт 1: Бял хляб
продукт 99: няма такъв продукт
продукт 5: Прясно мляко
продукт 98: няма такъв продукт
продукт 97: няма такъв продукт
продукт 7: Сирене
отворени връзки: 1, свободни: 1

Шест заявки с една връзка: всяка я връща и следващата я взима. С истинските библиотеки не пишеш пул сам. Взимаш връзка от пула и я затваряш както в първия урок на модула, а затварянето я връща в пула, вместо да я затвори наистина:

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/shop");
config.setUsername("shop_app");
config.setPassword(System.getenv("SHOP_DB_PASSWORD"));
config.setMaximumPoolSize(10);
HikariDataSource pool = new HikariDataSource(config); // веднъж, при старта
try (Connection conn = pool.getConnection()) { // close() я връща в пула
// заявките
}
По-точно, за любопитните

Тези примери не се пускат в проверката на сайта, защото искат PostgreSQL и пакетите на пуловете. Размерите по подразбиране са: HikariCP 10, Npgsql 100, SQLAlchemy 5 (и още 10 при нужда), pg 10.

По-голям пул не значи по-бърз сайт. Базата има толкова ядра, колкото има, и 200 заявки наведнъж просто чакат една друга. Ръководството на HikariCP препоръчва за начало около два пъти броя на ядрата на сървъра на базата. Ако сайтът е на пет сървъра с по 20 връзки, базата вижда 100. Когато връзките станат твърде много, пред PostgreSQL се слага отделен пул, например PgBouncer.

Идеята

Резервно копие

Базата ще се повреди някой ден: диск, грешна заявка, миграция с бъг. Копието е единственият път назад. В SQLite VACUUM INTO записва цялата база в нов файл, докато тя работи, а ATTACH отваря файла като втора база в същата връзка:

SQLбаза: магазин

Ето защо копието си заслужава. Скрипт изтрива отзивите за кафето по грешка, а копието отпреди минута ги връща:

SQLбаза: магазин

Пуснеш ли полето втори път, VACUUM INTO спира, защото файлът вече го има. Така е и в живота: всяко копие е нов файл с датата и часа в името.

По-точно, за любопитните

В PostgreSQL копие на една база прави pg_dump, а pg_basebackup с архив на WAL позволява възстановяване към точен момент („към 14:14, преди скрипта“). В MySQL е mysqldump. Честото правило е 3-2-1: три копия, на два вида носители, едно от тях на друго място.

Пробвай

Сверка след възстановяване

Копие, от което никога не си възстановявал, е само надежда. След всяко възстановяване (поне на пробна база) се проверява дали базата е цяла:

SQLбаза: магазин

и колко реда има във всяка таблица, за да се сравни с това преди копието.

Покажи за всяка от шестте таблици на магазина името ѝ (колона table_name) и броя на редовете в нея (колона row_count).

SQLбаза: магазин

Упражнение

Върни изтритото, без да губиш новото

Снощното копие е възстановено в таблицата reviews_2026_10_24. Днес сутринта скрипт е изтрил отзиви от reviews, а после клиент е написал нов отзив. Върни изтритите отзиви от копието, без да изгубиш новия.

SQLбаза: магазин

Идеята

Толкова права, колкото трябват

SQLite няма потребители: който може да отвори файла, може всичко. Сървърните бази имат, и сайтът влиза със свой потребител, който може само нужното:

CREATE ROLE shop_app LOGIN PASSWORD '…';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO shop_app;

Без DROP, без ALTER и без достъп до другите бази на сървъра. Ако някой пробие сайта, може толкова, колкото shop_app. Миграциите се пускат с друг потребител, собственик на таблиците, а паролата и на двамата стои извън кода, както в първия урок на модула.

Тест

Провери се

1. Сайтът отваря нова връзка с PostgreSQL за всяка страница. Какво се случва при голям наплив?
2. Пулът е с 10 връзки. Една функция излиза с return, преди да върне връзката. Какво става?
3. Кога знаеш, че резервното копие става?
4. Какви права трябват на потребителя, с който сайтът влиза в базата?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·