вторник, 29 января 2019 г.

Основы SQL на примере задачи

Основы SQL на примере задачи

  • В этом руководстве мы рассмотрим основные sql команды на примере небольшой задачи. При прочтении желательно сидеть за консолью mysql и вводить все запросы для большей наглядности.

Постановка задачи


Дана база данных, в ней 3 таблицы следующего вида:




table1: user_id (INT(5), PRIMARY KEY), username (VARCHAR(50), INDEX)
table2: phone_id (INT(5), PRIMARY KEY), user_id (INT(5), INDEX), phone_number (INT(10), INDEX)
table3: room_id (INT(5), PRIMARY KEY), phone_id (INT(5), INDEX), room_number(INT(4) INDEX)

Необходимо выбрать номер комнаты в которой сидит пользователь с ником qux…

Подготовка данных для задачи


Для создания баз данных и таблиц используются операторы CREATE DATABASE и CREATE TABLE, соответственно (для удаления DROP DATABASE и DROP TABLE). В конце каждой команды ставится точка с запятой (;). Сначала создадим базу с именем article:

CREATE DATABASE IF NOT EXISTS article;

Мы используем ключевые слова IF NOT EXISTS для того, чтобы не возникала ошибка, если указанная база данных или таблица уже существует (в дальнейшем IF NOT EXISTS для простоты будем опускать).
Теперь необходимо создать таблицы:

CREATE TABLE `table1` (`user_id` INT(5) NOT NULL AUTO_INCREMENT, `username` VARCHAR(50), PRIMARY KEY(`user_id`), INDEX(`username`));
CREATE TABLE `table2` (`phone_id` INT(5) NOT NULL AUTO_INCREMENT, `user_id` INT(5) NOT NULL, phone_number INT(10) NOT NULL, PRIMARY KEY (`phone_id`), INDEX(`user_id`, `phone_number`));
CREATE TABLE `table3` (`room_id` INT(5) NOT NULL AUTO_INCREMENT, `phone_id` INT(5) NOT NULL, `room_number` INT(4) NOT NULL, PRIMARY KEY(`room_id`), INDEX(`phone_id`, `room_number`));

Разберём эти команды по порядку. После CREATE TABLE указывается имя таблицы, далее в скобках следуют имена полей с типами и атрибутами, перечисленные через запятую и указания ключей. Первой командой мы создаём таблицу с именем table1 и полями user_id, username. Поле user_id имеет целочисленный тип (INT) и длину 5-ть знаков, не может равняться нулю и обладает атрибутом auto_increment (при создании каждой записи, значение в этом поле создаётся автоматически и увеличивается на единицу), к тому же оно является первичным ключём. [ Первичный ключ (primary key) представляет собой один из примеров уникальных индексов и применяется для уникальной идентификации записей таблицы. Никакие из двух записей таблицы не могут иметь одинаковых значений первичного ключа. ] Поле username имеет символьный тип (длина 255 символов) и является индексом. Вторая и третья команды аналогичны первой.

Для проверки какие таблицы есть у Вас в базе можно использовать команду:

SHOW TABLES; 

Теперь необходимо добавить данные в таблицы. Для добавления записей используется оператор INSERT.

INSERT INTO table1 (username) VALUE ('foo'); 

