image

Доброго здравия, Хабражители! В процессе работы над проектом сайта знакомств возникла необходимость организовать хранение фотографий пользователей. По условиям ТЗ количество фотографий одного пользователя ограничено 10 файлами. Но пользователей-то могут быть десятки тысяч. Особенно учитывая то, что проект в его нынешнем виде существует аж с начала «нулевых». То есть там уже тысячи пользователей в базе. Почти любая файловая система, насколько мне известно, очень негативно реагирует на большое количество дочерних узлов в папке. По опыту могу сказать, что проблемы начинаются уже после 1000-1500 файлов/папок в родительской папке.

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

Теория


Помимо как таковой задачи хранения было ещё условие в ТЗ, согласно которому нужна была возможность оставлять к фотографиям подписи и заголовки. Само собой, без БД тут не обойтись. То есть первое, что мы делаем — это создаём таблицу, в которой прописываем сопоставление мета-данных (подписи, тайтлы и т.п.) с файлами на диске. Каждому файлу соответствует одна строка в БД. Соответственно, у каждого файла есть идентификатор.

Небольшое отступление. Поговорим про автоинкремент. На сайте знакомств может быть и десяток-другой тысяч пользователей. Вопрос в том, сколько вообще пользователей проходит через проект за всё время его существования. Например, активная аудитория «датинг-ру» составляет несколько сотен тысяч. Однако, только вообразите себе сколько пользователей удалилось за время жизни этого проекта; сколько пользователей не активировано до сих пор. А теперь приплюсуйте наше законодательство, обязывающее хранить информацию о пользователях не менее полугода… Рано или поздно 4 с копейками миллиарда UNSIGNED INT закончатся. По сему лучше всего для primary-ключа брать BIGINT.

А теперь попробуем представить себе число типа BIGINT. Это 8 байт. Каждый байт — это от 0 до 255. 255 дочерних нод — это вполне нормально для любой файловой системы. То есть берём идентификатор файла в шестнадцатеричном представлении, разбиваем оное на чанки по два символа. Используем эти чанки, как названия папок, причём последний в качестве имени физического файла. PROFIT!

0f/65/84/10/67/68/19/ff.file

Элегантно и просто. Расширение файла тут не принципиально. Всё равно файл будет отдаваться скриптом, который будет отдавать браузеру в частности MIME-тип, который мы тоже будем хранить в базе. Кроме того, хранение информации о файле в базе позволяет переопределять путь к нему для браузера. Скажем, файл у нас реально расположен относительно каталога проекта по пути /content/files/0f/65/84/10/67/68/19/ff.file. А в базе можно прописать ему URL, например, /content/users/678/files/somefile. SEO-шники сейчас, наверное, довольно улыбнулись. Всё это позволяет нам не беспокоиться больше о том, где размещать файл физически.

Таблица в БД


Помимо идентификатора, MIME-типа, URL и физического расположения мы будем хранить в таблице md5 и sha1 файлов для отсеивания одинаковых файлов при необходимости. Само собой нам нужно также хранить в этой таблице связи с сущностями. Допустим, ID пользователя, к которому относятся файлы. А если проект не шибко большой, то в той же системе мы можем хранить, скажем, фотографии товаров. По сему будем также хранить название класса сущности, к которой относится запись.

Кстати, о птичках. Если закрыть папку при помощи .htaccess для доступа извне, то файл можно будет получить только через скрипт. А в скрипте можно будет определить доступ к файлу. Немного забегая вперёд, скажу, что в моей CMS (на которой сейчас и пилится вышеупомянутый проект) доступ определяется базовыми пользовательскими группами, коих у меня 8 — гости, пользователи, менеджеры, админы, неактивированные, заблокированные, удалённые и супер-админы. Супер-админу можно абсолютно всё, так что его в определении доступа оный не участвует. Если есть у юзера флаг супер-админа, значит он супер-админ. Всё просто. То есть определять доступы будем оставшимся семи группам. Доступ простой — либо отдавать файл, либо не отдавать. Итого можно взять поле типа TINYINT.

И ещё один момент. Согласно нашему законодательству нам придётся физически хранить пользовательские картинки. То есть нам нужно как-то помечать картинки, как удалённые, вместо физического удаления. Удобнее всего для этих целей использовать битовое поле. Я обычно в таких случаях использую поле типа INT. Чтобы с запасом, так сказать. Притом у меня есть уже устоявшаяся традиция размещать флаг DELETED в 5-м бите с конца. Но это не принципиально опять таки же.

Что мы имеем в итоге:

create table `files` (
  `id`          bigint not null auto_increment, -- Первичный ключ
  `entity_type` char(32) not null default '', -- Тип сущности
  `entity`      bigint null, -- ID сущности
  `mime`        char(32) not null default '', -- MIME-тип
  `md5`         char(32) not null default '', -- MD5
  `sha1`        char(40) not null default '', -- SHA1
  `file`        char(64) not null default '', -- Физическое расположение
  `url`         varchar(250) not null default '', -- URL
  `meta`        text null, -- Мета-данные в формате JSON или сериализованного массива
  `size`        bigint not null default '0', -- Размер
  `created`     datetime not null, -- Дата создания
  `updated`     datetime null, -- Дата редактирования
  `access`      tinyint not null default '0', -- Битовый доступ
  `flags`       int not null default '0', -- Флаги
  primary key (`id`),
  index (`entity_type`),
  index (`entity`),
  index (`mime`),
  index (`md5`),
  index (`sha1`),
  index (`url`)  
) engine = InnoDB;

Класс-диспетчер


