четверг, 11 сентября 2014 г.

Почему компьютеры тормозят и глючат?

Я работаю программистом баз данных, и за годы этой деятельности, естественно, общался с коллегами, видел и изучал их методы. Встречал я и плохих программистов, и хороших. Встречал даже очень хороших, которые на программировании собаку съели. Очень плохих, правда, не видел. Но человеческий фактор нельзя отменить, в любой системе есть недоработки, баги и ляпы.


В последнее время мне довелось вплотную заниматься крупными промышленными базами данных, обслуживающими тысячи пользователей и миллионы клиентов со множеством как онлайн-трансакций, так и аналитических запросов на получение данных. Эти системы работают на Oracle (различных версий). Данная СУБД имеет потрясающую масштабируемость, производительность и надежность (если кто-то скажет, что какой-нибудь postgres или mysql не хуже, будьте уверены: это либо школьник, либо дурак), но против лома нет приема, и положить можно любой механизм, каким бы изощренным он ни был. Неприлично наговаривать на сородичей по профессии, я же не мудак. Хотя и мудаки в нашей сфере, как и везде, встречаются. Поэтому имен, названий и других признаков, по которым можно определить, откуда это взялось, не будет. Речь не пойдет о студентах, ковыряющихся в песочнице. Это серьезные люди, программирующие серьезные распределенные ИТ-системы, зарплата которых не позволяет все списать на "а что же вы от меня хотели?". И это не знаменитые индусы, а наши, русские программисты, которые всем программистам - программисты. Я просто перечислю TOP10 глупых ошибок и ляпов, с которыми мне довелось столкнуться в реальных крупных высоконагруженных базах данных, и из-за которых пользователи иной раз звонят и говорят "ой, чего-то у нас так все тормозит и глючит!" Ответ обычно туп и прост, ибо всегда можно сказать, что у нас, дескать, миллиарды записей в таблицах, админы криво настроили ноду, железо старое, и вообще, не объясните ли, зачем вы запустили сеанс, который завалил наш непотопляемый сервер? Так-то оно так, но ведь мы знаем, в чем может быть дело...

10 место: безусловные и дурацкие условные переходы


Давным давно какой-то дядька сказал (если не ошибаюсь, это был Никлаус Вирт), что тот, кто использует оператор безусловного перехода, идиот. Есть споры на эту тему, действительно идиот или просто раздолбай, однако нередко в PL/SQL коде, да и вообще в коде, можно встретить самый скандальный оператор всех времен и народов:
goto <<my_label>>;
В университете я был свидетелем драмы, когда студент доказывал ортодоксальному преподавателю, что, раз этот оператор существует, значит, он полезен. Что характерно, доказал, и это стало вопиющим прецедентом как для студента, так и для преподавателя, чьи принципы были попраны бессовестной молодежью. Сюда же относится другой, более мирный, но по сути точно такой же оператор безусловного перехода, который завершает работу подпрограммы:
return; 
Извратить программу можно не только безусловными переходами, но и условными. Например:
if x > 0 then
  if x < 10 then
    ...
  end if;
end if;
Все это не замедляет работу СУБД и напрямую не приводит к ошибкам. Но как только этот код начинают переделывать, вероятность появления багов существенно растет, потому что его сложно читать, и еще сложнее такой код понять, а значит корректно поправить. В любой системе, написанной впопыхах или множеством людей, подобных мест полно, и это потенциально грозит неприятностями.

9 место: NVL на NULL


Да, бывает и такое:
nvl(x, null)
Конструкция абсолютно бессмысленна, на работу программы влияния не оказывает, но процессор слегка нагружает. Наверное, была образована редактированием текста при помощи автозамены.

8 место: процедура фиксации трансакции


Как зафиксировать изменения? Очевидно же!
create or replace procedure p_commit
as
begin
  commit;
end;
Эта процедура предназначалась для вызова из приложения, видимо, разработчик не подозревал о том, что трансакциями можно управлять на уровне сессии. Что интересно, аналогичной процедуры отката не нашлось. То ли он не знал о такой возможности, то ли не дошел, а когда дошел, научился управлять трансакциями напрямую, непонятно, история умалчивает.

7 место: попытка осилить протоколирование


Рассмотрим пример процесса, в котором нужно записать в протокол его успешное завершение, и ошибочное тоже нужно записать.
begin
  run_my_process_procedure;
  write_log('OK');
  commit;