В поле user_id мы ничего не добавляем так как оно автоматически создаётся при каждом INSERT`е (вспоминаем про магический атрибут auto_increment). После названия таблицы в скобках (далее будем называть эти скобки кортежём) указывается список полей, которым мы будем присваивать значения. После VALUE указываются сами значения. Они должны стоять на соответствующих позициях в кортеже.
Такими же командами добавляем пользователей bar, baz, qux.
Для проверки используем команду:

[1]
SELECT * FROM table1; 

Саму команду SELECT мы рассмотрим подробнее позже.
Далее заполним таблицы table2 и table3.

[2]
INSERT INTO table2 (user_id, phone_number) VALUE ('2','200'); 

Здесь полю user_id присваивается значение 2, а полю phone_number — 200. Если поменять местами названия полей или значения в кортежах, то результат измениться. Например:

[3]
INSERT INTO table2 (user_id, phone_number) VALUE ('200','2'); 

Теперь полю user_id присваивается значение 200, а phone_number – 2.
Предположим, мы ошиблись при добавлении значений (использовали команду [3] вместо [2]), не надо рваться удалять таблицу или всю базу — значение можно изменить с помощью оператора UPDATE.

UPDATE table2 SET user_id='2', phone_number='200' WHERE phone_id='1';

После SET мы указываем поля, значения которых необходимо изменить, и соответственно новые значения через знак равно. Оператор WHERE мы видим впервые. Он необходимо для наложения ограничений на запрос. В данном случае изменения будут применяться не ко всем строкам таблицы, а только к тем у которых значение поля phone_id равно '1'.
Остальные данные добавляются по аналогии (что добавлять можно посмотреть вверху страницы).

Решение


Базу данных и таблицы мы создали. Теперь можно заняться решением самой задачи. Выборка в базе данных производится с помощью оператора SELECT, с которым мы немного знакомы по команде [1]. Рассмотрим его подробнее. В общем виде он выглядит так:
SELECT названия_полей FROM названия_таблиц WHERE условие [ORDER BY, LIMIT]
Где ORDER BY и LIMIT дополнительные опции.
Попробуем применить его. Выберем все значения поля username из таблицы table1.

SELECT username FROM table1;

и отсортируем их

SELECT username FROM table1 ORDER BY username;

Как видно, ORDER BY используется для сортировки по одному из полей, указанных после оператора SELECT. По умолчанию делается возрастающая сортировка, если хотим использовать сортировку в обратном порядке то после поля необходимо добавить DESC:

SELECT username FROM table1 ORDER BY username DESC;

Так как нам нужны все значения, то оператор WHERE можно не использовать. Ещё один пример: выбираем значения полей phone_id и user_id из таблицы table2, где phone_number равен '200'.

SELECT phone_id, user_id FROM table2 WHERE phone_number=200;
SELECT phone_id, user_id FROM table2 WHERE phone_number=200 LIMIT 1, 3;

LIMIT выводит строки в указанном диапазоне (нижняя граница не включается). Если первый аргумент не указан, то он считается равным 0.

Как мы можем видить, все три наши таблицы связаны. table1 и table2 через поле user_id, а table2 и table3 через phone_id. Для того, чтобы связать их в одно целое по указанным столбцам, необходимо воспользоваться оператором JOIN. JOIN, в переводе на великий и могучий, означает «объединять», то есть собирать из нескольких кусочков единое целое. В базе данных MySQL такими «кусочками» служат поля таблиц, которые можно объединять при выборке. Объединения позволяют извлекать данные из нескольких таблиц за один запрос. В зависимости от требований к результату, MySQL позволяет производить три разных типа объединения:
1. INNER JOIN (CROSS JOIN) — внутреннее (перекрёстное) объединение
2. LEFT JOIN — левостороннее внешнее объединение
3. RIGHT JOIN — правостороннее внешнее объединение

INNER JOIN позволяет извлекать строки, которые обязательно присутсвуют во всех объединяемых таблицах.
Попробуем написать запрос:

[4]
SELECT table3.room_number FROM table1 INNER JOIN table2 USING(user_id) INNER JOIN table3 USING(phone_id) WHERE table1.username = 'qux'; 

С помощью оператора USING мы указываем поле по которому будут связаны таблицы. Его использование возможно только если поля имеют одинаковое название. В противном случае необходимо использовать ON, так как показано ниже:

SELECT table3.room_number FROM table1 INNER JOIN table2 ON table1.user_id = table2.user_id INNER JOIN table3 ON table2.phone_id = table3.phone_id WHERE table1.username = 'qux';

LEFT/RIGHT JOIN позволяют извлекать данные из таблицы, дополняя их по возможности данными из другой таблицы. Чтобы показать разницу с INNER JOIN нам сначала необходимо будет добавить ещё одно поле в таблицу table1.

INSERT INTO table1 (username) VALUE ('quuz');

А теперь используем команду [4], только заменим INNER JOIN на LEFT JOIN, а qux на quuz:

SELECT table3.room_number FROM table1 LEFT JOIN table2 USING(user_id) LEFT JOIN table3 USING(phone_id) WHERE table1.username = 'quuz';

Мы получим следующий результат:


Новый пользователь получил user_id=5. Это значение отсутствует в других таблицах, поэтому в результате мы получили NULL. При INNER JOIN результат был бы пустой, так как выводятся только значения, которые есть во всех таблицах. Здесь же таблицы table1 и table2 дополняются значением из table3, даже если его и нет.

Аппендикс


Ниже приводятся примеры команд с небольшими пояснениями:

Удалить строку с user_id равным 1 из таблицы table1:
DELETE FROM table1 WHERE user_id = 1; 

Переименовываем таблицу table1 в nya:
RENAME TABLE table1 TO nya; 

Переименовать поле user_id в id (таблица table1):
ALTER TABLE table1 CHANGE user_id id INT; 

Меняем тип и атрибут поля phone_number:
ALTER TABLE table2 MODIFY phone_number VARCHAR(100) NOT NULL; 

Просмотр описания таблицы table1:
DESCRIBE table1; 

Добавляем поле abra типа DATE:
ALTER TABLE table3 ADD abra  DATE; 

Выбираем из table3 все значения поля room_id, для которых room_number начинается с цифры 3 (% означает любое количество любых символов; like проверяет совпадение символьной строки с заданным шаблоном):
SELECT room_id FROM table3 WHERE room_number LIKE '3%';


P.S.


1. Часть материала про join`ы взята из статьи MySQL немного о JOIN'ах.
2. Задача встретилась на одном из собеседований, которые я проходил. Она достаточно синтетическая, но хорошо подходит для описания материала.
3. Описания конструкций операторов намеренно упрощены для более лёгкого восприятия новичками. Для всех остальных есть Справочное руководство по MySQL