Теперь нам нужно создать класс, при помощи которого мы будем файлы загружать. Класс должен обеспечивать возможность создавать файлы, заменять/изменять файлы, удалять файлы. Кроме того, стоит учесть два момента. Во-первых, проект может быть перенесён с сервера на сервер. Значит в классе нужно определить свойство, содержащее корневую директорию файлов. Во-вторых, будет очень неприятно, если кто-нибудь грохнет таблицу в БД. Значит нужно предусмотреть возможность восстановления данных. С первым всё в общем-то понятно. Что же касается резервирования данных, то резервировать мы будем только то, что нельзя восстановить.

ID — восстанавливается из физического расположения файла
entity_type — не восстанавливается
entity — не восстанавливается
mime — восстанавливается при помощи расширения finfo
md5 — восстанавливается из самого файла
sha1 — восстанавливается из самого файла
file — восстанавливается из физического расположения файла
url — не восстанавливается
meta — не восстанавливается
size — восстанавливается из самого файла
created — можно взять информацию из файла
updated — можно взять информацию из файла
access — не восстанавливается
flags — не восстанавливается

Сразу можно отбросить мета-информацию. Она не критична для функционирования системы. И для более оперативного восстановления всё же нужно сохранять MIME-тип. Итого: тип сущности, ID сущности, MIME, URL, доступ и флаги. Дабы повысить надёжность системы, будем хранить резервную информацию по каждой конечной папке отдельно в самой папке.

Код класса
<?php

class BigFiles
{
    const FLAG_DELETED = 0x08000000; // Пока только флаг "Удалён"

    /** @var mysqli $_db */
    protected $_db       = null;
    protected $_webRoot  = '';
    protected $_realRoot = '';

    function __construct(mysqli $db = null) {
        $this->_db = $db;
    }

    /**
     * Установка/чтение корня для URL-ов
     * @param string $v  Значение
     * @return string
     */
    public function webRoot($v = null) {
        if (!is_null($v)) {
            $this->_webRoot = $v;
        }
        return $this->_webRoot;
    }

    /**
     * Установка/чтение корня для файлов
     * @param string $v  Значение
     * @return string
     */
    public function realRoot($v = null) {
        if (!is_null($v)) {
            $this->_realRoot = $v;
        }
        return $this->_realRoot;
    }

    /**
     * Загрузка файла
     * @param array  $data    Данные запроса
     * @param string $url     URL виртуальной папки
     * @param string $eType   Тип сущности
     * @param int    $eID     ID сущности
     * @param mixed  $meta    Мета-данные
     * @param int    $access  Доступ
     * @param int    $flags   Флаги
     * @param int    $fileID  ID существующего файла
     * @return bool
     * @throws Exception
     */
    public function upload(array $data, $url, $eType = '', $eID = null, $meta = null, $access = 127, $flags = 0, $fileID = 0) {
        $meta = is_array($meta) ? serialize($meta) : $meta;
        if (empty($data['tmp_name']) || empty($data['name'])) {
            $fid = intval($fileID);
            if (empty($fid)) {
                return false;
            }
            $meta = empty($meta) ? 'null' : "'" . $this->_db->real_escape_string($meta) . "'";
            $q = "`meta`={$meta},`updated`=now()";
            $this->_db->query("UPDATE `files` SET {$q} WHERE (`id` = {$fid}) AND (`entity_type` = '{$eType}')");
            return $fid;
        }
        // File data
        $meta  = empty($meta) ? 'null' : "'" . $this->_db->real_escape_string($meta) . "'";
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime  = finfo_file($finfo , $data['tmp_name']);
        finfo_close($finfo);
        // FID, file name
        if (empty($fileID)) {
            $eID = empty($eID) ? 'null' : intval($eID);
            $q = <<<sql
insert into `files` set
    `mime`       = '{$mime}',
    `entity`     = {$eID},
    `entityType` = '{$eType}',
    `created`    = now(),
    `access`     = {$access},
    `flags`      = {$flags}
sql;
            $this->_db->query($q);
            $fid = $this->_db->insert_id;
            list($ffs, $fhn) = self::fid($fid);
            $url = $this->_webRoot . $url . '/' . $fid;
            $fdir = $this->_realRoot . $ffs;
            self::validateDir($fdir);
            $index = self::getIndex($fdir);
            $index[$fhn] = array($fhn, $mime, $url, ($eID == 'null' ? 0 : $eID), $access, $flags);
            self::setIndex($fdir, $index);
            $fname = $ffs . '/' . $fhn . '.file';
        } else {
            $fid = intval($fileID);
            $fname = $this->fileName($fid);
        }
        // Move file
        $fdir = $this->_realRoot . $fname;
        if (!move_uploaded_file($data['tmp_name'], $fdir)) {
            throw new Exception('Upload error');
        }
        $q = '`md5`=\'' . md5_file($fdir) . '\',`sha1`=\'' . sha1_file($fdir) . '\','
           . '`size`=' . filesize($fdir) . ',`meta`=' . $meta . ','
           . (empty($fileID) ? "`url`='{$url}',`file`='{$fname}'" : '`updated`=now()');
        $this->_db->query("UPDATE `files` SET {$q} WHERE (`id` = {$fid}) AND (`entity_type` = '{$eType}')");
        return $fid;
    }

