пятница, 10 апреля 2009 г.

Хранилище записей фиксированной длины (часть IV)

Хранилище записей фиксированной длины
Хранилище записей фиксированной длины (часть II)
Хранилище записей фиксированной длины (часть III)

Давно обещанное сравнение производительности хранилища с SQLite. Если честно, вся эта эпопея с хранилищем утомила меня. Делов на копейку, а растянул уже на полгода. Смысл теста незатейлив. Требуется сохранить много-много пар {Guid, int}, а потом произвести некоторое количество запросов записи по Guid ключу, и сверить соответствующий ему int. Поиски делаются из разных потоков.

Для тестирования пресловутого хранилища и SQLite базы данных используется один и тот же код, который работает со следующим интерфейсом:
interface IStorage : IDisposable
{
   void StoreGuidsAndInts(Guid[] guids);

   void InitStorage();

   int GetInt32FromRecordByKey(Guid id);
}
Вот, собственно, код теста:
private const int RecordCount = 200000;
private static readonly int SearchCount = Math.Min(100000, RecordCount);

private static Guid[] GenereateGuids()
{
    var ids = new Guid[RecordCount];

    for (int i = 0; i < ids.Length; i++)
    {
        ids[i] = Guid.NewGuid();
    }
    return ids;
}

static void StressStorageTest(IStorage storage)
{
    Guid[] ids = GenereateGuids();

    Stopwatch sw1 = Stopwatch.StartNew();

    storage.StoreGuidsAndInts(ids);

    Console.WriteLine(
        "Быстрая вставка новых записей ({0} штук) - {1}", 
        RecordCount, 
        sw1.Elapsed);


    Stopwatch swInit = Stopwatch.StartNew();

    storage.InitStorage();

    Console.WriteLine("Инициализация индексированного хранилища - {0}", swInit.Elapsed);

    int searchCount = SearchCount;
    var syncRoot = new object();

    var rnd = new Random();

    var callbacks = new List<WaitCallback>(searchCount);

    for (int i = 0; i < searchCount; i++)
    {
        int index = rnd.Next(RecordCount);

        callbacks.Add(
            _ =>
            {
                int value = storage.GetInt32FromRecordByKey(ids[index]);
                Assert.AreEqual(index, value);
                if (Interlocked.Decrement(ref searchCount) == 0)
                {
                    lock (syncRoot)
                    {
                        Monitor.Pulse(syncRoot);
                    }
                }
            });
    }

    var sw2 = Stopwatch.StartNew();

    foreach (var callback in callbacks)
    {
        ThreadPool.QueueUserWorkItem(callback);
    }

    lock (syncRoot)
    {
        Monitor.Wait(syncRoot);
    }

    Console.WriteLine("Проверка индекса ({0} поисков - {1}", SearchCount, sw2.Elapsed);
}
Извиняюсь, долго не медитировал над кодом, хотя суть должна быть понятна. Заряжаем хранилища набором Guid-ов, создаем список делегатов, которые берут Guid по случайному индексу, просят хранилище вернуть индекс идентификатора, сравнивают индексы. Закидываем список делегатов (вот так вот грубо) в пул потоков и ждем когда декрементируется счетчик поисков. Хранилище, основанное на хранилище записей, не сильно сложно (относительно хранилища на SQLite).

Пришлось, правда, подтюнинговать метод записи пар. Его производительность меня волновала только в плане комфортного запуска тестов. Уж слишком долгая операция перераспределения места в файле, потому добавление записей через само хранилище безобразно долгое. Писал пары прямо в стрим данных, благо формат прозрачный.
class IndexedTestStorage : IndexedStorage<TestHeader, TestRow, Guid>, IStorage
{
    private Stream _bufferedStream;
    private BinaryWriter _writer;

    public IndexedTestStorage(Stream stream)
        : base(
            stream,
            Encoding.Default,
            TestRow.IdColumn,
            new DictionaryIndex<Guid>())
    {
    }

    public void StoreGuidsAndInts(Guid[] guids)
    {
        _bufferedStream = new BufferedStream(Stream);
        _writer = new BinaryWriter(_bufferedStream);
        for (int i = 0; i < guids.Length; i++)
        {
            _writer.Write(guids[i].ToByteArray());
            _writer.Write(i);
        }

        _bufferedStream.Flush();
    }

    public void InitStorage()
    {
        base.Initialize();
    }

    public int GetInt32FromRecordByKey(Guid id)
    {
        return GetRow(id).Int32Value;
    }

    public override void Dispose()
    {
        base.Dispose();
        _bufferedStream.Dispose();
        _writer.Close();
    }

    public TestRow GetRow(Guid id)
    {
        return GetRow(GetIndex(id));
    }

    public TestRow AddNewRow(Guid id)
    {
        return InsertRow(id);
    }
}
С вариантом реализации через SQLite пришлось повозиться. Открытие и закрытие соединения - слишком дорогая операция. Делать открытие и закрытие соединения на каждое обращение - непозволительная роскошь. Пришлось пойти окружными путями и использовать соединение из разных тредов, обеспечивая самостоятельно гарантию того, что соединение не будет использовано из разных тредов одновременно. Фактически пришлось организовать пул открытых соединений (это довольно безопасно, пока нет одновременной записи).

Вот код:
class SQLiteStorage: IStorage
{
    private readonly string _path;
    private readonly Stack<SQLiteConnection> _connectionPool = new Stack<SQLiteConnection>();
    private readonly object _syncRoot = new object();