понедельник, 28 января 2019 г.

Абстрактный доступ к БД с помощью ADODB

Абстрактный доступ к БД с помощью ADODB

Автор: Maxim Matyukhin
Подробное описание абстрактного класса баз данных ADODB. Установка, примеры использования, особенности, ADODB & PEAR.
Установка, примеры использования, особенности, ADODB & PEAR.
1.   Пару слов об ADODB
2.   Установка
3.   Простые примеры
4.   Практическое использование
4.1   Постраничный вывод и ограничение SELECT-запросов
4.2   Генерирование INSERT/UPDATE запросов
4.3   Работа с транзакциями
4.4   Последовательности
4.5   Кеширование запросов
4.6   Статистика запросов
5.   ADODB & PEAR
6.   Заключение
1. Пару слов об ADODB
Для начала, скажу что статья рассчитана на программистов, имеющих опыт работы с СУБД, а не на начинающих пхпешников. Я предполагаю, что вы знакомы с PHP, ОПП, SQL и имеете опыт разработки web-приложений.
ADODB — это абстрактный класс доступа к базам данных, написанный на PHP.
Поясню на примере:
Предположим вы написали скрипт под mysql. И тут заказчик говорит Вам, что хостинг меняется и там есть только PostgreSQL. Если вы не использовали класс абстрактного доступа к базам данных, то вам пришлось бы:
  • заменить весь код работы с mysql на postgresql
  • переписать SQL-запросы (так как есть отличия)
Если бы вы использовали абстрактный слой доступа к БД, то вам скорее всего не пришлось бы менять php-код (только в одном месте указали бы что используете postgresql) и изменить SQL-запросы (хотя иногда и это не понадобилось бы).
Я намеренно в этом описании использовал фразу "абстрактный класс доступа к БД",
поскольку ADODB — не единственный подобный класс. Наиболее известные конкуренты:
  • Pear :: DB
  • Pear :: MDB
Насколько я знаю, другие классы имеют слабую функциональность, хотя должен признать, работают быстрее. Многие пишут такие классы сами, но я не сторонник изобретения велосипедов.
Противники таких массивных классов, как ADODB или Pear::DB утверждают, что их использование плохо сказывается на производительнеости. Да, производительность падает и это вполне логично. НО:
  • Скорость не часто является самым важным фактором
    (Мало кто из вас пишет сайты с очень большой нагрузкой, на которых бы это снижение производительности стало критичным)
  • Использование таких классов повышает производительность программиста
  • При использовании софта, типа phpAccelerator падение производительности будет не таким заметным (и я не поверю, что популярные сайты не имеют возможности использовать такой софт)
  • Разработчики ADODB написали php-extension, который ускоряет работу класса (но работать можно и без него)
От себя могу добавить, что многие из написанных мною сайтов используют ADODB и проблем с производительностью не имеют.
2. Установка
Здесь все просто. Скачайте с http://php.weblogs.com/adodb архив и распакуйте его (например в папку ./adodb).
Все, класс готов к использованию.
Можете еще скачать и php-extension, но я его использовать не пробовал. Чтобы использовать класс, вам необходимо включить (include) файл ./adodb/adodb.inc.php
3. Простые примеры
Пример 1
<?php // подключаем класс include_once("adodb/adodb.inc.php");
// указываем тип БД $conn = &ADONewConnection('mysql'); // соединяемся с БД $conn->Connect('localhost''root''password''scripts'); // режим отладки&nbsp;&#151; включен (true) $conn->debug true$conn->setFetchMode(ADODB_FETCH_ASSOC); ?>&lt;/php> 
Данный пример демонстрирует подключение к БД. В строке
<?php $conn = &ADONewConnection('mysql'); ?> 
Создается объект соединения с базой данных. Именно через поля и методы данного объекта и будет в дальнейшем вестись работа с базой данных.
Что касается режима отладки, то по умолчанию он выключен. При включенном режиме отладки на экран броузера будут выводиться SQL-запросы и тексты ошибок (если такие были). Очень упрощает процесс написания и отладки скриптов.
Метод $conn->setFetchMode() — указывает, каким образом данные о записях будут записаны в массив — будет ли это ассоциативный массив, или простой нумерованный или и тот и другой. Ей нужно установить одно из значений (0, 1, 2, 3). Для пояснений приведу код из исходников adodb:
<?php   define('ADODB_FETCH_DEFAULT',0);
   