    /**
     * Чтение файла
     * @param string $url         URL
     * @param string $basicGroup  Базовая группа пользователя
     * @throws Exception
     */
    public function read($url, $basicGroup = 'anonimous') {
        if (!ctype_alnum(str_replace(array('/', '.', '-', '_'), '', $url))) {
            header('HTTP/1.1 400 Bad Request');
            exit;
        }
        $url = $this->_db->real_escape_string($url);
        $q = "SELECT * FROM `files` WHERE `url` = '{$url}' ORDER BY `created` ASC";
        if ($result = $this->_db->query($q)) {
            $vars = array();
            $ints = array('id', 'entity', 'size', 'access', 'flags');
            while ($row = $result->fetch_assoc()) {
                foreach ($ints as $i) {
                    $row[$i] = intval($row[$i]);
                }
                $fid = $row['id'];
                $vars[$fid] = $row;
            }
            if (empty($vars)) {
                header('HTTP/1.1 404 Not Found');
                exit;
            }
            $deleted = false;
            $access  = true;
            $found   = '';
            $mime    = '';
            foreach ($vars as $fdata) {
                $flags   = intval($fdata['flags']);
                $deleted = ($flags & self::FLAG_DELETED) != 0;
                $access  = self::granted($basicGroup, $fdata['access']);
                if (!$access || $deleted) {
                    continue;
                }
                $found   = $fdata['file'];
                $mime    = $fdata['mime'];
            }
            if (empty($found)) {
                if ($deleted) {
                    header('HTTP/1.1 410 Gone');
                    exit;
                } elseif (!$access) {
                    header('HTTP/1.1 403 Forbidden');
                    exit;
                }
            } else {
                header('Content-type: ' . $mime . '; charset=utf-8');
                readfile($this->_realRoot . $found);
                exit;
            }
        }
        header('HTTP/1.1 404 Not Found');
        exit;
    }

    /**
     * Удаление файла (файлов) из хранилища
     * @param mixed $fid  Идентификатор(ы)
     * @return bool
     * @throws Exception
     */
    public function delete($fid) {
        $fid = is_array($fid) ? implode(',', $fid) : $fid;
        $q = "delete from `table` where `id` in ({$fid})";
        $this->_db->query($q);
        $result = true;
        foreach ($fid as $fid_i) {
            list($ffs, $fhn) = self::fid($fid_i);
            $fdir = $this->_realRoot . $ffs;
            $index = self::getIndex($fdir);
            unset($index[$fhn]);
            self::setIndex($fdir, $index);
            $result &= unlink($fdir . '/'. $fhn . '.file');
        }
        return $result;
    }

    /**
     * Помечает файл(ы) флагом "удалено"
     * @param int  $fid    Идентификатор(ы)
     * @param bool $value  Значение флага
     * @return bool
     */
    public function setDeleted($fid, $value=true) {
        $fid = is_array($fid) ? implode(',', $fid) : $fid;
        $o = $value ? ' | ' . self::FLAG_DELETED : ' & ' . (~self::FLAG_DELETED);
        $this->_db->query("update `files` set `flags` = `flags` {$o} where `id` in ({$fid})");
        return true;
    }

    /**
     * Имя файла
     * @param int $fid  Идентификатор
     * @return string
     * @throws Exception
     */
    public function fileName($fid) {
        list($ffs, $fhn) = self::fid($fid);
        self::validateDir($this->_realRoot . $ffs);
        return $ffs . '/' . $fhn . '.file';
    }

    /**
     * Обработка идентификатора файла.
     * Возвращает массив с папкой к файлу и шестнадцатиричное представление младшего байта.
     * @param int $fid  Идентификатор файла
     * @return array
     */
    public static function fid($fid) {
        $ffs = str_split(str_pad(dechex($fid), 16, '0', STR_PAD_LEFT), 2);
        $fhn = array_pop($ffs);
        $ffs = implode('/', $ffs);
        return array($ffs, $fhn);
    }

    /**
     * Проверка каталога файла
     * @param string $f  Полный путь к каталогу
     * @return bool
     * @throws Exception
     */
    public static function validateDir($f) {
        if (!is_dir($f)) {
            if (!mkdir($f, 0700, true)) {
                throw new Exception('cannot make dir: ' . $f);
            }
        }
        return true;
    }

    /**
     * Чтение резервного индекса
     * @param string $f  Полный путь к файлу резервного индекса
     * @return array
     */
    public static function getIndex($f) {
        $index = array();
        if (file_exists($f . '/.index')) {
            $_ = file($f . '/.index');
            foreach ($_ as $_i) {
                $row = trim($_i);
                $row = explode('|', $row);
                array_walk($row, 'trim');
                $rid = $row[0];
                $index[$rid] = $row;
            }
        }
        return $index;
    }

    /**
     * Запись резервного индекса
     * @param string $f      Полный путь к файлу резервного индекса
     * @param array  $index  Массив данных индекса
     * @return bool
     */
    public static function setIndex($f, array $index) {
        $_ = array();
        foreach ($index as $row) {
            $_[] = implode('|', $row);
        }
        return file_put_contents($f . '/.index', implode("\r\n", $_));
    }

    /**
     * Проверка доступности
     * @param string $group  Название группы (см. ниже)
     * @param int    $value  Значение доступов
     * @return bool
     */
    public static function granted($group, $value=0) {
        $groups = array('anonimous', 'user', 'manager', 'admin', 'inactive', 'blocked', 'deleted');
        if ($group == 'root') {
            return true;
        }
        foreach ($groups as $groupID => $groupName) {
            if ($groupName == $group) {
                return (((1 << $groupID) & $value) != 0);
            }
        }
        return false;
    }
}


Рассмотрим некоторые моменты:

realRoot — полный путь до папки с файловой системой оканчивающийся слешем.
webRoot — путь от корня сайта без ведущего слеша (ниже увидите почему).
— В качестве СУБД я использую расширение MySQLi.
— По сути в метод upload первым аргументом передаётся информация из массива $_FILES.
— Если при вызове метода update передать ID существующего файла, он будет заменён, если в tmp_name входного массива будет непустым.
— Удалять и менять флаги файлов можно сразу по несколько штук. Для этого нужно передать вместо идентификатора файла либо массив с идентификаторами, либо строку с оными через запятую.

Маршрутизация


Собственно всё сводится к нескольким строчкам в htaccess в корне сайта (подразумевается, что mod_rewrite включен):

RewriteCond %{REQUEST_URI} ^/content/(.*)$
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ content/index.php?file=$1 [L,QSA]

