Вопросы для собеседования бэкенд-разработчика на Rust
15 отобранных вопросов для собеседования по бэкенду на Rust, сгруппированных по уровням квалификации. Используйте их для повторения основ, понимания практических компромиссов и рассуждений на уровне Senior-разработчика о производственных системах.
1Как правила владения в Rust определяют, когда передавать значение, заимствовать, клонировать или делить состояние?
У значения один владелец. Перемещение значения без `Copy` передаёт владение, а когда владелец завершает жизнь, значение обычно уничтожается. Функция может принять значение во владение, временно заимствовать его по общей или эксклюзивной изменяемой ссылке либо получить собственное значение через `clone`, если тип это поддерживает. Клонирование не обязано копировать вложенные данные: у `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 и как это влияет на сигнатуры обработчиков?
`&T` — общая ссылка: несколько таких ссылок могут использоваться одновременно, и обычное присваивание через них запрещено. Типы с внутренней изменяемостью могут предоставлять контролируемые изменения. `&mut T` — эксклюзивная ссылка, разрешающая изменение без конкурирующего доступа к той же части значения. Для чтения принимайте узкий тип вроде `&str`; для изменения — `&mut T`, владение или подходящую абстракцию. Для общего состояния параллельных обработчиков обычно используют общий дескриптор и синхронизацию.
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 и какие задачи решает размещение данных в куче?
`Box<T>` владеет значением; для обычного ненулевого размера оно размещается в куче. При уничтожении `Box` уничтожается и значение, а память освобождается. Указатель позволяет дать конечный размер рекурсивному типу и владеть значением динамического размера, например `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Для чего в Rust нужны 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` и `expect` оставляйте для тестов и проверенных инвариантов, поскольку они могут вызвать панику.
5Как в Rust работает оператор ? и как при этом преобразуется тип ошибки?
У `Result` выражение `expr?` извлекает значение из `Ok`, а при `Err` досрочно завершает функцию с ошибкой. У `Option` оно извлекает содержимое `Some` или возвращает `None`. Возвращаемый тип должен поддерживать такое распространение. Для `Result` ошибку можно преобразовать в тип ошибки функции реализацией `From`, написанной вручную или с помощью `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Как сопоставление с образцом для enum повышает корректность предметной модели и API?
Варианты перечисления задают допустимые состояния и могут хранить данные. `match` выбирает вариант и извлекает эти данные. Компилятор требует охватить все варианты; ветвь `_` тоже покрывает оставшиеся случаи. Если перечислить варианты явно, появление нового состояния заставит пересмотреть места обработки, а не незаметно направит его в общую ветвь.
7Что такое тип-обёртка (newtype) в Rust и как он помогает безопасно работать с идентификаторами, деньгами и секретами?
Создайте отдельный тип поверх существующего представления, например `struct UserId(Uuid);` или `struct AccessToken(String);`. Компилятор не позволит перепутать `UserId` с `OrderId`, хотя внутри у них один тип. То же касается центов и долларов, произвольной строки и проверенного токена. В конструкторе обёртки можно проверять инварианты, а реализациями `Debug`, `Display` и `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 в Rust, в каком порядке освобождаются ресурсы и почему асинхронное завершение требует отдельного метода?
При обычном уничтожении значения Rust сначала вызывает его `Drop::drop`, если он реализован, затем уничтожает поля в порядке объявления. Локальные переменные обычно уничтожаются в обратном порядке объявления. Ресурс привязывают к владеющему объекту: например, при уничтожении защитного объекта снимается блокировка. Это принцип RAII. Метод `drop` синхронный и не может ожидать асинхронную операцию, поэтому для гарантированного сброса буфера или завершения обмена вызывают явный асинхронный метод.
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Что такое время жизни ссылки в Rust и когда в API его приходится указывать явно?
Время жизни описывает, на каком участке ссылка действительна. Аннотации выражают связи между ссылками для компилятора, не продлевают жизнь значения и ничего не добавляют при выполнении. Простые связи выводятся из сигнатуры. Явная аннотация нужна, например, когда есть несколько входных ссылок и нужно указать источник возвращаемой ссылки, либо когда структура хранит ссылку.
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 Rust?
Ссылка с `'static` может оставаться действительной до конца программы, например ссылка на строковый литерал. Ограничение `T: 'static` исключает заимствования с более коротким временем жизни, но само значение можно уничтожить раньше. Исполнитель может продолжить фоновую задачу после возврата запустившей функции, поэтому обычный `spawn` требует подходящего `Future` с `'static`. Передавайте внутрь `async move` принадлежащие задаче данные или копию дескриптора `Arc`; не навязывайте `'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?
Типаж описывает общее поведение. Параметр `T: Trait` обычно даёт статический вызов через мономорфизацию. `impl Trait` в аргументе задаёт анонимный обобщённый параметр, а в результате скрывает один конкретный тип, выбранный реализацией. `dyn Trait` используют за ссылкой или указателем вроде `Box<dyn Trait>`: метод выбирается по таблице вызовов во время выполнения, что позволяет единообразно хранить разные реализации, если типаж допускает такое использование.
12Что такое ассоциированный тип типажа и когда он лучше обобщённого параметра?
Ассоциированный тип объявляют в типаже, а реализация его конкретизирует, как `Iterator::Item`. Он удобен, когда у каждой реализации один естественный тип результата или элемента. Обобщённый параметр подходит, когда выбор должен задавать вызывающий код либо один тип должен поддерживать несколько реализаций типажа с разными аргументами.
13Как реализовать транзакционную таблицу исходящих событий в Rust?
В одной транзакции сохраните изменение состояния и событие с уникальным идентификатором в таблице `outbox`. Рабочий процесс или захват изменений затем публикует события в брокер. Для конкурирующих обработчиков можно короткой транзакцией резервировать строки через `FOR UPDATE SKIP LOCKED`, а сетевую отправку выполнять после фиксации резерва. После подтверждения брокера отметьте отправку. Сбой между отправкой и отметкой приведёт к повтору, поэтому получатель должен обрабатывать идентификатор идемпотентно, а просроченный резерв — освобождаться.
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?
Сохраните намерение и состояние ожидания в локальной транзакции, зафиксируйте её и лишь затем вызывайте API. Передавайте один и тот же ключ идемпотентности при повторах, если провайдер поддерживает эту гарантию. При успехе отметьте завершение, при окончательном отказе — ошибку и при необходимости выполните компенсацию. Тайм-аут не показывает, был ли эффект: оставьте состояние неопределённым и сверяйте результат по статусу провайдера или повторяйте с тем же ключом в пределах его контракта.
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-бэкенд со строгой изоляцией данных и динамической маршрутизацией?
1. Middleware Axum/Actix определяет запрошенного арендатора из проверенных утверждений или маршрута и проверяет право аутентифицированного вызывающего. Клиентский header/subdomain сам не авторитетен. Типизированный TenantContext помещается в request extensions; extractors требуют его для бизнес-логики. 2. В общей БД таблицы имеют tenant_id, PostgreSQL RLS использует, например, параметризованный SELECT set_config('app.tenant_id', $1, true) внутри транзакции, проверенные политики и роли без обхода. Обёртка запросов — другой, прикладной контроль. На каждом полученном соединении задавайте транзакционный контекст, проверяйте повторное использование пула и фоновые задания. При schema-per-tenant используйте безопасно экранированные/разрешённые имена и ограниченное состояние search_path; схемы не заменяют привилегии и авторизацию. При database-per-tenant реестр пулов, например Arc<DashMap<TenantId, PgPool>> или LRU, динамически выбирает БД. 3. Передавайте контекст явно через границы async-задач. Globals и thread_local! не подходят: задача Tokio может мигрировать между рабочими потоками и смешать арендаторов.
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 })
}
}