define('ADODB_FETCH_NUM',1);
   
define('ADODB_FETCH_ASSOC',2);
   
define('ADODB_FETCH_BOTH',3); ?>
Судя по исходникам ADODB_FETCH_DEFAULT == ADODB_FETCH_BOTH
Теперь сделаем запрос к БД:
<?php // делаем запрос к БД $res $conn->Execute("SELECT id, title, description FROM tab"); // если по запросу найдены записи в таблице if ($res && $res->RecordCount() > 0) {
   
// выводим эти записи в цикле
   
while (!$res->EOF) {
      echo 
"ID = ".$res->fields['id']."\n";
      echo 
"title = ".$res->fields['title']."\n";
      echo 
"description".$res->fields['description'];
      
// переходим к следующей записи
      
$res->MoveNext();
   }
?>
Вот простейший пример запроса к БД. Метод $conn->Execute() выполняет запрос к базе данных и возвращает множество записей (recordset). Множество записей (recordset) — в ADODB является отдельным объектом, который имеет свои поля и методы для работы с полученными записями. Некоторые из них использованы в данном примере.
  • $res->EOF — равен true если обработаны все записи множества
  • $res->fields — хранит ассоциативный массив значений текущей записи
  • $res->RecordCount() — возвращает количество строк, полученных входе выполнения запроса
  • $res->MoveNext() — переходит к следующей записи (в массив $res->fields будет занесена следующая запись множества)
Метод $conn->Execute() — может быть использован для любых запросов:
<?php // делаем вставку строки $conn->Execute("INSERT INTO tab(name, value) VALUES ('name', 'ha ha ha')"); // получаем идентификатор вставки
// аналог mysql_insert_id(); 
$id $conn->Insert_ID();
$conn->Execute("DELETE FROM tab WHERE id = ".$id); ?>
Опишу еще некоторые полезные методы класса AdoConnection:
  • $conn->getRow($sql) — возвратит массив со значениями первой записи из всего множестве найденных записей.
  • $conn->getAll($sql) — возвратит 2-мерный массив со всеми найденными записями
4. Практическое использование
Думаю этот раздел будет наиболее интересен программистам.
4.1 Постраничный вывод и ограничение SELECT-запросов
Вообще-то не все базы данных умеют делать запросы типа: SELECT * FROM tab LIMIT 0, 10 а все те, которые умеют, делают это по разному:
MySQL:
ВЫБРАТЬ * ИЗ ВСТАВКИ LIMIT 0, 10 
PostgreSQL:
ВЫБРАТЬ * ОТ вкладки OFFSET 0, LIMIT 10 
FireBird:
ВЫБЕРИТЕ ПЕРВЫЙ 10 ПРОПУСТИТЬ 0 * ОТ вкладки
Класс adodb сам может делать ограниченные выборки, составляя правильные SQL-запросы под указанную БД, поддерживающую лимитированные SELECT-запросы
<? $res $conn->SelectLimit("SELECT * FROM tab"100); ?>
Метод $conn->SelectLimit() сам построит правильный SQL-запрос. На основе этого метода в ADODB работают функции для постраничной выборки:
<?php // определяем текущую страницу $start max(1intval($_GET['start'])); // количество записей на странице $rows_per_page 10$res $conn->PageExecute("SELECT * FROM tab"$rows_per_page$start);
// получаем найденное количество записей $records_amount $res->MaxRecordCount(); ?>
Метод $conn->PageExecute() кроме простого LIMIT-запроса делает автоматически еще и запрос типа: SELECT COUNT(*) FROM tab
Таким образом он сам узнает, сколько всего по данному запросу найдено строк. Это количество можно узнать с помощью метода: $res->MaxRecordCount();
Также для управления постраничным выводом есть следующие методы:
  • $res->AbsolutePage() — возвращает текущую страницу
  • $res->AtFirstPage() — возвращает true если текущая страница — первая
  • $res->AtLastPage() — возвращает true если текущая страница — последняя
  • $res->LastPageNo() — возвращает номер последней страницы
