← Все кейсы

Таможенное оформление

Классификация кодов ТН ВЭД

Год
2024
Роль
Бэкенд-инженер, ответственный за прод
Результаты
1 400 деклараций в минуту

Контекст

Таможенный оператор оформляет декларации для импортёров. Классификацию товара по номенклатуре делал человек, сверяясь со справочником ведомства; ошибка означала задержку груза на границе и разбирательство. Бизнес-задача — снять с декларанта поиск кода и оставить ему только подтверждение.

Ограничения

  • Внешний реестр падает примерно раз в сутки и не предупреждает об этом
  • Справочник обновляется без версионирования и без уведомлений
  • Ошибка классификации стоит клиенту задержки груза на границе

Решение

Реестр закрыт circuit breaker'ом, а разбор идёт через воркер-пул с ограниченным параллелизмом: тридцать две горутины подобраны замером, а не на глаз — выше пропускная способность не росла, а p99 начинал плыть.

internal/classifier/pool.go
func (p *Pool) Run(ctx context.Context, in <-chan Declaration) <-chan Result {
	out := make(chan Result, p.size)

	var wg sync.WaitGroup
	for i := 0; i < p.size; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for decl := range in {
				res, err := p.classify(ctx, decl)
				if err != nil {
					p.metrics.Failures.Inc()
					res = Result{Declaration: decl, Fallback: true}
				}
				out <- res
			}
		}()
	}

	go func() { wg.Wait(); close(out) }()

	return out
}

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

Архитектура

Реестр закрыт размыкателем: его падение не доходит до декларанта. декларант → api; api → prometheus; api → classifier; api → queue; classifier → реестр ↯ (breaker); classifier → postgres; queue → worker-pool; worker-pool → postgresbreakerдекларантapiprometheusclassifierqueueреестр ↯worker-poolpostgres
Реестр закрыт размыкателем: его падение не доходит до декларанта

Ключевые решения

  • Поведение при недоступном реестре

    Что выбрал

    Circuit breaker и кэш последнего успешного снимка справочника. При обрыве система отвечает данными вчерашней версии и помечает ответ как устаревший.

    Что отверг

    Ретраи с экспоненциальной задержкой без размыкателя: они превращают часовую недоступность реестра в часовую недоступность нашего API.

    Почему

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

  • Размер воркер-пула

    Что выбрал

    Тридцать две горутины — подобрано замером на боевом профиле нагрузки.

    Что отверг

    Пул по числу ядер или неограниченный запуск горутин на каждую декларацию.

    Почему

    Выше тридцати двух пропускная способность переставала расти, а p99 начинал плыть: узкое место переезжало в пул соединений к базе. Число из замера, а не из формулы.

Что было сложно

  • Проблема

    Справочник менялся без версионирования: ведомство молча правило позиции, и кэш отдавал устаревшие коды как актуальные.

    Решение

    Сборщик считает хэш выгрузки и сравнивает с прошлым. Расхождение помечает затронутые позиции и снимает их с кэша точечно, а не сбрасывает справочник целиком.

  • Проблема

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

    Решение

    Prometheus и дашборд поставил сразу после. Следующая деградация читалась с графика за полминуты.

Результаты

Под NDA

Кейс под NDA: абсолютные значения заменены относительными.

пропускная способность
≈ / мин
p95
≈ ms
ошибок классификации
≈ %
деградаций за 30 дней
≈ · 6 мин

Стек