exception
  when others then
  begin
    rollback;
    write_log(sqlerrm);
    commit;
  end;
end;
Очевидно, создатель сего не знал про автономные трансакции в Oracle, которые в нем есть уже черт знает сколько  времени. Это уже не просто ляп, а непонимание. Конечно, в ряде случаев и так сойдет, но кто знает, где будет использована еще эта процедура? Быть может она станет частью другого процесса. Тогда вы потеряете возможность управлять трансакциями со всеми вытекающими последствиями вплоть до появления несогласованных данных.

6 место: неправильная дата


В Oracle широко используются функции явного преобразования типов. Несколько раз я видел такую конструкцию:
to_date('01.12.2013')
И она иногда чудом работает. А потом, когда настройки клиента поменялись, перестает, и все вылетает с ошибкой. Это в лучшем случае. В худшем, начинает работать неправильно. Потому что без указания формата даты поведение этой функции непредсказуемо, но об этом почему-то забывают. Или не знают. Детская, но злая ошибка, которая способна забрать много жизненной энергии у того, кто решится ее найти среди завалов тысяч строк кода.

5 место: лишние подзапросы


Возьмем простой запрос, который, по задумке, должен возвратить данные из одной таблицы и приклеить к ним данные из другой по первичному ключу.
select
  t1.*,
  (select f1 from t2 where objid = t1.foreign_objid) t2f1,
  (select f2 from t2 where objid = t1.foreign_objid) t2f2
from
  t1
Этот запрос будет выполняться чуть менее, чем в 2 раза медленее, чем мог бы, если использовать соединение со второй таблицей. Если используется N подзапросов, замедление будет в N раз. Это большой удар по производительности, но и такие конструкции бывают в реальных базах. Можно заметить, что записей в соединяемой таблице может не быть, и тогда запрос вернет не все строки из первой, так что все сделано правильно, но неполное соединение на что дано? Тут уже попахивает не просто незнанием конкретной СУБД, но и непониманием реляционной алгебры. В реальности не всегда такие запросы приводят к заметному снижению быстродействия, потому что операция сканирования по уникальному индексу одна из наиболее быстрых, особенно, если объем индекса невелик. Но чем больше таких мест, тем хуже все работает.

4 место: обходимся без рекурсии


Когда я это увидел, то не сразу понял корень великой мысли.
create or replace view dual4 as
(
select 'dummy' from dual
union all
select 'dummy' from dual
union all
select 'dummy' from dual
union all
select 'dummy' from dual
);
Но это еще не все. Продолжаем:
create or replace view dual16 as
(
select 'dummy' from dual4
union all
select 'dummy' from dual4
union all
select 'dummy' from dual4
union all
select 'dummy' from dual4
);
Зачем это надо? Для построения запросов, в которых нужен рекурсивный анализ некоторой глубины. В данном случае уровня 4 и 16. Трудно представить, что было бы, если бы потребовалась глубина N, но, к счастью, здесь дело ограничилось только 4 и 16. Очевидно, автор творчства отродясь на слышал о рекурсивных запросах в Oracle. А голь на выдумки хитра, вот и выдумал. Хотя, по имеющимся разведданным, вовсе не такая уж и голь, скорее наоборот. Вот как должен выглядеть правильный запрос для случая с произвольным числом строк:
select level from dual connect by level <= N
где N есть любое натуральное число. Задача, конечно, первым способом тоже решена. Но при попытке решить другие задачи тот же автор проявляет иные, не менее потрясающие чудеса изобретательности.

3 место: выбор строки с максмальным (минимальным) значением


Это классическая задача, которая часто решается в SQL-запросах. Стандартный и очевидный подход выглядит примерно так:
select *
from t
where f1 = X and f2 = (select max(f2) from t where f1 = X)
Этот запрос, скорее всего, будет использовать  два прохода по таблице. Возможно два прохода по индексам, если они есть. Но в любом случае будет два отдельных ввода-вывода. Oracle специально для таких случаев имеет развитый механизм оконных функций, которые позволяют обойтись одним проходом, потому что условие фильтрации для обоих запросов одинаково, а значит нет нужды выполнять сканирование дважды. Но об этом, конечно же, нужно знать.

2 место: очередь с параллельным обслуживанием