4.2 Генерирование INSERT/UPDATE запросов
Для начала пример:
<?php // пример генерировани INSERT-запроса

// массив, который нужно вставить в таблицу 
$frm = array("field1"=>"value1""field2"=>"value2"); // делаем пустой запрос $res $conn->Execute("SELECT * FROM tab WHERE id = -1"); // формируем SQL-запрос $sql $conn->GetInsertSQL($res$frm); // выполняем запрос $conn->Execute($sql)
// пример генерирования UPDATE-запроса

// получаем данные о строке, которую нужно обновить 
$res $conn->Execute("SELECT * FROM tab WHERE id = 17"); $sql $conn->GetUpdateSQL($res$frm); // выполняем запрос $conn->Execute($sql?>
Так вот идея в том, чтобы все данные, которые нужно вставить записать в ассоциативный массив. Сделать запрос к БД чтобы получить имена полей таблицы и сконструировать SQL-запрос по этим данным.
Уверен, что будет много противников этого метода (мол лишний SQL-запрос к БД делвть), но мне эти функции кажутся очень удобными.
4.3 Работа с транзакциями
Ну это вообще сказка :). Вот пример из мануала:
<?php
   $conn
->StartTrans();
   
$conn->Execute("update table1 set val=$val1 where id=$id");
   
$conn->Execute("update table2 set val=$val2 where id=$id");
   
$conn->CompleteTrans(); ?>
Метод $conn->CompleteTrans(); сам проверит, были ли ошибки и если так  — сделает откат.
ADODB имеет еще и другие функции для работы с транзакциями, но они устарели и разработчики ADODB рекомендуют использовать этот вариант.
4.4 Последовательности
Часто при работе с таблицами каждой записи нужно присвоить уникальный идентификатор, который потом используется в качестве первичного ключа. Но не все СУБД поддерживают такую возможность. ADODB эмулирует эту возможность почти для всех СУБД. На практике это выглядит примерно так:
<?php
   $uid 
$conn->GenID('site_users');
   
$conn->Execute("INSERT INTO site_users(uid, login, password) VALUES
        ("
.$uid.", '$login', '$password')"); ?>
Метод $conn->GenID() создает последовательность site_users (если она до этого не была создана) и возвращает значение на единицу больше чем текущее значение последовательности.
4.5 Кеширование запросов
ADODB поддерживает серверное кеширование запросов. Суть в том, что при первом выполнении запроса его результаты заносятся в кеш-файл. При последующем таком же запросе (если кеш-файл не устарел) данные будут браться из файла.
Честно говоря, мне не нравится метод, которым они производят кеширование (по-моему они слишком уж универсальным сделали его) и предпочитаю делать кеширование своими руками.
Если вас все-таки интересует кеширование, то работает оно так:
<?php
   $ADODB_CACHE_DIR 
'/tmp/ADODB_cache';
   
$rs $conn->CacheExecute('SELECT * FROM tab'); ?>
По умолчанию время жизни кеш-файлов — 1 час. Это время можно изменить 2-мя путями:
<?
   $conn
->cacheSecs 24*3600 // 24 часа
   
$rs $conn->CacheExecute('SELECT * FROM tab');

   
// или так:
   // время жизни кеша может задаваться первым параметром
   // метода CacheExecute
   
$rs $conn->CacheExecute(24*3500'SELECT * FROM tab'); ?>
4.6 Статистика запросов.
Наверное видели на некоторых сайтах выводится статистика:
Страница сгенерирована за 0.0016 секунд. Запросов к базе данных — 12
Как вычисляется время генерирования страницы — к данной статье не относится, а вот посчитать количество запросов к БД (а также посчитать количество запросов, взятых из кеша) ADODB позволяет:
<?php // пример взят из мануала function CountExecs($conn$sql$inputarray) {
   global 
$EXECS;
   
$EXECS++;
}

function 
CountCachedExecs($conn$secs2cache$sql$inputarray) {
   global 
$CACHED;
   
$CACHED++;
}
$conn NewADOConnection('mysql'); $conn->Connect(...); $conn->fnExecute 'CountExecs'$conn->fnCacheExecute 'CountCachedExecs';

