Способы проектирования баз данных в Android

Работая Android разработчиком мне пришлось столкнуться с двумя различными подходами к проектированию баз данных в мобильных приложениях. Возможно кому-то излагаемое здесь покажется очевидным, а возможно кому-то даст новую идею или убережет от ошибок. В общем, без длинных предисловий переходим к делу…

Два взгляда на проблему


Как известно, в университетах учат строить базы данных по всем правилам: декомпозировать предметную область на сущности, выделить атрибуты и определить первичные ключи, определить отношения между сущностями, привести все это, как минимум, к 3-ей нормальной форме и т.д. Один из “побочных” эффектов такого подхода — падение производительности на операциях чтения, при достаточно сильной декомпозиции и нормализации, так как в запросах необходимо выполнять большее количество джойнов. И чем больше у вас записей в таблицах, тем дольше они выполняются.

Добавим сюда сильно ограниченные аппаратные возможности мобильных платформ, в частности крохотный объем оперативной памяти. Ее и без того мало, так в дополнение к этому, Android ограничивает количество доступной RAM на процесс в зависимости от версии ОС от 16 до 48 МБ. И даже из этих нескольких мегабайт СУБД получает лишь часть, ведь есть еще и само приложение. Ну и в заключение, сам SQLite, в виду своих особенностей поддерживает только два уровня изолированности транзакций. Они либо сериализуются, либо вообще отключены!

В ситуации когда производительность приложения начинает упираться в производительность СУБД на помощь и может прийти альтернативный подход, назовем его key-value ориентированным. Вместо декомпозиции сущности на атрибуты и создания отдельных полей в таблице на каждый атрибут, сущность сохраняется “как есть” в одно единственное поле типа BLOB, иначе говоря сериализуется.

Рассмотрим пример для полной ясности. Пусть наша модель данных в Java-коде выглядит следующим образом:
class Group {
    private Long _id;
    private String number;
    private List<Student> students;

    // getters and setters
    ...
}

class Student {
    private Long _id;
    private String name;
    private String surname;
    private Group group;

    // getters and setters
    ...
}

Таким образом, в “стандартном” варианте мы получим две таблицы с соответствующими наборами атрибутов.
create table Group(
  _id primary key integer autoincrement,
  number text);

create table Student(
  _id primary key integer autoincrement,
  name text,
  surname text,
  group_id integer foreign key);

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

При применении же key-value подхода таблицы будут выглядеть так
create table Group(
  _id primary key integer autoincrement,
  value blob);

create table Student(
  _id primary key integer autoincrement,
  value blob,
  group_id integer foreign key);

при этом группы и студенты сериализуются отдельно по разным таблицам. Либо вообще вот так:
create table Group(
  _id primary key integer autoincrement,
  value blob);

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

Сравнение подходов, плюсы и минусы


Возможности реляционной алгебры

При использовании стандартного подхода мы получаем все преимущества к которым так привыкли при использовании реляционного подхода, а именно язык SQL для удобной выборки, фильтровки и сортировки данных, а также модификации схемы БД. Для того чтобы получить коллекцию сущностей нам необходимо лишь сформировать требуемое условие и забрать наши данные из БД. В key-value же подходе, задача по фильтрованию или упорядочиванию данных лежит на плечах разработчика.

Объем файла БД

При использовании стандартного подхода файл БД как правило имеет меньший объем. Это обуславливается отсутствием избыточности при хранении данных, в следствии нормализации. В теории чем выше степень нормализации, тем меньше избыточность, однако, возрастает нагрузка на БД при чтении этих данных. Значительные ресурсы тратятся на джойны таблиц. При использовании key-value подхода степень избыточности данных выше, так как, как правило, уровень нормализации значительно меньше, что приводит к увеличению размера файла БД.

Гибкость при изменении схемы БД

