В моём зоопарке проектов приложения работают с SQLite3, PostgreSQL, MySql. На MySql крутится всего один проект, да и то только лишь потому, что довольно давно его делал и менять СУБД не вижу смысла. Остальные используют либо SQLite3, либо PostgreSQL.
Больше всего я симпатизирую SQLite, потому что работать с ней просто, из питона доступна в любой момент, вся база лежит одним файлом на диске. И вот стало мне интересно, насколько правильно подобраны у меня СУБД к разным проектам, и насколько правильно я их использую. Ну и насколько тяжко будет сменить одну СУБД на другую, ежели понадобится.
Итак, что у меня есть, что я буду проверять и чем я буду пользоваться:
- проекты, использующие SQLite3
- проекты, использующие PostgreSQL
- большие SQL-запросы, которые сами генерируют полезные отчёты
- код на скриптовых языках программирования, которые используют короткие SQL-запросы
- код на python с использованием ORM (sqlalchemy)
Вот всё это хозяйство я и захотел протестировать. Ещё я хотел посмотреть, насколько отличается скорость работы при использовании индексов и без них (как на вставке, так и на чтении).
Структура базы данных
Для начала нужна тестовая база данных. Пусть у нас будет база данных для хранения статей. У статьи должен быть автор, уровень сложности материала и комментарии. В итоге имеем таблицы tags (это у меня уровень сложности), authors, articles, comments. Базу я описывал при помощи sqlalchemy. Модуль dbstruct.py содержит описание таблиц, параметр withindex позволяет мне создавать базу данных как с индексами, так и без них.
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.schema import Index
from sqlalchemy.ext.declarative import declarative_base
def createdb(engine, withindex):
connection = engine.connect()
queries = (
'drop table if exists comments',
'drop table if exists articles',
'drop table if exists authors',
'drop table if exists tags'
)
map(connection.execute, queries)
Base = declarative_base(engine)
class Tag(Base):
__tablename__ = 'tags'
id = Column(Integer, primary_key=True)
tagname = Column(String)
if withindex:
__table_args__ = (Index('idx_tag_id', id),)
def __init__(self, tagname):
self.tagname = tagname
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True)
nickname = Column(String)
fullname = Column(String)
if withindex:
__table_args__ = (Index('idx_author_id', id),)
def __init__(self, nickname, fullname):
self.nickname = nickname
self.fullname = fullname
class Article(Base):
__tablename__ = 'articles'
id = Column(Integer, primary_key=True)
title = Column(String)
body = Column(Text)
author_id = Column(Integer, ForeignKey('authors.id'), nullable=False)
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
if withindex:
__table_args__ = (Index('idx_article_id', id, author_id, tag_id),)
def __init__(self, title, body, author_id, tag_id):
self.title = title
self.body = body
self.tag_id = tag_id
self.author_id = author_id
class Comment(Base):
__tablename__ = 'comments'
id = Column(Integer, primary_key=True)
body = Column(Text)
article_id = Column(Integer, ForeignKey('articles.id'), nullable=False)
if withindex:
__table_args__ = (Index('idx_comment_id', id, article_id),)
def __init__(self, body, article_id):
self.body = body
self.article_id = article_id
Base.metadata.create_all(engine)
return Tag, Author, Article, Comment
Работа с данными
Теперь нужно подготовить функции, которые будут делать вставку и выборку данных.
Все функции будут лежать в модуле dbtestfunc.py
.
Вставка данных в базу
Вставку я делаю с помощью sqlalchemy. Уровней сложности статей у меня 3 - junior, middle, guru. Сложность для статей просто чередуется в цикле. Имена авторов генерируются в цикле. Текст статьи сделал не очень длинным, но и не в 10 символов, примерно, как короткая заметка в блоге.
def fillDB(engine, Tag, Author, Article, Comment, authorsCount, articlesCount, commentsCount):
session = sessionmaker(engine)()
tags = ('junior', 'middle', 'guru')
map(session.add, [Tag(tagname) for tagname in tags])
session.commit()
for authorNum in xrange(authorsCount):
author = Author('au_%d' % authorNum, 'author %d fullname' % authorNum)
session.add(author)
session.commit()
for articleNum in xrange(articlesCount):
title = 'article %d from auhtor %d' % (articleNum, authorNum)
body = 'some text of article ' * 200
tagId = (articleNum % 3) + 1
article = Article(title, body, author.id, tagId)
session.add(article)
session.commit()
for commentNum in xrange(commentsCount):
body = 'some text of comment ' * 20
comment = Comment(body, article.id)
session.add(comment)
session.commit()
Статистика по всем таблицам
Нужно получить статистику по количеству данных в каждой таблице.
def getCountByTable(engine):
query = '''
select 'tags' as table_name, count(id) as population from tags
union all
select 'authors', count(id) from authors
union all
select 'articles', count(id) from articles
union all
select 'comments', count(id) from comments
'''
connection = engine.connect()
result = connection.execute(query)
tline = '+%s+%s+' % ('-' * 15, '-' * 20)
print tline
for row in result:
print '|%-15s|%20d|' % (row[0], row[1])
print tline
Большой sql-запрос с 2 уровнями вложенности подзапросов
Нужно получить список всех авторов, с количеством статей, разбитых по уровню сложности, с количеством комментариев к его статьям, с учётом того, статьи какой сложности лучше комментируются. На этой тестовой базе запрос выглядит слегка натянутым, но на одном реальном проекте у меня есть подобный запрос, который выдаёт статистику по оплате за разные объекты, которые разбиты по категориям.
def useBigSelectQuery(engine):
query = '''
select nickname,
(select count(*) from articles where author_id = authors.id and tag_id = 1),
(select count(*) from articles where author_id = authors.id and tag_id = 2),
(select count(*) from articles where author_id = authors.id and tag_id = 3),
(select count(*) from comments where article_id in (
select id from articles where author_id = authors.id and tag_id = 1)),
(select count(*) from comments where article_id in (
select id from articles where author_id = authors.id and tag_id = 2)),
(select count(*) from comments where article_id in (
select id from articles where author_id = authors.id and tag_id = 3))
from authors
'''
connection = engine.connect()
result = connection.execute(query)
format = '|' + '|'.join(['%10s' for x in range(7)]) + '|'
separator = '+' + '+'.join(['%s' % '-' * 10 for x in range(7)]) + '+'
print '''
SQL query select test
+----------+----------+----------+----------+----------+----------+----------+
| |junior |middle |guru |junior |middle |guru |
|nickname |article |article |article |comment |comment |comment |
| |count |count |count |count |count |count |
+----------+----------+----------+----------+----------+----------+----------+'''
for row in result:
print format % tuple(map(str, row))
print separator
Запрос с меньшим количеством вложенностей подзапросов
Забегая вперёд, скажу, что предыдущий запрос просто насмерть вешал SQLite.
Скорость работы падала до нуля, поэтому я переписал его, используя JOIN
.
Ну а раз он есть, то и померил скорость его работы не только на SQLite, но и на PostgreSQL.
def useJoinBigQuery(engine):
query = '''
select nickname,
(select count(id) from articles where author_id = authors.id and tag_id = 1),
(select count(id) from articles where author_id = authors.id and tag_id = 2),
(select count(id) from articles where author_id = authors.id and tag_id = 3),
(select count(comments.id)
from comments
join articles on article_id = articles.id
where author_id = authors.id and tag_id = 1),
(select count(comments.id)
from comments
join articles on article_id = articles.id
where author_id = authors.id and tag_id = 2),
(select count(comments.id)
from comments
join articles on article_id = articles.id
where author_id = authors.id and tag_id = 3)
from authors
'''
connection = engine.connect()
result = connection.execute(query)
format = '|' + '|'.join(['%10s' for x in range(7)]) + '|'
separator = '+' + '+'.join(['%s' % '-' * 10 for x in range(7)]) + '+'
print '''
SQL query select with join test
+----------+----------+----------+----------+----------+----------+----------+
| |junior |middle |guru |junior |middle |guru |
|nickname |article |article |article |comment |comment |comment |
| |count |count |count |count |count |count |
+----------+----------+----------+----------+----------+----------+----------+'''
for row in result:
print format % tuple(map(str, row))
print separator
Используем ORM для получения данных
Теперь для получения тех же данных буду использовать ORM (sqlalchemy). Тут я, по сути, написал код, подобный первому большому запросу (без join).
def useORM(engine, Tag, Author, Article, Comment):
from sqlalchemy.orm import sessionmaker
session = sessionmaker(engine)()
format = '|' + '|'.join(['%10s' for x in range(7)]) + '|'
separator = '+' + '+'.join(['%s' % '-' * 10 for x in range(7)]) + '+'
print '''
ORM select test
+----------+----------+----------+----------+----------+----------+----------+
| |junior |middle |guru |junior |middle |guru |
|nickname |article |article |article |comment |comment |comment |
| |count |count |count |count |count |count |
+----------+----------+----------+----------+----------+----------+----------+'''
for author in session.query(Author):
data = [author.nickname,
session.query(Article).filter(
Article.author_id==author.id).filter(Article.tag_id==1).count(),
session.query(Article).filter(
Article.author_id==author.id).filter(Article.tag_id==2).count(),
session.query(Article).filter(
Article.author_id==author.id).filter(Article.tag_id==3).count(),
session.query(Comment).filter(
Comment.article_id.in_(
session.query(Article.id).filter(
Article.author_id==author.id).filter(
Article.tag_id==1))).count(),
session.query(Comment).filter(
Comment.article_id.in_(
session.query(Article.id).filter(
Article.author_id==author.id).filter(
Article.tag_id==2))).count(),
session.query(Comment).filter(
Comment.article_id.in_(
session.query(Article.id).filter(
Article.author_id==author.id).filter(
Article.tag_id==3))).count()
]
print format % tuple(map(str, data))
print separator
Использование нескольких маленьких запросов
Ну и функция, которая нужна для проверки эффективности использования нескольких небольших запросов, которые вызывают в цикле.
def useSimpleQueries(engine):
format = '|' + '|'.join(['%10s' for x in range(7)]) + '|'
separator = '+' + '+'.join(['%s' % '-' * 10 for x in range(7)]) + '+'
print '''
Some simple SQL select test
+----------+----------+----------+----------+----------+----------+----------+
| |junior |middle |guru |junior |middle |guru |
|nickname |article |article |article |comment |comment |comment |
| |count |count |count |count |count |count |
+----------+----------+----------+----------+----------+----------+----------+'''
connection = engine.connect()
selAuthors = 'select id, nickname from authors'
rAuthors = connection.execute(selAuthors)
for author in rAuthors:
result = [author.nickname]
for tagId in range(1, 4):
selArtCount = 'select count(*) from articles where author_id = %d and tag_id = %d' %\
(author.id, tagId)
result.append(map(None, connection.execute(selArtCount))[0][0])
for tagId in range(1, 4):
selComCount = '''select count(*) from comments where article_id in (
select id from articles where author_id = %d and tag_id = %d)''' %\
(author.id, tagId)
result.append(map(None, connection.execute(selComCount))[0][0])
print format % tuple(map(str, result))
print separator
Чтобы все эти функции заработали вместе, в начале модуля нужен код:
from sqlalchemy.orm import sessionmaker
Тестирование
Чтобы проверить время работы каждой функции из модуля dbtestfuncs.py, я написал модуль dbloadtest.py. Он запускается из командной строки и получает в качестве параметров тип СУБД: postgresql, sqlite, memory (sqlite в памяти), флаг использования индексов в базе и количество авторов (не обязательный параметр). На выходе табличка с результатами теста.
import sys
import dbstruct
from dbtestfuncs import *
from sqlalchemy import create_engine
def usage():
man = '''Options:
dbms type:
-p - postgresql
-s - slqite3. work with file
-m - sqlite3. work with memory
-i - using index in tables
-n - using no index in tables
-a=authors_count - count of authors
'''
return man
if __name__ == '__main__':
authorsCount = 10
articlesCount = 10
commentsCount = 20
try:
options = sys.argv[1:]
if '-p' in options:
dbparams = 'postgresql://user:secret@localhost:5432/_dbloadtest'
elif '-s' in options:
import os
try:
os.remove('_dbloadtest.sqlite')
except: pass
dbparams = 'sqlite:///_dbloadtest.sqlite'
elif '-m' in options:
dbparams = 'sqlite:///:memory:'
else:
raise IndexError
if '-i' in options:
withindex = True
elif '-n' in options:
withindex = False
else:
raise IndexError
authc = [x for x in options if x.startswith('-a=')]
if authc:
authorsCount = int(authc[0].split('=')[1])
print 'count of authors = ', authorsCount
except IndexError:
print usage()
sys.exit(1)
print 'options: %s' % str(options)
engine = create_engine(dbparams, echo=False)
Tag, Author, Article, Comment = dbstruct.createdb(engine, withindex)
from timeit import Timer
setup = 'from __main__ import fillDB, engine, Tag, Author, Article, Comment, ' +\
'authorsCount, articlesCount, commentsCount'
call = 'fillDB(engine, Tag, Author, Article, Comment, authorsCount, articlesCount, commentsCount)'
fillTime = Timer(call, setup).timeit(1)
setup = 'from __main__ import getCountByTable, engine'
countByTableTime = Timer('getCountByTable(engine)', setup).timeit(1)
setup = 'from __main__ import useBigSelectQuery, engine'
bigSelectQTime = Timer('useBigSelectQuery(engine)', setup).timeit(1)
setup = 'from __main__ import useJoinBigQuery, engine'
bigJoinQTime = Timer('useJoinBigQuery(engine)', setup).timeit(1)
setup = 'from __main__ import useORM, engine, Tag, Author, Article, Comment'
ormUseTime = Timer('useORM(engine, Tag, Author, Article, Comment)', setup).timeit(1)
setup = 'from __main__ import useSimpleQueries, engine'
simpleQTime = Timer('useSimpleQueries(engine)', setup).timeit(1)
times = (fillTime, countByTableTime, bigSelectQTime, bigJoinQTime,
ormUseTime, simpleQTime)
heads = ('fill', 'count by tables', 'big select', 'big select join',
'orm use', 'simple queries')
line = '+%s+%s+' % ('-' * 20, '-' * 20)
print
print line
for i in range(len(times)):
print '|%-20s|%20f|' % (heads[i], times[i])
print line
print '\ntesting ended!'
Для запуска предыдущего файла я использовал небольшой bash-скрипт (при написании которого я мог допустить кучу косяков, потому что для меня bash пока что неизученная территория).
date
AUTH_COUNTS=(1 10 50 100 200)
ELEMENTS=${#AUTH_COUNTS[@]}
RESULT_DIR=all_test_results
if test -e $RESULT_DIR; then
rm -rf $RESULT_DIR
fi
mkdir $RESULT_DIR
echo "*postgresql test"
i=0
while [ $i -lt $ELEMENTS ]
do
COUNT=${AUTH_COUNTS[$i]}
i=$[$i+1]
echo " iteration $i start..."
python dbloadtest.py -p -i -a=$COUNT > $RESULT_DIR/[$i]_pg_with_index.txt
python dbloadtest.py -p -n -a=$COUNT > $RESULT_DIR/[$i]_pg_no_index.txt
echo " ...done"
echo
done
echo "*sqlite3 memory test"
i=0
while [ $i -lt $ELEMENTS ]
do
COUNT=${AUTH_COUNTS[$i]}
i=$[$i+1]
echo " iteration $i start..."
python dbloadtest.py -m -i -a=$COUNT > $RESULT_DIR/[$i]_memory_with_index.txt
python dbloadtest.py -m -n -a=$COUNT > $RESULT_DIR/[$i]_memory_no_index.txt
echo " ...done"
echo
done
echo "*sqlite3 in file test"
i=0
while [ $i -lt $ELEMENTS ]
do
COUNT=${AUTH_COUNTS[$i]}
i=$[$i+1]
echo " iteration $i start..."
python dbloadtest.py -s -i -a=$COUNT > $RESULT_DIR/[$i]_sqlite_with_index.txt
python dbloadtest.py -s -n -a=$COUNT > $RESULT_DIR/[$i]_sqlite_no_index.txt
echo " ...done"
echo
done
date
Обработка результатов
В результате запуска предыдущего скрипта получил кучу файлов с результатами. Тут сразу оговорюсь, что во время последнего теста я немного менял скрипт dbloadtest.py, потому что на количестве авторов больше 50 дождаться завершения теста с sqlite было невозможно.
Чтобы получить сводную табличку из всех файлов с результатами, написал ещё один скрипт
resultDirName = 'result'
patternsFileName = ('_memory_no_index.txt', '_memory_with_index.txt',
'_pg_no_index.txt', '_pg_with_index.txt', '_sqlite_no_index.txt',
'_sqlite_with_index.txt')
authorsCounts = (1, 10, 50, 100, 200)
heads = ('fill', 'count by tables', 'big select', 'big select join',
'orm use', 'simple queries')
times = {}
for pattern in patternsFileName:
times[pattern] = []
for idxFile in range(len(authorsCounts)):
fileName = resultDirName + '/' + ('[%d]' % (idxFile + 1)) + pattern
with open(fileName, 'rt') as fp:
times[pattern].append(map(lambda x: float(x.split('|')[2]),
fp.readlines()[-14:-2:2]))
line = '+' + '-' * 20 + '+' + '+'.join(['-' * 10 for x in range(5)]) + '+'
for dbType in times:
print '\n', dbType
print line
print ('|%-20s' % 'action/author_count') + ('|%10s|%10s|%10s|%10s|%10s|' % authorsCounts)
print line
for idxActionName in range(len(heads)):
action = heads[idxActionName]
dbTypeTimes = times[dbType]
actionTimes = tuple([str(t[idxActionName]) for t in dbTypeTimes])
print ('|%-20s' % action) + ('|%10s|%10s|%10s|%10s|%10s|' % actionTimes)
print line
Результаты
Результаты сгруппировал по типу СУБД и по наличию индексов в базе. Там, где стоит -1, я просто не дождался завершения работы скрипта.
_pg_no_index.txt
+--------------------+----------+----------+----------+----------+----------+
|action/author_count | 1| 10| 50| 100| 200|
+--------------------+----------+----------+----------+----------+----------+
|fill | 0.231285| 1.72187| 7.842117| 18.868243| 41.146092|
+--------------------+----------+----------+----------+----------+----------+
|count by tables | 0.002108| 0.00162| 0.003346| 0.006318| 0.009838|
+--------------------+----------+----------+----------+----------+----------+
|big select | 0.002859| 0.013254| 0.263669| 1.146801| 4.791618|
+--------------------+----------+----------+----------+----------+----------+
|big select join | 0.002679| 0.012731| 0.260424| 1.170647| 4.940643|
+--------------------+----------+----------+----------+----------+----------+
|orm use | 0.020962| 0.137301| 0.924065| 2.461377| 7.775531|
+--------------------+----------+----------+----------+----------+----------+
|simple queries | 0.004852| 0.033245| 0.398083| 1.480214| 5.614259|
+--------------------+----------+----------+----------+----------+----------+
_memory_with_index.txt
+--------------------+----------+----------+----------+----------+----------+
|action/author_count | 1| 10| 50| 100| 200|
+--------------------+----------+----------+----------+----------+----------+
|fill | 0.046558| 0.435866| 2.297696| 4.44086| 9.182301|
+--------------------+----------+----------+----------+----------+----------+
|count by tables | 0.000232| 0.000598| 0.002518| 0.004859| 0.008582|
+--------------------+----------+----------+----------+----------+----------+
|big select | 0.010185| 4.173265| 495.3262| -1.0| -1.0|
+--------------------+----------+----------+----------+----------+----------+
|big select join | 0.000852| 0.04177| 1.102579| 4.700694| 19.02497|
+--------------------+----------+----------+----------+----------+----------+
|orm use | 0.011013| 0.119054| 1.132743| 3.507863| 12.158737|
+--------------------+----------+----------+----------+----------+----------+
|simple queries | 0.000809| 0.028106| 0.581097| 2.499086| 10.060157|
+--------------------+----------+----------+----------+----------+----------+
_memory_no_index.txt
+--------------------+----------+----------+----------+----------+----------+
|action/author_count | 1| 10| 50| 100| 200|
+--------------------+----------+----------+----------+----------+----------+
|fill | 0.04515| 0.435912| 2.266461| 4.3318| 8.932379|
+--------------------+----------+----------+----------+----------+----------+
|count by tables | 0.000238| 0.000627| 0.002452| 0.004428| 0.008911|
+--------------------+----------+----------+----------+----------+----------+
|big select | 0.010438| 4.221292| 492.76092| -1.0| -1.0|
+--------------------+----------+----------+----------+----------+----------+
|big select join | 0.000861| 0.042676| 1.035481| 4.751594| 18.770619|
+--------------------+----------+----------+----------+----------+----------+
|orm use | 0.011256| 0.121582| 1.041038| 3.585144| 12.383719|
+--------------------+----------+----------+----------+----------+----------+
|simple queries | 0.000857| 0.02478| 0.514296| 2.501858| 9.707688|
+--------------------+----------+----------+----------+----------+----------+
_sqlite_with_index.txt
+--------------------+----------+----------+----------+----------+----------+
|action/author_count | 1| 10| 50| 100| 200|
+--------------------+----------+----------+----------+----------+----------+
|fill | 2.345496| 26.964783|127.245174|248.164527|485.671184|
+--------------------+----------+----------+----------+----------+----------+
|count by tables | 0.022325| 0.023069| 0.02127| 0.037057| 0.06431|
+--------------------+----------+----------+----------+----------+----------+
|big select | 0.030343| 4.971248| 9700.4559| -1.0| -1.0|
+--------------------+----------+----------+----------+----------+----------+
|big select join | 0.023542| 0.07756| 5.236409| 22.053952| 84.810086|
+--------------------+----------+----------+----------+----------+----------+
|orm use | 0.036379| 0.148815| 5.117857| 19.794298| 74.20158|
+--------------------+----------+----------+----------+----------+----------+
|simple queries | 0.025443| 0.047866| 4.520323| 18.618552| 72.834561|
+--------------------+----------+----------+----------+----------+----------+
_sqlite_no_index.txt
+--------------------+----------+----------+----------+----------+----------+
|action/author_count | 1| 10| 50| 100| 200|
+--------------------+----------+----------+----------+----------+----------+
|fill | 2.302192| 22.608962|114.141749|215.950755| 432.98446|
+--------------------+----------+----------+----------+----------+----------+
|count by tables | 0.038948| 0.019935| 0.028326| 0.034316| 0.063907|
+--------------------+----------+----------+----------+----------+----------+
|big select | 0.030463| 4.933617| -1.0| -1.0| -1.0|
+--------------------+----------+----------+----------+----------+----------+
|big select join | 0.024648| 0.077713| 5.32001| 20.818145| 85.383302|
+--------------------+----------+----------+----------+----------+----------+
|orm use | 0.030698| 0.149703| 5.12185| 19.155246| 74.946809|
+--------------------+----------+----------+----------+----------+----------+
|simple queries | 0.019438| 0.047167| 4.594021| 17.915685| 72.765067|
+--------------------+----------+----------+----------+----------+----------+
Заключение
По данным в таблице сделал для себя несколько выводов:
- SQLite не так плох, даже в сравнении с таким монстром, как PostgreSQL.
- Скорость работы PostgreSQL конечно же превосходит SQLite.
- PostgreSQL одинаково хорошо работает как с одним большим запросом, так и с очередью маленьких.
- SQLAlchemy с PostgreSQL даёт уменьшение скорости работы примерно в два раза.
- SQLAlchemy + SQLite уступает в производительности sql-запросам, но не намного.
- SQLite плохо переваривает подзапросы с уровнем вложенности больше 1.
- Индексы в SQLite не дают прироста производительности, в PostgreSQL скорость выборки увеличивается в 2 раза. При этом скорость вставки данных не падает (во всяком случае если и падает, то не сильно).
- Скорость вставки SQLite в памяти (memory) очень высока. Можно использовать при каких-нибудь тестах, чтобы выполнялось быстрее.