«content» — это папка в корне сайта в моём случае. Само собой Вы можете назвать папку по-другому. Ну и конечно же сам index.php, хранящийся в моём случае в папке content:

<?php
    $dbHost = '127.0.0.1';
    $dbUser = 'user';
    $dbPass = '****';
    $dbName = 'database';

    try {
        if (empty($_REQUEST['file'])) {
            header('HTTP/1.1 400 Bad Request');
            exit;
        }
        $userG = 'anonimous';
        // Вот тут будем определять группу юзера; любое решение на Ваш выбор
        $files = new BigFiles(new mysqli($dbHost,$dbUser,$dbPass,$dbName));
        $files->realRoot(dirname(__FILE__).'/files/');
        $files->read($_REQUEST['file'],$userG);
    } catch (Exception $e) {
        header('HTTP/1.1 500 Internal Error');
        header('Content-Type: text/plain; charset=utf-8');
        echo $e->getMessage();
        exit;
    }

Ну и само собой закроем саму файловую систему от внешнего доступа. Положим в корень папки content/files файл .htaccess с одной лишь строчкой:

Deny from all

Итог


Данное решение позволяет избежать потерь производительности файловой системы из-за увеличения количества файлов. По крайней мере беды в виде тысяч файлов в одной папке точно можно избежать. И вместе с тем мы можем организовать и контролировать доступ к файлам по человеко-понятным адресам. Плюс соответствие нашему мрачному законодательству. Сразу оговорюсь, данное решение НЕ является полноценным способом защиты контента. Помните: если что-то воспроизводится в браузере, это можно скачать бесплатно.