Обычно с развитием проекта схема БД преобразуется не однократно, добавляются новые поля, удаляются ранее используемые, сущности могут дробиться на несколько новых или наоборот проводиться их денормализация и объединение нескольких таблиц в одну. В случае, если при обновлении схемы мы можем пожертвовать накопленными в БД данными, то все просто: мы создаем новый файл БД каждый раз когда обновляем схему, а старый удаляем. Но что делать, если данные должны быть сохранены и преобразованы к новому формату?
В таком варианте стандартный подход имеет преимущества. Достаточно написать соответствующие апдейт-скрипты, которые преобразуют схему БД к необходимому виду и обновят новые поля значениями по умолчанию или высчитают их с применением той или иной логики. При использовании же сериализации, обновление схемы БД уже не такая простая задача. Необходимо преобразовать схему с сохранением всех данных, а так же обновить сами данные, десиреализовав их, инициализировав новые поля и сериализовав обратно. Возрастает как логическая сложность операции, так и временные затраты необходимые на обновление.

Синхронизация доступа к экземплярам сущностей

Один из основных недостатков key-value подхода, как мне кажется, то что для того чтобы изменить всего одно поле в сущности нам необходимо десериализовать весь объект целиком. Это значительно усложняет доступ к объектам. Например, в случае когда группа сериализуется в базу вместе со всеми студентами, то для того, чтобы изменить фамилию одного из студентов, нам необходимо вынуть из БД всю группу, поменять одну фамилию и сохранить обратно. В случае, если в приложении несколько потоков, сервисов и/или контент провайдеров, которые могут работать с одними и теми же сущностями, то задача многократно усложняется. Чем больше потенциальных “писателей”, тем больше блокировок будет возникать и тем сложнее нам будет обеспечивать синхронизацию доступа к объектам. В случае же стандартного подхода эта задача решается на уровне СУБД.

Производительность

С одной стороны, key-value подход позволяет добиться более высокой производительности при выборке небольших объемов данных. Количество джойнов сокращается, конкретный запрос да и СУБД в целом работает быстрее. С другой, при больших объемах данных, если нам необходимо выполнять фильтрацию или сортировку этих данных по полю которое сериализуется вместе со всем объектом, то для выполнения этой операции нам сначала будет необходимо причитать все сущности, а только потом уже отфильтровать все лишнее, что может привести не к выигрышу производительности, а к ее еще большему ухудшению. В качестве альтернативы можно хранить поля участвующие в запросе фильтровки или сортировки стандартным подходом, а всю остальную сущность в виде BLOBa, но такую кашу потом будет сложно поддерживать.

Объем кода

В стандартном подходе возрастает количество SQL кода, различные скрипты создания и модификации схемы БД, запросы и условия, DAO-объекты и т.д. В key-value, количество подобного кода сокращается, зато возрастает количество кода выполняющего различные сортировки, группировки и фильтрацию по условиям ведь все это приходится выполнять “вручную”, когда при стандартном подходе это делает СУБД, а нам необходимо только написать требуемый запрос.

Сериализация

Минус key-value подхода может состоять в падении производительности связанном с использованием стандартной Java сериализации / десериализации, которая как известно не отличается высокой скоростью. Здесь в качестве альтернативы можно использовать одну из библиотек решающих эту проблему, например protobuf от Google. Помимо скорости дополнительным плюсом, в случае исползьования protobuf’a, будет версионность, т.к. данный протокол поддерживает версионирование объектов.

Заключение