Еще одна классическая задача в ИТ-системах. Тут все тривиально. Пусть у нас есть N обработчиков (окошек с кассирами в сбербанке) очереди (пенсионеров, жаждущих получить деньги), пронумерованных с 0 по N-1. Тогда к любой выборке с первичным ключом (номер талона) можно обращаться параллельно, ничего не испортив, с использованием функции деления по модулю (в школе это называется остатком от деления).
select *
from t
where mod(objid, total_job_count) = this_job_number
Элегантное, на первый взгляд, решение. Правда будет тормозить на больших очередях с большим количеством обработчиков, потому что всегда будет выполняться полное сканирование всей таблицы. И поделать с этим ничего нельзя. Индекс по функции не построишь, ведь количество обработчиков априори неизвестно. Можно, конечно, его зафиксировать и не менять, но это очень сильное ограничение. Если их слишком много, еще полбеды. Что, делать, если нужно добавить? Перестраивать индекс? На живой очереди? Придется на время прекратить обслуживание. Правда, тормоза не такие большие, и это не сильно важно, хотя скорость обработки снизится. Хуже, если один из обработчиков сломался (кассир заболел). Элементы очереди больше не могут быть им обработаны, и перекинуть их другим обработчикам тоже нельзя, потому что те работают со своим значением остатка от деления. Остается только переинициализировать процесс, изменив текущее количество обработчиков и перевыдав всем пенсионерам талоны. С другой стороны, хоть криво, но такой подход работает, если обработчики хорошо написаны и не ломаются. Причем без блокировок. Ровно до тех пор, пока что-то пойдет не так, то есть до первых выходных или отпуска.

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


Если обязать каждый обработчик  сначала проходить по очереди и маркировать ее элементы признаком, что этот обработчик решил забрать их себе, это позволит решить проблему с произвольным числом обработчиков и их поломкой. Правда, если все же какой-то обработчик поломается, не обслужив все элементы из очереди, то остаток этих элементов будет до скончания века ждать и не дождется. Но это решается маленькой заплаткой сверху - достаточно записывать дату пометки, и отдельным обработчиком (да-да, еще одним специальным процессом, который тоже может поломаться, но будем оптимистами) проходить и сбрасывать пометки со слишком давно ожидающих элементов. Период нужно подобрать заведомо большим, чтобы не ошибиться, но и достаточно малым, чтобы очередь совсем не остановилась на неопределенное время. Но как ставить пометки, чтобы не было конфликта между обработчиками? Вдруг они начнут выборку из очереди одновременно? И тут нам поможет адская, страшная как ядерный взрыв для любой многопользовательской базы данных команда:
lock table t in exclusive mode;
Если честно, я даже не знал о существовании такой в Oracle. Это больше похоже на инструмент администратора, чем разработчика. На время выполнения этой команды никто не сможет менять в таблице данные. Всю мощь реляционного движка Oracle, обеспечивающего версионность, блокировку на уровне строк, позволяющего одновременно и параллельно работать тысячам сессий, можно послать к чертям одной лишь строчкой. Старина Том Кайт недоумевает и плачет. Был изобретен велосипед с квадратными колесами, который не мог как следует ездить, а все из-за того, что изобретатель ничего не знал об управлении блокировками. В особенности того факта, что блокировки в Oracle не являются ценным ресурсом, и их можно ставить хоть на всю таблицу индивидуально на каждую запись. Надо ли говорить, что эта процедура обработки никогда нормально не работала? Без сомнения, это победа. Законное первое место в спецолимпиаде по программированию.

Можно ли обогнать победителя? Можно попробовать, написав одну крохотную функцию. Например:
create or replace function is_busy
return boolean
as
begin
  return is_busy;
end;
Ее запуск гарантированно положит сервер. Слабый сдастся через 5 минут, хороший продержится полчаса. У него закончится память и закипит процессор. Это лишь вопрос времени. Не каждый админ способен выявить причину, поэтому можно тихонько хихикать, пока все думают, что же случилось. Конечно, обойти победителя таким маневром не получится, потому что данная функция вообще не работает и ничего не делает, но можно создать ее и вызывать по мере необходимости, в моменты, когда система работает чересчур стабильно, а дату возникновения последних проблем никто не может вспомнить.

Комментариев нет:

Отправить комментарий