15 подбрани въпроса за интервю за Rust бекенд, групирани по ниво на старшинство. Използвайте ги, за да прегледате основите, практическите компромиси и начина на мислене на Senior ниво за производство.
1Обяснете правилата за собственост на Rust и как те оформят практическите избори при разработка на бекенд, като клониране, заемане (borrowing) и споделяне на състоянието на приложението.
Rust дава на всяка стойност единствен собственик. Преместването на `non-Copy` стойност прехвърля собствеността, а стойността се унищожава, когато собственикът ѝ излезе от обхват. Кодът може или да заема данни неизменяемо чрез споделени референции, или изменяемо чрез изключителна изменяема референция. В бекенд кода, това оформя дали една функция поема собствеността, заема временно, клонира, за да получи независима притежавана стойност, или споделя дългосрочно състояние чрез типове като `Arc`, със синхронизация или други примитиви за конкурентност, когато е необходима споделена промяна.
use std::sync::Arc;
struct AppState {
service_name: String,
}
async fn handler(state: Arc<AppState>, request_id: String) -> String {
// Borrow: no ownership transfer of the String inside state.
let name: &str = &state.service_name;
// Clone only if an owned independent value is needed.
let owned_name = name.to_owned();
format!("{owned_name}:{request_id}")
}
fn configure(state: Arc<AppState>) {
// Cloning Arc increments the reference count; it does not clone AppState.
let state_for_route = Arc::clone(&state);
let _ = state_for_route;
}
2Каква е разликата между заемане с `&T` и `&mut T`, и как тези правила влияят на `API` (Application Programming Interface) и сигнатурите на обработчиците?
`&T` е споделена препратка: тя позволява достъп само за четене и много споделени препратки към една и съща стойност могат да бъдат активни едновременно. `&mut T` е изключителна изменяема препратка: тя позволява промяна, но докато е активна, никакви други споделени или изменяеми препратки към същата стойност не могат да бъдат използвани. Тези правила оформят API, като карат функциите само за четене да приемат `&T` или по-тесни заети типове като `&str`, докато изменящите функции приемат `&mut T`, поемат собственост или използват вътрешна изменяемост/синхронизация. Бекенд обработчиците обикновено избягват директен `&mut` достъп до споделено състояние на приложението при едновременни заявки и вместо това използват споделени дръжки плюс синхронизация или други модели на собственост.
struct User {
name: String,
login_count: u64,
}
fn display_name(user: &User) -> &str {
&user.name
}
fn record_login(user: &mut User) {
user.login_count += 1;
}
fn main() {
let mut user = User { name: "Ada".into(), login_count: 0 };
let name = display_name(&user); // shared borrow for reading
println!("{name}");
record_login(&mut user); // exclusive mutable borrow for mutation
}
3Как работи `Box` и какви проблеми решава разпределянето на памет в динамичната памет (heap) чрез `Box` в Rust?
`Box<T>` е интелигентен указател, който управлява собствеността, чиято `T` се съхранява в динамичната памет (heap), докато самата `Box` е стойност с фиксиран размер, подобна на указател. Той има единична собственост и освобождава динамично разпределената памет, когато излезе извън обхват. `Box` осигурява индиректност, което помага при големи стойности, рекурсивни типове, които се нуждаят от известен размер, стойности с динамичен размер и trait обекти като `Box<dyn Trait>`. Преместването на `Box` прехвърля собствеността върху алокацията, без да мести или копира самата стойност, съхранена в динамичната памет.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Опишете `Result` и `Option`, както и основните идиоматични начини за работа с отсъствие на стойност и податливи на грешки операции.
`Option<T>` представлява или `Some(T)`, или `None` и се използва за очаквано отсъствие на стойност. `Result<T, E>` представлява или `Ok(T)`, или `Err(E)` и се използва за податливи на грешки операции, при които информацията за грешка е важна. Идиоматичното обработване включва `match`, `if let`/`let else`, комбинатори като `map`, `and_then`, `ok_or`, `unwrap_or` и `unwrap_or_else`, както и оператора `?` за ранно разпространение от функции, връщащи съвместими типове `Option` или `Result`. `unwrap` и `expect` е най-добре да се запазват за инварианти, тестове, прототипи или невъзстановими ситуации, а не за нормални възстановими грешки в бекенда.
5Как работи операторът `?` и какво позволява преобразуването между различни типове грешки?
Операторът `?` е съкращение за разпространение на грешка. За `Result`, `expr?` връща `Ok` стойността и продължава, или се връща рано от текущата функция с `Err`; за `Option` той връща `Some` стойността или връща `None`. Обхващащата функция трябва да връща съвместим тип. За `Result` различните типове грешки могат да се съчетават, защото изходната грешка се преобразува в типа грешка на функцията с помощта на преобразуване от тип `From`/`Into`, често имплементирано ръчно или изведено с помощни крейтове (crates) като `thiserror`.
use std::{fs, io, num::ParseIntError};
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_number(path: &str) -> Result<i32, AppError> {
let s = fs::read_to_string(path)?; // io::Error -> AppError
let n = s.trim().parse::<i32>()?; // ParseIntError -> AppError
Ok(n)
}
6Обяснете съпоставянето по шаблон (pattern matching) при изброими типове (enums) и как изчерпателното съпоставяне (exhaustive matching) подобрява коректността на предметната област и програмния интерфейс (API).
Изброимите типове (enums) моделират стойност, която може да бъде един от фиксиран набор от варианти, като вариантите могат да носят данни. `match` разклонява според варианта и може да деструктурира данните вътре. Rust проверява дали съпоставянията на enum са изчерпателни, освен ако не се използва шаблон с маска (wildcard) или общ шаблон (catch-all). Това подобрява коректността на предметната област и API, защото когато се добави ново състояние или вариант на отговор, компилаторът може да принуди кода, който съпоставя този enum, да реши как да обработи новия случай, вместо мълчаливо да го игнорира.
7Какво представляват newtype обвивките и как подобряват типовата безопасност за ID-та, пари, токени и тайни?
Една newtype обвивка е отделен Rust тип, често структура на кортеж с едно поле, около съществуващо представяне, като например `struct UserId(Uuid);` или `struct AccessToken(String);`. Тя подобрява типовата безопасност, защото компилаторът няма да обърка семантично различни стойности, които споделят един и същ базов тип, като например `UserId` срещу `OrderId`, центове срещу долари или сурови низове срещу валидирани токени. Newtype обвивките могат също така да централизират правилата за конструиране и валидиране и да контролират трейтове като `Display`, `Debug`, `Serialize` или поведението при редактиране за тайни.
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);
struct SecretToken(String);
impl std::fmt::Debug for SecretToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretToken(**redacted**)")
}
}
fn load_user(id: UserId) {
// query by user id
}
fn example(user_id: UserId, order_id: OrderId) {
load_user(user_id);
// load_user(order_id); // does not compile: expected UserId, found OrderId
}
8Как работи трейтът `Drop`, включително редът на изтриване, почистването чрез RAII и ограниченията около асинхронното почистване?
`Drop` е детерминираният механизъм за почистване в Rust. Когато дадена притежавана стойност бъде унищожена, например когато излезе от обхват, Rust автоматично извиква метода ѝ `drop(&mut self)`, ако той имплементира `Drop`, след което изтрива нейните полета. Това поддържа RAII (Resource Acquisition Is Initialization): ресурси като файлове, сокети, заключвания, транзакции, разрешителни или буфери са обвързани с притежаващи стойности/пазители и се освобождават, когато тези стойности бъдат изтрити. Локалните променливи се изтриват в обратен ред на създаване; за `struct` с `Drop`, методът `drop` на структурата се изпълнява преди нейните полета да бъдат изтрити, а полетата се изтриват в реда на деклариране. `Drop::drop` е синхронен и не може да бъде `async` или `awaited`, така че грациозното асинхронно почистване обикновено изисква изричен асинхронен метод за затваряне/спиране/изчистване (*close/shutdown/flush*) или друг дизайн; `Drop` може да извършва само синхронно или „по най-добър опит“ (*best-effort*) почистване.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
struct Pair {
first: Guard,
second: Guard,
}
impl Drop for Pair {
fn drop(&mut self) {
println!("drop Pair");
}
}
fn main() {
let _a = Guard("a");
let _pair = Pair {
first: Guard("first"),
second: Guard("second"),
};
let _b = Guard("b");
}
9Обяснете времената на живот (lifetimes) и защо понякога са необходими изрични анотации за време на живот (lifetime annotations) в API интерфейсите на Rust.
Време на живот описва колко дълго една референция е валидна, позволявайки на компилатора да отхвърля висящи референции. Анотациите за време на живот са ограничения по време на компилация, които изразяват връзки между референции; те не удължават времето на живот на базовите данни или не добавят поведение по време на изпълнение. Много прости сигнатури се обработват от елизия на време на живот (lifetime elision), но изрични анотации са необходими, когато компилаторът не може да изведе как входящите и изходящите референции са свързани, когато типовете съхраняват референции, или когато един API трябва да изрази общи ограничения за заемане (borrowing constraints).
fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
struct UserView<'a> {
name: &'a str,
}
fn main() {
let a = String::from("short");
let b = String::from("longer");
let result = longest(&a, &b);
let view = UserView { name: result };
println!("{}", view.name);
}
10Какво означава времето на живот `'static` и как взаимодейства то със стартирани задачи и дизайн на бекенд API?
Обозначението `'static` върху референция означава, че реферираните данни са валидни за цялата програма, както е при стринговите литерали или `static` елементи. Ограничение от типа `T: 'static` означава, че стойностите от тип `T` не съдържат заети референции, които не са `'static`, така че те могат да бъдат държани за произволно време на живот; това не означава, че самата стойност трябва да съществува вечно или да не може да бъде изтрита. Стартирани задачи често изискват `'static` futures, тъй като изпълнителят може да продължи да ги изпълнява, след като стековият фрейм, който ги е стартирал, се е върнал. В бекенд кода това обикновено означава преместване на притежавани данни в задачата с `async move`, използване на притежавани типове или клониране на споделени дръжки като `Arc`, вместо заемане на локални променливи. API интерфейсите трябва да използват `'static` ограничения за съхранени обратни извиквания (callbacks), фонови задачи и дълготрайно състояние, но да избягват ненужни `'static` ограничения за краткотрайни заети операции.
use std::sync::Arc;
struct AppState {
name: String,
}
fn spawn_background(state: Arc<AppState>, user_id: String) {
tokio::spawn(async move {
// The task owns an Arc handle and a String, so it does not borrow this function's stack.
println!("{}:{user_id}", state.name);
});
}
11Опишете как Rust използва трейтове за полиморфизъм и сравнете `impl Trait`, генерици и `dyn Trait`.
Rust използва трейтове, за да изразява споделено поведение. Генерични ограничения като `fn f<T: Trait>(x: T)` обикновено използват статично диспечиране чрез мономорфизация. `impl Trait` на позиция на аргумент е най-вече съкращение за анонимен генеричен параметър. `impl Trait` на позиция на връщана стойност скрива един конкретен тип на връщана стойност, избран от функцията. `dyn Trait` е обект от трейт, използван зад указател като `&dyn Trait` или `Box<dyn Trait>`; той използва динамично диспечиране чрез vtable и поддържа изтриване на типове по време на изпълнение/хетерогенни стойности, подлежащи на ограничения за безопасност на обекта.
12Какво представляват асоциираните типове в трейтове и кога са за предпочитане пред генеричните типови параметри?
Асоциираните типове са именувани плейсхолдъри за типове, декларирани вътре в трейт и специфицирани от всяка имплементация, например `Iterator::Item`. Те са за предпочитане, когато типът е част от договора на трейта и всеки имплементатор има един естествен избор за него. Генеричните типови параметри са за предпочитане, когато извикващият код трябва да избере типа или когато един и същ имплементатор може да се нуждае от множество имплементации за различни типови избори.
13Как бихте имплементирали модела на транзакционна изходяща кутия в Rust услуга?
За да имплементирате модела на транзакционна изходяща кутия в Rust услуга, запишете актуализациите на домейновите данни и полезните товари на изходящите събития атомарно в рамките на една база данни транзакция (напр. използвайки `sqlx::Transaction`). Таблица `outbox` съхранява дестинационната тема, полезния товар и статуса или времевото клеймо на създаване. Фонов асинхронен работник (или процес за улавяне на промени в данните като Debezium) след това проучва или стриймва чакащи събития и ги публикува към брокера за съобщения (като Kafka, RabbitMQ или NATS). Когато се използва работник, който проучва за събития в Rust (напр. в `tokio::spawn` цикъл), използването на `SELECT ... FOR UPDATE SKIP LOCKED` позволява на множество инстанции на услугата да извличат и изпращат редове от outbox таблицата едновременно без конфликт на заключване. След като доставката от брокера е потвърдена, работникът актуализира статуса на записа в outbox таблицата или изтрива реда. Тъй като повторните опити в мрежата могат да доведат до доставка най-малко веднъж, консуматорите надолу по веригата трябва да са проектирани да обработват съобщения идемпотентно.
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub async fn create_user_with_outbox(
mut tx: Transaction<'_, Postgres>,
username: &str,
email: &str,
) -> Result<(), sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
"INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
user_id, username, email
)
.execute(&mut *tx)
.await?;
let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
sqlx::query!(
"INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
Uuid::new_v4(),
"user_events",
payload
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn run_outbox_relay(pool: PgPool) {
loop {
let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
let rows = sqlx::query!(
"SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
)
.fetch_all(&mut *tx)
.await;
if let Ok(events) = rows {
for event in events {
// publish_to_broker(&event.topic, &event.payload).await;
let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
.execute(&mut *tx)
.await;
}
let _ = tx.commit().await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
14Как бихте проектирали многостъпкова операция, която актуализира база данни и извиква външен API (Application Programming Interface)?
Проектирането на многостъпкова операция, която обхваща запис в база данни и извикване на външен API, изисква управление на частични откази и мрежови разделения, без да се разчита на двуфазно потвърждение (2PC).
1. **Първо запазете състоянието / намерението (Persist State / Intent First):** Запишете първоначално състояние (като `PENDING` или `INITIATED`) и необходимия полезен товар в локалната транзакция на базата данни. Потвърдете тази транзакция, преди да инициирате външното мрежово извикване, за да се избегне задържането на заключвания на базата данни и слотове от пула на връзки по време на бавно мрежово I/O.
2. **Идемпотентна външна заявка (Idempotent External Request):** Извикайте външния API, използвайки детерминистичен ключ за идемпотентност (напр. извлечен от UUID на операцията). Това гарантира, че повторните опити не причиняват дублиращи се странични ефекти в реалния свят (като двойно таксуване).
3. **Преходно състояние и обработка на изчаквания (Transition State & Handle Timeouts):** Ако извикването на API върне успешен отговор, актуализирайте записа в базата данни на `COMPLETED`. Ако се провали окончателно, маркирайте го като `FAILED` и изпълнете компенсиращи действия. Ако извикването изтече или върне мрежова грешка, маркирайте записа като `INDETERMINATE` / `REQUIRES_RECONCILIATION` и оставете фонов работник да съгласува състоянието, като извлича информация от крайния пункт за състоянието на доставчика или повтаря опита със същия ключ за идемпотентност.
use uuid::Uuid;
pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
let payment_id = Uuid::new_v4();
let idempotency_key = format!("pay_{}", payment_id);
// 1. Commit intent locally before making external network calls
sqlx::query!(
"INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
payment_id, user_id, amount, idempotency_key
)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// 2. Call external API with timeout and idempotency key
let client = reqwest::Client::new();
let api_result = client.post("https://api.payment.com/v1/charges")
.header("Idempotency-Key", &idempotency_key)
.json(&serde_json::json!({ "amount": amount }))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
// 3. Update state based on outcome; handle timeouts via reconciliation
match api_result {
Ok(resp) if resp.status().is_success() => {
sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Ok(_) => {
sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Err(_) => {
sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
15Как бихте проектирали многопотребителски Rust бекенд със стриктна изолация на данните и динамично маршрутизиране?
Проектирането на многопотребителски Rust бекенд включва три основни области: идентификация на наемателите, стратегия за изолация на данните и динамично маршрутизиране.
1. **Извличане на наемател и разпространение на контекст**: HTTP междинен софтуер (напр. в Axum или Actix-web) разрешава идентичността на наемателя от `JWT` (JSON Web Token) твърдения, поддомейни или хедъри. Той конструира строго типизиран `TenantContext`, съхраняван в разширения на заявката. Обработчиците използват Rust екстрактори на типове, за да гарантират, че бизнес логиката не може да се изпълни без валиден контекст на наемателя.
2. **Стратегия за изолация на данните**:
* *Споделена база данни със защита на ниво колона / ред (RLS - Row-Level Security)*: Всички наематели споделят таблици с колона `tenant_id`. Изолацията се прилага чрез PostgreSQL RLS, използвайки променливи на сесията на ниво връзка (напр. `SET LOCAL app.tenant_id = $1`) или обвивки на конструктора на заявки.
* *Схема за всеки наемател*: Наемателите споделят инстанция на база данни, но имат изолирани схеми; маршрутизирането коригира пътя за търсене за всяка връзка.
* *База данни за всеки наемател*: Наемателите имат отделни бази данни. Услугата поддържа регистър на пуловете (напр. `Arc<DashMap<TenantId, PgPool>>` или LRU кеш на пулове), за да разрешава динамично пуловете от връзки.
3. **Предотвратяване на изтичане на данни**: За да се избегне замърсяване между наематели в асинхронни Tokio приложения, контекстът на наемателя трябва да се предава изрично през границите на задачите, вместо да се съхранява в глобално състояние или локално за нишката хранилище (`thread_local!`), което се нарушава при миграции на асинхронни работни нишки.
use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);
#[derive(Clone)]
pub struct AppState {
pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}
#[derive(Clone)]
pub struct TenantContext {
pub tenant_id: TenantId,
pub pool: PgPool,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = (axum::http::StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let tenant_header = parts.headers.get("X-Tenant-ID")
.and_then(|v| v.to_str().ok())
.ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
let tenant_id = TenantId(tenant_header.to_string());
let pool = match state.pool_registry.get(&tenant_id) {
Some(p) => p.value().clone(),
None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
};
Ok(TenantContext { tenant_id, pool })
}
}