... 
// выводим статистику echo "Всего запросов к базе данных: <b>".$EXECS+$CACHED."</b><br />";
echo 
"Из них взято из кеша : <b>".$CACHED."</b>"?>
Данные функции вызываются до запроса, поэтому вы можете с их помощью переписать SQL-запрос.
5. ADODB & PEAR
Я являюсь фанатом как adodb так и репозитария ГРУША.
К сожалению основным классом работы с базами данных в PEAR является PEAR::DB
И многие PEAR-классы используют его. Что же делать любителям adodb?
Во-первых, если хорошо присмотреться, то классов, использующих PEAR::DB не так уж и много. У меня почти весь pear-репозитарий на компьютере и там pear::DB используют лишь
  • DB :: NestedSet
  • DB :: DataObject
  • DB :: Пейджер
  • DB :: QueryTool
  • HTML :: Выбрать
  • XML :: SQL2XML
  • Auth
  • кэш
  • Журнал
  • LiveUser
  • Mail :: Tail
  • Перевод
Во-вторых, многие классы использую "контейнеры", и для этих классов можно написать контейнер, использующий ADODB (как писать контейнеры — смотрите на примере контейнеров pear::DB указанных классов).
В-третьих, ADODB имеет файл adodb-pear.inc.php который является эмуляцией класса PEAR::DB и остальные классы можно подогнать под работу с adodb с минимальными телодвижениями (часто достаточно в тексте класса строку     require_once('DB.php'); заменить на     require_once('adodb-pear.inc.php'); но так бывает не всегда).
Так что ADODB можно успешно применять с pear-классами. Приведу пример использования adodb c классом pear::XML::sql2xml. Для тех кто не в курсе — этот класс трансформирует результат запроса (SELECT) к БД в XML-строку:
<?php require_once("adodb/adodb-pear.inc.php");
require_once(
"XML/sql2xml.php");
$db DB::connect("mysql://root@localhost/lot"); $sql2xml = new xml_sql2xml();                        $result $db->getAll("select * from lot_sessions");         $xmlstring $sql2xml->getXML($result); ?>
Те кто уже имеют опыт работы с XML_sql2xml наверное чаще применяют код, который предлагает автор класса XML_sql2xml:
<?php require_once("XML/sql2xml.php"); $sql2xml = new xml_sql2xml("mysql://root@localhost/lot"); $sql2xml->Add("select * from lot_sessions");                         $xmlstring $sql2xml->getXML(); ?>
и
<?php
$db 
DB::connect("mysql://root@localhost/lot"); $sql2xml = new xml_sql2xml();                        $result $db->query("select * from lot_sessions"); $xmlstring $sql2xml->getXML($result); ?>
Оба эти примера не сработают и нужно будет править класс XML_sql2xml.
6. Заключение
Поскольку статья носит ознакомительный характер, многое осталось "за кадром". Я не пытался описать все классы, поля и методы — для этого есть официальная документация. Также я не описывал функциональные возможности, которые не использовал на практике:
  • хранение сессий в БД (в том числе и зашифрованных сессий)
  • работа с хранимыми процедурами
  • работа с БД, находящейся на удаленном сервере
  • словари (позволяют программно создавать базы данных и таблицы)
Ссылки по теме

воскресенье, 27 января 2019 г.

Ошибки БД. Параллельное выполнение скриптов

Ошибки БД. Параллельное выполнение скриптов

Автор:Дмитрий Бородин 
Здесь рассматривается вопрос, что бывает, если запустить некий скрипт почти одновременно (что происходит, например, при большой нагруженности сервера) несколько раз, т.е. запустить несколько копий одного и того же скрипта. При некотором описанном стечении обстоятельств это приводит к нарушению целостности базы данных (короче говоря - можно существенно подпортить ваш блестящий алгоритм и программу).

См. также:
  • Ошибки в файлах. Параллельное выполнение скриптов

Предствьте, нам надо решить некую задачу, которая свелась к следующему алгоритму:
  • есть таблица mytest с полями a и b (это 2 переменные)
  • в таблице только одна строка, изначально в поле a записано число нуль, в поле b некоторое число, нам не известное
  • при возникновении команды от пользователя (человек нажал кнопку SUBMIT или при любом другом событии) надо проверить, равно ли поле a нулю, и если да, то записать в a единицу и увеличить поле b на единицу
Обратите внимание:
  • число b надо "УВЕЛИЧИТЬ НА", а не записать туда что-то
  • нельзя брать заранее значение b, т.к. данный простейший алгоритм считает это лишней нагрузкой (на счет этого пункта в конце)
  • если a не равно нулю, не надо ничего делать
  • другими словами, надо сделать программу, работающую в точном соответствии с описанием