Комментарии (40)


  1. Dmitry88
    20.09.2018 17:48
    +1

    Я не специалист, но разве при при большом количестве файлов не стоит обратится к object storage, как к решению, которое заточено под такую задачу. Раз уж делаете свой тиндер, но закладывайтесь на архитектуру для больших данных.
    p.s. хотя и дорогое решение


    1. chupasaurus
      20.09.2018 18:03
      +1

      Minio — под свободной лицензией, за физическое место придётся платить в любом случае.


  1. nikitasius
    20.09.2018 19:06
    +1

    ~~ entity_type char(32) not null default '', — Тип сущности~~

    Можно было обойтись 'small int'
    Вообще все переделать.


    • entity_type char(32) not null default '', — Тип сущности
    • mime char(32) not null default '', — MIME-тип
    • md5 char(32) not null default '', — MD5
    • sha1 char(40) not null default '', — SHA1
    • file char(64) not null default '', — Физическое расположение

    Это ад и содомия. От такого вытекают глаза :)




    Нужно:
    • Вынести в отдельные таблицы
      • entity_type
      • mime
      • url
      • file
      • md5
      • sha1
    • связать все по айди.

    Иначе:
    То есть берём идентификатор файла в шестнадцатеричном представлении, разбиваем оное на чанки по два символа.

    Решение отличное, но при fixed rows у вас ~495 байт на запись, или ~246-495 в зависимости от заполненности varchar, когда у вас есть шанс иметь число цифровую главную табличку и пачку привязанных к ней. Удобство страдает только в самом начале (приходится более сложные sql писать), но эффективность и выдаваемый перфоманс максимальны.


    Всегда следует стремится к 6й форме (кроме частных случаев, когда это стоит дорого в плане кода на относительно хорошо спланированной архитектуре). Но 6-ая форма, это идеал к которому рано или поздно (что хуже) придется прибегать.


    Вопрос: зачем вам и md5 и sha1?


    На md5 коллизии не новы, на sha1 гугл давно предоставил 2 pdf с одним хешем. Используйте sha256, и место в сумме съэкономите.


    upd: забыл добавить про md5 & sha1.


    1. XanderBass Автор
      20.09.2018 19:29

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

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

      Ну и в-третьих. Зачем выносить в отдельные таблицы URL, MD5 и SHA1 я вообще ума не приложу. А физическое расположение файла нужно исключительно на тот случай, если будет изменён алгоритм формирования физического имени файла. У меня в первоначальной версии было md5(последний_байт + URL).

      В сухом остатке из Ваших тезисов имеет смысл задуматься только над entity_type и алгоритмами хэширования. Кои, кстати, в данном случае в принципе имеют чисто декоративное значение :)


      1. Agel_Nash
        20.09.2018 19:57
        -2

        Два запроса лучше одного с JOIN. Тем более на больших данных.


      1. nikitasius
        20.09.2018 21:46

        В реальной системе entity_type по факту означает модель данных

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


        Во-вторых, MIME-типы. Если следовать Вашей логике, то нужно делать системный справочник.

        Конечно.


        Если мы будем грузить только изображения, то большая часть справочника будет тупо не задействована.

        Добавлять по мере использования. В идеале код, который будет сам обновлять справочник mime.


        а в Вашем придётся делать JOIN к таблице MIME-типов

        Я сам ранее был не сторонником JOIN'ов, чисто в визуальном плане + когда данные есть гарантированно, и не будет null, то и обычный запрос к двум таблицам, саб и join вернут одинаковый результат (хотя оптимизатор может переделать join в саб, а скорее всего сам саб в join, об этом есть тонны тасков про перфоманс в гугле).
        Варианты:
        1)


        select d.*, t.mimeDesc from tdata d, tmimes t where 1=1 and t.id=d.mimeId;

        2)


        select d.*, (select t.mimeDesc from tmimes t where t.id=d.mimeId) as 'mimeDesc' from tdata d where 1=1;

        3)


        select d.*, t.mimeDesc from tdata d left join tmimes t on t.id=d.mimeId where 1=1;

        Для случая, когда данные в tmimes есть всегда вам подойдет первый запрос. Он простой и очень быстрый.


        Зачем выносить в отдельные таблицы URL, MD5 и SHA1 я вообще ума не приложу

        Потому, что сие очень сильно ест размер таблицы, у вас там char, а записей хотите делать много. Далее — решили вы удалить md5, ибо больше не используете его на 245,874,492 записях… Грохнуть 8 байтовый столбик и табличку с md5 или 32 байтовой поле, как бы разный масштаб :)


        В сухом остатке из Ваших тезисов имеет смысл задуматься только над entity_type и алгоритмами хэширования

        Нет, стоит задуматся над переносом на 6 форму. От этого не уйти, рано или поздно оно прижмет и будет ныть и болеть. То, что я делал в стиле "удобно" сейчас переводится на 6-ую. Количество таблиц растет, глаза разбегаются, но софту то насрать. А когда будете вносить радикальные правки на большом объеме, то на 6й форме можно плакать от восторга, или ломать таблицы или базу на других формах.


        На счет алгоритмов хеширование — на современном железе самый быстрый это sha1 (просто быстрый, так же и бесполезный). Если нужен алгоритм, который не sha1, это sha512:


        type             16 bytes     64 bytes    256 bytes   1024 bytes   8192 bytes  16384 bytes
        sha256           98888.60k   219086.70k   399258.79k   495975.42k   532501.85k   535702.19k
        sha512           63937.97k   255262.34k   446925.14k   671571.97k   787614.38k   797944.49k```
        
        Ибо sha512 расчитывается быстрее, чем 256, как только данные больше чем 64 байта (я его использую).
        
        З.Ы. кстати, ключи можете делать `unsigned`, они же 1+, отрицательных нету.


      1. SemaIVVV
        20.09.2018 22:43

        Ну, второй пример, который вы нашли (и указали ссылку в начале своего текста) как бэ является де-факто решением для хранения всех файлов, с небольшим дополнением связей из конкретно вашей БД.

        700 единиц хранения на каждую папку (примите как аксиому от человека, который на практике тестировал всё это), то есть в каждой папке или 700 других папок, или 700 файлов, если это конечная глубина. 4 уровня вниз — 343 миллиона файлов в одной старшей папке.

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

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

        Лень делать скрины, но приведу вам стату: по запросу в гугле site: мой домен — ответ примерно такой: нашлось примерно 243 миллиона страниц.
        В консоли гугла, средняя скорость загрузки страниц — 242 мc. Хостинг шаред (общий тысячи пользователей на одной машине) 300 рублей в месяц, плюс 50Гб места докупил, чтобы файлы влезали, нагрузки нет особой на хостинг от моего сайта этого.


      1. zapimir
        21.09.2018 02:59

        Зачем вам вообще MIME-типы для картинок? Всё равно список расширений обычно фильтруется софтом, да и для фоток будет jpg или jpg? Вы же не будете фотки bmp хранить? gif и png тоже не особо подходят (ну для аватарок разве что), ну разве что новомодный webp, но вряд ли на сайте знакомств найдется, хотя бы один пользователь который зальёт свои фотки в webp.
        Ну, a char(32) для mime, и еще и индекс по этому полю (вот интересно зачем он вообще) — это фэйл. Если так хотите хранить текстом MIME то для этого есть тип ENUM в MySQL.

        Что касается хэшей, то я лично ничего не имею против md5 и sha1. Я не из тех кто кричит, караул в них нашли коллизии — значит их нельзя использовать. В любом хэше есть коллизии, так как такова природа самих кэшей, нельзя в 16-64 байта загнать мегабайт, чтобы не было коллизий. Потому, имхо, лучше использовать 2 хэша (чтобы было 2 разных алгоритма), что обычно и делается в программах бэкапа и дедупликации. Обычно используется быстрый хэш и медленный, но скажем так более стойкий.
        Но у Вас проблема в том, что на этот более стойкий у вас тоже индекс. Зачем? Чтобы был?
        Вы же картинки добавляете своим софтом, поэтому если нашли по md5, то проверили у найденных строк sha1. Нет смысла искать сразу по sha1, а потом по md5, так как если быстрый хэш не совпадает, то медленный уже даже можно не сравнивать.

        Ну и конечно эта любовь к Bigint. К тому времени, как у Вас количество файлов превысит 4 млрд, вы уже не один раз переделаете и базу, и систему хранения, если вообще не закинете это дело. Особенно это касается полей по которым индексы.
        Про Bigint для Size даже смешно, Вы что реально думаете, что файлы больше 4 гигов, будут заливаться тем же кодом, что и фотки/аватарки юзеров? Там своих костыликов припасено.

        Ну и откройте для себя UNSIGNED для целочисленный полей.


        1. VolCh
          22.09.2018 09:29

          mime type должен отдаваться сервером при отдаче файла, один раз его определить, записать в базу и потом отдавать из неё — неплохой вариант.

          Список mime меняется, при каждом новом делать alter table?

          bigint спорно, но вот unsigned лучше не использовать без очень-очень веских причин, особенно в случае если заведомо будут на клиентах языки его не поддерживающие, такие как PHP или JS.


          1. zapimir
            22.09.2018 13:51

            и потом отдавать из неё — неплохой вариант

            Что неплохо в том чтобы для трёх mime-типов по 10 символов (image/png, image/gif, image/jpg), держать в базе столбец CHAR(32) и еще и индекс по этому столбцу?
            Список mime меняется, при каждом новом делать alter table?

            И в чем проблема у Вас каждую неделю новый формат для изображений придумывают?
            но вот unsigned лучше не использовать без очень-очень веских причин

            Ага, особенно с UNSIGNED TINYINT ужасные проблемы.


  1. FanatPHP
    20.09.2018 19:18
    -2

    Году в 2008 вас бы похвалили за такой код.


    Судя по оговоркам ("В качестве СУБД я использую расширение MySQLi", "законодательство, обязывающее хранить информацию о пользователях не менее полугода" в контексте обсуждения автоинкремента и пр.) — вы начинающий программист. Если поставите себе целью подтянуть код до современных стандартов, то через пару лет у вас получится то, что не стыдно показать широкой аудитории.


    Сейчас же, уж извините, у вас старый добрый спгетти-код, даром что завернутый в класс. В одну кучу смешались SQL, HTTP, файловая система. Никакой абстракции, никакого разделения ответственности. Обработка ошибок где-то лишняя ('cannot make dir: ' ), где-то отсутствует совсем (mysqli), где-то однозначно вредная (echo $e->getMessage();).


    В качестве работы над ошибками попробуйте сделать отдельные сервисы для работы с БД и HTTP. Для работы с таблицей файлов в БД также нужен будет отдельный класс. И ради бога, забудьте уже этот чудовищный mysql_query стайл. А то получается как в анекдоте — "ложечку вынул, а глаз все равно зажмуриваешь". Буковку i к вызовам функций приписал, а все остальное осталось как прежде.


    В итоге вместо ручного колупания с SQL должно получиться что-то вроде


    $this->repository->save($url, $meta, остальные параметры);

    В конкретной реализации тоже много странного (непонятно, зачем отдельный запрос для сохранения хэшей и размера файла; непонятно, почему логика получения файла сделана в РНР, а не в БД, почему вообще по одному и тому же url может оказаться больше одного файла и пр.) но это уже мелочи по сравнению со структурными проблемами.


    1. XanderBass Автор
      20.09.2018 19:36
      +1

      Простите, какой «mysql_query стайл»? MySQLi там используется в ООП-варианте. Там всё на ООП. Это раз. И два. Данный класс я выдернул из целостной системы, где и все сервисы существуют и обработка ошибок и вот это вот всё. Затем адаптировал для отдельного использования. Статья не о конкретном коде, а о самом принципе решения задачи. Код — лишь простейший пример реализации, являющий собой порядком урезанный код из реально работающей системы.


      1. FanatPHP
        20.09.2018 21:09
        -1

        Ой, зря вы взяли этот обиженный тон.
        Поймите, ваш код говорит о себе куда больше, чем вы хотели бы сказать оправданиями про "реальный код, в котором все правильно".


        Добавления палочки со стрелочкой к вызову функции тоже недостаточно. Оно не делает ваш код объектно-ориентированным, а работу с БД менее унылой.


        И ваша реакция на совершенно справедливое замечание о нормализации БД в комментарии выше — это тоже очень, очень печально.


        Не стыдно чего-то не знать. Стыдно принимать в штыки критику и отказываться учиться.


        1. XanderBass Автор
          21.09.2018 09:04

          А я и не воспринимаю критику в штыки. Я лишь уточняю, делаю пояснения. Поймите, уважаемый, я — не профессионал. Я — лишь любитель. И по сему имею право чихать с высокой колокольни и на стандарты кода (к слову код под PSR я причесал исключительно для Хабра), и на типовые решения, и на PROFIT-методики, и даже на традиции. Я многого не знаю, многое открываю для себя заново, порой только сейчас. Но я не скован рамками профессионализма ;-)


          1. FanatPHP
            21.09.2018 10:32

            Ну вот, теперь по крайней мере честно.


            Ваша бравада эмоционально оправдана, но со стороны смотрится жалко. Звучит это всё, как "Я не профессионал, я любитель. И поэтому я чихал на традиционные представления, что дважды два равно четыре. У меня будет 7!". И с таким отношением вы никогда не дойдете до квадратных уравнений.


  1. Serge78rus
    20.09.2018 19:32
    +3

    Вы запрещаете физическое удаление файла («для соответствия законодательству»), но при этом разрешаете его модификацию без сохранения первоначальной версии. Где логика?


  1. berez
    20.09.2018 20:06
    +2

    PROFIT!

    0f/65/84/10/67/68/19/ff.file

    У вас получилось 7 уровней вложенности папок. Это слишком много:

    1. На каждый проход вглубь тратится лишнее время (а ведь на диске сектора, в которых описана каждая папка, могут лежать вразброс). По мере добавления новых файлов папки будут раскидываться по диску. В один прекрасный момент перестанет хватать буферизации и все начнет тормозить.

    2. Большинство папок, кроме корневой, будет содержать ровно по одной записи. Это я вам вполне квалифицированно заявляю: у меня в одном из проектиков файлы разложены примерно так же, только структура двухуровневая (ab/cd/efgh1231231243232.file). И хотя файлов сложено уже несколько десятков тысяч, внутри папок второго уровня редко лежит больше одного-двух файлов.

    Отсюда вывод: делать структуру папок с более чем двумя уровнями вложенности — нехорошее излишество.

    Если уж вы интересуетесь производительностью файлового доступа, то неплохо было бы провести эксперименты, так сказать, «в натурных условиях». Создать структуру папок с определенной вложенностью, напихать в нее энцать миллионов файлов. Затем проверить производительность, вычитывая из нее случайные файлы. И так — для различных уровней вложенности и различных «наполненностей» папок (например, если резать не по два, а по три символа, то максимальное количество файлов в папке станет равно 4096). А если вы еще и на различных файловых системах эксперимент проведете, то вообще будет прекрасно — ведь, например, ReiserFS изначально разрабатывалась из расчета на быстрый поиск в папках. Думаю, результаты таких экспериментов были бы интересны многим завсегдатаям.


    1. XanderBass Автор
      20.09.2018 20:19

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


      1. berez
        20.09.2018 20:43
        +1

        Однако, с другой стороны перечитайте абзац про автоинкремент.

        Перечитал, но не понимаю, что вы имели в виду. Если вы о том, что трех байт может не хватить для нумерации всех файлов — так я и не предлагаю укорачивать имя файла. Просто
        0f/65/84/10/67/68/19/ff.file станет 0f/65/8410676819ff.file.
        Если идентификатором файла будет некий хэш, а не просто порядковый номер, то файлы «размажутся» по подпапкам более-менее равномерно. В результате в каждой конечной папке будет всего несколько десятков файлов (пара сотен, если количество файлов перевалит за 16777216). Ну а если файлов станет больше, то у вас возникнут проблемы совсем другого масштаба (в частности, приходит на ум исчерпание инодов файловой системы).


        1. b0sun
          21.09.2018 17:14

          Поддержу, не нужна такая глубокая вложенность. Из жизни: от хэша берутся два блока по 3 символа на каталоги, abc/def/filename. Занимая, на текущий момент, чуть больше 40 TB, эта структура содержит 3-5 файлов на каталог.
          «Обратный индекс», то бишь ревизия всех файлов для сравнения фактического наличия с БД — занимает около 3х суток (zfs). Чем глубже — тем дольше. Ну и тюнинг файловой системы, конечно, не помешает.


        1. morozovsk
          21.09.2018 17:21

          berez делал по схеме, которую вы описали, включая:

          Если идентификатором файла будет некий хэш, а не просто порядковый номер, то файлы «размажутся» по подпапкам более-менее равномерно.
          спустя полгода вышли из строя два новых HDD-диска в зеркальном рейде, на них проводился бекап изображений с основного сервера с SSD-рейдом, с помощью rsync.
          Логи atop были утеряны, а другой нагрузки кроме суточных бекапов не было, поэтому есть подозрения, что из-за «равномерного размазывания по подпапкам» rsync сканировал много лишнего и если бы я использовал вместо хеша инкремент, то старые папки бы не пересканировались лишний раз, потому что даты их изменения не менялись и диски бы прожили гораздо дольше.
          Но это только предположение. Может здесь есть специалисты по rsync, которые в курсе как он работает «под капотом».
          Во избежание подобных нюансов в будущем, к структуре с «равномерным размазыванием по подпапкам» был добавлен ещё один уровень папок «сегодняшняя дата», итого имеем:
          2018-09-21/0f/65/8410676819ff.file
          Данный подход позволяет создавать инкрементальные суточные бекапы без пересканирования всей структуры. Просто берётся папка за предыдущий день и создаётся архив или зеркалируется на другой сервер с помощью rsync.


          1. oxidmod
            21.09.2018 17:51

            А может они умерли просто потому, что умерли.


          1. berez
            21.09.2018 17:52
            +1

            Ну так-то да, у «размазывания» есть свои недостатки. :)
            Кстати, если у вас в день было не очень много файлов (тыщ 50-100), то можно по идее один уровень вложенности папок удалить: 2018-09-21/0f/658410676819ff.file
            В среднем это даст 200-300 файлов в папке, что вполне нормально.


  1. MzMz
    20.09.2018 20:09
    +2

    Как уже сказали, это отличная технология для 2002 года. Но, во-первых, ничего не сказано про sendfile. Во-вторых, завтра у вас сдохнет диск со всем каталогом — что с репликацией? RAID? Ну ок, тогда сдохла вся машина целиком, выключилась и не грузится. Что тогда делать? В-третьих, средний размер перепакованного аватара — 650 байт, а размер кластера на диске — 4096 байт, видите подвох?

    www.s3-client.com/s3-compatible-storage-solutions.html


  1. rPman
    20.09.2018 21:15
    -1

    У вас хранилище почти write only, т.е. писать с накоплением,… в чем то похожем я вообще не стал пользоваться файловой системой а реализовал примитивный формат хранения в блочном файле диска (точнее раздела но это уже не важно, хоть в файле пишите), где идентификатором было смещение на диске (точнее номер 4 килобайтового сектора) и размер файла, упакованные в 8 байт (6 байта номер сектора и 2 байта размер, вы можете сделать 5 и 3, если максимальный размер файла у вас другой, так же никто не мешает использовать больше числа), у меня файлы были маленькие, но никто не мешает и тут проявить смекалку, в зависимости от задач, размер файла можно вытягивать из содержимого (по типу файла), а размер задавать в секторах, заполнив остатки нулями.

    Так же можно хранить метаинформацию, по которой не нужно проводить индексацию и поиск, так же тут же рядом с файлом, например в структуре данных перед файлом, все равно вы ее тоже будете запрашивать, так зачем тратить на это базу данных.
    [точный размер метаинформации + точный размер файла+ мета-информация + файл + нули]

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

    Работать с таким архивом одно удовольствие — он выносится на отдельный физический носитель, линейное чтение при резервном копировании, и можно в принципе отключить lazy writes т.е. кеширование записи, сэкономив тут на дисковых кешах, а за кеширование чтения отвечает операционная система. Так же файл можно открыть замапив его в адресное пространство и работать с ним как с обычной памятью, это очень удобно.

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

    А еще любые изменения в файлах — это создание нового файла (с новым идентификатором и отметкой в своей базе об удалении старого), это нормально и вообще то считается фичей, ну возможно не сильно эффективной по месту на диске.


    1. iig
      20.09.2018 22:54

      КМК, 1000 файлов в каталоге это беда для fat32. Файловые системы из linux этим не страдают. Время открытия файла из ext4 сильно изменится. Правда, листинг такого каталога (для backup к примеру) будет делаться дорого.


      1. rPman
        20.09.2018 23:02

        Сам каталог — это лишнее чтение с диска, ничем не соптимизируешь. В статье предлагают несколько таких чтений, гарантированных на доступ к любому файлу.

        Можно конечно выкрутиться. Точно помню, есть файловые системы, позволяющие вынести хранение именно данных файлов на отдельный носитель, а всю структуру каталогов, распределение файлов по диску и журналы — на другом, например маленьком и быстром — оперативная память или ssd, толи jfs толи xfs, когда то давно я с этим игрался.

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


        1. iig
          21.09.2018 06:55

          Для такого хранилища файловая система избыточна. Раз все атрибуты файла хранятся в БД, а никакие свойства ФС не нужны, можно дописывать картинки в конец бинарного файла и запоминать в базе смещение. Нет проблем с инодами. С неиспользованным местом при хранении мелких файлов. С сохранением удаленных и перезалитых картинок. С дедупликацией… С backup… Удалять файлы навсегда трудно, ага.


          1. iig
            21.09.2018 07:00

            Упсь… было уже, сорри.


      1. tushev
        21.09.2018 13:00

        1000 файлов в каталоге это беда для fat32. Файловые системы из linux этим не страдают
        У меня как раз сейчас стоит задача положить около миллиона файлов в один каталог. (Зачем? Ответ — иначе придется сильно перепиливать legacy-код, а этого делать не хочется.)
        Каталог планируется разместить на отдельном 10 терабайтном HDD на ext4, который через симлинк будет подключен к основной файловой системе.
        Формально пишут, что число файлов в одном каталоге ext4 не ограничено. Утверждают что поиск файла в директории в ext4 идет по B-tree, т.е. вроде должен быть быстрым на большом количестве записей. Но все равно опасаюсь «подводных камней».
        Подскажите, пожалуйста, на какие проблемы я могу нарваться?


        1. iig
          21.09.2018 15:47

          Зависит от этого кода. Если внутри только fopen(filename) — скорее всего, ничего страшного. Если, конечно, правда то о чем пишут про B-tree.
          Если же внутри есть opendir/readdir — будут тормоза.
          Можно провести эксперимент. Создать текстовый файл в 1М рандомных имен файлов.

          time for ((i=0; i<1000000; i++)); do dd if=/dev/urandom bs=512 count=1 2>/dev/null  | md5sum - | awk '{print $1}' >> list.txt; done
          
          real	49m6.400s
          user	6m24.928s
          sys	9m55.281s
          

          Создать 1М этих файлов (непустых, для чистоты эксперимента).
          time while read l; do echo "$l$l" > dir/$l ; done < list.txt 
          
          real	2m51.659s
          user	0m35.826s
          sys	0m32.230s
          

          Посмотреть, сколько времени займет cat ${произвольный_файл_из_середины_списка}

          time cat dir/5f818b958f8b4be383b13d70145ad671
          5f818b958f8b4be383b13d70145ad6715f818b958f8b4be383b13d70145ad671
          
          real	0m0.018s
          user	0m0.000s
          sys	0m0.000s
          


          Создать новый файл в огромном каталоге
          time touch dir/876685e36bf04e096b40ba987c843ff8_
          
          real	0m0.291s
          user	0m0.000s
          sys	0m0.000s
          


          Новый файл в пустом каталоге
          time touch 876685e36bf04e096b40ba987c843ff8_
          
          real	0m0.002s
          user	0m0.000s
          sys	0m0.000s
          


          Листинг огромного каталога
          time ls dir| wc -l
          1000001
          
          real	1m16.389s
          user	0m8.033s
          sys	0m1.572s
          


          1. tushev
            22.09.2018 18:31

            Благодарю! Я сам планировал провести эти эксперименты, написать тестовые скрипты. А вы все сделали за меня. Огромное спасибо!
            Сейчас соберу сервер и погоняю ваши тесты на реальном железе, а потом и на реальном legacy-софте с реальными данными. Но думаю теперь уже вполне понятно чего можно ожидать.


      1. berez
        21.09.2018 13:06

        Файловые системы из linux этим не страдают.

        Страдают. Только это не так явно проявляется, как в винде, и на больших размерах каталогов.


  1. Stas911
    20.09.2018 23:07
    -1

    Картинки на S3, метадату в DynamoDB


    1. rPman
      20.09.2018 23:09

      А оплачивать вы будете?
      Готовые решения завсегда по стоимости выше своего получаются, ведь там приходится платить не только затраты на собственно задачу но и налоги и маржу нескольких посредников.


      1. Stas911
        20.09.2018 23:17
        -1

        Когда вы построите/найдете датацентр сравнимого качества и реализуете сервисы сравнимой надежности (гляньте просто для интереса, какова надежность S3) — тогда и можно будет сравнить стоимость.


        1. rPman
          22.09.2018 08:48

          Нет, высокая маржа на оплату сетевого трафика на столько высока, что во многих случаях использование s3 не оправдано.

          Тем более в задачи не сказано о характере нагрузки, количестве пользователей и вообще необходимости онлайн, поэтому оценить различия в затратах не представляется возможности. А так да, существует ограниченный список задач, при которых s3 оправдан более чем.


        1. VolCh
          22.09.2018 09:44

          Сравнивать цены нужно не после выбора самых надежных (как S3 с РКН дружит, кстати), а после отброса недостаточно надёжных. Если требования к надёжности на порядки ниже предоставляемых S3, а он на порядок дороже альтернатив, то вряд ли он будет хорошим выбором.


  1. Arik
    21.09.2018 09:15

    Был примерно подобный опыт, но использовали MongoId, на практике в одних папках лежало овер4к файлов, в других 2-3. Как тут с равномерностью? Пока новых задач не было под большое кол-во файлов, поэтому просто положил оригинальные файлы под primaryKey /files/000/000/001.jpg, при этом извне недоступны, дальше по запросу нарезается(?) раздается под кэшем со storage-(1-2-3).site.tld/access-key/$file_id/(full|120x90|...).ext. Таблица файлов естественно знает откуда раздается файл


  1. Cyclop
    21.09.2018 20:12

    Увы — ничто не ново под луной. )
    Данный принцип хранения файлов используется в EMC Documentum со времен царя Гороха.
    С другой стороны это говорит только о том, что мысль XanderBass ушла в правильном направлении. )