    public SQLiteStorage()
    {
        string path1 = Path.GetFullPath(@"..\..\StreamStorage\sqlitedb.s3db");

        _path = Path.GetFullPath("sqlitedb.s3db");
        if (File.Exists(_path))
        {
            File.Delete(_path);
        }

        File.Copy(path1, _path);
    }

    private SQLiteConnection GetConnection()
    {
        lock (_syncRoot)
        {
            if (_connectionPool.Count > 0)
            {
                return _connectionPool.Pop();
            }
        }

        var result = new SQLiteConnection("Data Source=" + _path);
        result.Open();
        return result;
    }

    private void ReturnToPool(SQLiteConnection connection)
    {
        lock(_syncRoot)
        {
            _connectionPool.Push(connection);
        }
    }

    public void Dispose()
    {
        foreach(var connection in _connectionPool)
        {
            connection.Dispose();
        }
        _connectionPool.Clear();
    }

    public void StoreGuidsAndInts(Guid[] guids)
    {
        var connection = GetConnection();
        Action<string> simpleCommand = text =>
        {
            using (var cmd = new SQLiteCommand(text, connection))
                cmd.ExecuteNonQuery();
        };
        var insertCommand = new SQLiteCommand(connection)
        {
            CommandText =
                "INSERT INTO [TestTable] ([Id], [IntValue]) VALUES (@Id, @Value)"
        };
        var valueParameter = insertCommand.Parameters.Add("@Value", System.Data.DbType.Int32);
        var id1Parameter = insertCommand.Parameters.Add("@Id", System.Data.DbType.Guid);

        //connection.Open();
        simpleCommand("BEGIN");

        try
        {
            for (int i = 0; i < guids.Length; i++)
            {
                id1Parameter.Value = guids[i];
                valueParameter.Value = i;
                insertCommand.ExecuteNonQuery();
            }

            simpleCommand("COMMIT");
        }
        finally
        {
            //connection.Close();
            ReturnToPool(connection);
        }
    }

    public void InitStorage()
    {
    }

    public int GetInt32FromRecordByKey(Guid id)
    {
        var connection = GetConnection();
        var getIntValueCommand = new SQLiteCommand(connection)
        {
            CommandText = "SELECT [IntValue] FROM [TestTable] WHERE [Id] = @Id"
        };
        var idParameter = getIntValueCommand.Parameters.Add("@Id", System.Data.DbType.Guid);

        idParameter.Value = id;

        //connection.Open();
        object obj = getIntValueCommand.ExecuteScalar();
        //connection.Close();
        ReturnToPool(connection);
        return Convert.ToInt32(obj);
    }
}
Места, где я поначалу пытался открывать и закрывать соединения, остались закомментированы. Сами тесты:
[Test]
public void StressIndexedStorageTest()
{
    using(var stream = new FileStream(
        "Storage.tmp",
        FileMode.Create,
        FileAccess.ReadWrite,
        FileShare.None,
        128,
        FileOptions.RandomAccess))
    using (var storage = new IndexedTestStorage(stream))
    {
        StressStorageTest(storage);
    }
}

[Test]
public void StressSQLiteTest()
{
    using (var storage = new SQLiteStorage())
    {
        StressStorageTest(storage);
    }
}
Ну и наконец, результаты:
 
StressIndexedStorageTest
Быстрая вставка новых записей (200000 штук) - 00:00:00.0763303
Инициализация индексированного хранилища - 00:00:00.1788403  
Проверка индекса (100000 поисков) - 00:00:01.4525583
 
StressSQLiteTest
Быстрая вставка новых записей (200000 штук) - 00:00:10.6895979
Инициализация индексированного хранилища - 00:00:00.0000385  
Проверка индекса (100000 поисков) - 00:00:12.0554961

При открытии и закрытии соединения на каждый запрос по индексу, время SQLite составило больше минуты на 100000 запросов. Должен признать, SQLite с пулом открытых соединений оказался достаточно быстр для нужд проекта! Но он слил. Ни в коем случае не испытываю радости по этому поводу. Описанный мной тест не может претендовать на объективность. Слишком специфичны условия (одиночные обращения к хранилищу из разных потоков)... В защиту SQLite тот факт, что он хранит индекс в файле, в то время, как мое хранилище использует словарь в памяти.

При тестировании варианта хранилища с индексом в файле, разница в производительности между SQLite и моим хранилищем тает (SQLite уступает лишь в 2 раза). Безусловно, SQLite годится на гораздо большее, чем хранилище записей фиксированной длины. Однако, предпочтение было отдано хранилищу записей по ряду причин: В основном, из-за прозрачной организации файла. Еще одна причина - ощутимо более быстрое добавление записей, чем в таблицу SQLite. Последняя: к моменту обсуждения возможности использования SQLite (начало ноября) времени на переделку не было, хотя много времени и не требовалось. Надо как-то подытожить 4 длинных поста на тему хранилища: Велосипеды - вещь в себе, особенно узкоспециализированные. Они могут дать неплохое преимущество перед инструментами широкого профиля. Но, время потраченное на исследование доступных инструментов, вполне может окупить время, потраченное на написание велосипеда. Данный велосипед безусловно порвал SQLite по производительности, хотя производительность SQLite-а (с пулом открытых соединений) вполне могла удовлетворить требованиям задачи.

Потрачено на написание хранилища записей фиксированной длины было около 3х рабочих дней. Смог бы я за это время грамотно прикрутить к SQLite пул открытых соединений с возможностью безопасной записи? Возможно... Какие-то противоречивые чувства меня раздирают. Одно ясно точно: надо больше смотреть по сторонам.

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

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