Для тех, кто не понял, что же это за простейший алгоритм, объясняем его другими словами: 1) взять $a и $b из базы данных 2) если $a равно нулю, то записать в базу данных в переменную $a число 1 и увеличить $b на единицу.

Какие же проблемы могут возникнуть?

При запуске 2-х или более параллельно работающих скриптов легко обнаружить, что число в поле b (или $b - в упрощенном примере), к сожалению, может неоднократно увеличиваться на единицу. Представьте: запустился скрипт 1 и проверил, что $a содержит нуль. В этот момент запустился скрипт 2 и тоже узнал, что $a (это уже будет отдельная переменная в отдельном процессе номер 2) тоже содержит нуль. После этого оба скрипта решают, что надо установить $aв единицу и увеличить $b на единицу. Таким образом, можно так запустить параллельно работающие скрипты, чтобы произошла ошибка - увеличение переменной $b более одного раза. А это противоречит нашему алгоритму, который требует, увеличивать $b только один раз.

Когда эта проблема может возникнуть?

Да когда угодно. Здесь мы опишем ситуацию, когда человек нажимает в форме много раз кнопку SUBMIT (отправить форму). Скрипт с помощью нехитрого алгоритма должен этому противостоять. Т.е. если программа видит установленную переменную $a, то программа должна игнорировать действия пользователя и ничего не делать. А если некий флаг еще не установлен (переменная $a пока равна нулю), то программа что-то делает. В нашем случае - прибавляет к $b единицу.

Проверка данного факта

Верите ли вы, что все описанное действительно суровая правда, а не теория? Если в примере со счетчиком очень легко было убедиться в его проблемах, то тут это может показаться не очевиным. Поэтому проверим, что наша теория верна и напишем программу, отвечающую алгоритму.
<? PHP

/*
  Перед началом программы создайте таблицу mytest:

  CREATE TABLE mytest (
     tinyint (4) ПО УМОЛЧАНИЮ '0' NOT NULL,
     b tinyint (4) ПО УМОЛЧАНИЮ '0' НЕ ПУСТО
  )

  и поместите туда одну строку с двумя нулями:

  INSERT INTO mytest VALUES ('0', '0');

*/
  
  // следующие 4 параметра (хост, имя пользователя, пароль, база данных)
  // должны соответствовать вашим данным
  mysql_connect ("127.0.0.1", "<имя пользователя>", "<пароль>") или die ("не могу открыть");
  mysql_select_db ("<база данных>") или die ("не могу выбрать");

  // переменная $с - текущая команда
  // всего 3 конанды: 1) "" (ничего) - вывести значение переменных
  //                  2) "clear" - обнулить поля (чтобы в ручную не обнулять)
  //                  3) "submit" - сама операция

  switch ($ c) {

  дело "": 
     // ничего хитрого, просто выводим перменные на экран

     $ res = mysql_query ("SELECT * FROM mytest") или die ("ошибка 1");
     $ А = mysql_result ($ разреш, 0, "а");
     $ Ь = mysql_result ($ разреш, 0, "б");
     echo "A = $ a, B = $ b & nbsp; <a href=$PHP_SELF?c=clear> сбросить в 0 </a>
        <form action = $ PHP_SELF> 
        <input type = hidden name = c value = 'submit'>
        <input type = submit>
        </ Форма> ";

  перерыв;

  дело "ясно": 
     // сброс в нуль, если человек использует ссылку "СБРОСИТЬ В 0"

     $ res = mysql_query («ОБНОВИТЬ mytest SET a = 0, b = 0») или die («ошибка 2»);
     header («Местоположение: $ PHP_SELF»);

  перерыв;

  дело "подать": 
     // оновная программа, демонстрирующая проблему параллельно работающих 
     // скриптов

     // первая часть алгоритма - взять переменные $a и $b из базы данных
     $ res = mysql_query («SELECT * FROM mytest») или die («ошибка 3»);
     $ А = mysql_result ($ разреш, 0, "а");

     // вторая часть алгоритма, если $a равно нулю....
     if ($ a == 0) {
        // то обновить данные в таблице: в $a записать 1, к $b прибавить 1
        сна (5);
        $ res = mysql_query («ОБНОВИТЬ mytest SET a = 1, b = b + 1») или die («ошибка 2»);
        выход (заголовок («Местоположение: $ PHP_SELF»));
     }
     еще 
        // если $a уже не равно нулю, то вывести сообщение, что 
        // человек пытался нажать 2 раза на кнопку SUBMIT и, соотвественно,
        // 2 раза выполнился скрипт
        echo "Не обновлено, т.к. A не равно 0 (A=$a).";

  перерыв;

  }