Вышло немного сумбурно, но в целом, что хотелось сказать: оба подхода хороши, необходимо выбирать по ситуации, рассматривая все перечисленные плюсы и минусы. Как правило, если проблем с производительностью нет, то лучше использовать стандартный подход, обладающий большей гибкостью. Если эти проблемы начинают возникать, попробуйте использовать денормализацию. Возможно, если критических участков в программе всего несколько, то это может все решить. При возникновении же постоянных проблем с производительностью, когда денормализация уже не спасает, стоит присмотреться к key-value подходу.
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 25

    –4
    Из крайности в крайность. В андройде, тем не менее, есть общепринятый способ изоляции работы с БД через контент-провайдеры. Если использовать их и дружить с жизненным циклом андройда, то сами объекты сущностей часто оказываются не нужны — хватает курсора из базы и какого-нибудь интового уникального айдишника для адресации нужных данных
      +4
      Наличие контент провайдера никак не влияет на то, что находится за ним — на схему ваших данны, то как организованы таблицы и связи между ними. Опять же дело вкуса, что использовать value object'ы или курсоры. Лично я предпочитаю в различных ListActivity'ях использовать курсоры, потому что они предоставляют все необходимое прямо из коробки. А в «обычных» экранах, которые отображают одну-две отдельные сущности value-object'ы.
        +4
        В андройде, тем не менее


        image
        –1
        Один из “побочных” эффектов такого подхода — падение производительности на операциях чтения, при достаточно сильной декомпозиции и нормализации, так как в запросах необходимо выполнять большее количество джойнов. И чем больше у вас записей в таблицах, тем дольше они выполняются.

        большое количество джойнов ведёт не у ухудшению а, наоборот, к увеличению производительности. С диска-то меньше данных читается.
          0
          вы серьезно??
            0
            это большой сюрприз для тех кто считает SQL слишком сложным.
              0
              А то, что для join надо полностью прочесть как минимум поля, участвующие в нем это не считается? А то, глядишь, и вся таблица будет полностью считаться, особенности реализации sqllite лично я не знаю.
              И процессорная обработка при этом накладывает свои ограничения — тоже мелочи.

              Не говоря уже о том, что одним из принципов хорошо спроектированной базы данных считается наличие 0й записи, куда указывают при отсутствии ссылки.
              Соответственно join ни как не может выступать условием, ограничивающим выборку и всегда идет полное объединение, запись к записи.
                0
                судя по написанному, вы даже примерно не представляете как работают современные движки баз данных (и SQLite встроенный в андроид в том числе).

                Нет, для джойна поля не читаются либо читаются частично.

                Про принципы проектирования с 0й записью — есть старые правила (вики) которые до сих пор никто улучшить не смог. Ни про какие 0е записи там ничего нет.
                  –2
                  Да, как работают современные движки БД я имею мало представления.

                  С нормальными формами я знаком, но помимо нормальных форм есть еще, как бы их назвать, «лучшие практики» или «признаки хорошего вкуса», на Хабре статья проскакивала как-то (сейчас я ее что-то не найду).
                  Там одним из признаков было как раз наличие такой записи.

                  А если вернуться к тому, «что быстрее» — то в контексте автора было сказано, что 3я нормальная форма ограничивает производительность за счет необходимости большого числа джойнов.

                  Скажем, нам надо найти, кто живет на такой-то улице.
                  Быстрее будет сделать 4 джойна: Человек + Документ + Адрес + Улица.
                  Или если у нас сразу у человека есть ссылка на улицу: Человек + Улица.

                  Тут не идет речи, что джойн играет роль условия выборки. В этом случае да, скорость будет выше.
                  Тут идет речь, что нам не нужны промежуточные джойны за счет того, что мы отказались от строгого следования 3й нормальной форме.
                  И за счет уменьшения этих джойнов мы и выигрываем.
                    0
                    Да, как работают современные движки БД я имею мало представления.


                    после этого следует прекратить обсуждение. Представь что в таком же ключе будут обсуждать, скажем, стоматологию: «я сам не доктор но считаю что диаритовый бур позволяет сверлить зубы качественнее чем стальной».

                    Нет, вышеприведённые утверждения неверны. Движок базы данных использует изощрённую оптимизацию в результате которой данные могут вообще не читаться с диска.

                    Именно поэтому большое количество объединений таблиц ведён к экспоненциальному повышению производительности в большинстве случаев. А не наоборот, как это кажется непосвящённым.
                      0
                      Странная вера в движки.
                      Да, там и буферы, и хранение индексов отдельно от данных, и кеширование всего, что только можно. Но как минимум 1 раз считать все это придется. А учитывая ограничения платформы Android, то и сильно агрессивно все кешировать он не будет.
                      Да и чтение того же буфера — тоже чтение, пусть и быстрое. А это и обработка на ЦП и нагрузка на шины и т.д.

                      И пока нет ни одной причины, почему 4 джойна отработают быстрее 1, при условии одинаковый итоговых выборок (в общем случае, но такого поведения добиться можно, тут сложно спорить).
                        0
                        То, что join может ускорить операцию — я на спорю, он играет роль ограничивающего условия и может сильно нам снизить само количество считываемых данных.
                        Чем больше джойнов — тем сильнее данный эффект можно наблюдать.

                        Но если итоговая выборка одна и та же (скажем, считывается вся таблица, каждый объект джойнится с чем-то), то тут они только ограничат производительность.
                          0
                          Даже могу пример привести:
                          Пример, MS SQL
                          -- Один джойн
                          DECLARE @Begin datetime;
                          DECLARE @Count int;
                          DECLARE @End datetime; 
                          
                          set @Begin = (select GETDATE());
                          set @Count = (select COUNT(1) from tablVisit
                          join tablMKAB on MKABID = tablVisit.rf_MKABID
                          where MKABID!=0 and tablVisit.rf_TAPID != 0)
                          set @End = (select GETDATE());
                          
                          -- Два джойна
                          DECLARE @Begin2 datetime;
                          DECLARE @Count2 int;
                          DECLARE @End2 datetime; 
                          set @Begin2 = (select GETDATE());
                          set @Count2 = (select COUNT(1) from tablVisit
                          join tablTAP on TAPID = tablVisit.rf_TAPID
                          join tablMKAB on MKABID = tablTAP.rf_MKABID
                          where MKABID!=0 and tablVisit.rf_TAPID != 0)
                          set @End2 = (select GETDATE());
                          
                          -- Результаты
                          select '1 join' as [Джойнов], @Count as [Результат], DATEPART(Ms, @End - @Begin) as [Время, ms]
                          union all
                          select '2 join' as [Джойнов], @Count2 as [Результат], DATEPART(Ms, @End2 - @Begin2) as [Время, ms]
                          


                          Результат
                          Джойнов Результат   Время, ms
                          ------- ----------- -----------
                          1 join  126446      60
                          2 join  126445      93
                          
                          (2 row(s) affected)

                          И это после нескольких запусков, т.е. чтение из буфера.
                            –1
                            если ты запустишь этот скрипт десять раз подряд то результат будет другой — после первой выборки остальный будут на порядок быстрее, вплоть до нуля. Просто потому что будут браться из памяти а не с диска.

                            В запросах стоит MKABID!=0 хотя в любом учебнике пишут что неравенство это неоптимизируемая операция и должна всегда исключаться.

                            На мой взгляд пример написан безграмотно и ничего кроме плохого владения предметом не демонстрирует. Нет смысла продолжать.
              +2
              Есть ли пример приложения, в котором key-value подход повысил производительность?

              Бины на Android лучше делать максимально простыми:
              class Bean {
                  public long id;
                  public int number;
                  public String title;
                  // no getters & setters
              }
              
              • UFO just landed and posted this here
                +1
                Автор, может я что-то упустил, но как в вашем случае выбрать данные по некому условию?
                  –1
                  Он вроде пишет — считать в память всю группу и пробежаться через for. Оч удобно.
                    –1
                    помогите развидеть мне ваш коммент обратно. что, если в группе 100500 элементов? а что, если мы хотим упорядочить их? или группировку сделать?
                      +1
                      Я о том же — это не оч удобно, везде нужно будет писать кастомный метод. И медленно, если джава и если 100500 элементов.
                  +2
                  Ну а если у нас хранятся сущности, размер которых при сериализации неизменен, то можно вообще использовать просто файл и обращаться к нашим данным через смещение. Ключи в данном случае(если их значения не могут быть сгенерированы по какому либо однозначному алгоритму) можно хранить в отдельной сущности в Map, в котором будет соответствие нашего ключа — смещению в файле. Более того, их можно «склеить» в один файл, учтя размеры при сериализации/десериализации.
                    +1
                    По прочтении статьи у меня возник один вопрос — а зачем тогда нужна база данных вообще? Пишите в файл, в двоичном виде, всё подряд, и никаких проблем!
                      0
                      БД дает поиск по ключу.
                      Конечно, этого можно и своими силами добиться, но БД все-таки удобнее )
                        0
                        Точно, спасибо. Лучше не писать то, что уже кем-то написано
                      0
                      Не понял, причем тут Android. Есть задача, есть тип хранения который лучше всего подходит для ее решения. В вашей статье я вообще не увидел акцента на мобильной платформе.

                      Only users with full accounts can post comments. Log in, please.