# linkding на Go: сутки на порт и неделя на то, что всплыло после


Закладки я давно храню в [linkding](https://github.com/sissbruecker/linkding). Привык к нему настолько, что на прошлой неделе написал для него клиент под Android[^1]. К самому серверу претензия у меня была одна, зато постоянная.

linkding написан на Python и Django. Настройки запуска я не менял, а по умолчанию образ поднимает uWSGI в два процесса по два потока[^12]. Одновременно сервер обрабатывает четыре запроса, остальные ждут в очереди. При этом мой linkding смотрит в интернет, и кроме меня к нему ходят сканеры и боты, которые перебирают адреса. Время от времени он просто переставал отвечать, и приходилось ждать, пока отпустит. Число процессов можно поднять, но каждый занимает память, а пользуется сервисом один человек.

В какой-то момент я задумался, нельзя ли перенести linkding на Go. Писать руками порт чужого проекта с админкой, OIDC, импортом, снимками страниц и двумя базами данных я бы не стал. Поэтому поручил анализ и работу Codex.

## Сутки

Сначала появился документ с условиями приёмки[^2]. В нём зафиксирована версия оригинала, v1.47.0, и перечислено, что должно совпасть: маршруты, поля JSON, коды ответов, тексты ошибок, внешний вид страниц, настройки `LD_*`. Отдельной строкой записано главное правило всей работы: если документация и код оригинала расходятся, образцом считается ответ запущенного linkding этой версии.

Так и шла работа. Каждый шаг сверялся с Python-версией, и при любом расхождении порт дорабатывался. По истории репозитория это видно лучше, чем по моему пересказу: десятки коммитов подряд начинаются со слова `match`. Совпадение формы ошибки при битом заголовке с токеном. Совпадение ответов на `HEAD` и `OPTIONS`. Порядок вариантов сортировки в выпадающем списке. Счётчики в фильтрах админки. Переводы админки, взятые из каталогов Django.

Первый коммит датирован вечером 25 сентября, релиз v1.47.0 вышел днём 26-го[^3]. Между ними 76 коммитов. Codex работал чуть больше суток почти без остановок, и на выходе получился сервер, который собирается без CGO в один исполняемый файл и умеет SQLite и PostgreSQL.

Существующую установку порт принимает через отдельную команду миграции[^4]. Она переносит пользователей, хеши паролей, закладки, теги, токены API и файлы в новую базу, а исходную не трогает, так что откатиться можно в любой момент. Пароли и токены остаются прежними, заново нужно только войти.

## Первый запуск: OIDC

Я перенёс данные, запустил порт на месте оригинала и не смог войти.

Перед linkding у меня стоит Caddy: он принимает HTTPS, а до приложения запрос идёт по обычному HTTP. Порт видел у себя HTTP и именно с этой схемой собирал адрес возврата для OIDC. Провайдер такой адрес не принимал.

В оригинале эту задачу решает промежуточный слой Django, который читает заголовки прокси. В порте пришлось чинить код: с настройкой `LD_USE_X_FORWARDED_HOST` сервер берёт схему и имя хоста из заголовков `X-Forwarded-Proto` и `X-Forwarded-Host`[^5]. Без неё он эти заголовки игнорирует, иначе адрес возврата мог бы подставить любой клиент; на оба случая есть тесты. Исправление вышло в v1.47.1, через два часа после первого релиза.

## Go оказался медленнее

Ради чего всё затевалось, нужно было проверить. Я попросил нагрузочное сравнение: один и тот же набор из 10 000 закладок, оба сервера в контейнерах с одинаковыми ограничениями, запускаются по очереди[^6].

Результат на SQLite меня удивил.

| Запрос, 8 клиентов | Python, запросов/с | Go, запросов/с | Go к Python |
| --- | ---: | ---: | ---: |
| Список | 177 | 235 | 1,33 |
| Поиск | 66 | 55 | 0,84 |
| Поиск с тегом | 59 | 41 | 0,70 |
| Страница интерфейса | 90 | 71 | 0,79 |
| Создание закладки | 347 | 307 | 0,89 |

Порт проигрывал оригиналу почти везде, где работали несколько клиентов сразу. А в том же прогоне нашлась вещь похуже скорости: при восьми параллельных клиентах 664 из 1000 запросов на создание закладки вернули HTTP 500. SQLite не пускал вторую пишущую транзакцию, пока шла первая, а порт не ждал. Это закрыл тот же релиз v1.47.1: транзакции теперь открываются в режиме `IMMEDIATE`.

Дальше началась обычная работа с базой. Разбор запросов показал, что язык тут ни при чём:

- Страницу из ста закладок порт собирал сотней отдельных запросов, по одному на закладку с её тегами. Python забирал теги для всей страницы разом.
- При создании закладки проверка на дубликат не попадала в индекс и просматривала все закладки владельца.
- Поиск по тегу повторял соединение таблиц для каждой закладки-кандидата.
- На каждое слово в поиске функция сравнения без учёта регистра вызывалась четыре раза.

Каждая правка мерилась отдельной парой прогонов «до и после»[^7]:

| Что изменилось | Было, запросов/с | Стало, запросов/с |
| --- | ---: | ---: |
| Список, 32 клиента | 244 | 349 |
| Поиск, 32 клиента | 57 | 107 |
| Страница интерфейса, 32 клиента | 69 | 231 |
| Создание закладки, 8 клиентов | 315 | 1462 |

Пары относятся к разным экспериментам, перемножать их в одно общее ускорение нельзя. И победа не полная: на поиске с тегом при 32 клиентах хвост задержек у Go в последнем сравнении остался длиннее, чем у Python. Справедливости ради, оригинал тоже выигрывает от настройки базы: после `ANALYZE` создание закладок на Python ускорилось с 348 до 412 запросов в секунду.

Мне важнее было другое измерение: сколько ресурсов сервер тратит при одинаковой нагрузке. Оба получали по 50 запросов в секунду[^8].

| | Python | Go |
| --- | ---: | ---: |
| Процессор, ядер (четыре сценария API) | 0,43–1,63 | 0,18–0,64 |
| Память под нагрузкой, МиБ | 120–158 | 14–24 |
| Память в простое, МиБ | 68 | 9 |

Это и есть ответ на исходную претензию. Сканеры никуда не делись, но теперь их обслуживает один процесс, которому хватает двух десятков мегабайт.

## Неделя на собственном сервере

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

**Фильтр, который сбрасывался.** Я открыл закладки с тегом `#inbox`, отметил первую страницу и отправил в архив. В строке поиска по-прежнему стояло `#inbox`, а список под ней показывал все закладки подряд. Порт не передавал текущий запрос в адрес действия, оригинал передаёт. При проверке соседних форм нашлось то же самое в поиске и в его настройках: они теряли активные фильтры, которых не было среди видимых полей[^9].

**Длинный заголовок.** Закладка со страницы, у которой заголовок длиннее 512 символов, не сохранялась: сервер отвечал ошибкой 400. Заодно выяснилось, что некоторые пути записи это ограничение обходили и упирались уже в базу. Теперь заголовок нормализуется и обрезается до 512 символов Юникода в одном месте, через которое проходят API, формы, админка, импорт и фоновое обновление метаданных[^10].

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

**Общий адрес у многих владельцев.** Эту проблему показал разбор запросов, сам я с ней столкнуться не мог: если одну и ту же ссылку сохранили тысячи пользователей, поиск дубликата просматривал их всех. Составной индекс по адресу и владельцу поднял скорость создания в таком сценарии с 533 до 6884 запросов в секунду[^11]. На моей установке с одним пользователем разницы нет, но оставлять это не хотелось.

За пять дней вышло восемь релизов, от v1.47.0 до v1.47.7.

## Что в итоге

Сейчас у меня работает порт, и разницы с оригиналом я не замечаю: тот же интерфейс, те же расширения для браузера, тот же адрес, ShareDing ходит в тот же API.

Что стоит знать, прежде чем пробовать:

- Порт привязан к linkding v1.47.0. Новые версии оригинала сами в него не попадут.
- Это независимый проект. Оригинал по-прежнему ведёт его автор, Sascha Ißbrücker ([sissbruecker](https://github.com/sissbruecker)), и вопросы по порту ему задавать не нужно.
- Все измерения сделаны на ноутбуке короткими прогонами. Они показывают, где порт быстрее или медленнее на одних и тех же данных, и ничего не обещают о предельной нагрузке.
- Проверен он одной установкой — моей.

Последний пункт я могу исправить только с вашей помощью. Код лежит в репозитории [juev/linkding](https://github.com/juev/linkding), готовые сборки и образы — в релизах, порядок переноса описан в руководстве по миграции[^4]. Исходная база при переносе остаётся нетронутой, так что попробовать можно без риска для закладок. Если что-то работает не так, как в оригинале, или чего-то не хватает, заведите issue.

[^1]: [ShareDing для Android: ссылки в linkding, даже когда сервер недоступен](/2026/09/29/shareding-android/).
[^2]: [linkding v1.47.0 migration contract](https://github.com/juev/linkding/blob/main/docs/specs/linkding-parity.md).
[^3]: [Релизы juev/linkding](https://github.com/juev/linkding/releases).
[^4]: [Migrate from Python linkding v1.47.0](https://github.com/juev/linkding/blob/main/docs/migration.md).
[^5]: Коммит [120980f](https://github.com/juev/linkding/commit/120980f): схема из заголовка прокси для OIDC и режим транзакций SQLite. Настройка описана в [руководстве по установке](https://github.com/juev/linkding/blob/main/docs/installation.md).
[^6]: [Local load comparison: Python linkding and the Go port](https://github.com/juev/linkding/blob/main/docs/performance/2026-09-26-linkding-comparison.md) — условия, полные таблицы для SQLite и PostgreSQL, способ повторить.
[^7]: [Performance optimization results](https://github.com/juev/linkding/blob/main/docs/performance/2026-09-26-optimization-summary.md).
[^8]: [CPU and memory at equal request rates](https://github.com/juev/linkding/blob/main/docs/performance/2026-09-26-resource-comparison.md). Память — анонимная память контейнера, медиана трёх прогонов.
[^9]: [Pull request #3](https://github.com/juev/linkding/pull/3), вошёл в v1.47.6.
[^10]: Коммит [2d80805](https://github.com/juev/linkding/commit/2d80805), вошёл в v1.47.7.
[^11]: [Pull request #1](https://github.com/juev/linkding/pull/1), вошёл в v1.47.5.
[^12]: [uwsgi.ini](https://github.com/sissbruecker/linkding/blob/v1.47.0/uwsgi.ini) в linkding v1.47.0: `processes = 2`, `threads = 2`.