?>

Запустите программу и нажмите на кнопку SUBMIT быстро пару раз. Через некоторое время, когда скрип закончит работу, на главной странице вы увидите, что в $a записана единица, а в $b число, большее единицы.

Для чего мы написали команду sleep(5), которая останавливает выполнения скрипта на 5 секунд? Специально, чтобы указать на узкое место в нашем алгоритме. Сервер - это не идеальное устройство. ПХП-процессов не идельный интерпретатор файлов: мы знаем, что скрипты обрабатываются не последовательно, а часто параллельно. Поэтому время между выполнением двух критичных команд может быть большим. И в это время могут отнять обработки других скриптов, в том числе и того ужасного, что поступает от пользователя, случайно нажавшего SUBMIT два раза... Или хакера, который теоретически может получить выгоду от того, что некоторые действия могут быть выполнены большое число раз, хотя алгоритм (в поставленной задаче и так, как хотел программист) требовал всего один раз.

Откуда взялась переменная $b и нельзя ли от нее избавиться?

Можно! Но не всегда. (А как работает ваша программа?) Если ваш алгоритм можно немного изменить, т.е. не УВЕЛИЧИВАТЬ $b НА ЕДИНИЦУ, а записывать в $b число, равное переменной $b и единицы - это решение проблемы. Но представьте, что у вас не такая простейшая задача. Представьте, что вам нужно рельно сделать все тоже самое, только делать не изменение переменной $b, а чего-нибуль более значительного (если человек обманет систему провеки на двойное нажатие):
  • разослать почту по куче адресов
  • в какой-то финансовой среде использовать бонус (в виде переменной $a) и прибавить к счету в банке ($b) определенную сумму (константа)
  • вывести на принтер фразу "Привет, мир!"

Итог демонстрирования проблемы

У вас возник вопрос, с чего взялась эта кнопка SUBMIT и форма? Посмотрите на исходную постановку алгоритма. Там сказано только о некотором СОБЫТИИ. Кнопа SUBMIT как нельзя более точно описывает, что такое собитие. Все знают и долгое время работают и с кнопками, и с формами, и сталкивались с проблемами повторных нажатий. Некоторые даже вставляли защиту от повторого нажатия. Но даже такая защита не верна, что мы подробно разобрали в примерах и программе.

Решение проблемы

Отвлекитесь от кнопок и форм. Если вы программист (а не человек, желающий побыстрее накатать скрипт и заработать деньги), вы долны подумать о всех тонких моментах работы скрипта. В часности, что будет при параллельном выполнении вашего скрипта. Теперь о главном. Мы описали простой алгоритм и решение у него тоже простое. Если вы храните ваши данные в базе данных и не хотите привлекать сюда посторонние предметы (создание файлов - флагов, использование расшаренной памяти, сессий или др) то поможет метод блокирования таблицы MySQL перед моментом чтения данных и до окончания записи в нее. Если вы читали пример со счетчиком, то для решения используется та же самая идея блокирования места, откуда поступают переменные.

Чтобы заблокировать таблицу от чтения и записи дополним программу командой LOCK TABLES имяТаблицы WRITE и после использования таблицы снимаем блокировку UNLOCK TABLES. Кусок модифицированной части программы:
  дело "подать": 

     if ($ lock) mysql_query ("LOCK TABLES mytest WRITE") или die ("ошибка 4");
     $ res = mysql_query («SELECT * FROM mytest») или die («ошибка 3»);
     $ А = mysql_result ($ разреш, 0, "а");
     if ($ a == 0) {
        сна (5);
        $ res = mysql_query («ОБНОВИТЬ mytest SET a = 1, b = b + 1») или die («ошибка 2»);
        mysql_query ("UNLOCK TABLES") или die ("error 5");
        выход (заголовок («Местоположение: $ PHP_SELF»));
     }
После этого запускаем программу и пробуем нажать несколько раз (быстро) на кнопку SUBMIT. Через некоторое время программа известит, что не смогла ничего обновить (т.к. $a уже не нуль). Вернее, программа сделает обновление bтолько 1 раз. А все остальные копии скрипта, которые будут приостановлены из-за блокировки таблицы на 5 секунд, ничего не испортят.

И на последок...

Разумеется, если вы постаратесь не использовать такого алгоритма, это будет решение, при котором не придется блокировать таблицы, приводящие к снижению скорости работы сервера. Еще, вы можете отказаться от использования базы данных и хранить важные флаги в сессиях.