94. Переименование файла (ФС + Postgres) в транзакции
Условие задачи:
Есть система, которая дает юзерам возможность работать с файлами в браузере. Стек стандартный. Java, Spring, React, Postgres. Файлы хранятся на файловой системе на беке. Метаданные файлов в БД. Команда реализовала фичу - переименование файла. К вам пришел разработчик и показывает код на ревью. В команде принято следующее: На код ревью приходит код с зеленым билдом, подвохов с компиляцией нет. Код базово покрыт автотестами с поднятием Спрингового контекста. Подвохов со Спринг прокси тоже нет. Проведите код ревью
Код:
@Transactional
public void process(String oldName,
String newName) {
Long id = exec("select id from file
where name='" + oldName + "'"); //
выполнение запроса к БД
processFile(oldName, newName); //
файла на диске переименование
exec("update file set name=\"" +
newName + "\" where id = " + id); //
выполнение запроса к БД
Спойлеры к решению
Подсказки
💡 SQL-конкатенация = SQL injection + проблемы с кавычками + экранирование.
💡
file — потенциально зарезервированное слово/плохое имя таблицы.💡 Поиск по
name может вернуть несколько строк → не определено поведение.💡 Гонки: два запроса + операция ФС между ними → возможны race conditions.
💡 Нет проверки на существование
newName (уникальность), нет обработки коллизий на ФС.💡 Нет валидации входа (пустые имена, path traversal
../).💡 Нет «атомарности»: что делать, если файл переименовался, а update не прошёл (или наоборот)?
Решение
Что плохо в текущем решении #
- SQL Injection + кавычки
"where name='" + oldName + "'"
Любой
oldNameс'ломает запрос.Можно инжектить SQL.
✅ Исправление: только параметризованные запросы (PreparedStatement/JdbcTemplate/Repository).
- Несогласованность БД и файловой системы
@Transactionalоткатывает только БД.Если
processFile()переименовал файл, аupdateупал → БД откатится, а файл останется переименованным.Если наоборот: БД обновили, а файл не смогли переименовать → метаданные врут.
✅ Нужна стратегия согласованности:
либо сначала БД + запись “в процессе”, потом ФС, потом “готово” (saga/state machine),
либо outbox/интеграционный подход,
либо строгое правило: сначала ФС, потом БД, и при ошибке ФС делать компенсирующее действие (rename back).
- Гонки и конкуренция
Между
selectиupdateможет вмешаться другой поток:файл уже переименовали,
запись изменили,
появилось другое имя.
✅ Решение:
использовать
SELECT ... FOR UPDATEпо id,или сразу
UPDATE ... WHERE name = ?и проверять количество обновлённых строк,или уникальный индекс на
name+ обработкаduplicate key.
- Поиск по имени
Имя не обязано быть уникальным (или не сказано).
select id where name=...может вернуть несколько строк.
✅ В модели должен быть стабильный идентификатор: переименовываем по fileId, а не по имени.
- Валидация и безопасность имени
newNameможет содержать../,\0, слэши, спецсимволы → path traversal, выход из директории.oldName/newNameмогут быть пустыми или слишком длинными.
✅ Валидация:
разрешённый набор символов,
запрет
..,/,\,длина,
нормализация Unicode (по требованиям).
- Нет обработки коллизий
newNameуже существует:на ФС (файл с таким именем есть),
в БД (уникальность должна быть обеспечена).
✅ Решение:
уникальный индекс в БД (например,
(folder_id, name)),проверка/обработка
FileAlreadyExistsException.
Как бы я переписал (концептуально, без привязки к ORM) #
Идея: работать по fileId, держать блокировку и гарантировать согласованность.
Псевдо-код:
@Transactional
public void rename(long fileId, String newName) {
// 1) заблокировали запись
FileMeta meta = repository.findByIdForUpdate(fileId)
.orElseThrow(...);
String oldName = meta.getName();
// 2) валидация
validateName(newName);
// 3) обновили БД (можно поставить статус RENAMING)
meta.setName(newName);
repository.save(meta);
// 4) после коммита делаем ФС-операцию
// чтобы не держать транзакцию БД на время IO
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
try {
fileStorage.rename(oldName, newName); // atomic move в пределах FS
} catch (Exception ex) {
// компенсация: отправить в очередь на reconcile/rollback
// например, outbox event "RENAME_FAILED"
}
}
}
);
}
Пояснение:
Внутри транзакции БД — только работа с метаданными (быстро).
IO (файловая система) — после коммита, чтобы не держать транзакцию открытой.
Если rename на ФС упал — нужен механизм компенсации (reconcile job / outbox / статус “ошибка”).
Минимальные обязательные правки прямо сейчас (если “быстро пофиксить”) #
Параметризовать SQL.
Переименовывать по
fileId, а не по имени.Сделать уникальность имени (обычно по папке) на уровне БД.
Добавить валидацию
newName.Добавить обработку ошибок и компенсацию (rename back / статус).
Сильный “production” вариант #
В БД: поля
status(READY/RENAMING/ERROR),updated_at, уникальный индекс(folder_id, name).События: outbox “FILE_RENAMED” / “FILE_RENAME_FAILED”.
Фоновая джоба reconcile: сверяет БД и ФС, чинит расхождения.